render-tree.mjs
573 lines · archify/renderers/tree/render-tree.mjs at a406594
1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
4import {
5 animateAttr,
6 focusEdgeAttrs,
7 focusNodeAttrs,
8 focusNodeTitle,
9 loadDiagram,
10 svgAccessibleText,
11 svgRootAttrs,
12 writeDiagram,
13} from '../shared/cli.mjs';
14import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
15import { nodeTextFit } from '../shared/text-fit.mjs';
16import { translateMessage as i18nText } from '../shared/i18n.mjs';
17import { asArray, roundedPath, routePointsValue } from '../shared/geometry.mjs';
19const __dirname = path.dirname(fileURLToPath(import.meta.url));
20const layoutJsonMode = process.argv.includes('--layout-json');
21const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
22const { diagram: tree, template, outPath, sourceEvidence } = loadDiagram({
23 rendererDir: __dirname,
24 diagramType: 'tree',
25 defaultExample: 'payment-platform.tree.json',
26 argv: cliArgs,
27});
28const locale = tree.meta.locale;
29const direction = tree.layout?.direction === 'right' ? 'right' : 'down';
30const down = direction === 'down';
32const layout = {
33 margin: 32,
34 top: 64,
35 // Sibling gap and generation gap. A downward tree needs room under each
36 // parent for its toggle and the elbow; a rightward one needs room beside it.
37 gapX: tree.layout?.gapX ?? (direction === 'down' ? 28 : 48),
38 gapY: tree.layout?.gapY ?? (direction === 'down' ? 56 : 18),
39 nodeW: tree.layout?.nodeW ?? 140,
40 nodeMaxW: tree.layout?.nodeMaxW ?? (direction === 'down' ? 220 : 200),
41 padX: 14,
42 padY: 12,
43 labelFont: 13,
44 labelLine: 17,
45 sublabelFont: 11,
46 sublabelLine: 14,
47 rootLabelFont: 14.5,
48 toggleR: 7.5,
49};
51// ---- Structure -----------------------------------------------------------------
52// The hierarchy is exactly what the author declared: one root, and every other
53// node names its parent. Nothing is inferred, so a missing parent, a cycle, or
54// a second root is refused rather than silently attached somewhere.
55function validateStructure() {
56 const problems = [];
57 const details = [];
58 const fail = (code, message, subject, evidence, supportedFixes) => {
59 problems.push(message);
60 details.push({ code, severity: 'error', message, subject: { diagramType: 'tree', ...subject }, evidence, supportedFixes });
61 };
62 const nodes = asArray(tree.nodes);
63 const byId = new Map();
64 for (const [index, node] of nodes.entries()) {
65 if (byId.has(node.id)) {
66 fail('tree/duplicate-id', `Node id "${node.id}" is declared twice.`, { path: `/nodes/${index}/id` }, { id: node.id }, ['Give every node a unique id.']);
67 }
68 byId.set(node.id, node);
69 }
70 for (const [index, node] of nodes.entries()) {
71 if (node.parent !== undefined && !byId.has(node.parent)) {
72 fail('tree/missing-parent', `Node "${node.id}" names parent "${node.parent}", which is not declared.`,
73 { path: `/nodes/${index}/parent`, nodeId: node.id }, { parent: node.parent },
74 [`Declare a node with id "${node.parent}".`, 'Point parent at an existing node.', 'Remove parent to make this the root.']);
75 }
76 }
77 const roots = nodes.filter((node) => node.parent === undefined);
78 if (roots.length !== 1) {
79 fail('tree/root-count', roots.length
80 ? `A tree has one root, but ${roots.length} nodes have no parent: ${roots.map((node) => `"${node.id}"`).join(', ')}.`
81 : 'A tree has one root, but every node names a parent.',
82 { path: '/nodes' }, { roots: roots.map((node) => node.id) },
83 roots.length ? ['Give all but one of these nodes a parent.', 'Add a single root that the top-level nodes name as parent.'] : ['Remove parent from the root node.']);
84 }
85 // Walk each node's parent chain. With one root and every parent declared,
86 // the only way a node can fail to reach the root is a cycle in its chain,
87 // which also accounts for every node hanging beneath that cycle.
88 const reported = new Set();
89 for (const [index, node] of nodes.entries()) {
90 const chain = [];
91 let current = node;
92 while (current && current.parent !== undefined && !chain.includes(current.id)) {
93 chain.push(current.id);
94 current = byId.get(current.parent);
95 }
96 if (current && chain.includes(current.id)) {
97 const cycle = chain.slice(chain.indexOf(current.id));
98 const key = [...cycle].sort().join('|');
99 if (!reported.has(key)) {
100 reported.add(key);
101 fail('tree/cycle', `Parent links form a cycle: ${[...cycle, cycle[0]].map((id) => `"${id}"`).join(' -> ')}.`,
102 { path: `/nodes/${index}/parent`, nodeId: node.id }, { cycle },
103 ['Point one node in the cycle at a parent outside it.']);
104 }
105 }
106 }
107 if (problems.length) {
108 throwDiagnosticProblems('Tree structure validation failed', problems, { code: 'tree/structure', subject: { diagramType: 'tree' }, diagnostics: details });
109 }
110 return { byId, root: roots[0] };
111}
113const { root: authoredRoot } = validateStructure();
115const childrenOf = new Map(asArray(tree.nodes).map((node) => [node.id, []]));
116for (const node of asArray(tree.nodes)) if (node.parent !== undefined) childrenOf.get(node.parent).push(node.id);
118// ---- Measurement ---------------------------------------------------------------
119// Labels wrap at word boundaries (CJK at any character) inside a bounded box,
120// so a long name grows the node downward instead of overflowing or being cut.
121const ADVANCE = nodeTextFit.widthFactor;
123function wrapText(text, font, maxWidth) {
124 const limit = Math.max(4, Math.floor(maxWidth / (font * ADVANCE)));
125 if (textUnits(text) <= limit) return [text];
126 const tokens = String(text).match(/[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]|[^\s\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]+\s*|\s+/g) || [text];
127 const lines = [];
128 let current = '';
129 for (const token of tokens) {
130 if (textUnits((current + token).trimEnd()) <= limit) {
131 current += token;
132 continue;
133 }
134 if (current.trim()) lines.push(current.trimEnd());
135 current = token.trimStart();
136 while (textUnits(current.trimEnd()) > limit) {
137 const chars = Array.from(current);
138 let cut = 0;
139 for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= limit; cut += 1) units += textUnits(chars[cut]);
140 lines.push(chars.slice(0, Math.max(1, cut)).join(''));
141 current = chars.slice(Math.max(1, cut)).join('');
142 }
143 }
144 if (current.trim()) lines.push(current.trimEnd());
145 return lines;
146}
148const depthOf = new Map();
149(function assignDepth(id, depth) {
150 depthOf.set(id, depth);
151 for (const child of childrenOf.get(id)) assignDepth(child, depth + 1);
152}(authoredRoot.id, 0));
154// Compact leaf runs (going down only): three or more consecutive leaf
155// children of one parent are stacked as an indented list under it instead of
156// being spread across the row. A wide fan of leaves is what makes a real
157// hierarchy (a repository, a capability map) far wider than any screen; a list
158// keeps it readable at the same scale. Shorter runs and every branch keep the
159// classic row layout.
160const STACK_MIN = 3;
161const STACK_GAP = 10;
162const stackOf = new Map();
163const groupsOf = new Map();
164for (const [id, children] of childrenOf) {
165 const groups = [];
166 let run = [];
167 const flush = () => {
168 if (down && run.length >= STACK_MIN) groups.push({ stack: run });
169 else for (const child of run) groups.push({ single: child });
170 run = [];
171 };
172 for (const child of children) {
173 if (childrenOf.get(child).length) {
174 flush();
175 groups.push({ single: child });
176 } else run.push(child);
177 }
178 flush();
179 // A list that is a parent's only child group drops its spine from inside
180 // the parent's box, clear of the rounded corner; one beside other children
181 // hangs off the shared crossbar.
182 for (const group of groups) {
183 if (!group.stack) continue;
184 group.spineOffset = groups.length === 1 ? 22 : 12;
185 group.indent = group.spineOffset + 16;
186 }
187 groupsOf.set(id, groups);
188 for (const group of groups) if (group.stack) for (const child of group.stack) stackOf.set(child, group);
189}
191// Width: a label wraps only when it must, and then at the narrowest width
192// that keeps the same number of lines, so a long name becomes a balanced
193// two-line box instead of one wide box beside narrow siblings.
194const measured = new Map(asArray(tree.nodes).map((node) => {
195 const isRoot = node.id === authoredRoot.id;
196 const labelFont = isRoot ? layout.rootLabelFont : layout.labelFont;
197 const labelAdvance = labelFont * ADVANCE * 1.06;
198 // Text wraps between words, never inside one: the longest single word may
199 // widen a node past nodeMaxW (up to half again), and only a word longer
200 // than that is split.
201 const longestWord = Math.max(...String(node.label).split(/\s+/).map((word) => textUnits(word) * labelAdvance));
202 const floor = Math.max(layout.nodeW, Math.ceil(longestWord + layout.padX * 2 + 2));
203 const cap = Math.ceil(Math.min(layout.nodeMaxW * 1.5, Math.max(floor, layout.nodeMaxW)));
204 const linesAt = (width) => ({
205 label: wrapText(node.label, labelFont * 1.06, width - layout.padX * 2),
206 sublabel: node.sublabel ? wrapText(node.sublabel, layout.sublabelFont, width - layout.padX * 2) : [],
207 });
208 const natural = Math.ceil(Math.max(textUnits(node.label) * labelAdvance, node.sublabel ? textUnits(node.sublabel) * layout.sublabelFont * ADVANCE : 0) + layout.padX * 2 + 2);
209 // A stacked list shares one width anyway, so its entries are not balanced.
210 const stacked = stackOf.has(node.id);
211 let width = Math.min(cap, Math.max(floor, natural));
212 const target = linesAt(width);
213 for (let candidate = floor; !stacked && candidate < width; candidate += 4) {
214 const lines = linesAt(candidate);
215 if (lines.label.length <= target.label.length && lines.sublabel.length <= target.sublabel.length) {
216 width = candidate;
217 break;
218 }
219 }
220 const { label: labelLines, sublabel: sublabelLines } = linesAt(width);
221 const contentHeight = labelLines.length * layout.labelLine + (sublabelLines.length ? 4 + sublabelLines.length * layout.sublabelLine : 0);
222 // Verified source badges occupy the top-right 18px of a card. Give sourced
223 // nodes a separate header so centred and wrapped labels never sit under it;
224 // diagrams without sources retain their existing geometry.
225 const evidenceHeader = sourceEvidence?.nodes?.[node.id]?.length ? 18 : 0;
226 return [node.id, { ...node, isRoot, labelFont, labelLines, sublabelLines, width, height: Math.ceil(layout.padY * 2 + evidenceHeader + contentHeight), contentHeight, evidenceHeader, depth: depthOf.get(node.id), stacked }];
227}));
229// A stacked run reads as one list: one width, one row height.
230for (const group of new Set(stackOf.values())) {
231 const members = group.stack.map((id) => measured.get(id));
232 group.width = Math.max(...members.map((node) => node.width));
233 group.rowH = Math.max(...members.map((node) => node.height));
234 for (const node of members) measured.set(node.id, { ...node, width: group.width, height: group.rowH });
235}
237// ---- Layout --------------------------------------------------------------------
238// Every generation shares one band (a row going down, a column going right),
239// sized by its largest node, so siblings and cousins line up. Along the other
240// axis each subtree owns a contiguous span: leaves are laid out in order and a
241// parent is centred over its children. A stacked run hangs from its parent's
242// band and owns its own span, so it never meets a cousin. Positions never
243// depend on collapse state, so expanding or collapsing a branch leaves every
244// visible node where the reader last saw it.
245const breadthSize = (node) => (down ? node.width : node.height);
246const depthSize = (node) => (down ? node.height : node.width);
247const breadthGap = down ? layout.gapX : layout.gapY;
248const depthGap = down ? layout.gapY : layout.gapX;
250const maxDepth = Math.max(...[...measured.values()].map((node) => node.depth));
251const bandSize = Array.from({ length: maxDepth + 1 }, () => 0);
252for (const node of measured.values()) if (!node.stacked) bandSize[node.depth] = Math.max(bandSize[node.depth], depthSize(node));
253const bandStart = [];
254bandSize.reduce((offset, size, depth) => {
255 bandStart[depth] = offset;
256 return offset + size + depthGap;
257}, down ? layout.top : layout.margin);
259// Nodes of one generation share their band's size along the depth axis (the
260// same height going down, the same width going right), so a generation reads
261// as one even row or column instead of a ragged one.
262for (const [id, node] of measured) {
263 if (node.stacked) continue;
264 measured.set(id, down ? { ...node, height: bandSize[node.depth] } : { ...node, width: bandSize[node.depth] });
265}
267const groupSpan = (group) => (group.stack ? group.indent + group.width : span.get(group.single));
268const groupsSpan = (groups) => groups.reduce((sum, group, index) => sum + groupSpan(group) + (index ? breadthGap : 0), 0);
269const span = new Map();
270(function measureSpan(id) {
271 for (const child of childrenOf.get(id)) measureSpan(child);
272 span.set(id, Math.max(breadthSize(measured.get(id)), groupsSpan(groupsOf.get(id))));
273}(authoredRoot.id));
275const placed = new Map();
276// Where a group meets its parent's crossbar: a single child at its centre, a
277// stacked run at its spine.
278const attachOf = (group) => (group.stack ? group.spine : centerOf(group.single));
279(function place(id, start) {
280 const node = measured.get(id);
281 const groups = groupsOf.get(id);
282 // A parent whose only children are one stacked run sits at the run's left
283 // edge, with the spine dropping straight out of it.
284 const onlyStack = groups.length === 1 && groups[0].stack;
285 let cursor = onlyStack ? start : start + (span.get(id) - groupsSpan(groups)) / 2;
286 for (const group of groups) {
287 if (group.stack) {
288 group.spine = cursor + group.spineOffset;
289 let y = bandStart[node.depth + 1];
290 for (const child of group.stack) {
291 const leaf = measured.get(child);
292 placed.set(child, { ...leaf, x: Math.round(cursor + group.indent), y: Math.round(y) });
293 y += leaf.height + STACK_GAP;
294 }
295 } else place(group.single, cursor);
296 cursor += groupSpan(group) + breadthGap;
297 }
298 let center = groups.length ? (attachOf(groups[0]) + attachOf(groups.at(-1))) / 2 : start + span.get(id) / 2;
299 // A middle child a few px off the midpoint would put a small jog in the
300 // stem; the parent sits exactly over it instead.
301 const half = breadthSize(node) / 2;
302 const middle = groups.slice(1, -1).map(attachOf)
303 .find((attach) => Math.abs(attach - center) < 24 && attach - half >= start && attach + half <= start + span.get(id));
304 if (middle !== undefined) center = middle;
305 const breadth = onlyStack ? start : center - breadthSize(node) / 2;
306 // A row going down centres each node in its band; a column going right
307 // left-aligns them so every link into the column has the same stub.
308 const depth = bandStart[node.depth] + (down ? (bandSize[node.depth] - depthSize(node)) / 2 : 0);
309 const [x, y] = down ? [breadth, depth] : [depth, breadth];
310 placed.set(id, { ...node, x: Math.round(x), y: Math.round(y), toggleAt: onlyStack ? Math.round(groups[0].spine) : null });
311}(authoredRoot.id, down ? layout.margin : layout.top));
313function centerOf(id) {
314 const node = placed.get(id);
315 return down ? node.x + node.width / 2 : node.y + node.height / 2;
316}
318const all = asArray(tree.nodes).map((node) => placed.get(node.id));
319const viewBox = Array.isArray(tree.meta?.viewBox) ? tree.meta.viewBox : [
320 Math.max(320, Math.ceil(Math.max(...all.map((node) => node.x + node.width)) + layout.margin + (down ? 0 : layout.toggleR + 4))),
321 Math.max(240, Math.ceil(Math.max(...all.map((node) => node.y + node.height)) + layout.margin + (down ? layout.toggleR + 4 : 0))),
322];
324// ---- Edges ---------------------------------------------------------------------
325// One elbow per parent-child link: out of the parent's toggle side, across at
326// the midpoint of the generation gap, and into the child. Siblings share the
327// stem and the crossbar, which is how a containment tree is read. A stacked
328// child is reached along its run's spine and enters from the left.
329function edgeStart(parent) {
330 if (!down) return [parent.x + parent.width, parent.y + parent.height / 2];
331 return [parent.toggleAt ?? parent.x + parent.width / 2, parent.y + parent.height];
332}
333function edgePoints(parent, child) {
334 const start = edgeStart(parent);
335 if (down && child.stacked) {
336 const spine = stackOf.get(child.id).spine;
337 const end = [child.x, child.y + child.height / 2];
338 if (parent.toggleAt !== null) return [start, [spine, end[1]], end];
339 const midY = bandStart[child.depth] - depthGap / 2;
340 return [start, [start[0], midY], [spine, midY], [spine, end[1]], end];
341 }
342 if (down) {
343 const end = [child.x + child.width / 2, child.y];
344 const midY = bandStart[child.depth] - depthGap / 2;
345 // Rounded parent/child centres can differ by ~1px and draw a useless
346 // elbow. Snap near-aligned stems to a shared x so they stay straight.
347 if (Math.abs(start[0] - end[0]) < 2) {
348 const x = Math.round((start[0] + end[0]) / 2);
349 return [[x, start[1]], [x, end[1]]];
350 }
351 return [start, [start[0], midY], [end[0], midY], end];
352 }
353 const end = [child.x, child.y + child.height / 2];
354 const midX = bandStart[child.depth] - depthGap / 2;
355 if (Math.abs(start[1] - end[1]) < 2) {
356 const y = Math.round((start[1] + end[1]) / 2);
357 return [[start[0], y], [end[0], y]];
358 }
359 return [start, [midX, start[1]], [midX, end[1]], end];
360}
362const edges = all.filter((node) => node.parent !== undefined).map((node, index) => ({
363 from: node.parent,
364 to: node.id,
365 index,
366 points: edgePoints(placed.get(node.parent), node),
367}));
369function ancestorsOf(id) {
370 const chain = [];
371 for (let current = placed.get(id); current?.parent !== undefined; current = placed.get(current.parent)) chain.push(current.parent);
372 return chain;
373}
375function descendantCount(id) {
376 return childrenOf.get(id).reduce((sum, child) => sum + 1 + descendantCount(child), 0);
377}
379// ---- Canvas checks -------------------------------------------------------------
380{
381 const problems = [];
382 if (Array.isArray(tree.meta?.viewBox)) {
383 for (const node of all) {
384 if (node.x + node.width + layout.margin / 2 > viewBox[0] || node.y + node.height + layout.margin / 2 > viewBox[1]) {
385 problems.push(`Node "${node.id}" lies outside the authored meta.viewBox ${viewBox.join('x')} — remove meta.viewBox or enlarge it.`);
386 }
387 }
388 }
389 if (problems.length) throwDiagnosticProblems('Tree layout validation failed', problems, { code: 'layout/constraint', subject: { diagramType: 'tree' } });
390}
392function buildLayoutReport() {
393 return {
394 diagram_type: 'tree',
395 direction,
396 viewBox,
397 nodes: all.map((node) => ({ id: node.id, parent: node.parent ?? null, depth: node.depth, x: node.x, y: node.y, width: node.width, height: node.height, lines: node.labelLines.length, stacked: node.stacked })),
398 edges: edges.map((edge) => ({ from: edge.from, to: edge.to, points: edge.points })),
399 };
400}
402// ---- Rendering -----------------------------------------------------------------
403function treeAttrs(id) {
404 const ancestors = ancestorsOf(id);
405 return ancestors.length ? ` data-tree-ancestors="${esc(ancestors.join(' '))}"` : '';
406}
408// Each top-level branch owns one hue that its whole subtree inherits, so a
409// reader can tell which capability a deep leaf belongs to without tracing
410// lines. Hue is paint only: role (root, branch, leaf) stays in the passport.
411const SUBTREE_HUES = ['backend', 'database', 'cloud', 'frontend', 'messagebus', 'external'];
412const hueOf = new Map();
413childrenOf.get(authoredRoot.id).forEach((id, index) => {
414 const hue = SUBTREE_HUES[index % SUBTREE_HUES.length];
415 (function paint(current) {
416 hueOf.set(current, hue);
417 for (const child of childrenOf.get(current)) paint(child);
418 }(id));
419});
420const hueClass = (id) => (hueOf.has(id) ? `tree-hue-${hueOf.get(id)}` : 'tree-hue-root');
422function renderNode(node) {
423 const leaf = !childrenOf.get(node.id).length;
424 const tone = node.isRoot ? 'frontend' : leaf ? 'external' : 'backend';
425 const role = node.isRoot ? 'tree-root' : leaf ? 'tree-leaf' : 'tree-branch';
426 const context = i18nText(locale, node.isRoot ? 'node.context.tree.root' : childrenOf.get(node.id).length ? 'node.context.tree.branch' : 'node.context.tree.leaf');
427 const passport = { kind: tone, sublabel: node.sublabel, context };
428 // A stacked list reads left-aligned, like a directory listing; a node in a
429 // row is centred under its link.
430 const cx = node.stacked ? node.x + layout.padX + 2 : node.x + node.width / 2;
431 const anchor = node.stacked ? 'start' : 'middle';
432 let y = node.y + node.evidenceHeader + (node.height - node.evidenceHeader - node.contentHeight) / 2;
433 const label = node.labelLines.map((line) => {
434 y += layout.labelLine;
435 return `<tspan x="${cx}" y="${y - 4.5}">${esc(line)}</tspan>`;
436 }).join('');
437 y += node.sublabelLines.length ? 4 : 0;
438 const sublabel = node.sublabelLines.map((line) => {
439 y += layout.sublabelLine;
440 return `<tspan x="${cx}" y="${y - 3.5}">${esc(line)}</tspan>`;
441 }).join('');
442 // A collapsed branch shows a stacked card behind it, so the hidden subtree
443 // stays visible as "there is more here" even before the badge is read.
444 const stack = childrenOf.get(node.id).length
445 ? `<rect data-tree-stack="" x="${node.x + 5}" y="${node.y + 5}" width="${node.width}" height="${node.height}" rx="10" class="tree-stack-${tone}" stroke-width="1.2"/>`
446 : '';
447 // A listed entry carries its subtree hue as a spine; every other node shows
448 // it where its link lands, as a socket on the edge it is entered from.
449 const accent = leaf && node.stacked
450 ? `<path class="tree-accent" d="M ${node.x + 4.5} ${node.y + 9} V ${node.y + node.height - 9}" stroke-width="3" stroke-linecap="round" fill="none"/>`
451 : '';
452 const [sx, sy] = node.stacked || !down ? [node.x, node.y + node.height / 2] : [node.x + node.width / 2, node.y];
453 const socket = node.isRoot ? '' : `<circle cx="${sx}" cy="${sy}" r="2.6" class="tree-socket" stroke-width="1.4"/>`;
454 return ` <g ${focusNodeAttrs(node.id, node.label, passport, locale)} data-tree-depth="${node.depth}"${treeAttrs(node.id)} class="${hueClass(node.id)}">
455 ${focusNodeTitle(node.label, passport)}
456 ${stack}
457 <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="10" class="c-mask"/>
458 <rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="10" class="c-${tone} ${role}"${animateAttr(tree.meta, 'node', node.depth)} stroke-width="${node.isRoot ? 0 : leaf ? 1 : 1.2}"/>
459 ${accent}
460 ${socket}
461 <text data-node-label="" x="${cx}" class="t-primary tree-label" font-size="${node.labelFont}" font-weight="${node.isRoot ? 750 : leaf ? 560 : 700}" text-anchor="${anchor}">${label}</text>
462 ${sublabel ? `<text data-detail="context" x="${cx}" class="t-muted tree-sublabel" font-size="${layout.sublabelFont}" text-anchor="${anchor}">${sublabel}</text>` : ''}
463 </g>`.replace(/[ \t]+$/gm, '');
464}
466// The toggle is a separate control after its node, not nested inside it, so a
467// keyboard reader meets "node, then its branch control" and each has one role.
468function renderToggle(node) {
469 const children = childrenOf.get(node.id);
470 if (!children.length) return '';
471 const [cx, cy] = edgeStart(node);
472 const hidden = descendantCount(node.id);
473 const collapse = i18nText(locale, 'tree.toggle.collapse', { label: node.label, count: hidden });
474 const expand = i18nText(locale, 'tree.toggle.expand', { label: node.label, count: hidden });
475 const r = layout.toggleR;
476 const arm = r * 0.42;
477 return ` <g data-tree-toggle="${esc(node.id)}" data-tree-count="${hidden}"${treeAttrs(node.id)}${node.collapsed ? ' data-tree-initially-collapsed=""' : ''} data-tree-label-collapse="${esc(collapse)}" data-tree-label-expand="${esc(expand)}" role="button" tabindex="0" aria-expanded="true" aria-label="${esc(collapse)}" class="${hueClass(node.id)}">
478 <circle cx="${cx}" cy="${cy}" r="${r + 7}" class="tree-toggle-hit"/>
479 <circle cx="${cx}" cy="${cy}" r="${r}" class="tree-toggle-disc" stroke-width="1.2"/>
480 <path class="tree-toggle-glyph" d="M ${cx - arm} ${cy} H ${cx + arm}" stroke-width="1.5" stroke-linecap="round"/>
481 <path class="tree-toggle-glyph tree-toggle-plus" d="M ${cx} ${cy - arm} V ${cy + arm}" stroke-width="1.5" stroke-linecap="round"/>
482 <g class="tree-toggle-count">
483 <rect x="${cx + r + 3}" y="${cy - 7.5}" width="${String(hidden).length * 6.2 + 11}" height="15" rx="7.5" class="tree-count-pill"/>
484 <text x="${cx + r + 3 + (String(hidden).length * 6.2 + 11) / 2}" y="${cy + 3.6}" class="t-primary" font-size="10" font-weight="700" text-anchor="middle">${hidden}</text>
485 </g>
486 </g>`.replace(/[ \t]+$/gm, '');
487}
489function renderEdge(edge) {
490 // Links out of the root share one trunk, so they stay neutral; inside a
491 // subtree every link shares its branch's hue.
492 const hued = edge.from !== authoredRoot.id;
493 const d = roundedPath(edge.points, 10);
494 return ` <path ${focusEdgeAttrs(edge.from, edge.to, undefined, edge.index)}${treeAttrs(edge.to)} data-composition-points="${routePointsValue(edge.points)}" d="${d}" class="a-default tree-edge${hued ? ` tree-edge-hued ${hueClass(edge.to)}` : ' tree-edge-trunk'}"${animateAttr(tree.meta, 'edge', placed.get(edge.to).depth)} stroke-width="${hued ? 1.5 : 1.8}"/>`;
495}
497function renderSvg() {
498 const readerFit = tree.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height" data-reader-min-text="7.5"';
499 const expandAll = esc(i18nText(locale, 'tree.expandAll'));
500 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(tree.meta)} data-tree-ui="" data-tree-direction="${direction}"${readerFit}>
501${svgAccessibleText(tree.meta, 'tree')}
502${renderDefinitions()}
503 <style>
504${['root', ...SUBTREE_HUES].map((hue) => hue === 'root'
505 ? ' svg[data-tree-ui] .tree-hue-root { --tree-hue: var(--text-muted); --tree-wash: transparent; }'
506 : ` svg[data-tree-ui] .tree-hue-${hue} { --tree-hue: var(--${hue}-stroke); --tree-wash: var(--${hue}-fill); }`).join('\n')}
507 svg[data-tree-ui] .tree-edge { stroke-linejoin: round; stroke-linecap: round; }
508 svg[data-tree-ui] .tree-edge-trunk { stroke: color-mix(in srgb, var(--text) 18%, var(--arrow)); }
509 svg[data-tree-ui] .tree-edge-hued { stroke: color-mix(in srgb, var(--tree-hue) 42%, var(--arrow)); }
510 svg[data-tree-ui] .tree-root { fill: var(--text); stroke: none; }
511 svg[data-tree-ui] [data-tree-depth="0"] .tree-label { fill: var(--mask); }
512 svg[data-tree-ui] [data-tree-depth="0"] .tree-sublabel { fill: var(--mask); opacity: 0.68; }
513 svg[data-tree-ui] .tree-branch { fill: var(--tree-wash); stroke: color-mix(in srgb, var(--tree-hue) 48%, var(--lane-stroke)); }
514 svg[data-tree-ui] .tree-leaf { fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 22%, var(--lane-stroke)); }
515 svg[data-tree-ui] .tree-socket { fill: var(--mask); stroke: var(--tree-hue); pointer-events: none; }
516 svg[data-tree-ui] .tree-accent { stroke: var(--tree-hue); pointer-events: none; }
517 svg[data-tree-ui] [data-tree-stack] { display: none; fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 70%, var(--lane-stroke)); }
518 svg[data-tree-ui] [data-tree-collapsed] [data-tree-stack] { display: inline; }
519 svg[data-tree-ui] [data-tree-hidden] { display: none; }
520 svg[data-tree-ui] .tree-toggle-hit { fill: transparent; }
521 svg[data-tree-ui] .tree-toggle-disc { fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 55%, var(--lane-stroke)); transition: stroke 0.15s ease, fill 0.15s ease; }
522 svg[data-tree-ui] .tree-toggle-glyph { stroke: var(--text-muted); fill: none; transition: stroke 0.15s ease; }
523 svg[data-tree-ui] .tree-toggle-count { display: none; }
524 svg[data-tree-ui] .tree-count-pill { fill: var(--tree-hue); stroke: none; }
525 svg[data-tree-ui] .tree-toggle-count text { fill: var(--mask); }
526 svg[data-tree-ui] [data-tree-toggle] { cursor: pointer; outline: none; }
527 svg[data-tree-ui] [data-tree-toggle][aria-expanded="true"] .tree-toggle-plus { display: none; }
528 svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-count { display: inline; }
529 svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-disc { fill: var(--tree-hue); stroke: var(--tree-hue); }
530 svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-glyph { stroke: var(--mask); }
531 svg[data-tree-ui] [data-tree-toggle]:hover .tree-toggle-disc,
532 svg[data-tree-ui] [data-tree-toggle]:focus-visible .tree-toggle-disc { stroke: var(--tree-hue); stroke-width: 2; }
533 svg[data-tree-ui] [data-tree-toggle]:hover .tree-toggle-glyph,
534 svg[data-tree-ui] [data-tree-toggle]:focus-visible .tree-toggle-glyph { stroke: var(--text); }
535 svg[data-tree-ui] [data-tree-expand-all] { cursor: pointer; outline: none; }
536 svg[data-tree-ui] [data-tree-expand-all] rect { fill: var(--mask); stroke: var(--lane-stroke); }
537 svg[data-tree-ui] [data-tree-expand-all]:is(:hover, :focus-visible) rect { stroke: var(--arrow-emphasis); stroke-width: 2; }
538 svg[data-tree-ui]:not([data-tree-any-collapsed]) [data-tree-expand-all] { display: none; }
539 </style>
541 <!-- Background Grid -->
542 <rect width="100%" height="100%" fill="url(#grid)" />
544 <!-- Containment links -->
545${edges.map(renderEdge).join('\n')}
547 <!-- Nodes -->
548${all.map(renderNode).join('\n')}
550 <!-- Branch toggles -->
551${all.map(renderToggle).filter(Boolean).join('\n')}
553 <!-- Expand all (shown only while a branch is collapsed) -->
554 <g data-tree-expand-all="" role="button" tabindex="0" aria-label="${expandAll}">
555 <rect x="${layout.margin}" y="18" width="${Math.ceil(textUnits(expandAll) * 10.5 * ADVANCE + 24)}" height="24" rx="12" stroke-width="1.2"/>
556 <text x="${layout.margin + 12}" y="34" class="t-primary" font-size="10.5" font-weight="650">${expandAll}</text>
557 </g>
558 </svg>`;
559}
561if (layoutJsonMode) {
562 console.log(JSON.stringify(buildLayoutReport(), null, 2));
563 process.exit(0);
564}
565writeDiagram({
566 outPath,
567 template,
568 diagramType: 'tree',
569 meta: tree.meta,
570 svg: renderSvg(),
571 cards: tree.cards,
572 sourceEvidence,
573});