@diagrammo/dgmo 0.15.0 → 0.16.0

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 (127) hide show
  1. package/README.md +23 -10
  2. package/dist/advanced.cjs +53094 -0
  3. package/dist/advanced.d.cts +4690 -0
  4. package/dist/advanced.d.ts +4690 -0
  5. package/dist/advanced.js +52849 -0
  6. package/dist/auto.cjs +2298 -2069
  7. package/dist/auto.js +132 -109
  8. package/dist/auto.mjs +2294 -2065
  9. package/dist/cli.cjs +175 -152
  10. package/dist/editor.cjs +8 -9
  11. package/dist/editor.js +8 -9
  12. package/dist/highlight.cjs +8 -9
  13. package/dist/highlight.js +8 -9
  14. package/dist/index.cjs +2281 -2048
  15. package/dist/index.d.cts +45 -1
  16. package/dist/index.d.ts +45 -1
  17. package/dist/index.js +2276 -2044
  18. package/dist/internal.cjs +2064 -1831
  19. package/dist/internal.d.cts +113 -113
  20. package/dist/internal.d.ts +113 -113
  21. package/dist/internal.js +2059 -1826
  22. package/dist/pert.cjs +325 -0
  23. package/dist/pert.d.cts +542 -0
  24. package/dist/pert.d.ts +542 -0
  25. package/dist/pert.js +294 -0
  26. package/docs/language-reference.md +83 -66
  27. package/gallery/fixtures/area.dgmo +3 -3
  28. package/gallery/fixtures/bar-stacked.dgmo +5 -5
  29. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  30. package/gallery/fixtures/c4-full.dgmo +8 -8
  31. package/gallery/fixtures/class-full.dgmo +2 -2
  32. package/gallery/fixtures/doughnut.dgmo +6 -6
  33. package/gallery/fixtures/flowchart-colors.dgmo +3 -3
  34. package/gallery/fixtures/function.dgmo +3 -3
  35. package/gallery/fixtures/gantt-full.dgmo +9 -9
  36. package/gallery/fixtures/gantt.dgmo +7 -7
  37. package/gallery/fixtures/infra-full.dgmo +6 -6
  38. package/gallery/fixtures/infra.dgmo +2 -2
  39. package/gallery/fixtures/kanban.dgmo +9 -9
  40. package/gallery/fixtures/line.dgmo +2 -2
  41. package/gallery/fixtures/multi-line.dgmo +3 -3
  42. package/gallery/fixtures/org-full.dgmo +6 -6
  43. package/gallery/fixtures/quadrant.dgmo +2 -2
  44. package/gallery/fixtures/sankey.dgmo +9 -9
  45. package/gallery/fixtures/scatter.dgmo +3 -3
  46. package/gallery/fixtures/sequence-tags-protocols.dgmo +8 -8
  47. package/gallery/fixtures/sequence-tags.dgmo +7 -7
  48. package/gallery/fixtures/sitemap-full.dgmo +7 -7
  49. package/gallery/fixtures/slope.dgmo +5 -5
  50. package/gallery/fixtures/spr-eras.dgmo +9 -9
  51. package/gallery/fixtures/timeline.dgmo +3 -3
  52. package/gallery/fixtures/venn.dgmo +3 -3
  53. package/package.json +28 -3
  54. package/src/advanced.ts +730 -0
  55. package/src/auto/index.ts +14 -13
  56. package/src/boxes-and-lines/layout.ts +481 -445
  57. package/src/boxes-and-lines/renderer.ts +5 -1
  58. package/src/c4/parser.ts +8 -8
  59. package/src/c4/renderer.ts +15 -8
  60. package/src/chart-types.ts +0 -5
  61. package/src/chart.ts +18 -9
  62. package/src/class/parser.ts +8 -15
  63. package/src/class/renderer.ts +17 -6
  64. package/src/cli.ts +15 -13
  65. package/src/completion-types.ts +28 -0
  66. package/src/completion.ts +28 -21
  67. package/src/cycle/layout.ts +2 -2
  68. package/src/cycle/parser.ts +14 -0
  69. package/src/cycle/renderer.ts +6 -3
  70. package/src/d3.ts +1537 -1164
  71. package/src/echarts.ts +37 -20
  72. package/src/editor/dgmo.grammar +1 -3
  73. package/src/editor/dgmo.grammar.js +8 -8
  74. package/src/editor/dgmo.grammar.terms.js +11 -12
  75. package/src/editor/highlight-api.ts +0 -1
  76. package/src/editor/highlight.ts +0 -1
  77. package/src/er/parser.ts +19 -20
  78. package/src/er/renderer.ts +20 -8
  79. package/src/gantt/calculator.ts +1 -11
  80. package/src/gantt/parser.ts +17 -17
  81. package/src/gantt/renderer.ts +9 -6
  82. package/src/graph/flowchart-parser.ts +19 -85
  83. package/src/graph/flowchart-renderer.ts +4 -9
  84. package/src/graph/layout.ts +0 -2
  85. package/src/graph/state-parser.ts +17 -62
  86. package/src/graph/state-renderer.ts +4 -9
  87. package/src/index.ts +17 -1
  88. package/src/infra/parser.ts +40 -30
  89. package/src/infra/renderer.ts +9 -6
  90. package/src/internal.ts +9 -721
  91. package/src/journey-map/parser.ts +10 -3
  92. package/src/journey-map/renderer.ts +3 -1
  93. package/src/kanban/parser.ts +12 -8
  94. package/src/kanban/renderer.ts +3 -1
  95. package/src/mindmap/layout.ts +1 -1
  96. package/src/mindmap/parser.ts +3 -3
  97. package/src/mindmap/renderer.ts +2 -1
  98. package/src/org/parser.ts +3 -3
  99. package/src/org/renderer.ts +5 -4
  100. package/src/pert/layout.ts +1 -1
  101. package/src/pert/monte-carlo.ts +2 -2
  102. package/src/pert/parser.ts +10 -10
  103. package/src/pert/renderer.ts +7 -2
  104. package/src/pert/types.ts +1 -1
  105. package/src/pyramid/parser.ts +12 -0
  106. package/src/raci/parser.ts +44 -14
  107. package/src/raci/renderer.ts +3 -2
  108. package/src/raci/types.ts +4 -3
  109. package/src/ring/parser.ts +12 -0
  110. package/src/sequence/parser.ts +15 -9
  111. package/src/sequence/renderer.ts +2 -5
  112. package/src/sitemap/layout.ts +0 -2
  113. package/src/sitemap/parser.ts +12 -38
  114. package/src/sitemap/renderer.ts +13 -13
  115. package/src/sitemap/types.ts +0 -1
  116. package/src/tech-radar/interactive.ts +1 -1
  117. package/src/tech-radar/renderer.ts +6 -4
  118. package/src/tech-radar/types.ts +2 -0
  119. package/src/utils/arrows.ts +3 -28
  120. package/src/utils/legend-d3.ts +12 -6
  121. package/src/utils/legend-layout.ts +1 -1
  122. package/src/utils/legend-types.ts +1 -1
  123. package/src/utils/parsing.ts +64 -35
  124. package/src/utils/tag-groups.ts +109 -30
  125. package/src/wireframe/layout.ts +11 -7
  126. package/src/wireframe/parser.ts +4 -4
  127. package/src/wireframe/renderer.ts +5 -2
@@ -1,38 +1,17 @@
1
1
  // ============================================================
2
2
  // Boxes and Lines Diagram — Layout Engine
3
3
  // ============================================================
4
-
5
- import dagre from '@dagrejs/dagre';
4
+ //
5
+ // Uses elkjs (layered algorithm) with a multi-trial scheme that runs
6
+ // several option variants and picks the best by:
7
+ // 1. crossings (with a forgiveness threshold for near-zero)
8
+ // 2. total area (prefer compact)
9
+ // 3. bend count (prefer fewer corners)
10
+
11
+ import ELK from 'elkjs/lib/elk.bundled.js';
6
12
  import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
7
13
 
8
- /**
9
- * Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
10
- * with given width/height, along the direction toward (tx, ty).
11
- * Returns the intersection point on the rectangle border.
12
- */
13
- function clipToRectBorder(
14
- cx: number,
15
- cy: number,
16
- w: number,
17
- h: number,
18
- tx: number,
19
- ty: number
20
- ): { x: number; y: number } {
21
- const dx = tx - cx;
22
- const dy = ty - cy;
23
- if (dx === 0 && dy === 0) return { x: cx, y: cy };
24
- const hw = w / 2;
25
- const hh = h / 2;
26
- // Scale factor to reach the border along the direction (dx, dy)
27
- const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
28
- const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
29
- const s = Math.min(sx, sy);
30
- return { x: cx + dx * s, y: cy + dy * s };
31
- }
32
-
33
14
  // ── Constants ──────────────────────────────────────────────
34
- const NODESEP = 60;
35
- const RANKSEP = 100;
36
15
  const MARGIN = 40;
37
16
  const CONTAINER_PAD_X = 30;
38
17
  const CONTAINER_PAD_TOP = 40;
@@ -40,6 +19,19 @@ const CONTAINER_PAD_BOTTOM = 24;
40
19
  const MAX_PARALLEL_EDGES = 5;
41
20
  const PARALLEL_SPACING = 22;
42
21
 
22
+ const PHI = 1.618;
23
+ const NODE_HEIGHT = 60;
24
+ const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
25
+ const DESC_NODE_WIDTH = 140;
26
+ const DESC_FONT_SIZE = 10;
27
+ const DESC_LINE_HEIGHT = 1.4;
28
+ const DESC_PADDING = 8;
29
+ const SEPARATOR_GAP = 4;
30
+ const MAX_DESC_LINES = 6;
31
+ const MAX_LABEL_LINES = 3;
32
+ const LABEL_LINE_HEIGHT = 1.3;
33
+ const LABEL_PAD = 12;
34
+
43
35
  // ── Result types ───────────────────────────────────────────
44
36
 
45
37
  export interface BLLayoutNode {
@@ -62,7 +54,8 @@ export interface BLLayoutEdge {
62
54
  yOffset: number;
63
55
  parallelCount: number;
64
56
  metadata: Record<string, string>;
65
- /** True for edges deferred from dagre (group endpoints) use linear curve */
57
+ /** Marker for renderer: draw with linear curve, not curveBasis (ELK gives
58
+ * us orthogonal polylines and curveBasis would smooth corners into waves) */
66
59
  deferred?: boolean;
67
60
  }
68
61
 
@@ -87,20 +80,6 @@ export interface BLLayoutResult {
87
80
 
88
81
  // ── Node sizing ────────────────────────────────────────────
89
82
 
90
- const PHI = 1.618;
91
- const NODE_HEIGHT = 60;
92
- const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ≈ 97
93
- const DESC_NODE_WIDTH = 140; // wider nodes when descriptions are shown
94
- const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
95
- const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px (matches infra META_LINE_HEIGHT)
96
- const DESC_PADDING = 8;
97
- const SEPARATOR_GAP = 4; // matches infra NODE_SEPARATOR_GAP
98
- const MAX_DESC_LINES = 6;
99
- const MAX_LABEL_LINES = 3;
100
- const LABEL_LINE_HEIGHT = 1.3;
101
- const LABEL_PAD = 12; // top + bottom padding around label area
102
-
103
- /** Split on camelCase boundaries */
104
83
  function splitCamelCase(word: string): string[] {
105
84
  const parts: string[] = [];
106
85
  let start = 0;
@@ -126,21 +105,17 @@ function splitCamelCase(word: string): string[] {
126
105
  return parts.length > 1 ? parts : [word];
127
106
  }
128
107
 
129
- /** Estimate how many lines a label needs (split on spaces/dashes/camelCase, font shrink 13→9) */
130
108
  function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
131
- // Split on spaces and dashes, then camelCase
132
109
  const rawParts = label.split(/[\s-]+/);
133
110
  const words: string[] = [];
134
111
  for (const part of rawParts) {
135
112
  if (!part) continue;
136
113
  words.push(...splitCamelCase(part));
137
114
  }
138
-
139
115
  for (let fontSize = 13; fontSize >= 9; fontSize--) {
140
116
  const charWidth = fontSize * 0.6;
141
117
  const maxChars = Math.floor((nodeWidth - 24) / charWidth);
142
118
  if (maxChars < 2) continue;
143
-
144
119
  let lines = 1;
145
120
  let current = '';
146
121
  for (const word of words) {
@@ -161,14 +136,9 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
161
136
  if (!node.description || node.description.length === 0) {
162
137
  return { width: NODE_WIDTH, height: NODE_HEIGHT };
163
138
  }
164
-
165
139
  const w = DESC_NODE_WIDTH;
166
-
167
- // Estimate label height (up to 3 lines)
168
140
  const labelLines = estimateLabelLines(node.label, w);
169
141
  const labelHeight = labelLines * 13 * LABEL_LINE_HEIGHT + LABEL_PAD;
170
-
171
- // Estimate wrapped line count using word-boundary wrapping (matches renderer)
172
142
  const charsPerLine = Math.floor((w - 24) / (DESC_FONT_SIZE * 0.6));
173
143
  let totalRenderedLines = 0;
174
144
  for (const line of node.description) {
@@ -179,7 +149,6 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
179
149
  let current = '';
180
150
  let lineCount = 0;
181
151
  for (const word of words) {
182
- // Words wider than line get truncated with "…" in renderer (1 line)
183
152
  const fitted =
184
153
  word.length > charsPerLine ? word.slice(0, charsPerLine) : word;
185
154
  const test = current ? `${current} ${fitted}` : fitted;
@@ -195,7 +164,6 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
195
164
  }
196
165
  }
197
166
  totalRenderedLines = Math.min(totalRenderedLines, MAX_DESC_LINES);
198
-
199
167
  const descriptionHeight =
200
168
  totalRenderedLines * DESC_FONT_SIZE * DESC_LINE_HEIGHT;
201
169
  const totalHeight =
@@ -204,43 +172,236 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
204
172
  DESC_PADDING +
205
173
  descriptionHeight +
206
174
  DESC_PADDING;
207
-
208
175
  return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
209
176
  }
210
177
 
178
+ // ── ELK types (minimal) ────────────────────────────────────
179
+
180
+ interface ElkPoint {
181
+ x: number;
182
+ y: number;
183
+ }
184
+ interface ElkEdgeSection {
185
+ id?: string;
186
+ startPoint: ElkPoint;
187
+ endPoint: ElkPoint;
188
+ bendPoints?: ElkPoint[];
189
+ }
190
+ interface ElkLayoutEdge {
191
+ id: string;
192
+ sources: string[];
193
+ targets: string[];
194
+ sections?: ElkEdgeSection[];
195
+ /** ELK marks the container whose local frame the section coords are in */
196
+ container?: string;
197
+ }
198
+ interface ElkNode {
199
+ id: string;
200
+ width?: number;
201
+ height?: number;
202
+ x?: number;
203
+ y?: number;
204
+ children?: ElkNode[];
205
+ edges?: ElkLayoutEdge[];
206
+ labels?: { text: string; width?: number; height?: number }[];
207
+ layoutOptions?: Record<string, string>;
208
+ }
209
+
210
+ let elkInstance: InstanceType<typeof ELK> | null = null;
211
+ function getElk(): InstanceType<typeof ELK> {
212
+ if (!elkInstance) elkInstance = new ELK();
213
+ return elkInstance;
214
+ }
215
+
216
+ // ── ELK option variants ────────────────────────────────────
217
+
218
+ interface Variant {
219
+ name: string;
220
+ options: Record<string, string>;
221
+ }
222
+
223
+ function baseOptions(): Record<string, string> {
224
+ return {
225
+ 'elk.algorithm': 'layered',
226
+ // INCLUDE_CHILDREN lets ELK route edges across container boundaries.
227
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
228
+ 'elk.edgeRouting': 'ORTHOGONAL',
229
+ 'elk.layered.unnecessaryBendpoints': 'true',
230
+ // Let edges leave from top/bottom of nodes (not just the flow-direction
231
+ // sides) when it reduces crossings.
232
+ 'elk.layered.allowNonFlowPortsToSwitchSides': 'true',
233
+ };
234
+ }
235
+
236
+ function bkBaseline(): Record<string, string> {
237
+ return {
238
+ ...baseOptions(),
239
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
240
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
241
+ 'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS',
242
+ 'elk.layered.compaction.connectedComponents': 'true',
243
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '90',
244
+ 'elk.spacing.nodeNode': '55',
245
+ 'elk.spacing.edgeNode': '55',
246
+ 'elk.spacing.edgeEdge': '18',
247
+ };
248
+ }
249
+
250
+ function getVariants(): Variant[] {
251
+ const bk = bkBaseline();
252
+ return [
253
+ {
254
+ name: 'bk-baseline',
255
+ options: {
256
+ ...bk,
257
+ 'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED',
258
+ },
259
+ },
260
+ {
261
+ name: 'bk-aggressive',
262
+ options: {
263
+ ...bk,
264
+ 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
265
+ 'elk.layered.thoroughness': '50',
266
+ },
267
+ },
268
+ {
269
+ name: 'bk-wide',
270
+ options: {
271
+ ...bk,
272
+ 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
273
+ 'elk.layered.thoroughness': '50',
274
+ 'elk.spacing.nodeNode': '70',
275
+ 'elk.spacing.edgeNode': '75',
276
+ 'elk.spacing.edgeEdge': '22',
277
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '120',
278
+ },
279
+ },
280
+ {
281
+ name: 'longest-path',
282
+ options: {
283
+ ...bk,
284
+ 'elk.layered.layering.strategy': 'LONGEST_PATH',
285
+ 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
286
+ 'elk.layered.thoroughness': '50',
287
+ },
288
+ },
289
+ {
290
+ name: 'bounded-width',
291
+ options: {
292
+ ...bk,
293
+ 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM',
294
+ 'elk.layered.layering.coffmanGraham.layerBound': '3',
295
+ 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
296
+ 'elk.layered.thoroughness': '50',
297
+ },
298
+ },
299
+ ];
300
+ }
301
+
302
+ // ── Crossing / quality counters ────────────────────────────
303
+
304
+ /**
305
+ * Count visible edge crossings in a layout. Each pair of edge segments is
306
+ * checked for proper intersection (interior, not endpoint-touch).
307
+ * O((E × P)²) where P = avg points per edge. For E~30, P~5, ~22k pairs ≈ 1-3ms.
308
+ */
309
+ function countCrossings(edges: BLLayoutEdge[]): number {
310
+ let count = 0;
311
+ for (let i = 0; i < edges.length; i++) {
312
+ const a = edges[i].points;
313
+ if (a.length < 2) continue;
314
+ for (let j = i + 1; j < edges.length; j++) {
315
+ const b = edges[j].points;
316
+ if (b.length < 2) continue;
317
+ // Skip edges that share an endpoint — they meet at a node, not a crossing
318
+ if (edges[i].source === edges[j].source) continue;
319
+ if (edges[i].source === edges[j].target) continue;
320
+ if (edges[i].target === edges[j].source) continue;
321
+ if (edges[i].target === edges[j].target) continue;
322
+ for (let ai = 0; ai < a.length - 1; ai++) {
323
+ for (let bi = 0; bi < b.length - 1; bi++) {
324
+ if (segmentsCross(a[ai], a[ai + 1], b[bi], b[bi + 1])) count++;
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return count;
330
+ }
331
+
332
+ function segmentsCross(
333
+ p1: ElkPoint,
334
+ p2: ElkPoint,
335
+ p3: ElkPoint,
336
+ p4: ElkPoint
337
+ ): boolean {
338
+ const d1x = p2.x - p1.x;
339
+ const d1y = p2.y - p1.y;
340
+ const d2x = p4.x - p3.x;
341
+ const d2y = p4.y - p3.y;
342
+ const denom = d1x * d2y - d1y * d2x;
343
+ if (Math.abs(denom) < 1e-9) return false;
344
+ const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom;
345
+ const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denom;
346
+ const EPS = 0.001;
347
+ return t > EPS && t < 1 - EPS && s > EPS && s < 1 - EPS;
348
+ }
349
+
350
+ function countTotalBends(edges: BLLayoutEdge[]): number {
351
+ let bends = 0;
352
+ for (const e of edges) bends += Math.max(0, e.points.length - 2);
353
+ return bends;
354
+ }
355
+
356
+ interface LayoutScore {
357
+ crossings: number;
358
+ bends: number;
359
+ area: number;
360
+ }
361
+
362
+ /** Up to this many crossings count as equivalent — among near-zero results,
363
+ * compactness decides. Prevents the optimizer picking a sprawling 0-crossing
364
+ * layout over a compact 1-crossing one. */
365
+ const CROSSINGS_FORGIVENESS = 1;
366
+
367
+ function scoreLayout(layout: BLLayoutResult): LayoutScore {
368
+ return {
369
+ crossings: countCrossings(layout.edges),
370
+ bends: countTotalBends(layout.edges),
371
+ area: layout.width * layout.height,
372
+ };
373
+ }
374
+
375
+ function cmpScore(a: LayoutScore, b: LayoutScore): number {
376
+ const aBucket = a.crossings <= CROSSINGS_FORGIVENESS ? 0 : a.crossings;
377
+ const bBucket = b.crossings <= CROSSINGS_FORGIVENESS ? 0 : b.crossings;
378
+ if (aBucket !== bBucket) return aBucket - bBucket;
379
+ if (a.area !== b.area) return a.area - b.area;
380
+ return a.bends - b.bends;
381
+ }
382
+
211
383
  // ── Main layout ────────────────────────────────────────────
212
384
 
213
- export function layoutBoxesAndLines(
385
+ export async function layoutBoxesAndLines(
214
386
  parsed: ParsedBoxesAndLines,
215
387
  collapseInfo?: {
216
388
  collapsedChildCounts: Map<string, number>;
217
- originalGroups: import('./types').BLGroup[];
389
+ originalGroups: BLGroup[];
218
390
  },
219
391
  layoutOptions?: { hideDescriptions?: boolean }
220
- ): BLLayoutResult {
392
+ ): Promise<BLLayoutResult> {
221
393
  const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
222
- const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
223
- g.setGraph({
224
- rankdir: parsed.direction,
225
- nodesep: NODESEP,
226
- ranksep: RANKSEP,
227
- marginx: MARGIN,
228
- marginy: MARGIN,
229
- });
230
- g.setDefaultEdgeLabel(() => ({}));
231
-
232
- // Determine which groups are collapsed (but not hidden inside a collapsed parent)
394
+ const direction = parsed.direction === 'TB' ? 'DOWN' : 'RIGHT';
395
+
396
+ // Determine which groups are collapsed (shown as plain nodes)
233
397
  const collapsedGroupLabels = new Set<string>();
234
398
  if (collapseInfo) {
235
- // Build set of all groups that are missing from parsed (collapsed or hidden)
236
399
  const missingGroups = new Set<string>();
237
400
  for (const og of collapseInfo.originalGroups) {
238
401
  if (!parsed.groups.some((g) => g.label === og.label)) {
239
402
  missingGroups.add(og.label);
240
403
  }
241
404
  }
242
- // Only show a collapsed group as a node if its parent is NOT also missing
243
- // (i.e., it's a directly collapsed group, not one hidden inside a collapsed parent)
244
405
  for (const label of missingGroups) {
245
406
  const og = collapseInfo.originalGroups.find((g) => g.label === label);
246
407
  const parentLabel = og?.parentGroup;
@@ -250,44 +411,7 @@ export function layoutBoxesAndLines(
250
411
  }
251
412
  }
252
413
 
253
- // Add collapsed groups as regular nodes same golden-ratio dimensions
254
- for (const label of collapsedGroupLabels) {
255
- const gid = `__group_${label}`;
256
- g.setNode(gid, { label, width: NODE_WIDTH, height: NODE_HEIGHT });
257
- }
258
-
259
- // Add expanded group nodes as compound parents
260
- for (const group of parsed.groups) {
261
- const gid = `__group_${group.label}`;
262
- g.setNode(gid, {
263
- label: group.label,
264
- paddingLeft: CONTAINER_PAD_X,
265
- paddingRight: CONTAINER_PAD_X,
266
- paddingTop: CONTAINER_PAD_TOP,
267
- paddingBottom: CONTAINER_PAD_BOTTOM,
268
- });
269
- }
270
-
271
- // Re-establish parent relationships for collapsed groups
272
- // (must run AFTER expanded groups are added to the graph)
273
- const originalGroupByLabel = new Map<string, BLGroup>();
274
- if (collapseInfo) {
275
- for (const og of collapseInfo.originalGroups) {
276
- originalGroupByLabel.set(og.label, og);
277
- }
278
- }
279
- for (const label of collapsedGroupLabels) {
280
- const og = originalGroupByLabel.get(label);
281
- if (og?.parentGroup && !collapsedGroupLabels.has(og.parentGroup)) {
282
- const gid = `__group_${label}`;
283
- const parentGid = `__group_${og.parentGroup}`;
284
- if (g.hasNode(parentGid)) {
285
- g.setParent(gid, parentGid);
286
- }
287
- }
288
- }
289
-
290
- // Compute node sizes — described nodes share uniform height (unless hidden)
414
+ // Compute node sizes with uniform-height pass for described nodes
291
415
  const nodeSizes = new Map<string, { width: number; height: number }>();
292
416
  let maxDescHeight = 0;
293
417
  for (const node of parsed.nodes) {
@@ -299,7 +423,6 @@ export function layoutBoxesAndLines(
299
423
  maxDescHeight = Math.max(maxDescHeight, size.height);
300
424
  }
301
425
  }
302
- // Apply uniform height to all described nodes
303
426
  if (maxDescHeight > 0) {
304
427
  for (const node of parsed.nodes) {
305
428
  if (node.description && node.description.length > 0) {
@@ -309,369 +432,282 @@ export function layoutBoxesAndLines(
309
432
  }
310
433
  }
311
434
 
312
- // Add nodes
313
- for (const node of parsed.nodes) {
314
- const size = nodeSizes.get(node.label)!;
315
- g.setNode(node.label, {
316
- label: node.label,
317
- width: size.width,
318
- height: size.height,
319
- });
320
- }
435
+ // Build a fresh ELK graph each variant call — elk.layout() mutates the tree
436
+ // setting x/y/sections, so we can't reuse it across trials.
437
+ const expandedGroupSet = new Set(parsed.groups.map((g) => g.label));
438
+ const gid = (label: string) => `__group_${label}`;
321
439
 
322
- // Set parent relationships for nested groups
323
- for (const group of parsed.groups) {
324
- if (group.parentGroup) {
325
- const childGid = `__group_${group.label}`;
326
- const parentGid = `__group_${group.parentGroup}`;
327
- if (g.hasNode(childGid) && g.hasNode(parentGid)) {
328
- g.setParent(childGid, parentGid);
329
- }
330
- }
331
- }
440
+ function buildGraph(): { roots: ElkNode[]; rootEdges: ElkLayoutEdge[] } {
441
+ const nodeById = new Map<string, ElkNode>();
442
+ const parentOf = new Map<string, string>();
332
443
 
333
- // Build set of group labels for skip-check below
334
- const groupLabelSet = new Set(parsed.groups.map((gr) => gr.label));
335
-
336
- // Set parent relationships for nodes in groups
337
- for (const group of parsed.groups) {
338
- const gid = `__group_${group.label}`;
339
- for (const child of group.children) {
340
- // Skip children that are sub-groups — their parent is set above
341
- if (groupLabelSet.has(child)) continue;
342
- if (g.hasNode(child)) {
343
- g.setParent(child, gid);
344
- }
444
+ for (const node of parsed.nodes) {
445
+ const size = nodeSizes.get(node.label)!;
446
+ nodeById.set(node.label, {
447
+ id: node.label,
448
+ width: size.width,
449
+ height: size.height,
450
+ labels: [{ text: node.label }],
451
+ });
345
452
  }
346
- }
347
453
 
348
- // Build set of expanded compound parent IDs (dagre can't handle edges
349
- // directly on compound parents — they have no rank of their own)
350
- const expandedGroupIds = new Set<string>();
351
- for (const group of parsed.groups) {
352
- expandedGroupIds.add(`__group_${group.label}`);
353
- }
454
+ for (const group of parsed.groups) {
455
+ nodeById.set(gid(group.label), {
456
+ id: gid(group.label),
457
+ labels: [{ text: group.label }],
458
+ layoutOptions: {
459
+ 'elk.padding': `[top=${CONTAINER_PAD_TOP},left=${CONTAINER_PAD_X},bottom=${CONTAINER_PAD_BOTTOM},right=${CONTAINER_PAD_X}]`,
460
+ // Suggest square-ish containers — has limited effect with
461
+ // INCLUDE_CHILDREN but doesn't hurt.
462
+ 'elk.aspectRatio': '1.4',
463
+ },
464
+ children: [],
465
+ edges: [],
466
+ });
467
+ }
354
468
 
355
- // Map expanded group IDs to their first child node (for proxy edges)
356
- const groupFirstChild = new Map<string, string>();
357
- for (const group of parsed.groups) {
358
- const gid = `__group_${group.label}`;
359
- // Find first child that is a plain node (not a sub-group)
360
- const firstChild = group.children.find(
361
- (c) => !groupLabelSet.has(c) && g.hasNode(c)
362
- );
363
- if (firstChild) {
364
- groupFirstChild.set(gid, firstChild);
469
+ for (const label of collapsedGroupLabels) {
470
+ nodeById.set(gid(label), {
471
+ id: gid(label),
472
+ width: NODE_WIDTH,
473
+ height: NODE_HEIGHT,
474
+ labels: [{ text: label }],
475
+ });
365
476
  }
366
- }
367
477
 
368
- // Add edges skip edges where either endpoint is an expanded compound parent
369
- const deferredEdgeIndices: number[] = [];
370
- let proxyIdx = 0;
371
- for (let i = 0; i < parsed.edges.length; i++) {
372
- const edge = parsed.edges[i];
373
- const src = edge.source;
374
- const tgt = edge.target;
375
- if (!g.hasNode(src) || !g.hasNode(tgt)) continue;
376
- if (expandedGroupIds.has(src) || expandedGroupIds.has(tgt)) {
377
- deferredEdgeIndices.push(i);
378
- // Add invisible proxy edge between child nodes so dagre ranks the groups
379
- const proxySrc = expandedGroupIds.has(src)
380
- ? groupFirstChild.get(src)
381
- : src;
382
- const proxyTgt = expandedGroupIds.has(tgt)
383
- ? groupFirstChild.get(tgt)
384
- : tgt;
385
- if (proxySrc && proxyTgt && proxySrc !== proxyTgt) {
386
- g.setEdge(
387
- proxySrc,
388
- proxyTgt,
389
- { label: '', minlen: 1 },
390
- `proxy${proxyIdx++}`
391
- );
478
+ for (const group of parsed.groups) {
479
+ if (group.parentGroup && nodeById.has(gid(group.parentGroup))) {
480
+ parentOf.set(gid(group.label), gid(group.parentGroup));
392
481
  }
393
- continue;
394
482
  }
395
- g.setEdge(src, tgt, { label: edge.label ?? '', minlen: 1 }, `e${i}`);
396
- }
397
-
398
- // Run dagre layout
399
- dagre.layout(g);
400
-
401
- // Extract node positions
402
- const layoutNodes: BLLayoutNode[] = [];
403
- for (const node of parsed.nodes) {
404
- const dagreNode = g.node(node.label);
405
- if (!dagreNode) continue;
406
- layoutNodes.push({
407
- label: node.label,
408
- x: dagreNode.x,
409
- y: dagreNode.y,
410
- width: dagreNode.width,
411
- height: dagreNode.height,
412
- });
413
- }
414
-
415
- // Extract group positions (expanded)
416
- const layoutGroups: BLLayoutGroup[] = [];
417
- for (const group of parsed.groups) {
418
- const gid = `__group_${group.label}`;
419
- const dagreNode = g.node(gid);
420
- if (!dagreNode) continue;
421
- layoutGroups.push({
422
- label: group.label,
423
- lineNumber: group.lineNumber,
424
- x: dagreNode.x,
425
- y: dagreNode.y,
426
- width: dagreNode.width,
427
- height: dagreNode.height,
428
- collapsed: false,
429
- });
430
- }
431
-
432
- // Extract collapsed group positions
433
- for (const label of collapsedGroupLabels) {
434
- const gid = `__group_${label}`;
435
- const dagreNode = g.node(gid);
436
- if (!dagreNode) continue;
437
- const og = collapseInfo?.originalGroups.find((g) => g.label === label);
438
- layoutGroups.push({
439
- label,
440
- lineNumber: og?.lineNumber ?? 0,
441
- x: dagreNode.x,
442
- y: dagreNode.y,
443
- width: dagreNode.width,
444
- height: dagreNode.height,
445
- collapsed: true,
446
- childCount: collapseInfo?.collapsedChildCounts.get(label) ?? 0,
447
- });
448
- }
449
-
450
- // Center-align groups connected by group-to-group edges.
451
- // Dagre can't rank expanded compound parents directly, and collapsed groups
452
- // may also end up misaligned. Post-process to share a common center axis.
453
- // Track per-group shifts so regular edge points can be adjusted too.
454
- const groupAlignShifts = new Map<string, number>(); // gid → shift in alignment axis
455
- {
456
- // Find all group-to-group edges (both deferred and regular)
457
- const groupEdges: { source: string; target: string }[] = [];
458
- for (const edge of parsed.edges) {
459
- if (
460
- edge.source.startsWith('__group_') &&
461
- edge.target.startsWith('__group_')
462
- ) {
463
- groupEdges.push(edge);
483
+ if (collapseInfo) {
484
+ for (const label of collapsedGroupLabels) {
485
+ const og = collapseInfo.originalGroups.find((g) => g.label === label);
486
+ if (
487
+ og?.parentGroup &&
488
+ !collapsedGroupLabels.has(og.parentGroup) &&
489
+ nodeById.has(gid(og.parentGroup))
490
+ ) {
491
+ parentOf.set(gid(label), gid(og.parentGroup));
492
+ }
464
493
  }
465
494
  }
466
-
467
- if (groupEdges.length > 0) {
468
- // Build connected components via union-find
469
- const groupParent = new Map<string, string>();
470
- const find = (x: string): string => {
471
- while (groupParent.has(x) && groupParent.get(x) !== x) {
472
- groupParent.set(x, groupParent.get(groupParent.get(x)!)!);
473
- x = groupParent.get(x)!;
495
+ for (const group of parsed.groups) {
496
+ for (const child of group.children) {
497
+ if (expandedGroupSet.has(child)) continue;
498
+ if (nodeById.has(child)) {
499
+ parentOf.set(child, gid(group.label));
474
500
  }
475
- return x;
476
- };
477
- const union = (a: string, b: string) => {
478
- const ra = find(a),
479
- rb = find(b);
480
- if (ra !== rb) groupParent.set(ra, rb);
481
- };
482
-
483
- for (const edge of groupEdges) {
484
- if (!groupParent.has(edge.source))
485
- groupParent.set(edge.source, edge.source);
486
- if (!groupParent.has(edge.target))
487
- groupParent.set(edge.target, edge.target);
488
- union(edge.source, edge.target);
489
501
  }
502
+ }
490
503
 
491
- // Group layout groups by connected component
492
- const components = new Map<string, BLLayoutGroup[]>();
493
- for (const lg of layoutGroups) {
494
- const gid = `__group_${lg.label}`;
495
- if (!groupParent.has(gid)) continue;
496
- const root = find(gid);
497
- if (!components.has(root)) components.set(root, []);
498
- components.get(root)!.push(lg);
504
+ const roots: ElkNode[] = [];
505
+ for (const [id, node] of nodeById) {
506
+ const parentId = parentOf.get(id);
507
+ if (parentId) {
508
+ const parent = nodeById.get(parentId)!;
509
+ parent.children = parent.children ?? [];
510
+ parent.children.push(node);
511
+ } else {
512
+ roots.push(node);
499
513
  }
514
+ }
500
515
 
501
- // For each component, align on the widest group's center
502
- const axis = parsed.direction === 'TB' ? 'x' : 'y';
503
- for (const groups of components.values()) {
504
- if (groups.length < 2) continue;
505
- const dim = axis === 'x' ? 'width' : 'height';
506
- let widest = groups[0];
507
- for (const g of groups) {
508
- if (g[dim] > widest[dim]) widest = g;
509
- }
510
- const targetCenter = widest[axis];
511
-
512
- for (const grp of groups) {
513
- const dx = targetCenter - grp[axis];
514
- if (dx === 0) continue;
515
- grp[axis] += dx;
516
- groupAlignShifts.set(`__group_${grp.label}`, dx);
517
- // Shift child nodes in this group (expanded groups only)
518
- const parsedGroup = parsed.groups.find(
519
- (pg) => pg.label === grp.label
520
- );
521
- if (parsedGroup) {
522
- for (const childLabel of parsedGroup.children) {
523
- const childNode = layoutNodes.find((n) => n.label === childLabel);
524
- if (childNode) childNode[axis] += dx;
525
- }
526
- }
527
- }
528
- }
516
+ const rootEdges: ElkLayoutEdge[] = [];
517
+ for (let i = 0; i < parsed.edges.length; i++) {
518
+ const edge = parsed.edges[i];
519
+ if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue;
520
+ rootEdges.push({
521
+ id: `e${i}`,
522
+ sources: [edge.source],
523
+ targets: [edge.target],
524
+ });
529
525
  }
530
- }
531
526
 
532
- // Compute parallel edge offsets
533
- const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
534
- const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
535
- const parallelGroups = new Map<string, number[]>();
536
-
537
- for (let i = 0; i < parsed.edges.length; i++) {
538
- const edge = parsed.edges[i];
539
- // Normalize key so A→B and B→A are in the same parallel group
540
- const [a, b] =
541
- edge.source < edge.target
542
- ? [edge.source, edge.target]
543
- : [edge.target, edge.source];
544
- const key = `${a}\x00${b}`;
545
- if (!parallelGroups.has(key)) parallelGroups.set(key, []);
546
- parallelGroups.get(key)!.push(i);
527
+ return { roots, rootEdges };
547
528
  }
548
529
 
549
- for (const group of parallelGroups.values()) {
550
- const capped = group.slice(0, MAX_PARALLEL_EDGES);
551
- for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
552
- edgeParallelCounts[idx] = 0;
553
- }
554
- if (capped.length < 2) continue;
555
- const effectiveSpacing = PARALLEL_SPACING;
556
- for (let j = 0; j < capped.length; j++) {
557
- edgeYOffsets[capped[j]] =
558
- (j - (capped.length - 1) / 2) * effectiveSpacing;
559
- edgeParallelCounts[capped[j]] = capped.length;
560
- }
530
+ async function runVariant(variant: Variant): Promise<BLLayoutResult> {
531
+ const { roots, rootEdges } = buildGraph();
532
+ const elkRoot: ElkNode = {
533
+ id: 'root',
534
+ layoutOptions: {
535
+ ...variant.options,
536
+ 'elk.direction': direction,
537
+ 'elk.padding': `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
538
+ },
539
+ children: roots,
540
+ edges: rootEdges,
541
+ };
542
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
543
+ const result = (await getElk().layout(elkRoot as any)) as ElkNode;
544
+ return extractLayout(result);
561
545
  }
562
546
 
563
- // Extract edge points
564
- const deferredSet = new Set(deferredEdgeIndices);
565
- const layoutEdges: BLLayoutEdge[] = [];
566
- for (let i = 0; i < parsed.edges.length; i++) {
567
- const edge = parsed.edges[i];
568
- if (edgeParallelCounts[i] === 0) continue;
569
-
570
- let points: { x: number; y: number }[];
571
-
572
- if (deferredSet.has(i)) {
573
- // Deferred edge (compound parent endpoint) — use post-alignment layout
574
- // positions and emit from center of the relevant border face
575
- const srcLayout = layoutGroups.find(
576
- (lg) => `__group_${lg.label}` === edge.source
577
- );
578
- const tgtLayout = layoutGroups.find(
579
- (lg) => `__group_${lg.label}` === edge.target
580
- );
581
- if (!srcLayout || !tgtLayout) {
582
- // Fallback to dagre node positions for collapsed groups / mixed endpoints
583
- const srcNode = g.node(edge.source);
584
- const tgtNode = g.node(edge.target);
585
- if (!srcNode || !tgtNode) continue;
586
- const srcPt = clipToRectBorder(
587
- srcNode.x,
588
- srcNode.y,
589
- srcNode.width,
590
- srcNode.height,
591
- tgtNode.x,
592
- tgtNode.y
593
- );
594
- const tgtPt = clipToRectBorder(
595
- tgtNode.x,
596
- tgtNode.y,
597
- tgtNode.width,
598
- tgtNode.height,
599
- srcNode.x,
600
- srcNode.y
601
- );
602
- const midX = (srcPt.x + tgtPt.x) / 2;
603
- const midY = (srcPt.y + tgtPt.y) / 2;
604
- points = [srcPt, { x: midX, y: midY }, tgtPt];
605
- } else if (parsed.direction === 'TB') {
606
- // TB: straight vertical line from bottom-center to top-center
607
- const cx = (srcLayout.x + tgtLayout.x) / 2;
608
- const srcPt = { x: cx, y: srcLayout.y + srcLayout.height / 2 };
609
- const tgtPt = { x: cx, y: tgtLayout.y - tgtLayout.height / 2 };
610
- const midY = (srcPt.y + tgtPt.y) / 2;
611
- points = [srcPt, { x: cx, y: midY }, tgtPt];
547
+ function extractLayout(result: ElkNode): BLLayoutResult {
548
+ const layoutNodes: BLLayoutNode[] = [];
549
+ const layoutGroups: BLLayoutGroup[] = [];
550
+ const allEdges: ElkLayoutEdge[] = [];
551
+ const containerAbs = new Map<string, { x: number; y: number }>();
552
+
553
+ function walk(
554
+ n: ElkNode,
555
+ offsetX: number,
556
+ offsetY: number,
557
+ isRoot: boolean
558
+ ): void {
559
+ const nx = (n.x ?? 0) + offsetX;
560
+ const ny = (n.y ?? 0) + offsetY;
561
+ const nw = n.width ?? 0;
562
+ const nh = n.height ?? 0;
563
+
564
+ if (isRoot) {
565
+ containerAbs.set('root', { x: nx, y: ny });
612
566
  } else {
613
- // LR: straight horizontal line from right-center to left-center
614
- const cy = (srcLayout.y + tgtLayout.y) / 2;
615
- const srcPt = { x: srcLayout.x + srcLayout.width / 2, y: cy };
616
- const tgtPt = { x: tgtLayout.x - tgtLayout.width / 2, y: cy };
617
- const midX = (srcPt.x + tgtPt.x) / 2;
618
- points = [srcPt, { x: midX, y: cy }, tgtPt];
567
+ const isGroup = n.id.startsWith('__group_');
568
+ if (isGroup) {
569
+ const label = n.id.slice('__group_'.length);
570
+ const collapsed = collapsedGroupLabels.has(label);
571
+ const og = collapseInfo?.originalGroups.find(
572
+ (g) => g.label === label
573
+ );
574
+ const pg = parsed.groups.find((g) => g.label === label);
575
+ layoutGroups.push({
576
+ label,
577
+ lineNumber: pg?.lineNumber ?? og?.lineNumber ?? 0,
578
+ x: nx + nw / 2,
579
+ y: ny + nh / 2,
580
+ width: nw,
581
+ height: nh,
582
+ collapsed,
583
+ childCount: collapsed
584
+ ? (collapseInfo?.collapsedChildCounts.get(label) ?? 0)
585
+ : undefined,
586
+ });
587
+ if (!collapsed) containerAbs.set(n.id, { x: nx, y: ny });
588
+ } else {
589
+ layoutNodes.push({
590
+ label: n.id,
591
+ x: nx + nw / 2,
592
+ y: ny + nh / 2,
593
+ width: nw,
594
+ height: nh,
595
+ });
596
+ }
619
597
  }
620
- } else {
621
- const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
622
- points = dagreEdge?.points ?? [];
623
- // If endpoints were shifted by center-alignment, adjust edge points
624
- const srcShift = groupAlignShifts.get(edge.source) ?? 0;
625
- const tgtShift = groupAlignShifts.get(edge.target) ?? 0;
626
- if (srcShift !== 0 || tgtShift !== 0) {
627
- const avgShift = (srcShift + tgtShift) / 2;
628
- const prop = parsed.direction === 'TB' ? 'x' : 'y';
629
- points = points.map((p) => ({ ...p, [prop]: p[prop] + avgShift }));
598
+
599
+ if (n.edges) for (const e of n.edges) allEdges.push(e);
600
+ if (n.children) for (const c of n.children) walk(c, nx, ny, false);
601
+ }
602
+ walk(result, 0, 0, true);
603
+
604
+ // Parallel edge offsets
605
+ const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
606
+ const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
607
+ const parallelGroups = new Map<string, number[]>();
608
+ for (let i = 0; i < parsed.edges.length; i++) {
609
+ const edge = parsed.edges[i];
610
+ const [a, b] =
611
+ edge.source < edge.target
612
+ ? [edge.source, edge.target]
613
+ : [edge.target, edge.source];
614
+ const key = `${a}\x00${b}`;
615
+ if (!parallelGroups.has(key)) parallelGroups.set(key, []);
616
+ parallelGroups.get(key)!.push(i);
617
+ }
618
+ for (const group of parallelGroups.values()) {
619
+ const capped = group.slice(0, MAX_PARALLEL_EDGES);
620
+ for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
621
+ edgeParallelCounts[idx] = 0;
622
+ }
623
+ if (capped.length < 2) continue;
624
+ for (let j = 0; j < capped.length; j++) {
625
+ edgeYOffsets[capped[j]] =
626
+ (j - (capped.length - 1) / 2) * PARALLEL_SPACING;
627
+ edgeParallelCounts[capped[j]] = capped.length;
628
+ }
629
+ }
630
+
631
+ const edgeById = new Map<string, ElkLayoutEdge>();
632
+ for (const e of allEdges) edgeById.set(e.id, e);
633
+
634
+ const layoutEdges: BLLayoutEdge[] = [];
635
+ for (let i = 0; i < parsed.edges.length; i++) {
636
+ const edge = parsed.edges[i];
637
+ if (edgeParallelCounts[i] === 0) continue;
638
+ const elkEdge = edgeById.get(`e${i}`);
639
+ if (!elkEdge || !elkEdge.sections || elkEdge.sections.length === 0)
640
+ continue;
641
+ const container = elkEdge.container ?? 'root';
642
+ const off = containerAbs.get(container) ?? { x: 0, y: 0 };
643
+ const s = elkEdge.sections[0];
644
+ const points = [
645
+ { x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
646
+ ...(s.bendPoints ?? []).map((p) => ({
647
+ x: p.x + off.x,
648
+ y: p.y + off.y,
649
+ })),
650
+ { x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
651
+ ];
652
+ let labelX: number | undefined;
653
+ let labelY: number | undefined;
654
+ if (edge.label && points.length >= 2) {
655
+ const mid = Math.floor(points.length / 2);
656
+ labelX = points[mid].x;
657
+ labelY = points[mid].y - 10;
630
658
  }
659
+ layoutEdges.push({
660
+ source: edge.source,
661
+ target: edge.target,
662
+ label: edge.label,
663
+ bidirectional: edge.bidirectional,
664
+ lineNumber: edge.lineNumber,
665
+ points,
666
+ labelX,
667
+ labelY,
668
+ yOffset: edgeYOffsets[i],
669
+ parallelCount: edgeParallelCounts[i],
670
+ metadata: edge.metadata,
671
+ deferred: true,
672
+ });
631
673
  }
632
674
 
633
- // Compute label position at midpoint
634
- let labelX: number | undefined;
635
- let labelY: number | undefined;
636
- if (edge.label && points.length >= 2) {
637
- const mid = Math.floor(points.length / 2);
638
- labelX = points[mid].x;
639
- labelY = points[mid].y - 10;
675
+ let maxX = 0;
676
+ let maxY = 0;
677
+ for (const node of layoutNodes) {
678
+ maxX = Math.max(maxX, node.x + node.width / 2);
679
+ maxY = Math.max(maxY, node.y + node.height / 2);
680
+ }
681
+ for (const group of layoutGroups) {
682
+ maxX = Math.max(maxX, group.x + group.width / 2);
683
+ maxY = Math.max(maxY, group.y + group.height / 2);
640
684
  }
641
685
 
642
- layoutEdges.push({
643
- source: edge.source,
644
- target: edge.target,
645
- label: edge.label,
646
- bidirectional: edge.bidirectional,
647
- lineNumber: edge.lineNumber,
648
- points,
649
- labelX,
650
- labelY,
651
- yOffset: edgeYOffsets[i],
652
- parallelCount: edgeParallelCounts[i],
653
- metadata: edge.metadata,
654
- deferred: deferredSet.has(i) || undefined,
655
- });
686
+ return {
687
+ nodes: layoutNodes,
688
+ edges: layoutEdges,
689
+ groups: layoutGroups,
690
+ width: maxX + MARGIN,
691
+ height: maxY + MARGIN,
692
+ };
656
693
  }
657
694
 
658
- // Compute total dimensions
659
- let maxX = 0;
660
- let maxY = 0;
661
- for (const node of layoutNodes) {
662
- maxX = Math.max(maxX, node.x + node.width / 2);
663
- maxY = Math.max(maxY, node.y + node.height / 2);
664
- }
665
- for (const group of layoutGroups) {
666
- maxX = Math.max(maxX, group.x + group.width / 2);
667
- maxY = Math.max(maxY, group.y + group.height / 2);
695
+ // Trivial graphs skip multi-trial — one variant is plenty.
696
+ const N = parsed.nodes.length + parsed.groups.length;
697
+ const E = parsed.edges.length;
698
+ const trivial = N < 8 && E < 10;
699
+ const variants = trivial ? [getVariants()[1]] : getVariants();
700
+
701
+ const results = await Promise.all(variants.map((v) => runVariant(v)));
702
+
703
+ let best = results[0];
704
+ let bestScore = scoreLayout(best);
705
+ for (let i = 1; i < results.length; i++) {
706
+ const s = scoreLayout(results[i]);
707
+ if (cmpScore(s, bestScore) < 0) {
708
+ best = results[i];
709
+ bestScore = s;
710
+ }
668
711
  }
669
-
670
- return {
671
- nodes: layoutNodes,
672
- edges: layoutEdges,
673
- groups: layoutGroups,
674
- width: maxX + MARGIN,
675
- height: maxY + MARGIN,
676
- };
712
+ return best;
677
713
  }