@diagrammo/dgmo 0.26.0 → 0.28.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 (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +101 -25
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1212 -96
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
@@ -0,0 +1,1200 @@
1
+ // ============================================================
2
+ // Boxes and Lines — experimental "search" layout (behind a flag)
3
+ // ============================================================
4
+ //
5
+ // dagre placement + spline routing, with a multi-seed search over node
6
+ // orderings, scored on the actual spline geometry (curveBasis, sampled
7
+ // headlessly — no DOM) plus an optional stability term (drift from the
8
+ // previous layout). Picks the lowest combined-score ordering.
9
+ //
10
+ // Rationale: dagre placement reads better than orthogonal routing; crossings
11
+ // come from within-layer ordering, so we search orderings rather than reroute.
12
+ // Sync (dagre) — no ELK, no async.
13
+
14
+ import dagre from '@dagrejs/dagre';
15
+ import { line as d3line, curveBasis } from 'd3-shape';
16
+ import type { ParsedBoxesAndLines, BLGroup } from './types';
17
+ import {
18
+ computeNodeSize,
19
+ NODE_WIDTH,
20
+ NODE_HEIGHT,
21
+ type BLLayoutResult,
22
+ type BLLayoutEdge,
23
+ } from './layout';
24
+ import { layeredCandidates } from './layout-layered';
25
+
26
+ type Pt = { x: number; y: number };
27
+
28
+ // Default stability weight: combined = crossings + lambda · (meanDriftPx / 100).
29
+ // Only applies when previousPositions is supplied (re-layout on edit/collapse).
30
+ const DEFAULT_LAMBDA = 4;
31
+
32
+ // Adaptive escalation thresholds (see layoutBoxesAndLinesSearch). Only graphs
33
+ // whose base-budget badness is at least ESCALATE_THRESHOLD spend an extra seed
34
+ // batch; ESCALATE_MAX_N bounds it so huge graphs don't blow the time budget.
35
+ const ESCALATE_THRESHOLD = 4;
36
+ const ESCALATE_MAX_N = 45;
37
+ const ESCALATE_SEEDS = 18;
38
+ const ESCALATE_REFINE = 10;
39
+
40
+ function rng(s: number) {
41
+ return () => {
42
+ s |= 0;
43
+ s = (s + 0x6d2b79f5) | 0;
44
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
45
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
46
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
47
+ };
48
+ }
49
+ function shuffle<T>(a: readonly T[], r: () => number): T[] {
50
+ const x = a.slice();
51
+ for (let i = x.length - 1; i > 0; i--) {
52
+ const j = Math.floor(r() * (i + 1));
53
+ [x[i], x[j]] = [x[j]!, x[i]!];
54
+ }
55
+ return x;
56
+ }
57
+
58
+ const splineGen = d3line<Pt>()
59
+ .x((d) => d.x)
60
+ .y((d) => d.y)
61
+ .curve(curveBasis);
62
+
63
+ // flatten an SVG path "d" (M/L/Q/C) into a polyline for crossing detection
64
+ function flatten(d: string): Pt[] {
65
+ const toks = d.match(/[MLQC]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
66
+ const pts: Pt[] = [];
67
+ let i = 0,
68
+ cx = 0,
69
+ cy = 0,
70
+ cmd = '';
71
+ const num = () => parseFloat(toks[i++]!);
72
+ const samp = (p0: Pt, c1: Pt, c2: Pt | null, p1: Pt) => {
73
+ for (let t = 0; t <= 1; t += 0.12) {
74
+ const u = 1 - t;
75
+ if (c2)
76
+ pts.push({
77
+ x:
78
+ u * u * u * p0.x +
79
+ 3 * u * u * t * c1.x +
80
+ 3 * u * t * t * c2.x +
81
+ t * t * t * p1.x,
82
+ y:
83
+ u * u * u * p0.y +
84
+ 3 * u * u * t * c1.y +
85
+ 3 * u * t * t * c2.y +
86
+ t * t * t * p1.y,
87
+ });
88
+ else
89
+ pts.push({
90
+ x: u * u * p0.x + 2 * u * t * c1.x + t * t * p1.x,
91
+ y: u * u * p0.y + 2 * u * t * c1.y + t * t * p1.y,
92
+ });
93
+ }
94
+ };
95
+ while (i < toks.length) {
96
+ const tk = toks[i]!;
97
+ if (/[MLQC]/i.test(tk)) {
98
+ cmd = tk;
99
+ i++;
100
+ }
101
+ if (cmd === 'M' || cmd === 'L') {
102
+ const x = num(),
103
+ y = num();
104
+ pts.push({ x, y });
105
+ cx = x;
106
+ cy = y;
107
+ } else if (cmd === 'Q') {
108
+ const c1 = { x: num(), y: num() },
109
+ p1 = { x: num(), y: num() };
110
+ samp({ x: cx, y: cy }, c1, null, p1);
111
+ cx = p1.x;
112
+ cy = p1.y;
113
+ } else if (cmd === 'C') {
114
+ const c1 = { x: num(), y: num() },
115
+ c2 = { x: num(), y: num() },
116
+ p1 = { x: num(), y: num() };
117
+ samp({ x: cx, y: cy }, c1, c2, p1);
118
+ cx = p1.x;
119
+ cy = p1.y;
120
+ } else i++;
121
+ }
122
+ return pts;
123
+ }
124
+ function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
125
+ const den = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
126
+ if (Math.abs(den) < 1e-9) return null;
127
+ const t =
128
+ ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / den,
129
+ u = ((p3.x - p1.x) * (p2.y - p1.y) - (p3.y - p1.y) * (p2.x - p1.x)) / den;
130
+ return t > 0 && t < 1 && u > 0 && u < 1
131
+ ? { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }
132
+ : null;
133
+ }
134
+
135
+ // trustworthy crossing count on the spline geometry: exclude intersections
136
+ // near a genuinely shared endpoint node; cluster near-duplicate hits.
137
+ // Exported so the playground + benchmark score with the SAME counter the
138
+ // engine optimizes against.
139
+ export function countSplineCrossings(layout: BLLayoutResult): number {
140
+ const center = new Map<string, Pt>();
141
+ for (const n of layout.nodes) center.set(n.label, { x: n.x, y: n.y });
142
+ // collapsed group boxes are edge endpoints too (`__group_<label>`); without
143
+ // them, edges meeting AT a collapsed box are miscounted as crossings.
144
+ for (const g of layout.groups)
145
+ if (g.collapsed) center.set('__group_' + g.label, { x: g.x, y: g.y });
146
+ const polys = layout.edges.map((e) => {
147
+ const pts =
148
+ e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
149
+ let x0 = Infinity,
150
+ y0 = Infinity,
151
+ x1 = -Infinity,
152
+ y1 = -Infinity;
153
+ for (const p of pts) {
154
+ if (p.x < x0) x0 = p.x;
155
+ if (p.x > x1) x1 = p.x;
156
+ if (p.y < y0) y0 = p.y;
157
+ if (p.y > y1) y1 = p.y;
158
+ }
159
+ return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
160
+ });
161
+ const R = 34;
162
+ let total = 0;
163
+ for (let a = 0; a < polys.length; a++)
164
+ for (let b = a + 1; b < polys.length; b++) {
165
+ const A = polys[a]!,
166
+ B = polys[b]!;
167
+ if (A.pts.length < 2 || B.pts.length < 2) continue;
168
+ if (A.x1 < B.x0 || B.x1 < A.x0 || A.y1 < B.y0 || B.y1 < A.y0) continue; // bbox disjoint
169
+ const shared = [A.s, A.t]
170
+ .filter((n) => n === B.s || n === B.t)
171
+ .map((n) => center.get(n))
172
+ .filter(Boolean) as Pt[];
173
+ const hits: Pt[] = [];
174
+ for (let i = 1; i < A.pts.length; i++)
175
+ for (let j = 1; j < B.pts.length; j++) {
176
+ const p = segPoint(
177
+ A.pts[i - 1]!,
178
+ A.pts[i]!,
179
+ B.pts[j - 1]!,
180
+ B.pts[j]!
181
+ );
182
+ if (!p) continue;
183
+ if (shared.some((c) => Math.hypot(p.x - c.x, p.y - c.y) < R))
184
+ continue;
185
+ if (!hits.some((h) => Math.hypot(h.x - p.x, h.y - p.y) < 6))
186
+ hits.push(p);
187
+ }
188
+ total += hits.length;
189
+ }
190
+ return total;
191
+ }
192
+
193
+ // distance from point p to segment a–b
194
+ function pointSegDist(p: Pt, a: Pt, b: Pt): number {
195
+ const dx = b.x - a.x,
196
+ dy = b.y - a.y;
197
+ const len2 = dx * dx + dy * dy;
198
+ if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y);
199
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
200
+ t = Math.max(0, Math.min(1, t));
201
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
202
+ }
203
+ function distToPoly(p: Pt, poly: readonly Pt[]): number {
204
+ let m = Infinity;
205
+ for (let i = 1; i < poly.length; i++)
206
+ m = Math.min(m, pointSegDist(p, poly[i - 1]!, poly[i]!));
207
+ return m;
208
+ }
209
+ type Rect = { x: number; y: number; w: number; h: number };
210
+ // distance from point p to an axis-aligned rectangle (0 if inside)
211
+ function pointRectDist(p: Pt, r: Rect): number {
212
+ const dx = Math.max(r.x - r.w / 2 - p.x, 0, p.x - (r.x + r.w / 2));
213
+ const dy = Math.max(r.y - r.h / 2 - p.y, 0, p.y - (r.y + r.h / 2));
214
+ return Math.hypot(dx, dy);
215
+ }
216
+
217
+ /** A stretch where one edge runs ALONG another (within `dist`, for at least
218
+ * `minLen` of length) — i.e. two lines "stepping on" each other. Distinct from
219
+ * a true X-crossing (which is a momentary touch, not a sustained run). */
220
+ export interface OverlapRun {
221
+ mid: Pt;
222
+ length: number;
223
+ pts: Pt[];
224
+ }
225
+
226
+ /**
227
+ * Detect edge-overlap runs on the rendered spline geometry. Two edges sharing
228
+ * an endpoint legitimately CONVERGE at that node's port — runs within `nodeClear`
229
+ * of a shared node centre are excluded; only overlap along the open path counts.
230
+ */
231
+ export function detectEdgeOverlaps(
232
+ layout: BLLayoutResult,
233
+ opts?: { dist?: number; minLen?: number; nodeClear?: number }
234
+ ): OverlapRun[] {
235
+ const dist = opts?.dist ?? 8;
236
+ const minLen = opts?.minLen ?? 16;
237
+ // Margin BEYOND the shared node's box that still counts as "converging to the
238
+ // port" (excluded). Edges legitimately meet at a node — only overlap out in
239
+ // the open, away from any shared node, is a real "stepping on another line".
240
+ const nodeClear = opts?.nodeClear ?? 12;
241
+
242
+ const rect = new Map<string, Rect>();
243
+ for (const n of layout.nodes)
244
+ rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
245
+ for (const g of layout.groups)
246
+ if (g.collapsed)
247
+ rect.set('__group_' + g.label, {
248
+ x: g.x,
249
+ y: g.y,
250
+ w: g.width,
251
+ h: g.height,
252
+ });
253
+
254
+ const polys = layout.edges.map((e) => {
255
+ const pts =
256
+ e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
257
+ let x0 = Infinity,
258
+ y0 = Infinity,
259
+ x1 = -Infinity,
260
+ y1 = -Infinity;
261
+ for (const p of pts) {
262
+ if (p.x < x0) x0 = p.x;
263
+ if (p.x > x1) x1 = p.x;
264
+ if (p.y < y0) y0 = p.y;
265
+ if (p.y > y1) y1 = p.y;
266
+ }
267
+ return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
268
+ });
269
+
270
+ const runs: OverlapRun[] = [];
271
+ for (let a = 0; a < polys.length; a++)
272
+ for (let b = a + 1; b < polys.length; b++) {
273
+ const A = polys[a]!,
274
+ B = polys[b]!;
275
+ if (A.pts.length < 2 || B.pts.length < 2) continue;
276
+ if (
277
+ A.x1 + dist < B.x0 ||
278
+ B.x1 + dist < A.x0 ||
279
+ A.y1 + dist < B.y0 ||
280
+ B.y1 + dist < A.y0
281
+ )
282
+ continue;
283
+ const shared = [A.s, A.t]
284
+ .filter((n) => n === B.s || n === B.t)
285
+ .map((n) => rect.get(n))
286
+ .filter(Boolean) as Rect[];
287
+ // Walk A; accumulate contiguous "covered" runs (close to B, off any shared
288
+ // node). A run counts once if it reaches minLen.
289
+ let run: Pt[] = [];
290
+ let runLen = 0;
291
+ const flush = (): void => {
292
+ if (runLen >= minLen && run.length >= 2)
293
+ runs.push({
294
+ mid: run[Math.floor(run.length / 2)]!,
295
+ length: runLen,
296
+ pts: run.slice(),
297
+ });
298
+ run = [];
299
+ runLen = 0;
300
+ };
301
+ for (const p of A.pts) {
302
+ const nearShared = shared.some((r) => pointRectDist(p, r) < nodeClear);
303
+ const covered = !nearShared && distToPoly(p, B.pts) < dist;
304
+ if (covered) {
305
+ if (run.length)
306
+ runLen += Math.hypot(
307
+ p.x - run[run.length - 1]!.x,
308
+ p.y - run[run.length - 1]!.y
309
+ );
310
+ run.push(p);
311
+ } else flush();
312
+ }
313
+ flush();
314
+ }
315
+ return runs;
316
+ }
317
+
318
+ /** Count of edge-overlap runs — the "stepping on another line" metric. */
319
+ export function countEdgeOverlaps(
320
+ layout: BLLayoutResult,
321
+ opts?: { dist?: number; minLen?: number; nodeClear?: number }
322
+ ): number {
323
+ return detectEdgeOverlaps(layout, opts).length;
324
+ }
325
+
326
+ /** An edge routing THROUGH a node box it doesn't connect to. Counts as a
327
+ * crossing — the line is where it shouldn't be. */
328
+ export interface NodePierce {
329
+ edgeIdx: number;
330
+ node: string;
331
+ pts: Pt[];
332
+ }
333
+
334
+ /**
335
+ * Detect edges that pass through (substantially inside, by `inset`) the box of a
336
+ * node that is NOT one of their endpoints. Endpoints — including collapsed group
337
+ * boxes (`__group_<label>`) — are excluded; an edge legitimately meets those.
338
+ */
339
+ export function detectEdgeNodePierces(
340
+ layout: BLLayoutResult,
341
+ opts?: { inset?: number; minPts?: number }
342
+ ): NodePierce[] {
343
+ const inset = opts?.inset ?? 6;
344
+ const minPts = opts?.minPts ?? 2;
345
+ const rects: (Rect & { key: string })[] = [];
346
+ for (const n of layout.nodes)
347
+ rects.push({ key: n.label, x: n.x, y: n.y, w: n.width, h: n.height });
348
+ for (const g of layout.groups)
349
+ if (g.collapsed)
350
+ rects.push({
351
+ key: '__group_' + g.label,
352
+ x: g.x,
353
+ y: g.y,
354
+ w: g.width,
355
+ h: g.height,
356
+ });
357
+ const inside = (p: Pt, r: Rect): boolean =>
358
+ Math.abs(p.x - r.x) < r.w / 2 - inset &&
359
+ Math.abs(p.y - r.y) < r.h / 2 - inset;
360
+ const out: NodePierce[] = [];
361
+ layout.edges.forEach((e, idx) => {
362
+ if (e.points.length < 2) return;
363
+ const poly = flatten(splineGen(e.points as Pt[]) ?? '');
364
+ for (const r of rects) {
365
+ if (
366
+ r.key === e.source ||
367
+ r.key === e.target ||
368
+ '__group_' + r.key === e.source ||
369
+ '__group_' + r.key === e.target
370
+ )
371
+ continue;
372
+ const hits = poly.filter((p) => inside(p, r));
373
+ if (hits.length >= minPts)
374
+ out.push({ edgeIdx: idx, node: r.key, pts: hits });
375
+ }
376
+ });
377
+ return out;
378
+ }
379
+
380
+ /** Count of edges routing through unrelated node boxes — the "line going through
381
+ * a node" metric. */
382
+ export function countEdgeNodePierces(
383
+ layout: BLLayoutResult,
384
+ opts?: { inset?: number; minPts?: number }
385
+ ): number {
386
+ return detectEdgeNodePierces(layout, opts).length;
387
+ }
388
+
389
+ /**
390
+ * Re-routed copy of a layout that bends edges AROUND any node box they pierce:
391
+ * for each pierced (edge, node) it inserts a waypoint pushing the edge out past
392
+ * the node, perpendicular to the edge, on the side it's already leaning. The
393
+ * curveBasis spline then bows around the node instead of through it. Returned as
394
+ * an ALTERNATIVE candidate — the caller keeps it only if total badness drops, so
395
+ * a detour that trades a pierce for a crossing is simply rejected.
396
+ */
397
+ export function deroutePierces(layout: BLLayoutResult): BLLayoutResult {
398
+ const pierces = detectEdgeNodePierces(layout);
399
+ if (!pierces.length) return layout;
400
+ const rect = new Map<string, Rect>();
401
+ for (const n of layout.nodes)
402
+ rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
403
+ for (const g of layout.groups)
404
+ if (g.collapsed)
405
+ rect.set('__group_' + g.label, {
406
+ x: g.x,
407
+ y: g.y,
408
+ w: g.width,
409
+ h: g.height,
410
+ });
411
+ const byEdge = new Map<number, string[]>();
412
+ for (const p of pierces) {
413
+ const arr = byEdge.get(p.edgeIdx);
414
+ if (arr) arr.push(p.node);
415
+ else byEdge.set(p.edgeIdx, [p.node]);
416
+ }
417
+ const edges = layout.edges.map((e, idx) => {
418
+ const nodes = byEdge.get(idx);
419
+ if (!nodes) return e;
420
+ let pts: Pt[] = e.points.map((p) => ({ x: p.x, y: p.y }));
421
+ for (const label of nodes) {
422
+ const r = rect.get(label);
423
+ if (r) pts = detourAround(pts, r);
424
+ }
425
+ return { ...e, points: pts };
426
+ });
427
+ return { ...layout, edges };
428
+ }
429
+
430
+ // Bend a waypoint polyline around a rectangle it passes through: find the
431
+ // segment whose closest point to the rect centre is nearest, and insert a
432
+ // waypoint there pushed out past the rect corner (+margin) on the leaning side.
433
+ function detourAround(pts: Pt[], r: Rect): Pt[] {
434
+ if (pts.length < 2) return pts;
435
+ const c = { x: r.x, y: r.y };
436
+ let bestSeg = -1;
437
+ let bestT = 0;
438
+ let bestD = Infinity;
439
+ let bestPt: Pt = pts[0]!;
440
+ for (let i = 0; i < pts.length - 1; i++) {
441
+ const a = pts[i]!,
442
+ b = pts[i + 1]!;
443
+ const dx = b.x - a.x,
444
+ dy = b.y - a.y;
445
+ const len2 = dx * dx + dy * dy || 1e-9;
446
+ let t = ((c.x - a.x) * dx + (c.y - a.y) * dy) / len2;
447
+ t = Math.max(0, Math.min(1, t));
448
+ const q = { x: a.x + t * dx, y: a.y + t * dy };
449
+ const d = Math.hypot(q.x - c.x, q.y - c.y);
450
+ if (d < bestD) {
451
+ bestD = d;
452
+ bestSeg = i;
453
+ bestT = t;
454
+ bestPt = q;
455
+ }
456
+ }
457
+ if (bestSeg < 0) return pts;
458
+ // push direction: away from rect centre, on the side the edge already leans;
459
+ // if the closest point is dead-centre, pick the box's shorter axis to exit.
460
+ let nx = bestPt.x - c.x;
461
+ let ny = bestPt.y - c.y;
462
+ if (Math.hypot(nx, ny) < 1) {
463
+ if (r.w <= r.h) {
464
+ nx = 1;
465
+ ny = 0;
466
+ } else {
467
+ nx = 0;
468
+ ny = 1;
469
+ }
470
+ }
471
+ const nlen = Math.hypot(nx, ny) || 1;
472
+ nx /= nlen;
473
+ ny /= nlen;
474
+ // Local edge direction (along the closest segment), for shaping a SMOOTH hump
475
+ // around the node instead of a single spike: ease out → peak → ease back in.
476
+ const a = pts[bestSeg]!,
477
+ b = pts[bestSeg + 1]!;
478
+ let ex = b.x - a.x,
479
+ ey = b.y - a.y;
480
+ const elen = Math.hypot(ex, ey) || 1;
481
+ ex /= elen;
482
+ ey /= elen;
483
+ const clear = Math.hypot(r.w, r.h) / 2 + 18;
484
+ const hw = Math.hypot(r.w, r.h) / 2; // hump half-width along the edge
485
+ void bestT;
486
+ const before = {
487
+ x: bestPt.x - ex * hw + nx * clear * 0.5,
488
+ y: bestPt.y - ey * hw + ny * clear * 0.5,
489
+ };
490
+ const peak = { x: c.x + nx * clear, y: c.y + ny * clear };
491
+ const after = {
492
+ x: bestPt.x + ex * hw + nx * clear * 0.5,
493
+ y: bestPt.y + ey * hw + ny * clear * 0.5,
494
+ };
495
+ const out = pts.slice(0, bestSeg + 1);
496
+ out.push(before, peak, after);
497
+ out.push(...pts.slice(bestSeg + 1));
498
+ return out;
499
+ }
500
+ /** Count of edges that come CLOSE to another edge without overlapping — lines
501
+ * that "almost touch". Runs within `near` px (but not already overlapping within
502
+ * `tight` px). Same shared-node exclusion as overlaps. */
503
+ export function countEdgeNearMiss(
504
+ layout: BLLayoutResult,
505
+ opts?: { near?: number; tight?: number; minLen?: number }
506
+ ): number {
507
+ const near = opts?.near ?? 14;
508
+ const tight = opts?.tight ?? 8;
509
+ const minLen = opts?.minLen ?? 18;
510
+ const close = detectEdgeOverlaps(layout, { dist: near, minLen }).length;
511
+ const over = detectEdgeOverlaps(layout, { dist: tight, minLen }).length;
512
+ return Math.max(0, close - over);
513
+ }
514
+
515
+ // Renderer extends a parent group's box UPWARD by this label zone (mirrors
516
+ // GROUP_LABEL_ZONE in renderer.ts) — so the collision metric must use the
517
+ // RENDERED box, or near-touching parents read as fine when they actually overlap.
518
+ const GROUP_LABEL_ZONE = 32;
519
+
520
+ /** Labels of GROUP boxes that touch/overlap a non-nested sibling group. Proper
521
+ * nesting (one box fully inside another) is excluded; only sibling/unrelated
522
+ * collisions count. Uses the RENDERED box (parent groups grow up by the label
523
+ * zone, which is what actually makes near-touching parents collide). */
524
+ export function detectGroupOverlaps(
525
+ layout: BLLayoutResult,
526
+ opts?: { margin?: number }
527
+ ): string[] {
528
+ const margin = opts?.margin ?? 4;
529
+ const raw = layout.groups.map((g) => ({
530
+ label: g.label,
531
+ l: g.x - g.width / 2,
532
+ r: g.x + g.width / 2,
533
+ t: g.y - g.height / 2,
534
+ b: g.y + g.height / 2,
535
+ }));
536
+ const contains = (
537
+ outer: (typeof raw)[number],
538
+ inner: (typeof raw)[number]
539
+ ): boolean =>
540
+ outer.l <= inner.l + margin &&
541
+ outer.r >= inner.r - margin &&
542
+ outer.t <= inner.t + margin &&
543
+ outer.b >= inner.b - margin;
544
+ const rend = raw.map((a, i) => {
545
+ const parent = raw.some((b, j) => j !== i && contains(a, b));
546
+ return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
547
+ });
548
+ const hit = new Set<string>();
549
+ for (let i = 0; i < raw.length; i++)
550
+ for (let j = i + 1; j < raw.length; j++) {
551
+ if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue; // nesting
552
+ const a = rend[i]!,
553
+ b = rend[j]!;
554
+ const dx = Math.max(a.l - b.r, b.l - a.r);
555
+ const dy = Math.max(a.t - b.b, b.t - a.b);
556
+ if (Math.max(dx, dy) < margin) {
557
+ hit.add(raw[i]!.label);
558
+ hit.add(raw[j]!.label);
559
+ }
560
+ }
561
+ return [...hit];
562
+ }
563
+
564
+ /** Count of group-box collisions (pairs touching/overlapping). */
565
+ export function countGroupOverlaps(
566
+ layout: BLLayoutResult,
567
+ opts?: { margin?: number }
568
+ ): number {
569
+ const margin = opts?.margin ?? 4;
570
+ const raw = layout.groups.map((g) => ({
571
+ l: g.x - g.width / 2,
572
+ r: g.x + g.width / 2,
573
+ t: g.y - g.height / 2,
574
+ b: g.y + g.height / 2,
575
+ }));
576
+ const contains = (
577
+ outer: (typeof raw)[number],
578
+ inner: (typeof raw)[number]
579
+ ): boolean =>
580
+ outer.l <= inner.l + margin &&
581
+ outer.r >= inner.r - margin &&
582
+ outer.t <= inner.t + margin &&
583
+ outer.b >= inner.b - margin;
584
+ const rend = raw.map((a, i) => {
585
+ const parent = raw.some((b, j) => j !== i && contains(a, b));
586
+ return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
587
+ });
588
+ let count = 0;
589
+ for (let i = 0; i < raw.length; i++)
590
+ for (let j = i + 1; j < raw.length; j++) {
591
+ if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue;
592
+ const a = rend[i]!,
593
+ b = rend[j]!;
594
+ const dx = Math.max(a.l - b.r, b.l - a.r);
595
+ const dy = Math.max(a.t - b.b, b.t - a.b);
596
+ if (Math.max(dx, dy) < margin) count++;
597
+ }
598
+ return count;
599
+ }
600
+ /**
601
+ * Separated copy of a layout that pushes overlapping TOP-LEVEL group bands apart
602
+ * along the cross-axis. dagre's compound layout sometimes wedges a small group
603
+ * into a too-tight channel between two others (the multi-cloud On-Prem box lands
604
+ * in AWS's label zone). Translating each top-level group rigidly along the
605
+ * cross-axis preserves every node's rank/column, so the only thing that changes
606
+ * is the gap between bands. Member nodes + nested sub-group boxes move by their
607
+ * band's delta; edge waypoints blend smoothly between their endpoints' deltas
608
+ * (intra-band edges keep their shape exactly; cross-band edges ease across).
609
+ *
610
+ * Returned as an ALTERNATIVE candidate — the caller keeps it only if total
611
+ * badness drops, so a separation that introduces a crossing/pierce is rejected.
612
+ * Fully-expanded graphs only (skips when any group is collapsed).
613
+ */
614
+ export function separateGroupBands(
615
+ layout: BLLayoutResult,
616
+ parsed: ParsedBoxesAndLines
617
+ ): BLLayoutResult {
618
+ if (layout.groups.some((g) => g.collapsed)) return layout;
619
+ if (countGroupOverlaps(layout) === 0) return layout;
620
+
621
+ // Resolve each group label to its top-level ancestor (walk parentGroup).
622
+ const parentOf = new Map<string, string | undefined>();
623
+ for (const g of parsed.groups) parentOf.set(g.label, g.parentGroup);
624
+ const topOf = (label: string): string => {
625
+ let cur = label;
626
+ const seen = new Set<string>();
627
+ for (;;) {
628
+ const p = parentOf.get(cur);
629
+ if (!p || seen.has(p)) return cur;
630
+ seen.add(cur);
631
+ cur = p;
632
+ }
633
+ };
634
+ const topLabels = parsed.groups
635
+ .filter((g) => !g.parentGroup)
636
+ .map((g) => g.label);
637
+ if (topLabels.length < 2) return layout;
638
+
639
+ // Each node's top-level group (a node sits in exactly one leaf group).
640
+ const nodeTop = new Map<string, string>();
641
+ for (const g of parsed.groups)
642
+ for (const child of g.children)
643
+ if (!parsed.groups.some((gg) => gg.label === child))
644
+ nodeTop.set(child, topOf(g.label));
645
+
646
+ // Cross-axis: LR stacks bands vertically (Y); TB stacks them horizontally (X).
647
+ // The label zone always extends a parent's box upward (Y), so it only lands on
648
+ // the cross-axis for LR — for TB this pass can still separate plain X overlaps.
649
+ const axis: 'x' | 'y' = parsed.direction === 'TB' ? 'x' : 'y';
650
+ const boxByLabel = new Map(layout.groups.map((g) => [g.label, g]));
651
+
652
+ // Rendered cross-interval per top-level group (label zone folded in on Y).
653
+ type Band = { label: string; lo: number; hi: number; c: number };
654
+ const bands: Band[] = topLabels.map((label) => {
655
+ const g = boxByLabel.get(label)!;
656
+ const half = (axis === 'y' ? g.height : g.width) / 2;
657
+ let lo = (axis === 'y' ? g.y : g.x) - half;
658
+ const hi = (axis === 'y' ? g.y : g.x) + half;
659
+ // A top-level group with children is a parent → its rendered box grows up.
660
+ const isParent = parsed.groups.some((c) => c.parentGroup === label);
661
+ if (axis === 'y' && isParent) lo -= GROUP_LABEL_ZONE;
662
+ return { label, lo, hi, c: (lo + hi) / 2 };
663
+ });
664
+ bands.sort((a, b) => a.c - b.c);
665
+
666
+ // 1D block-merge (PAV) separation: keep each band's centre as close to where
667
+ // it is as possible, subject to a minimum gap between consecutive bands.
668
+ const GAP = 16;
669
+ const half = bands.map((b) => (b.hi - b.lo) / 2);
670
+ const off: number[] = [0];
671
+ for (let i = 1; i < bands.length; i++)
672
+ off[i] = off[i - 1]! + half[i - 1]! + GAP + half[i]!;
673
+ const desired = bands.map((b, i) => b.c - off[i]!);
674
+ type Blk = { pos: number; count: number; sum: number; first: number };
675
+ const blocks: Blk[] = [];
676
+ for (let i = 0; i < bands.length; i++) {
677
+ let blk: Blk = { pos: desired[i]!, count: 1, sum: desired[i]!, first: i };
678
+ while (blocks.length && blocks[blocks.length - 1]!.pos >= blk.pos) {
679
+ const prev = blocks.pop()!;
680
+ const count = prev.count + blk.count;
681
+ const sum = prev.sum + blk.sum;
682
+ blk = { pos: sum / count, count, sum, first: prev.first };
683
+ }
684
+ blocks.push(blk);
685
+ }
686
+ const newC = new Array<number>(bands.length);
687
+ for (const blk of blocks)
688
+ for (let k = 0; k < blk.count; k++)
689
+ newC[blk.first + k] = blk.pos + off[blk.first + k]!;
690
+
691
+ // Delta per top-level group; bail if nothing actually moves.
692
+ const delta = new Map<string, number>();
693
+ let moved = false;
694
+ bands.forEach((b, i) => {
695
+ const d = newC[i]! - b.c;
696
+ delta.set(b.label, d);
697
+ if (Math.abs(d) > 0.5) moved = true;
698
+ });
699
+ if (!moved) return layout;
700
+
701
+ const nodeDelta = (label: string): number => {
702
+ const top = nodeTop.get(label);
703
+ return top ? (delta.get(top) ?? 0) : 0;
704
+ };
705
+ const shift = (p: Pt, d: number): Pt =>
706
+ axis === 'y' ? { x: p.x, y: p.y + d } : { x: p.x + d, y: p.y };
707
+
708
+ const nodes = layout.nodes.map((n) => {
709
+ const d = nodeDelta(n.label);
710
+ return d
711
+ ? { ...n, ...(axis === 'y' ? { y: n.y + d } : { x: n.x + d }) }
712
+ : n;
713
+ });
714
+ const groups = layout.groups.map((g) => {
715
+ const d = delta.get(topOf(g.label)) ?? 0;
716
+ return d
717
+ ? { ...g, ...(axis === 'y' ? { y: g.y + d } : { x: g.x + d }) }
718
+ : g;
719
+ });
720
+ const edges = layout.edges.map((e) => {
721
+ const ds = nodeDelta(e.source);
722
+ const dt = nodeDelta(e.target);
723
+ if (!ds && !dt) return e;
724
+ const N = e.points.length;
725
+ const points = e.points.map((p, i) => {
726
+ const f = N > 1 ? i / (N - 1) : 0;
727
+ return shift(p, ds * (1 - f) + dt * f);
728
+ });
729
+ return { ...e, points };
730
+ });
731
+
732
+ // Recompute canvas bbox (+ margin) over the shifted content.
733
+ let minX = Infinity,
734
+ minY = Infinity,
735
+ maxX = -Infinity,
736
+ maxY = -Infinity;
737
+ const acc = (x: number, y: number) => {
738
+ if (x < minX) minX = x;
739
+ if (x > maxX) maxX = x;
740
+ if (y < minY) minY = y;
741
+ if (y > maxY) maxY = y;
742
+ };
743
+ for (const n of nodes) {
744
+ acc(n.x - n.width / 2, n.y - n.height / 2);
745
+ acc(n.x + n.width / 2, n.y + n.height / 2);
746
+ }
747
+ for (const g of groups) {
748
+ acc(g.x - g.width / 2, g.y - g.height / 2 - GROUP_LABEL_ZONE);
749
+ acc(g.x + g.width / 2, g.y + g.height / 2);
750
+ }
751
+ for (const e of edges) for (const p of e.points) acc(p.x, p.y);
752
+ const M = 40;
753
+ const sx = M - minX,
754
+ sy = M - minY;
755
+ const reshift = sx !== 0 || sy !== 0;
756
+ return {
757
+ nodes: reshift
758
+ ? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
759
+ : nodes,
760
+ groups: reshift
761
+ ? groups.map((g) => ({ ...g, x: g.x + sx, y: g.y + sy }))
762
+ : groups,
763
+ edges: reshift
764
+ ? edges.map((e) => ({
765
+ ...e,
766
+ points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
767
+ }))
768
+ : edges,
769
+ width: maxX - minX + 2 * M,
770
+ height: maxY - minY + 2 * M,
771
+ };
772
+ }
773
+
774
+ // Fast crossing estimate for RANKING candidates: straight segments on raw
775
+ // waypoints (no curveBasis flatten) + bbox pruning + early-out per pair.
776
+ // ~10× cheaper than countSplineCrossings; topology-equivalent for ranking.
777
+ function segCross(p1: Pt, p2: Pt, p3: Pt, p4: Pt): boolean {
778
+ const d1x = p2.x - p1.x,
779
+ d1y = p2.y - p1.y,
780
+ d2x = p4.x - p3.x,
781
+ d2y = p4.y - p3.y;
782
+ const den = d1x * d2y - d1y * d2x;
783
+ if (Math.abs(den) < 1e-9) return false;
784
+ const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den;
785
+ const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den;
786
+ return t > 0.001 && t < 0.999 && s > 0.001 && s < 0.999;
787
+ }
788
+ function countCrossingsFast(layout: BLLayoutResult): number {
789
+ const E = layout.edges.filter((e) => e.points.length >= 2);
790
+ const bb = E.map((e) => {
791
+ let x0 = Infinity,
792
+ y0 = Infinity,
793
+ x1 = -Infinity,
794
+ y1 = -Infinity;
795
+ for (const p of e.points) {
796
+ if (p.x < x0) x0 = p.x;
797
+ if (p.x > x1) x1 = p.x;
798
+ if (p.y < y0) y0 = p.y;
799
+ if (p.y > y1) y1 = p.y;
800
+ }
801
+ return { x0, y0, x1, y1 };
802
+ });
803
+ let count = 0;
804
+ for (let i = 0; i < E.length; i++)
805
+ for (let j = i + 1; j < E.length; j++) {
806
+ const A = E[i]!,
807
+ B = E[j]!;
808
+ if (
809
+ A.source === B.source ||
810
+ A.source === B.target ||
811
+ A.target === B.source ||
812
+ A.target === B.target
813
+ )
814
+ continue;
815
+ const a = bb[i]!,
816
+ b = bb[j]!;
817
+ if (a.x1 < b.x0 || b.x1 < a.x0 || a.y1 < b.y0 || b.y1 < a.y0) continue;
818
+ const pa = A.points,
819
+ pb = B.points;
820
+ let hit = false;
821
+ for (let ai = 0; ai < pa.length - 1 && !hit; ai++)
822
+ for (let bi = 0; bi < pb.length - 1; bi++) {
823
+ if (segCross(pa[ai]!, pa[ai + 1]!, pb[bi]!, pb[bi + 1]!)) {
824
+ hit = true;
825
+ break;
826
+ }
827
+ }
828
+ if (hit) count++;
829
+ }
830
+ return count;
831
+ }
832
+ function meanDrift(
833
+ layout: BLLayoutResult,
834
+ prev: ReadonlyMap<string, Pt> | undefined
835
+ ): number {
836
+ if (!prev?.size) return 0;
837
+ let sum = 0,
838
+ n = 0;
839
+ for (const node of layout.nodes) {
840
+ const p = prev.get(node.label);
841
+ if (p) {
842
+ sum += Math.hypot(node.x - p.x, node.y - p.y);
843
+ n++;
844
+ }
845
+ }
846
+ return n ? sum / n : 0;
847
+ }
848
+ // total edge length — positioning tiebreaker (shorter/straighter reads better)
849
+ function edgeLength(layout: BLLayoutResult): number {
850
+ let total = 0;
851
+ for (const e of layout.edges)
852
+ for (let i = 1; i < e.points.length; i++)
853
+ total += Math.hypot(
854
+ e.points[i]!.x - e.points[i - 1]!.x,
855
+ e.points[i]!.y - e.points[i - 1]!.y
856
+ );
857
+ return total;
858
+ }
859
+
860
+ export function layoutBoxesAndLinesSearch(
861
+ parsed: ParsedBoxesAndLines,
862
+ collapseInfo?: {
863
+ collapsedChildCounts: Map<string, number>;
864
+ originalGroups: readonly BLGroup[];
865
+ },
866
+ opts?: {
867
+ hideDescriptions?: boolean;
868
+ previousPositions?: ReadonlyMap<string, Pt>;
869
+ /** Number of seed orderings to search (default: adaptive by node count). */
870
+ seeds?: number;
871
+ /** Stability weight (default 4). */
872
+ lambda?: number;
873
+ /** How many top candidates to re-rank with the exact counter (default 6). */
874
+ refineK?: number;
875
+ }
876
+ ): BLLayoutResult {
877
+ const hideDescriptions = opts?.hideDescriptions ?? false;
878
+
879
+ // collapsed group labels (shown as plain boxes) — mirrors the ELK path
880
+ const collapsedGroupLabels = new Set<string>();
881
+ if (collapseInfo) {
882
+ const missing = new Set<string>();
883
+ for (const og of collapseInfo.originalGroups)
884
+ if (!parsed.groups.some((g) => g.label === og.label))
885
+ missing.add(og.label);
886
+ for (const label of missing) {
887
+ const og = collapseInfo.originalGroups.find((g) => g.label === label);
888
+ const parent = og?.parentGroup;
889
+ if (!parent || !missing.has(parent)) collapsedGroupLabels.add(label);
890
+ }
891
+ }
892
+
893
+ // node sizes (computeNodeSize + uniform-height pass) — identical to ELK path
894
+ const sizes = new Map<string, { width: number; height: number }>();
895
+ let maxDescH = 0;
896
+ for (const node of parsed.nodes) {
897
+ const s = hideDescriptions
898
+ ? { width: NODE_WIDTH, height: NODE_HEIGHT }
899
+ : computeNodeSize(node, parsed.showValues === true);
900
+ sizes.set(node.label, s);
901
+ if (!hideDescriptions && node.description && node.description.length > 0)
902
+ maxDescH = Math.max(maxDescH, s.height);
903
+ }
904
+ if (maxDescH > 0)
905
+ for (const node of parsed.nodes)
906
+ if (node.description && node.description.length > 0) {
907
+ const s = sizes.get(node.label)!;
908
+ sizes.set(node.label, { width: s.width, height: maxDescH });
909
+ }
910
+
911
+ const gid = (label: string) => `__group_${label}`;
912
+ const rankdir = parsed.direction === 'TB' ? 'TB' : 'LR';
913
+
914
+ function place(cfg: {
915
+ ranker: string;
916
+ nodesep: number;
917
+ ranksep: number;
918
+ seed?: number;
919
+ }): BLLayoutResult {
920
+ const r = cfg.seed === undefined ? null : rng(cfg.seed + 1);
921
+ const ord = <T>(a: readonly T[]): T[] => (r ? shuffle(a, r) : a.slice());
922
+ const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
923
+ g.setGraph({
924
+ rankdir,
925
+ ranker: cfg.ranker,
926
+ nodesep: cfg.nodesep,
927
+ ranksep: cfg.ranksep,
928
+ edgesep: 20,
929
+ marginx: 40,
930
+ marginy: 40,
931
+ });
932
+ g.setDefaultEdgeLabel(() => ({}));
933
+ for (const grp of ord(parsed.groups))
934
+ g.setNode(gid(grp.label), { label: grp.label });
935
+ for (const node of ord(parsed.nodes)) {
936
+ const s = sizes.get(node.label)!;
937
+ g.setNode(node.label, { width: s.width, height: s.height });
938
+ }
939
+ for (const label of collapsedGroupLabels)
940
+ g.setNode(gid(label), { width: NODE_WIDTH, height: NODE_HEIGHT });
941
+ for (const grp of parsed.groups) {
942
+ if (grp.parentGroup && g.hasNode(gid(grp.parentGroup)))
943
+ g.setParent(gid(grp.label), gid(grp.parentGroup));
944
+ for (const c of ord(grp.children)) {
945
+ if (g.hasNode(c)) g.setParent(c, gid(grp.label));
946
+ }
947
+ }
948
+ if (collapseInfo)
949
+ for (const label of collapsedGroupLabels) {
950
+ const og = collapseInfo.originalGroups.find((x) => x.label === label);
951
+ if (
952
+ og?.parentGroup &&
953
+ !collapsedGroupLabels.has(og.parentGroup) &&
954
+ g.hasNode(gid(og.parentGroup))
955
+ )
956
+ g.setParent(gid(label), gid(og.parentGroup));
957
+ }
958
+ for (const e of ord(parsed.edges))
959
+ if (g.hasNode(e.source) && g.hasNode(e.target))
960
+ g.setEdge(e.source, e.target, {});
961
+ dagre.layout(g);
962
+
963
+ const nodes = parsed.nodes.map((n) => {
964
+ const p = g.node(n.label);
965
+ return {
966
+ label: n.label,
967
+ x: p.x,
968
+ y: p.y,
969
+ width: p.width,
970
+ height: p.height,
971
+ };
972
+ });
973
+ const groups: BLLayoutResult['groups'][number][] = parsed.groups.map(
974
+ (grp) => {
975
+ const p = g.node(gid(grp.label));
976
+ return {
977
+ label: grp.label,
978
+ lineNumber: grp.lineNumber,
979
+ x: p.x,
980
+ y: p.y,
981
+ width: p.width,
982
+ height: p.height,
983
+ collapsed: false,
984
+ childCount: grp.children.length,
985
+ };
986
+ }
987
+ );
988
+ for (const label of collapsedGroupLabels) {
989
+ const p = g.node(gid(label));
990
+ groups.push({
991
+ label,
992
+ lineNumber: 0,
993
+ x: p.x,
994
+ y: p.y,
995
+ width: p.width,
996
+ height: p.height,
997
+ collapsed: true,
998
+ childCount: collapseInfo?.collapsedChildCounts.get(label) ?? 0,
999
+ });
1000
+ }
1001
+ const edges: BLLayoutEdge[] = parsed.edges
1002
+ .filter((e) => g.hasEdge(e.source, e.target))
1003
+ .map((e) => {
1004
+ const ed = g.edge(e.source, e.target) as { points?: Pt[] };
1005
+ return {
1006
+ source: e.source,
1007
+ target: e.target,
1008
+ ...(e.label !== undefined && { label: e.label }),
1009
+ bidirectional: e.bidirectional,
1010
+ lineNumber: e.lineNumber,
1011
+ points: ed?.points ?? [],
1012
+ yOffset: 0,
1013
+ parallelCount: 1,
1014
+ metadata: e.metadata,
1015
+ };
1016
+ });
1017
+ const gg = g.graph() as { width?: number; height?: number };
1018
+ return {
1019
+ nodes,
1020
+ edges,
1021
+ groups,
1022
+ width: gg.width ?? 800,
1023
+ height: gg.height ?? 600,
1024
+ } as BLLayoutResult;
1025
+ }
1026
+
1027
+ const n = parsed.nodes.length;
1028
+ // ~500ms budget: search a larger pool, then refine the top few exactly.
1029
+ const seedCount =
1030
+ opts?.seeds ?? (n <= 12 ? 80 : n <= 22 ? 40 : n <= 35 ? 22 : 10);
1031
+ const REFINE_K = opts?.refineK ?? 6;
1032
+ const lambda = opts?.lambda ?? DEFAULT_LAMBDA;
1033
+ const prev = opts?.previousPositions;
1034
+
1035
+ // Candidate configs: every (ranker × spacing) combo + seed-shuffles of the
1036
+ // default. Diverse candidates lower the crossing floor; seed-shuffles vary
1037
+ // dagre's within-layer ordering.
1038
+ const RANKERS = ['network-simplex', 'tight-tree', 'longest-path'];
1039
+ const SPACINGS = [
1040
+ { nodesep: 50, ranksep: 60 },
1041
+ { nodesep: 34, ranksep: 46 },
1042
+ { nodesep: 66, ranksep: 82 },
1043
+ ];
1044
+ const configs: {
1045
+ ranker: string;
1046
+ nodesep: number;
1047
+ ranksep: number;
1048
+ seed?: number;
1049
+ }[] = [];
1050
+ for (const ranker of RANKERS)
1051
+ for (const sp of SPACINGS) configs.push({ ranker, ...sp });
1052
+ for (let s = 0; s < seedCount; s++)
1053
+ configs.push({
1054
+ ranker: 'network-simplex',
1055
+ nodesep: 50,
1056
+ ranksep: 60,
1057
+ seed: s,
1058
+ });
1059
+
1060
+ // Honest "badness" — every kind of line-in-the-wrong-place counts equally:
1061
+ // X true crossings + O overlap runs (lines stepping on each other)
1062
+ // + P edges piercing unrelated node boxes.
1063
+ // (A line through a node and two lines sharing a path are crossings too.)
1064
+ // `floor` lets callers skip the expensive O/P passes once X alone already
1065
+ // exceeds the best badness found so far (it can't win, return Infinity).
1066
+ const badness = (lay: BLLayoutResult, floor: number): number => {
1067
+ const x = countSplineCrossings(lay);
1068
+ if (x > floor) return Infinity;
1069
+ return (
1070
+ x +
1071
+ countEdgeOverlaps(lay) +
1072
+ countEdgeNodePierces(lay) +
1073
+ countGroupOverlaps(lay)
1074
+ );
1075
+ };
1076
+
1077
+ // Objective: badness dominates (×1e6, strictly fewer wins); ties broken by
1078
+ // total edge length (positioning) + stability drift (only when prev given).
1079
+ const objective = (lay: BLLayoutResult, viol: number) =>
1080
+ viol * 1e6 + edgeLength(lay) + lambda * meanDrift(lay, prev) * 10;
1081
+
1082
+ // Build the candidate pool.
1083
+ const pool: BLLayoutResult[] = [];
1084
+ for (const cfg of configs) {
1085
+ try {
1086
+ pool.push(place(cfg));
1087
+ } catch {
1088
+ /* some rankers choke on odd graphs */
1089
+ }
1090
+ }
1091
+ if (!pool.length)
1092
+ return place({ ranker: 'network-simplex', nodesep: 50, ranksep: 60 });
1093
+
1094
+ // Home-grown layered candidates (flat graphs only). These own the
1095
+ // crossing-minimization stage AND route back-edges around the periphery, so
1096
+ // they can reach layouts below dagre's ordering+routing floor (e.g. the
1097
+ // pirate-fleet K2,2). Their peripheral back-edges are curved loops that the
1098
+ // cheap straight-segment ranker mis-scores, so they bypass stage-1 and are
1099
+ // ALWAYS exact-scored in stage 2. Best-effort: never block the dagre pool.
1100
+ let layered: BLLayoutResult[] = [];
1101
+ try {
1102
+ layered = layeredCandidates(parsed, sizes);
1103
+ } catch {
1104
+ /* ignore */
1105
+ }
1106
+
1107
+ // Stage 1: rank the dagre pool with the cheap straight-segment counter — a
1108
+ // cheap proxy to pick which candidates are worth the expensive exact scoring.
1109
+ // Widen REFINE_K a little since the proxy only sees crossings, not O/P.
1110
+ pool.sort(
1111
+ (a, b) =>
1112
+ objective(a, countCrossingsFast(a)) - objective(b, countCrossingsFast(b))
1113
+ );
1114
+ const refineK = Math.min(REFINE_K, pool.length);
1115
+
1116
+ // Stage 2: exact-score the top-K dagre candidates on the FULL badness (X+O+P)
1117
+ // and pick the best — so the placement search avoids overlaps and node-pierces,
1118
+ // not just crossings.
1119
+ let best = pool[0]!;
1120
+ let bestObj = Infinity;
1121
+ let bestBad = Infinity;
1122
+ const consider = (lay: BLLayoutResult): void => {
1123
+ const bad = badness(lay, bestBad);
1124
+ if (bad === Infinity) return;
1125
+ const sc = objective(lay, bad);
1126
+ if (sc < bestObj) {
1127
+ bestObj = sc;
1128
+ bestBad = bad;
1129
+ best = lay;
1130
+ }
1131
+ };
1132
+ for (const lay of pool.slice(0, refineK)) consider(lay);
1133
+
1134
+ // Adaptive escalation: a still-high badness after the base seed budget means
1135
+ // the graph is genuinely hard — dense layouts (e.g. the marketplace) need many
1136
+ // more random restarts to stumble onto a low-crossing layer ordering, and the
1137
+ // good ordering is rare enough that refining the existing pool can't find it
1138
+ // (only generating fresh seeds can). Spend an extra seed batch — but ONLY when
1139
+ // the result is actually bad, so the easy 0-badness majority of the corpus
1140
+ // never pays the latency. Bounded by node count to keep the worst case ~1s.
1141
+ if (bestBad >= ESCALATE_THRESHOLD && n <= ESCALATE_MAX_N) {
1142
+ const extra: BLLayoutResult[] = [];
1143
+ for (let s = seedCount; s < seedCount + ESCALATE_SEEDS; s++) {
1144
+ try {
1145
+ extra.push(
1146
+ place({
1147
+ ranker: 'network-simplex',
1148
+ nodesep: 50,
1149
+ ranksep: 60,
1150
+ seed: s,
1151
+ })
1152
+ );
1153
+ } catch {
1154
+ /* ignore choking rankers */
1155
+ }
1156
+ }
1157
+ extra.sort(
1158
+ (a, b) =>
1159
+ objective(a, countCrossingsFast(a)) -
1160
+ objective(b, countCrossingsFast(b))
1161
+ );
1162
+ for (const lay of extra.slice(0, ESCALATE_REFINE)) consider(lay);
1163
+ }
1164
+
1165
+ // Layered candidates (and their better-routed, de-pierced variants) replace
1166
+ // the dagre winner ONLY on a STRICT total-badness reduction — never on an
1167
+ // edge-length tiebreak. They're few, so de-piercing each is cheap.
1168
+ for (const lay of layered) {
1169
+ const variants = [lay];
1170
+ const dp = deroutePierces(lay);
1171
+ if (dp !== lay) variants.push(dp);
1172
+ for (const v of variants) {
1173
+ const bad = badness(v, bestBad - 1);
1174
+ if (bad < bestBad) {
1175
+ bestBad = bad;
1176
+ best = v;
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // Feed the objective one more BETTER-ROUTED alternative: bend the current
1182
+ // winner's edges around any node they still pierce. Kept only if it strictly
1183
+ // lowers total badness (a detour that trades a pierce for a crossing/overlap
1184
+ // is rejected).
1185
+ if (bestBad > 0) {
1186
+ const rerouted = deroutePierces(best);
1187
+ if (rerouted !== best && badness(rerouted, bestBad - 1) < bestBad)
1188
+ best = rerouted;
1189
+ }
1190
+
1191
+ // Better-placed alternative: if the winner still has overlapping group bands
1192
+ // (dagre wedged a small group into a tight channel), push the bands apart along
1193
+ // the cross-axis. Kept only on strict total-badness drop.
1194
+ if (bestBad > 0 && countGroupOverlaps(best) > 0) {
1195
+ const separated = separateGroupBands(best, parsed);
1196
+ if (separated !== best && badness(separated, bestBad - 1) < bestBad)
1197
+ best = separated;
1198
+ }
1199
+ return best;
1200
+ }