-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender.js
More file actions
184 lines (172 loc) · 7.75 KB
/
Copy pathrender.js
File metadata and controls
184 lines (172 loc) · 7.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// =============================================================
// Minimal headless SVG renderer for the examples.
// -------------------------------------------------------------
// Reads node.x / node.y (set by any GraphJS layout) plus the graph's links
// and returns a self-contained SVG document string. Pure string building —
// no DOM — so it runs under `bun`/Node and the output can be written straight
// to a .svg file for the README.
//
// Two node styles:
// * circles (default) — for force-directed / radial graphs
// * cards (shape: "card") — a rounded panel with a title + subtitle,
// used by the org-chart / employee-tree example.
//
// `fit: { width, height }` scales and centres the laid-out graph into a fixed
// canvas so every README thumbnail comes out the same size.
// =============================================================
const escapeXml = (s) =>
String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
// A soft, light palette — legible on both light and dark GitHub themes.
const THEME = {
background: "#f6f8fa",
panelStroke: "#d0d7de",
link: "#b6c2cf",
label: "#1f2328",
muted: "#57606a",
cardBg: "#ffffff",
cardStroke: "#d8dee4",
};
export function renderGraphSVG(graph, options = {}) {
const nodes = graph.getNodes();
// Use the graph's links, or derive parent→child edges for tree structures.
let links = graph.linkList && graph.linkList.length ? graph.linkList : [];
if (!links.length) {
for (const n of nodes) for (const c of n.children || []) links.push({ source: n, target: c });
}
const padding = options.padding ?? 26;
const radius = options.radius ?? 20;
const background = options.background ?? THEME.background;
const panelStroke = options.panelStroke ?? THEME.panelStroke;
const linkColor = options.linkColor ?? THEME.link;
const labelColor = options.labelColor ?? THEME.label;
const mutedColor = options.mutedColor ?? THEME.muted;
const defaultFill = options.nodeFill ?? "#1f6feb";
const nodeFill = options.nodeFill instanceof Function ? options.nodeFill : () => defaultFill;
const nodeRadius = options.nodeRadius instanceof Function ? options.nodeRadius : () => radius;
const label = options.label ?? ((n) => (n.data && n.data.name) || n.id);
const showLinkLabels = options.showLinkLabels ?? false;
// --- Card mode -----------------------------------------------------------
const isCard = options.shape === "card";
const cardW = options.cardWidth ?? 150;
const cardH = options.cardHeight ?? 48;
const cardTitle = options.cardTitle ?? label;
const cardSubtitle = options.cardSubtitle ?? ((n) => (n.data && (n.data.title || n.data.role)) || "");
const accentOf = options.accent instanceof Function ? options.accent : () => options.accent ?? defaultFill;
// Half-extent each node occupies, so nothing clips at the canvas edge.
const halfW = (n) => (isCard ? cardW / 2 : Math.max(nodeRadius(n), 34));
const halfH = (n) => (isCard ? cardH / 2 : nodeRadius(n) + 20);
const maxHalfW = Math.max(...nodes.map(halfW));
const maxHalfH = Math.max(...nodes.map(halfH));
// Content bounds (node centres).
const xs = nodes.map((n) => n.x);
const ys = nodes.map((n) => n.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const contentW = Math.max(maxX - minX, 1);
const contentH = Math.max(maxY - minY, 1);
// Map layout coordinates → canvas coordinates. With `fit`, scale + centre
// into a fixed box; otherwise size the canvas to the content. Node glyphs
// (circles / cards) are a fixed pixel size — only the gaps scale — so a tree
// can stretch x and y independently (preserveAspect: false) to space its
// rows and columns evenly, while graphs keep their aspect ratio.
let width, height, scaleX, scaleY, offX, offY;
if (options.fit) {
width = options.fit.width;
height = options.fit.height;
const insetX = padding + maxHalfW;
const insetY = padding + maxHalfH;
const maxS = options.maxScale ?? 1.4;
scaleX = Math.min((width - 2 * insetX) / contentW, maxS);
scaleY = Math.min((height - 2 * insetY) / contentH, maxS);
if (options.preserveAspect !== false) scaleX = scaleY = Math.min(scaleX, scaleY);
offX = (width - contentW * scaleX) / 2 - minX * scaleX;
offY = (height - contentH * scaleY) / 2 - minY * scaleY;
} else {
scaleX = scaleY = 1;
width = Math.round(contentW + 2 * (padding + maxHalfW));
height = Math.round(contentH + 2 * (padding + maxHalfH));
offX = padding + maxHalfW - minX;
offY = padding + maxHalfH - minY;
}
const X = (n) => (n.x * scaleX + offX).toFixed(1);
const Y = (n) => (n.y * scaleY + offY).toFixed(1);
const parts = [];
parts.push(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif">`,
);
parts.push(
`<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="12" fill="${background}" stroke="${panelStroke}"/>`,
);
// Links
for (const link of links) {
const s = link.source;
const t = link.target;
if (!s || !t) continue;
const color = link.color || linkColor;
const w = link.width || 1.5;
const dash = link.dashArray ? ` stroke-dasharray="${link.dashArray}"` : "";
if (isCard) {
// Orthogonal elbow from parent's bottom edge to child's top edge, so
// the hierarchy reads clearly and the line never hides under a card.
const px = Number(X(s));
const py = Number(Y(s)) + cardH / 2;
const cx = Number(X(t));
const cy = Number(Y(t)) - cardH / 2;
const my = ((py + cy) / 2).toFixed(1);
parts.push(
`<path d="M ${px.toFixed(1)} ${py.toFixed(1)} L ${px.toFixed(1)} ${my} L ${cx.toFixed(1)} ${my} L ${cx.toFixed(1)} ${cy.toFixed(1)}" fill="none" stroke="${color}" stroke-width="${w}"/>`,
);
continue;
}
parts.push(`<line x1="${X(s)}" y1="${Y(s)}" x2="${X(t)}" y2="${Y(t)}" stroke="${color}" stroke-width="${w}"${dash}/>`);
if (showLinkLabels && link.label) {
const mx = ((Number(X(s)) + Number(X(t))) / 2).toFixed(1);
const my = ((Number(Y(s)) + Number(Y(t))) / 2).toFixed(1);
parts.push(
`<text x="${mx}" y="${my}" fill="${mutedColor}" font-size="10" text-anchor="middle" dy="-3">${escapeXml(link.label)}</text>`,
);
}
}
// Nodes
for (const node of nodes) {
if (isCard) {
const x = Number(X(node)) - cardW / 2;
const y = Number(Y(node)) - cardH / 2;
const accent = accentOf(node);
// panel
parts.push(
`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${cardW}" height="${cardH}" rx="9" fill="${THEME.cardBg}" stroke="${THEME.cardStroke}"/>`,
);
// left accent bar
parts.push(
`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="4" height="${cardH}" rx="2" fill="${accent}"/>`,
);
// avatar dot
parts.push(`<circle cx="${(x + 22).toFixed(1)}" cy="${(y + cardH / 2).toFixed(1)}" r="6.5" fill="${accent}"/>`);
// title + subtitle
const tx = (x + 38).toFixed(1);
const title = escapeXml(cardTitle(node));
const subtitle = escapeXml(cardSubtitle(node));
parts.push(
`<text x="${tx}" y="${(y + cardH / 2 - 4).toFixed(1)}" fill="${labelColor}" font-size="12.5" font-weight="600">${title}</text>`,
);
if (subtitle) {
parts.push(
`<text x="${tx}" y="${(y + cardH / 2 + 12).toFixed(1)}" fill="${mutedColor}" font-size="10.5">${subtitle}</text>`,
);
}
} else {
const r = nodeRadius(node);
parts.push(
`<circle cx="${X(node)}" cy="${Y(node)}" r="${r}" fill="${nodeFill(node)}" stroke="#ffffff" stroke-width="2.5"/>`,
);
parts.push(
`<text x="${X(node)}" y="${Y(node)}" fill="${labelColor}" font-size="11" font-weight="500" text-anchor="middle" dy="${r + 14}">${escapeXml(label(node))}</text>`,
);
}
}
parts.push(`</svg>`);
return parts.join("\n");
}