@diagrammo/dgmo 0.8.20 → 0.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -0,0 +1,88 @@
1
+ // ============================================================
2
+ // Mindmap Collapse/Expand — prune subtrees of collapsed nodes
3
+ // ============================================================
4
+
5
+ import type { MindmapNode } from './types';
6
+
7
+ // ============================================================
8
+ // Types
9
+ // ============================================================
10
+
11
+ export interface CollapsedMindmapResult {
12
+ /** Roots with collapsed subtrees pruned (deep-cloned, never mutates original) */
13
+ roots: MindmapNode[];
14
+ /** nodeId → count of hidden descendants */
15
+ hiddenCounts: Map<string, number>;
16
+ }
17
+
18
+ // ============================================================
19
+ // Helpers
20
+ // ============================================================
21
+
22
+ function cloneNode(node: MindmapNode): MindmapNode {
23
+ return {
24
+ id: node.id,
25
+ label: node.label,
26
+ description: node.description,
27
+ metadata: { ...node.metadata },
28
+ children: node.children.map(cloneNode),
29
+ parentId: node.parentId,
30
+ lineNumber: node.lineNumber,
31
+ color: node.color,
32
+ collapsed: node.collapsed,
33
+ };
34
+ }
35
+
36
+ function countDescendants(node: MindmapNode): number {
37
+ let count = 0;
38
+ for (const child of node.children) {
39
+ count += 1 + countDescendants(child);
40
+ }
41
+ return count;
42
+ }
43
+
44
+ function computeHiddenCounts(
45
+ nodes: MindmapNode[],
46
+ collapsedIds: Set<string>,
47
+ hiddenCounts: Map<string, number>
48
+ ): void {
49
+ for (const node of nodes) {
50
+ if (collapsedIds.has(node.id) && node.children.length > 0) {
51
+ hiddenCounts.set(node.id, countDescendants(node));
52
+ }
53
+ computeHiddenCounts(node.children, collapsedIds, hiddenCounts);
54
+ }
55
+ }
56
+
57
+ function pruneCollapsed(node: MindmapNode, collapsedIds: Set<string>): void {
58
+ for (const child of node.children) {
59
+ pruneCollapsed(child, collapsedIds);
60
+ }
61
+ if (collapsedIds.has(node.id) && node.children.length > 0) {
62
+ node.children = [];
63
+ }
64
+ }
65
+
66
+ // ============================================================
67
+ // Main
68
+ // ============================================================
69
+
70
+ export function collapseMindmapTree(
71
+ roots: MindmapNode[],
72
+ collapsedIds: Set<string>
73
+ ): CollapsedMindmapResult {
74
+ const hiddenCounts = new Map<string, number>();
75
+
76
+ if (collapsedIds.size === 0) {
77
+ return { roots, hiddenCounts };
78
+ }
79
+
80
+ computeHiddenCounts(roots, collapsedIds, hiddenCounts);
81
+
82
+ const clonedRoots = roots.map(cloneNode);
83
+ for (const root of clonedRoots) {
84
+ pruneCollapsed(root, collapsedIds);
85
+ }
86
+
87
+ return { roots: clonedRoots, hiddenCounts };
88
+ }
@@ -0,0 +1,605 @@
1
+ // ============================================================
2
+ // Mindmap Two-Sided Horizontal Tree Layout
3
+ // ============================================================
4
+ //
5
+ // Classic mindmap layout: root centered, children branch left and right.
6
+ // Each side is a recursive vertical stacking — no D3 tree layout needed.
7
+ // Nodes at the same depth share the same X column. Hierarchy reads
8
+ // left→right (right branches) or right→left (left branches).
9
+
10
+ import type { MindmapNode } from './types';
11
+ import type {
12
+ MindmapLayoutNode,
13
+ MindmapLayoutEdge,
14
+ MindmapLayoutResult,
15
+ } from './types';
16
+ import type { ParsedMindmap } from './types';
17
+ import type { PaletteColors } from '../palettes';
18
+ import type { TagGroup } from '../utils/tag-groups';
19
+ import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
20
+ import { computeNodeText } from './text-wrap';
21
+
22
+ // ============================================================
23
+ // Constants
24
+ // ============================================================
25
+
26
+ const ROOT_WIDTH = 180;
27
+ const DEPTH1_WIDTH = 150;
28
+ const LEAF_WIDTH = 120;
29
+
30
+ const SINGLE_LABEL_HEIGHT = 28;
31
+ const LABEL_LINE_HEIGHT = 18; // per line when multi-line
32
+ const DESC_LINE_HEIGHT = 14; // per description line
33
+ const NODE_V_PAD = 10;
34
+
35
+ const H_GAP = 40; // horizontal gap between parent edge and child edge
36
+ const V_GAP = 12; // vertical gap between sibling nodes
37
+ const MARGIN = 40;
38
+ const MULTI_ROOT_GAP = 60; // horizontal gap between independent root trees
39
+
40
+ // ============================================================
41
+ // Direction — which side a subtree grows toward
42
+ // ============================================================
43
+
44
+ type Direction = 'right' | 'left';
45
+
46
+ // ============================================================
47
+ // Internal positioned node (intermediate before final output)
48
+ // ============================================================
49
+
50
+ interface PositionedNode {
51
+ node: MindmapNode;
52
+ x: number; // top-left x
53
+ y: number; // top-left y
54
+ width: number;
55
+ height: number;
56
+ depth: number;
57
+ direction: Direction;
58
+ subtreeHeight: number; // total height of this node's subtree
59
+ }
60
+
61
+ // ============================================================
62
+ // Main entry point
63
+ // ============================================================
64
+
65
+ export function layoutMindmap(
66
+ parsed: ParsedMindmap,
67
+ palette: PaletteColors,
68
+ options?: {
69
+ interactive?: boolean;
70
+ hiddenCounts?: Map<string, number>;
71
+ activeTagGroup?: string | null;
72
+ hideDescriptions?: boolean;
73
+ }
74
+ ): MindmapLayoutResult {
75
+ const roots = parsed.roots;
76
+ if (roots.length === 0) {
77
+ return { nodes: [], edges: [], width: 0, height: 0 };
78
+ }
79
+
80
+ const hiddenCounts = options?.hiddenCounts ?? new Map<string, number>();
81
+ const activeTagGroup = options?.activeTagGroup ?? null;
82
+ const hideDescriptions = options?.hideDescriptions ?? false;
83
+
84
+ // Populate depth cache for nodeHeight() wrapping calculations
85
+ populateDepthCache(roots);
86
+
87
+ // Inject default tag metadata (idempotent — fills empty metadata keys)
88
+ const allNodes: MindmapNode[] = [];
89
+ const collectAll = (nodes: MindmapNode[]) => {
90
+ for (const n of nodes) {
91
+ allNodes.push(n);
92
+ collectAll(n.children);
93
+ }
94
+ };
95
+ collectAll(roots);
96
+ injectDefaultTagMetadata(allNodes, parsed.tagGroups);
97
+
98
+ // Color resolution happens in finalize() per layout node — NOT by mutating parsed nodes.
99
+ // This allows tag group switching to recolor correctly.
100
+ const tagGroups = parsed.tagGroups;
101
+
102
+ if (roots.length === 1) {
103
+ return layoutSingleRoot(
104
+ roots[0],
105
+ hiddenCounts,
106
+ hideDescriptions,
107
+ tagGroups,
108
+ activeTagGroup
109
+ );
110
+ }
111
+ return layoutMultiRoot(
112
+ roots,
113
+ parsed,
114
+ hiddenCounts,
115
+ hideDescriptions,
116
+ tagGroups,
117
+ activeTagGroup
118
+ );
119
+ }
120
+
121
+ // ============================================================
122
+ // Single root — two-sided horizontal tree
123
+ // ============================================================
124
+
125
+ function layoutSingleRoot(
126
+ root: MindmapNode,
127
+ hiddenCounts: Map<string, number>,
128
+ hideDescriptions: boolean,
129
+ tagGroups: TagGroup[] = [],
130
+ activeTagGroup: string | null = null
131
+ ): MindmapLayoutResult {
132
+ const positioned: PositionedNode[] = [];
133
+ const rootW = nodeWidth(0);
134
+ const rootH = nodeHeight(root, hideDescriptions);
135
+
136
+ // Split children into right and left sides, balancing by subtree weight
137
+ const children = root.children;
138
+ const { right: rightChildren, left: leftChildren } = balancedSplit(
139
+ children,
140
+ hideDescriptions
141
+ );
142
+
143
+ // Compute subtree heights for each side
144
+ const rightHeight = computeGroupHeight(rightChildren, 1, hideDescriptions);
145
+ const leftHeight = computeGroupHeight(leftChildren, 1, hideDescriptions);
146
+ const maxSideHeight = Math.max(rightHeight, leftHeight, rootH);
147
+
148
+ // Root position: centered vertically
149
+ const rootX = 0; // will be offset later
150
+ const rootY = maxSideHeight / 2 - rootH / 2;
151
+
152
+ positioned.push({
153
+ node: root,
154
+ x: rootX,
155
+ y: rootY,
156
+ width: rootW,
157
+ height: rootH,
158
+ depth: 0,
159
+ direction: 'right',
160
+ subtreeHeight: maxSideHeight,
161
+ });
162
+
163
+ // Layout right side
164
+ const rootCenterY = rootY + rootH / 2;
165
+ const rightStartX = rootX + rootW + H_GAP;
166
+ layoutSide(
167
+ rightChildren,
168
+ rightStartX,
169
+ rootCenterY,
170
+ 1,
171
+ 'right',
172
+ hideDescriptions,
173
+ positioned
174
+ );
175
+
176
+ // Layout left side
177
+ const leftStartX = rootX - H_GAP;
178
+ layoutSide(
179
+ leftChildren,
180
+ leftStartX,
181
+ rootCenterY,
182
+ 1,
183
+ 'left',
184
+ hideDescriptions,
185
+ positioned
186
+ );
187
+
188
+ // Compute bounding box and normalize to positive coordinates
189
+ return finalize(
190
+ positioned,
191
+ hiddenCounts,
192
+ hideDescriptions,
193
+ root,
194
+ tagGroups,
195
+ activeTagGroup
196
+ );
197
+ }
198
+
199
+ // ============================================================
200
+ // Multi-root — each root gets its own two-sided tree, arranged horizontally
201
+ // ============================================================
202
+
203
+ function layoutMultiRoot(
204
+ roots: MindmapNode[],
205
+ _parsed: ParsedMindmap,
206
+ hiddenCounts: Map<string, number>,
207
+ hideDescriptions: boolean,
208
+ tagGroups: TagGroup[],
209
+ activeTagGroup: string | null
210
+ ): MindmapLayoutResult {
211
+ const subResults: MindmapLayoutResult[] = [];
212
+ for (const r of roots) {
213
+ subResults.push(
214
+ layoutSingleRoot(
215
+ r,
216
+ hiddenCounts,
217
+ hideDescriptions,
218
+ tagGroups,
219
+ activeTagGroup
220
+ )
221
+ );
222
+ }
223
+
224
+ const totalWidth =
225
+ subResults.reduce((sum, r) => sum + r.width, 0) +
226
+ MULTI_ROOT_GAP * (subResults.length - 1);
227
+ const maxHeight = Math.max(...subResults.map((r) => r.height));
228
+
229
+ const allNodes: MindmapLayoutNode[] = [];
230
+ const allEdges: MindmapLayoutEdge[] = [];
231
+ let xOffset = 0;
232
+
233
+ for (const sub of subResults) {
234
+ const yOffset = (maxHeight - sub.height) / 2;
235
+ for (const n of sub.nodes) {
236
+ allNodes.push({ ...n, x: n.x + xOffset, y: n.y + yOffset });
237
+ }
238
+ for (const e of sub.edges) {
239
+ // Offset all coordinates in the path string
240
+ allEdges.push({
241
+ sourceId: e.sourceId,
242
+ targetId: e.targetId,
243
+ path: offsetPath(e.path, xOffset, yOffset),
244
+ });
245
+ }
246
+ xOffset += sub.width + MULTI_ROOT_GAP;
247
+ }
248
+
249
+ return {
250
+ nodes: allNodes,
251
+ edges: allEdges,
252
+ width: totalWidth,
253
+ height: maxHeight,
254
+ };
255
+ }
256
+
257
+ // ============================================================
258
+ // Recursive side layout
259
+ // ============================================================
260
+
261
+ function layoutSide(
262
+ children: MindmapNode[],
263
+ startX: number,
264
+ parentCenterY: number,
265
+ depth: number,
266
+ direction: Direction,
267
+ hideDescriptions: boolean,
268
+ positioned: PositionedNode[]
269
+ ): void {
270
+ if (children.length === 0) return;
271
+
272
+ const groupHeight = computeGroupHeight(children, depth, hideDescriptions);
273
+ let currentY = parentCenterY - groupHeight / 2;
274
+
275
+ for (const child of children) {
276
+ const w = nodeWidth(depth);
277
+ const h = nodeHeight(child, hideDescriptions);
278
+ const subtreeH = computeSubtreeHeight(child, depth, hideDescriptions);
279
+
280
+ // Node is vertically centered within its subtree allocation
281
+ const nodeY = currentY + subtreeH / 2 - h / 2;
282
+ const nodeX = direction === 'right' ? startX : startX - w;
283
+
284
+ positioned.push({
285
+ node: child,
286
+ x: nodeX,
287
+ y: nodeY,
288
+ width: w,
289
+ height: h,
290
+ depth,
291
+ direction,
292
+ subtreeHeight: subtreeH,
293
+ });
294
+
295
+ // Recurse into children
296
+ if (child.children.length > 0) {
297
+ const childCenterY = nodeY + h / 2;
298
+ const nextX = direction === 'right' ? nodeX + w + H_GAP : nodeX - H_GAP;
299
+ layoutSide(
300
+ child.children,
301
+ nextX,
302
+ childCenterY,
303
+ depth + 1,
304
+ direction,
305
+ hideDescriptions,
306
+ positioned
307
+ );
308
+ }
309
+
310
+ currentY += subtreeH + V_GAP;
311
+ }
312
+ }
313
+
314
+ // ============================================================
315
+ // Height computation
316
+ // ============================================================
317
+
318
+ /** Total height of a group of siblings (including their subtrees) */
319
+ function computeGroupHeight(
320
+ children: MindmapNode[],
321
+ depth: number,
322
+ hideDescriptions: boolean
323
+ ): number {
324
+ if (children.length === 0) return 0;
325
+ let total = 0;
326
+ for (const child of children) {
327
+ total += computeSubtreeHeight(child, depth, hideDescriptions);
328
+ }
329
+ total += V_GAP * (children.length - 1);
330
+ return total;
331
+ }
332
+
333
+ /** Height of a single node's subtree (the node + its descendants stacked vertically) */
334
+ function computeSubtreeHeight(
335
+ node: MindmapNode,
336
+ depth: number,
337
+ hideDescriptions: boolean
338
+ ): number {
339
+ const h = nodeHeight(node, hideDescriptions);
340
+ if (node.children.length === 0) return h;
341
+ const childrenHeight = computeGroupHeight(
342
+ node.children,
343
+ depth + 1,
344
+ hideDescriptions
345
+ );
346
+ return Math.max(h, childrenHeight);
347
+ }
348
+
349
+ // ============================================================
350
+ // Finalization — normalize coordinates, generate output
351
+ // ============================================================
352
+
353
+ function resolveNodeColor(
354
+ node: MindmapNode,
355
+ tagGroups: TagGroup[],
356
+ activeGroupName: string | null
357
+ ): string | undefined {
358
+ // Explicit inline (color) always wins
359
+ if (node.color) return node.color;
360
+ return resolveTagColor(node.metadata, tagGroups, activeGroupName);
361
+ }
362
+
363
+ function finalize(
364
+ positioned: PositionedNode[],
365
+ hiddenCounts: Map<string, number>,
366
+ _hideDescriptions: boolean,
367
+ _rootMindmapNode: MindmapNode,
368
+ tagGroups: TagGroup[] = [],
369
+ activeTagGroup: string | null = null
370
+ ): MindmapLayoutResult {
371
+ // Compute bounding box
372
+ let minX = Infinity,
373
+ minY = Infinity,
374
+ maxX = -Infinity,
375
+ maxY = -Infinity;
376
+ for (const p of positioned) {
377
+ minX = Math.min(minX, p.x);
378
+ minY = Math.min(minY, p.y);
379
+ maxX = Math.max(maxX, p.x + p.width);
380
+ maxY = Math.max(maxY, p.y + p.height);
381
+ }
382
+
383
+ const offsetX = -minX + MARGIN;
384
+ const offsetY = -minY + MARGIN;
385
+ const totalWidth = maxX - minX + MARGIN * 2;
386
+ const totalHeight = maxY - minY + MARGIN * 2;
387
+
388
+ // Build node index for edge generation
389
+ const nodeMap = new Map<string, PositionedNode>();
390
+ for (const p of positioned) {
391
+ nodeMap.set(p.node.id, p);
392
+ }
393
+
394
+ const nodes: MindmapLayoutNode[] = [];
395
+ const edges: MindmapLayoutEdge[] = [];
396
+
397
+ for (const p of positioned) {
398
+ nodes.push({
399
+ id: p.node.id,
400
+ label: p.node.label,
401
+ description: p.node.description,
402
+ metadata: p.node.metadata,
403
+ lineNumber: p.node.lineNumber,
404
+ color: resolveNodeColor(p.node, tagGroups, activeTagGroup),
405
+ x: p.x + offsetX,
406
+ y: p.y + offsetY,
407
+ width: p.width,
408
+ height: p.height,
409
+ depth: p.depth,
410
+ angle: 0,
411
+ radius: 0,
412
+ hiddenCount: hiddenCounts.get(p.node.id),
413
+ hasChildren: hiddenCounts.has(p.node.id) || p.node.children.length > 0,
414
+ });
415
+ }
416
+
417
+ // Generate bus-style edges: one trunk + vertical bar + individual drops per parent.
418
+ // This prevents overlapping semi-transparent segments.
419
+ const childrenByParent = new Map<string, PositionedNode[]>();
420
+ for (const p of positioned) {
421
+ if (p.node.parentId) {
422
+ const arr = childrenByParent.get(p.node.parentId) ?? [];
423
+ arr.push(p);
424
+ childrenByParent.set(p.node.parentId, arr);
425
+ }
426
+ }
427
+
428
+ // Split children by direction so left and right get separate bus edges
429
+ const groupedEdges: [string, PositionedNode[]][] = [];
430
+ for (const [parentId, children] of childrenByParent) {
431
+ const rightKids = children.filter((c) => c.direction === 'right');
432
+ const leftKids = children.filter((c) => c.direction === 'left');
433
+ if (rightKids.length > 0) groupedEdges.push([parentId, rightKids]);
434
+ if (leftKids.length > 0) groupedEdges.push([parentId, leftKids]);
435
+ }
436
+
437
+ for (const [parentId, children] of groupedEdges) {
438
+ const parent = nodeMap.get(parentId)!;
439
+ const px = parent.x + offsetX;
440
+ const py = parent.y + offsetY;
441
+ const isLeft = children[0].direction === 'left';
442
+
443
+ // Parent connection point
444
+ const srcX = isLeft ? px : px + parent.width;
445
+ const srcY = py + parent.height / 2;
446
+
447
+ // Midpoint X between parent edge and children column
448
+ const firstChild = children[0];
449
+ const childEdgeX = isLeft
450
+ ? firstChild.x + offsetX + firstChild.width
451
+ : firstChild.x + offsetX;
452
+ const midX = (srcX + childEdgeX) / 2;
453
+
454
+ if (children.length === 1) {
455
+ // Single child — simple elbow, no bus needed
456
+ const c = children[0];
457
+ const tgtX = isLeft ? c.x + offsetX + c.width : c.x + offsetX;
458
+ const tgtY = c.y + offsetY + c.height / 2;
459
+ edges.push({
460
+ sourceId: parentId,
461
+ targetId: c.node.id,
462
+ path: `M ${srcX} ${srcY} L ${midX} ${srcY} L ${midX} ${tgtY} L ${tgtX} ${tgtY}`,
463
+ });
464
+ } else {
465
+ // Bus pattern: trunk → vertical bar → drops
466
+ // 1. Trunk: parent edge → midX
467
+ edges.push({
468
+ sourceId: parentId,
469
+ targetId: parentId, // self-ref = trunk
470
+ path: `M ${srcX} ${srcY} L ${midX} ${srcY}`,
471
+ });
472
+
473
+ // 2. Vertical bar from topmost child to bottommost child at midX
474
+ const childYs = children.map((c) => c.y + offsetY + c.height / 2);
475
+ const minChildY = Math.min(...childYs);
476
+ const maxChildY = Math.max(...childYs);
477
+ edges.push({
478
+ sourceId: parentId,
479
+ targetId: parentId, // self-ref = bar
480
+ path: `M ${midX} ${minChildY} L ${midX} ${maxChildY}`,
481
+ });
482
+
483
+ // 3. Individual horizontal drops from midX to each child
484
+ for (let i = 0; i < children.length; i++) {
485
+ const c = children[i];
486
+ const tgtX = isLeft ? c.x + offsetX + c.width : c.x + offsetX;
487
+ const tgtY = childYs[i];
488
+ edges.push({
489
+ sourceId: parentId,
490
+ targetId: c.node.id,
491
+ path: `M ${midX} ${tgtY} L ${tgtX} ${tgtY}`,
492
+ });
493
+ }
494
+ }
495
+ }
496
+
497
+ return { nodes, edges, width: totalWidth, height: totalHeight };
498
+ }
499
+
500
+ /**
501
+ * Split children into right and left groups, balancing by subtree height.
502
+ * Preserves source order on each side. Alternating assignment to the
503
+ * lighter side keeps both sides visually balanced.
504
+ */
505
+ function balancedSplit(
506
+ children: MindmapNode[],
507
+ hideDescriptions: boolean
508
+ ): { right: MindmapNode[]; left: MindmapNode[] } {
509
+ if (children.length <= 1) {
510
+ return { right: children, left: [] };
511
+ }
512
+ if (children.length === 2) {
513
+ return { right: [children[0]], left: [children[1]] };
514
+ }
515
+
516
+ // Compute subtree heights for weighting
517
+ const weights = children.map((c, i) => ({
518
+ index: i,
519
+ node: c,
520
+ height: computeSubtreeHeight(c, 1, hideDescriptions),
521
+ }));
522
+
523
+ // Greedy assignment: iterate in source order, assign each to the lighter side
524
+ const right: MindmapNode[] = [];
525
+ const left: MindmapNode[] = [];
526
+ let rightWeight = 0;
527
+ let leftWeight = 0;
528
+
529
+ for (const w of weights) {
530
+ if (rightWeight <= leftWeight) {
531
+ right.push(w.node);
532
+ rightWeight += w.height;
533
+ } else {
534
+ left.push(w.node);
535
+ leftWeight += w.height;
536
+ }
537
+ }
538
+
539
+ return { right, left };
540
+ }
541
+
542
+ /** Offset all coordinates in an SVG path by (dx, dy) */
543
+ function offsetPath(path: string, dx: number, dy: number): string {
544
+ if (dx === 0 && dy === 0) return path;
545
+ return path.replace(
546
+ /([ML])\s*([\d.e+-]+)\s+([\d.e+-]+)/g,
547
+ (_, cmd, x, y) => `${cmd} ${parseFloat(x) + dx} ${parseFloat(y) + dy}`
548
+ );
549
+ }
550
+
551
+ // ============================================================
552
+ // Node sizing
553
+ // ============================================================
554
+
555
+ function nodeWidth(depth: number): number {
556
+ if (depth === 0) return ROOT_WIDTH;
557
+ if (depth === 1) return DEPTH1_WIDTH;
558
+ return LEAF_WIDTH;
559
+ }
560
+
561
+ function nodeHeight(node: MindmapNode, hideDescriptions: boolean): number {
562
+ const depth = getNodeDepth(node);
563
+ const w = nodeWidth(depth);
564
+ const text = computeNodeText(
565
+ node.label,
566
+ node.description,
567
+ depth,
568
+ w,
569
+ hideDescriptions
570
+ );
571
+ const labelLineCount = text.labelLines.length;
572
+ const labelH =
573
+ labelLineCount <= 1
574
+ ? SINGLE_LABEL_HEIGHT
575
+ : LABEL_LINE_HEIGHT * labelLineCount;
576
+ let h = labelH + NODE_V_PAD;
577
+ if (text.descLines.length > 0) {
578
+ h += DESC_LINE_HEIGHT * text.descLines.length + 4; // 4px separator gap
579
+ }
580
+ return h;
581
+ }
582
+
583
+ /** Walk parentId chain to compute depth. Cached via roots traversal isn't needed — trees are small. */
584
+ function getNodeDepth(node: MindmapNode): number {
585
+ // The node structure doesn't carry depth directly, but we can
586
+ // infer from the layout context. For nodeHeight we need depth
587
+ // for font sizing. Use a simple heuristic: walk up parentId.
588
+ // Since MindmapNode doesn't have a parent reference (only parentId),
589
+ // and we don't have the node map here, we use a depth cache.
590
+ return nodeDepthCache.get(node.id) ?? 0;
591
+ }
592
+
593
+ const nodeDepthCache = new Map<string, number>();
594
+
595
+ /** Populate depth cache by walking the tree. Call before layout. */
596
+ function populateDepthCache(roots: MindmapNode[]): void {
597
+ nodeDepthCache.clear();
598
+ const walk = (nodes: MindmapNode[], depth: number) => {
599
+ for (const n of nodes) {
600
+ nodeDepthCache.set(n.id, depth);
601
+ walk(n.children, depth + 1);
602
+ }
603
+ };
604
+ walk(roots, 0);
605
+ }