@diagrammo/dgmo 0.47.0 → 0.48.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 (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -1,1678 +0,0 @@
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
- import { groupedTierCandidates } from './layout-grouped';
26
- import { measureEdgeLabel, EDGE_LABEL_FONT_SIZE } from './label-placement';
27
-
28
- type Pt = { x: number; y: number };
29
-
30
- // Default stability weight: combined = crossings + lambda · (meanDriftPx / 100).
31
- // Only applies when previousPositions is supplied (re-layout on edit/collapse).
32
- const DEFAULT_LAMBDA = 4;
33
-
34
- // Adaptive escalation thresholds (see layoutBoxesAndLinesSearch). Only graphs
35
- // whose base-budget badness is at least ESCALATE_THRESHOLD spend an extra seed
36
- // batch; ESCALATE_MAX_N bounds it so huge graphs don't blow the time budget.
37
- const ESCALATE_THRESHOLD = 4;
38
- const ESCALATE_MAX_N = 45;
39
- const ESCALATE_SEEDS = 18;
40
- const ESCALATE_REFINE = 10;
41
-
42
- function rng(s: number) {
43
- return () => {
44
- s |= 0;
45
- s = (s + 0x6d2b79f5) | 0;
46
- let t = Math.imul(s ^ (s >>> 15), 1 | s);
47
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
48
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
49
- };
50
- }
51
- function shuffle<T>(a: readonly T[], r: () => number): T[] {
52
- const x = a.slice();
53
- for (let i = x.length - 1; i > 0; i--) {
54
- const j = Math.floor(r() * (i + 1));
55
- [x[i], x[j]] = [x[j]!, x[i]!];
56
- }
57
- return x;
58
- }
59
-
60
- const splineGen = d3line<Pt>()
61
- .x((d) => d.x)
62
- .y((d) => d.y)
63
- .curve(curveBasis);
64
-
65
- // Path tokenizer for `flatten` — module-scope so it isn't recompiled per call.
66
- // String.match with a /g regex is stateless (ignores lastIndex), so a shared
67
- // instance is safe to reuse.
68
- const PATH_TOKEN_RE = /[MLQC]|-?\d*\.?\d+(?:e-?\d+)?/gi;
69
- // flatten an SVG path "d" (M/L/Q/C) into a polyline for crossing detection
70
- function flatten(d: string): Pt[] {
71
- const toks = d.match(PATH_TOKEN_RE) ?? [];
72
- const pts: Pt[] = [];
73
- let i = 0,
74
- cx = 0,
75
- cy = 0,
76
- cmd = '';
77
- const num = () => parseFloat(toks[i++]!);
78
- const samp = (p0: Pt, c1: Pt, c2: Pt | null, p1: Pt) => {
79
- for (let t = 0; t <= 1; t += 0.12) {
80
- const u = 1 - t;
81
- if (c2)
82
- pts.push({
83
- x:
84
- u * u * u * p0.x +
85
- 3 * u * u * t * c1.x +
86
- 3 * u * t * t * c2.x +
87
- t * t * t * p1.x,
88
- y:
89
- u * u * u * p0.y +
90
- 3 * u * u * t * c1.y +
91
- 3 * u * t * t * c2.y +
92
- t * t * t * p1.y,
93
- });
94
- else
95
- pts.push({
96
- x: u * u * p0.x + 2 * u * t * c1.x + t * t * p1.x,
97
- y: u * u * p0.y + 2 * u * t * c1.y + t * t * p1.y,
98
- });
99
- }
100
- };
101
- while (i < toks.length) {
102
- const tk = toks[i]!;
103
- if (/[MLQC]/i.test(tk)) {
104
- cmd = tk;
105
- i++;
106
- }
107
- if (cmd === 'M' || cmd === 'L') {
108
- const x = num(),
109
- y = num();
110
- pts.push({ x, y });
111
- cx = x;
112
- cy = y;
113
- } else if (cmd === 'Q') {
114
- const c1 = { x: num(), y: num() },
115
- p1 = { x: num(), y: num() };
116
- samp({ x: cx, y: cy }, c1, null, p1);
117
- cx = p1.x;
118
- cy = p1.y;
119
- } else if (cmd === 'C') {
120
- const c1 = { x: num(), y: num() },
121
- c2 = { x: num(), y: num() },
122
- p1 = { x: num(), y: num() };
123
- samp({ x: cx, y: cy }, c1, c2, p1);
124
- cx = p1.x;
125
- cy = p1.y;
126
- } else i++;
127
- }
128
- return pts;
129
- }
130
- // Flattened edge polyline + its bbox. Building it requires an SVG-string
131
- // round-trip (d3 spline → regex parse), so it's memoized per layout: every
132
- // candidate is scored by countSplineCrossings + countEdgeOverlaps +
133
- // countEdgeNodePierces, which would otherwise each re-flatten all edges.
134
- type FlatPoly = { pts: Pt[]; x0: number; y0: number; x1: number; y1: number };
135
- const FLAT_CACHE = new WeakMap<object, FlatPoly[]>();
136
- function flatPolys(layout: BLLayoutResult): FlatPoly[] {
137
- const key = layout.edges as unknown as object;
138
- const hit = FLAT_CACHE.get(key);
139
- if (hit) return hit;
140
- const polys = layout.edges.map((e) => {
141
- const pts =
142
- e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
143
- let x0 = Infinity,
144
- y0 = Infinity,
145
- x1 = -Infinity,
146
- y1 = -Infinity;
147
- for (const p of pts) {
148
- if (p.x < x0) x0 = p.x;
149
- if (p.x > x1) x1 = p.x;
150
- if (p.y < y0) y0 = p.y;
151
- if (p.y > y1) y1 = p.y;
152
- }
153
- return { pts, x0, y0, x1, y1 };
154
- });
155
- FLAT_CACHE.set(key, polys);
156
- return polys;
157
- }
158
- function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
159
- const den = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
160
- if (Math.abs(den) < 1e-9) return null;
161
- const t =
162
- ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / den,
163
- u = ((p3.x - p1.x) * (p2.y - p1.y) - (p3.y - p1.y) * (p2.x - p1.x)) / den;
164
- return t > 0 && t < 1 && u > 0 && u < 1
165
- ? { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }
166
- : null;
167
- }
168
-
169
- // trustworthy crossing count on the spline geometry: exclude intersections
170
- // near a genuinely shared endpoint node; cluster near-duplicate hits.
171
- // Exported so the playground + benchmark score with the SAME counter the
172
- // engine optimizes against.
173
- export function countSplineCrossings(
174
- layout: BLLayoutResult,
175
- /** Abort early once the running total exceeds this (the caller only needs to
176
- * know X has passed the best-so-far badness — the exact value no longer
177
- * matters). Returns a value > floor in that case; identical otherwise. */
178
- floor = Infinity
179
- ): number {
180
- const center = new Map<string, Pt>();
181
- for (const n of layout.nodes) center.set(n.label, { x: n.x, y: n.y });
182
- // collapsed group boxes are edge endpoints too (`__group_<label>`); without
183
- // them, edges meeting AT a collapsed box are miscounted as crossings.
184
- for (const g of layout.groups)
185
- if (g.collapsed) center.set('__group_' + g.label, { x: g.x, y: g.y });
186
- const polys = flatPolys(layout);
187
- const edges = layout.edges;
188
- const R = 34;
189
- let total = 0;
190
- for (let a = 0; a < polys.length; a++)
191
- for (let b = a + 1; b < polys.length; b++) {
192
- const A = polys[a]!,
193
- B = polys[b]!;
194
- if (A.pts.length < 2 || B.pts.length < 2) continue;
195
- if (A.x1 < B.x0 || B.x1 < A.x0 || A.y1 < B.y0 || B.y1 < A.y0) continue; // bbox disjoint
196
- const ea = edges[a]!,
197
- eb = edges[b]!;
198
- // Shared-endpoint centres (≤2) — inlined to avoid allocating a filter/map
199
- // array on every one of the O(E²) edge pairs.
200
- let sh0: Pt | undefined, sh1: Pt | undefined;
201
- if (ea.source === eb.source || ea.source === eb.target)
202
- sh0 = center.get(ea.source);
203
- if (ea.target === eb.source || ea.target === eb.target)
204
- sh1 = center.get(ea.target);
205
- const hits: Pt[] = [];
206
- const ap = A.pts,
207
- bp = B.pts;
208
- for (let i = 1; i < ap.length; i++) {
209
- const a0 = ap[i - 1]!,
210
- a1 = ap[i]!;
211
- const axMin = a0.x < a1.x ? a0.x : a1.x,
212
- axMax = a0.x > a1.x ? a0.x : a1.x,
213
- ayMin = a0.y < a1.y ? a0.y : a1.y,
214
- ayMax = a0.y > a1.y ? a0.y : a1.y;
215
- for (let j = 1; j < bp.length; j++) {
216
- const b0 = bp[j - 1]!,
217
- b1 = bp[j]!;
218
- // per-segment bbox reject — disjoint segments can't cross
219
- if (axMax < (b0.x < b1.x ? b0.x : b1.x)) continue;
220
- if ((b0.x > b1.x ? b0.x : b1.x) < axMin) continue;
221
- if (ayMax < (b0.y < b1.y ? b0.y : b1.y)) continue;
222
- if ((b0.y > b1.y ? b0.y : b1.y) < ayMin) continue;
223
- const p = segPoint(a0, a1, b0, b1);
224
- if (!p) continue;
225
- if (sh0 && Math.hypot(p.x - sh0.x, p.y - sh0.y) < R) continue;
226
- if (sh1 && Math.hypot(p.x - sh1.x, p.y - sh1.y) < R) continue;
227
- if (!hits.some((h) => Math.hypot(h.x - p.x, h.y - p.y) < 6))
228
- hits.push(p);
229
- }
230
- }
231
- total += hits.length;
232
- if (total > floor) return total; // can't win — stop counting
233
- }
234
- return total;
235
- }
236
-
237
- // distance from point p to segment a–b
238
- function pointSegDist(p: Pt, a: Pt, b: Pt): number {
239
- const dx = b.x - a.x,
240
- dy = b.y - a.y;
241
- const len2 = dx * dx + dy * dy;
242
- if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y);
243
- let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
244
- t = Math.max(0, Math.min(1, t));
245
- return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
246
- }
247
- function distToPoly(p: Pt, poly: readonly Pt[]): number {
248
- let m = Infinity;
249
- for (let i = 1; i < poly.length; i++)
250
- m = Math.min(m, pointSegDist(p, poly[i - 1]!, poly[i]!));
251
- return m;
252
- }
253
- type Rect = { x: number; y: number; w: number; h: number };
254
- // distance from point p to an axis-aligned rectangle (0 if inside)
255
- function pointRectDist(p: Pt, r: Rect): number {
256
- const dx = Math.max(r.x - r.w / 2 - p.x, 0, p.x - (r.x + r.w / 2));
257
- const dy = Math.max(r.y - r.h / 2 - p.y, 0, p.y - (r.y + r.h / 2));
258
- return Math.hypot(dx, dy);
259
- }
260
-
261
- /** A stretch where one edge runs ALONG another (within `dist`, for at least
262
- * `minLen` of length) — i.e. two lines "stepping on" each other. Distinct from
263
- * a true X-crossing (which is a momentary touch, not a sustained run). */
264
- export interface OverlapRun {
265
- mid: Pt;
266
- length: number;
267
- pts: Pt[];
268
- }
269
-
270
- /**
271
- * Detect edge-overlap runs on the rendered spline geometry. Two edges sharing
272
- * an endpoint legitimately CONVERGE at that node's port — runs within `nodeClear`
273
- * of a shared node centre are excluded; only overlap along the open path counts.
274
- */
275
- export function detectEdgeOverlaps(
276
- layout: BLLayoutResult,
277
- opts?: { dist?: number; minLen?: number; nodeClear?: number }
278
- ): OverlapRun[] {
279
- const dist = opts?.dist ?? 8;
280
- const minLen = opts?.minLen ?? 16;
281
- // Margin BEYOND the shared node's box that still counts as "converging to the
282
- // port" (excluded). Edges legitimately meet at a node — only overlap out in
283
- // the open, away from any shared node, is a real "stepping on another line".
284
- const nodeClear = opts?.nodeClear ?? 12;
285
-
286
- const rect = new Map<string, Rect>();
287
- for (const n of layout.nodes)
288
- rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
289
- for (const g of layout.groups)
290
- if (g.collapsed)
291
- rect.set('__group_' + g.label, {
292
- x: g.x,
293
- y: g.y,
294
- w: g.width,
295
- h: g.height,
296
- });
297
-
298
- const polys = flatPolys(layout);
299
- const edges = layout.edges;
300
-
301
- const runs: OverlapRun[] = [];
302
- for (let a = 0; a < polys.length; a++)
303
- for (let b = a + 1; b < polys.length; b++) {
304
- const A = polys[a]!,
305
- B = polys[b]!;
306
- if (A.pts.length < 2 || B.pts.length < 2) continue;
307
- if (
308
- A.x1 + dist < B.x0 ||
309
- B.x1 + dist < A.x0 ||
310
- A.y1 + dist < B.y0 ||
311
- B.y1 + dist < A.y0
312
- )
313
- continue;
314
- const ea = edges[a]!,
315
- eb = edges[b]!;
316
- // Shared-endpoint rects (≤2) — inlined to avoid a per-pair filter/map array.
317
- let shr0: Rect | undefined, shr1: Rect | undefined;
318
- if (ea.source === eb.source || ea.source === eb.target)
319
- shr0 = rect.get(ea.source);
320
- if (ea.target === eb.source || ea.target === eb.target)
321
- shr1 = rect.get(ea.target);
322
- // Walk A; accumulate contiguous "covered" runs (close to B, off any shared
323
- // node). A run counts once if it reaches minLen.
324
- let run: Pt[] = [];
325
- let runLen = 0;
326
- const flush = (): void => {
327
- if (runLen >= minLen && run.length >= 2)
328
- runs.push({
329
- mid: run[Math.floor(run.length / 2)]!,
330
- length: runLen,
331
- pts: run.slice(),
332
- });
333
- run = [];
334
- runLen = 0;
335
- };
336
- for (const p of A.pts) {
337
- const nearShared =
338
- (shr0 !== undefined && pointRectDist(p, shr0) < nodeClear) ||
339
- (shr1 !== undefined && pointRectDist(p, shr1) < nodeClear);
340
- const covered = !nearShared && distToPoly(p, B.pts) < dist;
341
- if (covered) {
342
- if (run.length)
343
- runLen += Math.hypot(
344
- p.x - run[run.length - 1]!.x,
345
- p.y - run[run.length - 1]!.y
346
- );
347
- run.push(p);
348
- } else flush();
349
- }
350
- flush();
351
- }
352
- return runs;
353
- }
354
-
355
- /** Count of edge-overlap runs — the "stepping on another line" metric. */
356
- export function countEdgeOverlaps(
357
- layout: BLLayoutResult,
358
- opts?: { dist?: number; minLen?: number; nodeClear?: number }
359
- ): number {
360
- return detectEdgeOverlaps(layout, opts).length;
361
- }
362
-
363
- /** An edge routing THROUGH a node box it doesn't connect to. Counts as a
364
- * crossing — the line is where it shouldn't be. */
365
- export interface NodePierce {
366
- edgeIdx: number;
367
- node: string;
368
- pts: Pt[];
369
- }
370
-
371
- /**
372
- * Detect edges that pass through (substantially inside, by `inset`) the box of a
373
- * node that is NOT one of their endpoints. Endpoints — including collapsed group
374
- * boxes (`__group_<label>`) — are excluded; an edge legitimately meets those.
375
- */
376
- export function detectEdgeNodePierces(
377
- layout: BLLayoutResult,
378
- opts?: { inset?: number; minPts?: number }
379
- ): NodePierce[] {
380
- const inset = opts?.inset ?? 6;
381
- const minPts = opts?.minPts ?? 2;
382
- const rects: (Rect & { key: string })[] = [];
383
- for (const n of layout.nodes)
384
- rects.push({ key: n.label, x: n.x, y: n.y, w: n.width, h: n.height });
385
- for (const g of layout.groups)
386
- if (g.collapsed)
387
- rects.push({
388
- key: '__group_' + g.label,
389
- x: g.x,
390
- y: g.y,
391
- w: g.width,
392
- h: g.height,
393
- });
394
- const inside = (p: Pt, r: Rect): boolean =>
395
- Math.abs(p.x - r.x) < r.w / 2 - inset &&
396
- Math.abs(p.y - r.y) < r.h / 2 - inset;
397
- const out: NodePierce[] = [];
398
- const polys = flatPolys(layout);
399
- layout.edges.forEach((e, idx) => {
400
- if (e.points.length < 2) return;
401
- const poly = polys[idx]!.pts;
402
- for (const r of rects) {
403
- if (
404
- r.key === e.source ||
405
- r.key === e.target ||
406
- '__group_' + r.key === e.source ||
407
- '__group_' + r.key === e.target
408
- )
409
- continue;
410
- const hits = poly.filter((p) => inside(p, r));
411
- if (hits.length >= minPts)
412
- out.push({ edgeIdx: idx, node: r.key, pts: hits });
413
- }
414
- });
415
- return out;
416
- }
417
-
418
- /** Count of edges routing through unrelated node boxes — the "line going through
419
- * a node" metric. */
420
- export function countEdgeNodePierces(
421
- layout: BLLayoutResult,
422
- opts?: { inset?: number; minPts?: number }
423
- ): number {
424
- return detectEdgeNodePierces(layout, opts).length;
425
- }
426
-
427
- /**
428
- * Re-routed copy of a layout that bends edges AROUND any node box they pierce:
429
- * for each pierced (edge, node) it inserts a waypoint pushing the edge out past
430
- * the node, perpendicular to the edge, on the side it's already leaning. The
431
- * curveBasis spline then bows around the node instead of through it. Returned as
432
- * an ALTERNATIVE candidate — the caller keeps it only if total badness drops, so
433
- * a detour that trades a pierce for a crossing is simply rejected.
434
- */
435
- export function deroutePierces(layout: BLLayoutResult): BLLayoutResult {
436
- const pierces = detectEdgeNodePierces(layout);
437
- if (!pierces.length) return layout;
438
- const rect = new Map<string, Rect>();
439
- for (const n of layout.nodes)
440
- rect.set(n.label, { x: n.x, y: n.y, w: n.width, h: n.height });
441
- for (const g of layout.groups)
442
- if (g.collapsed)
443
- rect.set('__group_' + g.label, {
444
- x: g.x,
445
- y: g.y,
446
- w: g.width,
447
- h: g.height,
448
- });
449
- const byEdge = new Map<number, string[]>();
450
- for (const p of pierces) {
451
- const arr = byEdge.get(p.edgeIdx);
452
- if (arr) arr.push(p.node);
453
- else byEdge.set(p.edgeIdx, [p.node]);
454
- }
455
- const edges = layout.edges.map((e, idx) => {
456
- const nodes = byEdge.get(idx);
457
- if (!nodes) return e;
458
- let pts: Pt[] = e.points.map((p) => ({ x: p.x, y: p.y }));
459
- for (const label of nodes) {
460
- const r = rect.get(label);
461
- if (r) pts = detourAround(pts, r);
462
- }
463
- return { ...e, points: pts };
464
- });
465
- return { ...layout, edges };
466
- }
467
-
468
- // Bend a waypoint polyline around a rectangle it passes through: find the
469
- // segment whose closest point to the rect centre is nearest, and insert a
470
- // waypoint there pushed out past the rect corner (+margin) on the leaning side.
471
- function detourAround(pts: Pt[], r: Rect): Pt[] {
472
- if (pts.length < 2) return pts;
473
- const c = { x: r.x, y: r.y };
474
- let bestSeg = -1;
475
- let bestT = 0;
476
- let bestD = Infinity;
477
- let bestPt: Pt = pts[0]!;
478
- for (let i = 0; i < pts.length - 1; i++) {
479
- const a = pts[i]!,
480
- b = pts[i + 1]!;
481
- const dx = b.x - a.x,
482
- dy = b.y - a.y;
483
- const len2 = dx * dx + dy * dy || 1e-9;
484
- let t = ((c.x - a.x) * dx + (c.y - a.y) * dy) / len2;
485
- t = Math.max(0, Math.min(1, t));
486
- const q = { x: a.x + t * dx, y: a.y + t * dy };
487
- const d = Math.hypot(q.x - c.x, q.y - c.y);
488
- if (d < bestD) {
489
- bestD = d;
490
- bestSeg = i;
491
- bestT = t;
492
- bestPt = q;
493
- }
494
- }
495
- if (bestSeg < 0) return pts;
496
- // push direction: away from rect centre, on the side the edge already leans;
497
- // if the closest point is dead-centre, pick the box's shorter axis to exit.
498
- let nx = bestPt.x - c.x;
499
- let ny = bestPt.y - c.y;
500
- if (Math.hypot(nx, ny) < 1) {
501
- if (r.w <= r.h) {
502
- nx = 1;
503
- ny = 0;
504
- } else {
505
- nx = 0;
506
- ny = 1;
507
- }
508
- }
509
- const nlen = Math.hypot(nx, ny) || 1;
510
- nx /= nlen;
511
- ny /= nlen;
512
- // Local edge direction (along the closest segment), for shaping a SMOOTH hump
513
- // around the node instead of a single spike: ease out → peak → ease back in.
514
- const a = pts[bestSeg]!,
515
- b = pts[bestSeg + 1]!;
516
- let ex = b.x - a.x,
517
- ey = b.y - a.y;
518
- const elen = Math.hypot(ex, ey) || 1;
519
- ex /= elen;
520
- ey /= elen;
521
- const clear = Math.hypot(r.w, r.h) / 2 + 18;
522
- const hw = Math.hypot(r.w, r.h) / 2; // hump half-width along the edge
523
- void bestT;
524
- const before = {
525
- x: bestPt.x - ex * hw + nx * clear * 0.5,
526
- y: bestPt.y - ey * hw + ny * clear * 0.5,
527
- };
528
- const peak = { x: c.x + nx * clear, y: c.y + ny * clear };
529
- const after = {
530
- x: bestPt.x + ex * hw + nx * clear * 0.5,
531
- y: bestPt.y + ey * hw + ny * clear * 0.5,
532
- };
533
- const out = pts.slice(0, bestSeg + 1);
534
- out.push(before, peak, after);
535
- out.push(...pts.slice(bestSeg + 1));
536
- return out;
537
- }
538
- /** Count of edges that come CLOSE to another edge without overlapping — lines
539
- * that "almost touch". Runs within `near` px (but not already overlapping within
540
- * `tight` px). Same shared-node exclusion as overlaps. */
541
- export function countEdgeNearMiss(
542
- layout: BLLayoutResult,
543
- opts?: { near?: number; tight?: number; minLen?: number }
544
- ): number {
545
- const near = opts?.near ?? 14;
546
- const tight = opts?.tight ?? 8;
547
- const minLen = opts?.minLen ?? 18;
548
- const close = detectEdgeOverlaps(layout, { dist: near, minLen }).length;
549
- const over = detectEdgeOverlaps(layout, { dist: tight, minLen }).length;
550
- return Math.max(0, close - over);
551
- }
552
-
553
- // Renderer extends a parent group's box UPWARD by this label zone (mirrors
554
- // GROUP_LABEL_ZONE in renderer.ts) — so the collision metric must use the
555
- // RENDERED box, or near-touching parents read as fine when they actually overlap.
556
- const GROUP_LABEL_ZONE = 32;
557
-
558
- /** Labels of GROUP boxes that touch/overlap a non-nested sibling group. Proper
559
- * nesting (one box fully inside another) is excluded; only sibling/unrelated
560
- * collisions count. Uses the RENDERED box (parent groups grow up by the label
561
- * zone, which is what actually makes near-touching parents collide). */
562
- export function detectGroupOverlaps(
563
- layout: BLLayoutResult,
564
- opts?: { margin?: number }
565
- ): string[] {
566
- const margin = opts?.margin ?? 4;
567
- const raw = layout.groups.map((g) => ({
568
- label: g.label,
569
- l: g.x - g.width / 2,
570
- r: g.x + g.width / 2,
571
- t: g.y - g.height / 2,
572
- b: g.y + g.height / 2,
573
- }));
574
- const contains = (
575
- outer: (typeof raw)[number],
576
- inner: (typeof raw)[number]
577
- ): boolean =>
578
- outer.l <= inner.l + margin &&
579
- outer.r >= inner.r - margin &&
580
- outer.t <= inner.t + margin &&
581
- outer.b >= inner.b - margin;
582
- const rend = raw.map((a, i) => {
583
- const parent = raw.some((b, j) => j !== i && contains(a, b));
584
- return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
585
- });
586
- const hit = new Set<string>();
587
- for (let i = 0; i < raw.length; i++)
588
- for (let j = i + 1; j < raw.length; j++) {
589
- if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue; // nesting
590
- const a = rend[i]!,
591
- b = rend[j]!;
592
- const dx = Math.max(a.l - b.r, b.l - a.r);
593
- const dy = Math.max(a.t - b.b, b.t - a.b);
594
- if (Math.max(dx, dy) < margin) {
595
- hit.add(raw[i]!.label);
596
- hit.add(raw[j]!.label);
597
- }
598
- }
599
- return [...hit];
600
- }
601
-
602
- /** Count of group-box collisions (pairs touching/overlapping). */
603
- export function countGroupOverlaps(
604
- layout: BLLayoutResult,
605
- opts?: { margin?: number }
606
- ): number {
607
- const margin = opts?.margin ?? 4;
608
- const raw = layout.groups.map((g) => ({
609
- l: g.x - g.width / 2,
610
- r: g.x + g.width / 2,
611
- t: g.y - g.height / 2,
612
- b: g.y + g.height / 2,
613
- }));
614
- const contains = (
615
- outer: (typeof raw)[number],
616
- inner: (typeof raw)[number]
617
- ): boolean =>
618
- outer.l <= inner.l + margin &&
619
- outer.r >= inner.r - margin &&
620
- outer.t <= inner.t + margin &&
621
- outer.b >= inner.b - margin;
622
- const rend = raw.map((a, i) => {
623
- const parent = raw.some((b, j) => j !== i && contains(a, b));
624
- return parent ? { ...a, t: a.t - GROUP_LABEL_ZONE } : a;
625
- });
626
- let count = 0;
627
- for (let i = 0; i < raw.length; i++)
628
- for (let j = i + 1; j < raw.length; j++) {
629
- if (contains(raw[i]!, raw[j]!) || contains(raw[j]!, raw[i]!)) continue;
630
- const a = rend[i]!,
631
- b = rend[j]!;
632
- const dx = Math.max(a.l - b.r, b.l - a.r);
633
- const dy = Math.max(a.t - b.b, b.t - a.b);
634
- if (Math.max(dx, dy) < margin) count++;
635
- }
636
- return count;
637
- }
638
- /**
639
- * Separated copy of a layout that pushes overlapping TOP-LEVEL group bands apart
640
- * along the cross-axis. dagre's compound layout sometimes wedges a small group
641
- * into a too-tight channel between two others (the multi-cloud On-Prem box lands
642
- * in AWS's label zone). Translating each top-level group rigidly along the
643
- * cross-axis preserves every node's rank/column, so the only thing that changes
644
- * is the gap between bands. Member nodes + nested sub-group boxes move by their
645
- * band's delta; edge waypoints blend smoothly between their endpoints' deltas
646
- * (intra-band edges keep their shape exactly; cross-band edges ease across).
647
- *
648
- * Returned as an ALTERNATIVE candidate — the caller keeps it only if total
649
- * badness drops, so a separation that introduces a crossing/pierce is rejected.
650
- * Fully-expanded graphs only (skips when any group is collapsed).
651
- */
652
- export function separateGroupBands(
653
- layout: BLLayoutResult,
654
- parsed: ParsedBoxesAndLines
655
- ): BLLayoutResult {
656
- if (layout.groups.some((g) => g.collapsed)) return layout;
657
- if (countGroupOverlaps(layout) === 0) return layout;
658
-
659
- // Resolve each group label to its top-level ancestor (walk parentGroup).
660
- const parentOf = new Map<string, string | undefined>();
661
- for (const g of parsed.groups) parentOf.set(g.label, g.parentGroup);
662
- const topOf = (label: string): string => {
663
- let cur = label;
664
- const seen = new Set<string>();
665
- for (;;) {
666
- const p = parentOf.get(cur);
667
- if (!p || seen.has(p)) return cur;
668
- seen.add(cur);
669
- cur = p;
670
- }
671
- };
672
- const topLabels = parsed.groups
673
- .filter((g) => !g.parentGroup)
674
- .map((g) => g.label);
675
- if (topLabels.length < 2) return layout;
676
-
677
- // Each node's top-level group (a node sits in exactly one leaf group).
678
- const nodeTop = new Map<string, string>();
679
- for (const g of parsed.groups)
680
- for (const child of g.children)
681
- if (!parsed.groups.some((gg) => gg.label === child))
682
- nodeTop.set(child, topOf(g.label));
683
-
684
- // Cross-axis: LR stacks bands vertically (Y); TB stacks them horizontally (X).
685
- // The label zone always extends a parent's box upward (Y), so it only lands on
686
- // the cross-axis for LR — for TB this pass can still separate plain X overlaps.
687
- const axis: 'x' | 'y' = parsed.direction === 'TB' ? 'x' : 'y';
688
- const boxByLabel = new Map(layout.groups.map((g) => [g.label, g]));
689
-
690
- // Rendered cross-interval per top-level group (label zone folded in on Y).
691
- type Band = { label: string; lo: number; hi: number; c: number };
692
- const bands: Band[] = topLabels.map((label) => {
693
- const g = boxByLabel.get(label)!;
694
- const half = (axis === 'y' ? g.height : g.width) / 2;
695
- let lo = (axis === 'y' ? g.y : g.x) - half;
696
- const hi = (axis === 'y' ? g.y : g.x) + half;
697
- // A top-level group with children is a parent → its rendered box grows up.
698
- const isParent = parsed.groups.some((c) => c.parentGroup === label);
699
- if (axis === 'y' && isParent) lo -= GROUP_LABEL_ZONE;
700
- return { label, lo, hi, c: (lo + hi) / 2 };
701
- });
702
- bands.sort((a, b) => a.c - b.c);
703
-
704
- // 1D block-merge (PAV) separation: keep each band's centre as close to where
705
- // it is as possible, subject to a minimum gap between consecutive bands.
706
- const GAP = 16;
707
- const half = bands.map((b) => (b.hi - b.lo) / 2);
708
- const off: number[] = [0];
709
- for (let i = 1; i < bands.length; i++)
710
- off[i] = off[i - 1]! + half[i - 1]! + GAP + half[i]!;
711
- const desired = bands.map((b, i) => b.c - off[i]!);
712
- type Blk = { pos: number; count: number; sum: number; first: number };
713
- const blocks: Blk[] = [];
714
- for (let i = 0; i < bands.length; i++) {
715
- let blk: Blk = { pos: desired[i]!, count: 1, sum: desired[i]!, first: i };
716
- while (blocks.length && blocks[blocks.length - 1]!.pos >= blk.pos) {
717
- const prev = blocks.pop()!;
718
- const count = prev.count + blk.count;
719
- const sum = prev.sum + blk.sum;
720
- blk = { pos: sum / count, count, sum, first: prev.first };
721
- }
722
- blocks.push(blk);
723
- }
724
- const newC = new Array<number>(bands.length);
725
- for (const blk of blocks)
726
- for (let k = 0; k < blk.count; k++)
727
- newC[blk.first + k] = blk.pos + off[blk.first + k]!;
728
-
729
- // Delta per top-level group; bail if nothing actually moves.
730
- const delta = new Map<string, number>();
731
- let moved = false;
732
- bands.forEach((b, i) => {
733
- const d = newC[i]! - b.c;
734
- delta.set(b.label, d);
735
- if (Math.abs(d) > 0.5) moved = true;
736
- });
737
- if (!moved) return layout;
738
-
739
- const nodeDelta = (label: string): number => {
740
- // Group-incident edge endpoints carry the group's gid, not a node label.
741
- if (label.startsWith('__group_'))
742
- return delta.get(topOf(label.slice('__group_'.length))) ?? 0;
743
- const top = nodeTop.get(label);
744
- return top ? (delta.get(top) ?? 0) : 0;
745
- };
746
- const shift = (p: Pt, d: number): Pt =>
747
- axis === 'y' ? { x: p.x, y: p.y + d } : { x: p.x + d, y: p.y };
748
-
749
- const nodes = layout.nodes.map((n) => {
750
- const d = nodeDelta(n.label);
751
- return d
752
- ? { ...n, ...(axis === 'y' ? { y: n.y + d } : { x: n.x + d }) }
753
- : n;
754
- });
755
- const groups = layout.groups.map((g) => {
756
- const d = delta.get(topOf(g.label)) ?? 0;
757
- return d
758
- ? { ...g, ...(axis === 'y' ? { y: g.y + d } : { x: g.x + d }) }
759
- : g;
760
- });
761
- const edges = layout.edges.map((e) => {
762
- const ds = nodeDelta(e.source);
763
- const dt = nodeDelta(e.target);
764
- if (!ds && !dt) return e;
765
- const N = e.points.length;
766
- const points = e.points.map((p, i) => {
767
- const f = N > 1 ? i / (N - 1) : 0;
768
- return shift(p, ds * (1 - f) + dt * f);
769
- });
770
- return { ...e, points };
771
- });
772
-
773
- // Recompute canvas bbox (+ margin) over the shifted content.
774
- let minX = Infinity,
775
- minY = Infinity,
776
- maxX = -Infinity,
777
- maxY = -Infinity;
778
- const acc = (x: number, y: number) => {
779
- if (x < minX) minX = x;
780
- if (x > maxX) maxX = x;
781
- if (y < minY) minY = y;
782
- if (y > maxY) maxY = y;
783
- };
784
- for (const n of nodes) {
785
- acc(n.x - n.width / 2, n.y - n.height / 2);
786
- acc(n.x + n.width / 2, n.y + n.height / 2);
787
- }
788
- for (const g of groups) {
789
- acc(g.x - g.width / 2, g.y - g.height / 2 - GROUP_LABEL_ZONE);
790
- acc(g.x + g.width / 2, g.y + g.height / 2);
791
- }
792
- for (const e of edges) for (const p of e.points) acc(p.x, p.y);
793
- const M = 40;
794
- const sx = M - minX,
795
- sy = M - minY;
796
- const reshift = sx !== 0 || sy !== 0;
797
- return {
798
- nodes: reshift
799
- ? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
800
- : nodes,
801
- groups: reshift
802
- ? groups.map((g) => ({ ...g, x: g.x + sx, y: g.y + sy }))
803
- : groups,
804
- edges: reshift
805
- ? edges.map((e) => ({
806
- ...e,
807
- points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
808
- }))
809
- : edges,
810
- width: maxX - minX + 2 * M,
811
- height: maxY - minY + 2 * M,
812
- };
813
- }
814
-
815
- // Fast crossing estimate for RANKING candidates: straight segments on raw
816
- // waypoints (no curveBasis flatten) + bbox pruning + early-out per pair.
817
- // ~10× cheaper than countSplineCrossings; topology-equivalent for ranking.
818
- function segCross(p1: Pt, p2: Pt, p3: Pt, p4: Pt): boolean {
819
- const d1x = p2.x - p1.x,
820
- d1y = p2.y - p1.y,
821
- d2x = p4.x - p3.x,
822
- d2y = p4.y - p3.y;
823
- const den = d1x * d2y - d1y * d2x;
824
- if (Math.abs(den) < 1e-9) return false;
825
- const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den;
826
- const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den;
827
- return t > 0.001 && t < 0.999 && s > 0.001 && s < 0.999;
828
- }
829
- function countCrossingsFast(layout: BLLayoutResult): number {
830
- const E = layout.edges.filter((e) => e.points.length >= 2);
831
- const bb = E.map((e) => {
832
- let x0 = Infinity,
833
- y0 = Infinity,
834
- x1 = -Infinity,
835
- y1 = -Infinity;
836
- for (const p of e.points) {
837
- if (p.x < x0) x0 = p.x;
838
- if (p.x > x1) x1 = p.x;
839
- if (p.y < y0) y0 = p.y;
840
- if (p.y > y1) y1 = p.y;
841
- }
842
- return { x0, y0, x1, y1 };
843
- });
844
- let count = 0;
845
- for (let i = 0; i < E.length; i++)
846
- for (let j = i + 1; j < E.length; j++) {
847
- const A = E[i]!,
848
- B = E[j]!;
849
- if (
850
- A.source === B.source ||
851
- A.source === B.target ||
852
- A.target === B.source ||
853
- A.target === B.target
854
- )
855
- continue;
856
- const a = bb[i]!,
857
- b = bb[j]!;
858
- if (a.x1 < b.x0 || b.x1 < a.x0 || a.y1 < b.y0 || b.y1 < a.y0) continue;
859
- const pa = A.points,
860
- pb = B.points;
861
- let hit = false;
862
- for (let ai = 0; ai < pa.length - 1 && !hit; ai++)
863
- for (let bi = 0; bi < pb.length - 1; bi++) {
864
- if (segCross(pa[ai]!, pa[ai + 1]!, pb[bi]!, pb[bi + 1]!)) {
865
- hit = true;
866
- break;
867
- }
868
- }
869
- if (hit) count++;
870
- }
871
- return count;
872
- }
873
- function meanDrift(
874
- layout: BLLayoutResult,
875
- prev: ReadonlyMap<string, Pt> | undefined
876
- ): number {
877
- if (!prev?.size) return 0;
878
- let sum = 0,
879
- n = 0;
880
- for (const node of layout.nodes) {
881
- const p = prev.get(node.label);
882
- if (p) {
883
- sum += Math.hypot(node.x - p.x, node.y - p.y);
884
- n++;
885
- }
886
- }
887
- return n ? sum / n : 0;
888
- }
889
- // total edge length — positioning tiebreaker (shorter/straighter reads better)
890
- function edgeLength(layout: BLLayoutResult): number {
891
- let total = 0;
892
- for (const e of layout.edges)
893
- for (let i = 1; i < e.points.length; i++)
894
- total += Math.hypot(
895
- e.points[i]!.x - e.points[i - 1]!.x,
896
- e.points[i]!.y - e.points[i - 1]!.y
897
- );
898
- return total;
899
- }
900
-
901
- export async function layoutBoxesAndLinesSearch(
902
- parsed: ParsedBoxesAndLines,
903
- collapseInfo?: {
904
- collapsedChildCounts: Map<string, number>;
905
- originalGroups: readonly BLGroup[];
906
- },
907
- opts?: {
908
- hideDescriptions?: boolean;
909
- previousPositions?: ReadonlyMap<string, Pt>;
910
- /** Number of seed orderings to search (default: adaptive by node count). */
911
- seeds?: number;
912
- /** Stability weight (default 4). */
913
- lambda?: number;
914
- /** How many top candidates to re-rank with the exact counter (default 6). */
915
- refineK?: number;
916
- /** Progress hook for the interactive path. When provided, the search yields
917
- * to a macrotask after each candidate so the host UI can paint a progress
918
- * indicator. Omit it (CLI/export) and the search runs straight through with
919
- * no added latency. */
920
- onProgress?: (done: number, total: number, phase: string) => void;
921
- /** Last-resort label legibility: reserve dagre label space for every labeled
922
- * edge so the layout opens a gap wide enough for the (wrapped) label. Only
923
- * set by layout.ts when steps 1–2 left a label overlapping a node box. */
924
- reserveEdgeLabels?: boolean;
925
- }
926
- ): Promise<BLLayoutResult> {
927
- const hideDescriptions = opts?.hideDescriptions ?? false;
928
- const reserveEdgeLabels = opts?.reserveEdgeLabels ?? false;
929
- const onProgress = opts?.onProgress;
930
- // Yield to a macrotask (lets the browser repaint between heavy placements);
931
- // no-op when there's no progress observer so non-interactive callers pay
932
- // nothing.
933
- const tick = onProgress
934
- ? (): Promise<void> => new Promise<void>((r) => setTimeout(r))
935
- : (): undefined => undefined;
936
- // Yielding is only worth its cost once a search is genuinely slow. A typical
937
- // diagram finishes its whole candidate pool in tens of ms; yielding (a
938
- // setTimeout, clamped to ~4ms/turn in browsers) after every one of ~90
939
- // candidates would turn a 30ms layout into ~1s — all to flash a throbber that
940
- // never shows (the app only reveals it after 150ms). So we suppress yields
941
- // until the search has burned through a budget, then throttle them: fast
942
- // diagrams run flat-out, only truly heavy ones cede the main thread to paint.
943
- const YIELD_AFTER_MS = 150;
944
- const YIELD_EVERY_MS = 30;
945
- const searchStart = performance.now();
946
- let lastYield = searchStart;
947
-
948
- // collapsed group labels (shown as plain boxes) — mirrors the ELK path
949
- const collapsedGroupLabels = new Set<string>();
950
- if (collapseInfo) {
951
- const missing = new Set<string>();
952
- for (const og of collapseInfo.originalGroups)
953
- if (!parsed.groups.some((g) => g.label === og.label))
954
- missing.add(og.label);
955
- for (const label of missing) {
956
- const og = collapseInfo.originalGroups.find((g) => g.label === label);
957
- const parent = og?.parentGroup;
958
- if (!parent || !missing.has(parent)) collapsedGroupLabels.add(label);
959
- }
960
- }
961
-
962
- // node sizes (computeNodeSize + uniform-height pass) — identical to ELK path
963
- const sizes = new Map<string, { width: number; height: number }>();
964
- let maxDescH = 0;
965
- for (const node of parsed.nodes) {
966
- const s = hideDescriptions
967
- ? { width: NODE_WIDTH, height: NODE_HEIGHT }
968
- : computeNodeSize(node, parsed.showValues === true);
969
- sizes.set(node.label, s);
970
- if (!hideDescriptions && node.description && node.description.length > 0)
971
- maxDescH = Math.max(maxDescH, s.height);
972
- }
973
- if (maxDescH > 0)
974
- for (const node of parsed.nodes)
975
- if (node.description && node.description.length > 0) {
976
- const s = sizes.get(node.label)!;
977
- sizes.set(node.label, { width: s.width, height: maxDescH });
978
- }
979
-
980
- const gid = (label: string) => `__group_${label}`;
981
- const rankdir = parsed.direction === 'TB' ? 'TB' : 'LR';
982
-
983
- // Group-incident edges: dagre cannot attach edges to a compound cluster node
984
- // (it throws while ranking). So for any edge whose endpoint is an EXPANDED
985
- // group, we route the dagre edge through a representative descendant node and
986
- // later clip the polyline back to the group's boundary rect. Collapsed groups
987
- // are already plain (childless) nodes, so their edges need no redirect.
988
- const nodeLabelSet = new Set(parsed.nodes.map((n) => n.label));
989
- const directNodeChildren = new Map<string, string[]>();
990
- const childGroupsOf = new Map<string, string[]>();
991
- for (const grp of parsed.groups) {
992
- directNodeChildren.set(
993
- grp.label,
994
- grp.children.filter((c) => nodeLabelSet.has(c))
995
- );
996
- if (grp.parentGroup) {
997
- const arr = childGroupsOf.get(grp.parentGroup) ?? [];
998
- arr.push(grp.label);
999
- childGroupsOf.set(grp.parentGroup, arr);
1000
- }
1001
- }
1002
- // All descendant node labels of a group (direct children + nested subgroups).
1003
- const membersCache = new Map<string, string[]>();
1004
- const membersOf = (label: string): string[] => {
1005
- const cached = membersCache.get(label);
1006
- if (cached) return cached;
1007
- membersCache.set(label, []); // cycle guard
1008
- const out = [...(directNodeChildren.get(label) ?? [])];
1009
- for (const sub of childGroupsOf.get(label) ?? [])
1010
- out.push(...membersOf(sub));
1011
- membersCache.set(label, out);
1012
- return out;
1013
- };
1014
-
1015
- // For a group used as an edge SOURCE the connector should leave from the
1016
- // group's internal exit (the member at the deepest rank); used as a TARGET it
1017
- // should enter at the group's entry (a member with no internal predecessor).
1018
- // Picking by internal-DAG depth keeps the group a contiguous block and the
1019
- // overall flow monotonic, instead of stretching it across the target's rank.
1020
- const gidSourceRep = new Map<string, string>(); // gid -> deepest member
1021
- const gidTargetRep = new Map<string, string>(); // gid -> shallowest member
1022
- for (const grp of parsed.groups) {
1023
- const members = membersOf(grp.label);
1024
- if (members.length === 0) continue;
1025
- const memberSet = new Set(members);
1026
- // Internal predecessors (node->node edges fully inside the group). A
1027
- // member's rank = longest predecessor chain, so entry members rank 0 and
1028
- // exit members rank highest — matching how dagre lays the members out.
1029
- const preds = new Map<string, string[]>();
1030
- for (const e of parsed.edges)
1031
- if (memberSet.has(e.source) && memberSet.has(e.target)) {
1032
- const arr = preds.get(e.target) ?? [];
1033
- arr.push(e.source);
1034
- preds.set(e.target, arr);
1035
- }
1036
- const rankCache = new Map<string, number>();
1037
- const onPath = new Set<string>();
1038
- const rankOf = (n: string): number => {
1039
- const c = rankCache.get(n);
1040
- if (c !== undefined) return c;
1041
- if (onPath.has(n)) return 0; // cycle: treat as root
1042
- onPath.add(n);
1043
- let r = 0;
1044
- for (const p of preds.get(n) ?? []) r = Math.max(r, rankOf(p) + 1);
1045
- onPath.delete(n);
1046
- rankCache.set(n, r);
1047
- return r;
1048
- };
1049
- let exit = members[0]!;
1050
- let entry = members[0]!;
1051
- for (const mem of members) {
1052
- if (rankOf(mem) > rankOf(exit)) exit = mem;
1053
- if (rankOf(mem) < rankOf(entry)) entry = mem;
1054
- }
1055
- gidSourceRep.set(gid(grp.label), exit);
1056
- gidTargetRep.set(gid(grp.label), entry);
1057
- }
1058
- const srcRep = (label: string) => gidSourceRep.get(label) ?? label;
1059
- const tgtRep = (label: string) => gidTargetRep.get(label) ?? label;
1060
- const edgeKey = (e: { source: string; target: string; lineNumber: number }) =>
1061
- `${e.source}->${e.target}#${e.lineNumber}`;
1062
-
1063
- // Clip a polyline endpoint to a group's boundary rect. `rect` is centre-based.
1064
- // Returns the point where the ray from the rect centre toward `toward` exits
1065
- // the rect.
1066
- const rectBorderPoint = (
1067
- rect: { x: number; y: number; w: number; h: number },
1068
- toward: Pt
1069
- ): Pt => {
1070
- const dx = toward.x - rect.x;
1071
- const dy = toward.y - rect.y;
1072
- const sx = dx === 0 ? Infinity : rect.w / 2 / Math.abs(dx);
1073
- const sy = dy === 0 ? Infinity : rect.h / 2 / Math.abs(dy);
1074
- const s = Math.min(sx, sy);
1075
- if (!Number.isFinite(s)) return { x: rect.x, y: rect.y };
1076
- return { x: rect.x + dx * s, y: rect.y + dy * s };
1077
- };
1078
- const isInsideRect = (
1079
- p: Pt,
1080
- rect: { x: number; y: number; w: number; h: number }
1081
- ) =>
1082
- Math.abs(p.x - rect.x) <= rect.w / 2 &&
1083
- Math.abs(p.y - rect.y) <= rect.h / 2;
1084
-
1085
- // ── Pinned-layout bypass (Canvas Editor spike, Decisions 3/7) ──
1086
- // When a `layout` block positions EVERY node, place nodes directly from the
1087
- // stored coordinates and skip the whole dagre search. Edges become
1088
- // border-clipped straight connectors (no obstacle avoidance — honest-but-ugly).
1089
- // FLAT, EXPANDED groups are honored: each group's container rect is computed
1090
- // from its members' pinned positions (canvas group editing). A FLAT group can
1091
- // also be COLLAPSED while pinned — it renders as a plain box at its members'
1092
- // bbox-centre (so collapse no longer forces a full dagre reflow). Nested groups
1093
- // still fall back to dagre (deferred).
1094
- const pinned = parsed.nodePositions;
1095
- const groupLabelSet = new Set(parsed.groups.map((g) => g.label));
1096
- const groupsAreFlat = parsed.groups.every(
1097
- (g) => !g.parentGroup && !g.children.some((c) => groupLabelSet.has(c))
1098
- );
1099
- // A collapsed group can stay pinned only when it's FLAT (top-level, no
1100
- // sub-groups) and every one of its members has a pinned coord — otherwise we
1101
- // can't place its box without a search, so fall back to dagre.
1102
- const allOriginalGroupLabels = new Set(
1103
- (collapseInfo?.originalGroups ?? parsed.groups).map((g) => g.label)
1104
- );
1105
- const collapsedAreFlatPinned =
1106
- collapsedGroupLabels.size === 0 ||
1107
- (pinned !== undefined &&
1108
- collapseInfo !== undefined &&
1109
- [...collapsedGroupLabels].every((label) => {
1110
- const og = collapseInfo.originalGroups.find((g) => g.label === label);
1111
- if (!og || og.parentGroup) return false;
1112
- return og.children.every(
1113
- (c) => pinned.has(c) && !allOriginalGroupLabels.has(c)
1114
- );
1115
- }));
1116
- const allPinned =
1117
- pinned !== undefined &&
1118
- (parsed.nodes.length > 0 || collapsedGroupLabels.size > 0) &&
1119
- parsed.nodes.every((n) => pinned.has(n.label)) &&
1120
- groupsAreFlat &&
1121
- collapsedAreFlatPinned;
1122
- function placePinned(pins: ReadonlyMap<string, Pt>): BLLayoutResult {
1123
- // Collapsed flat groups → a plain NODE-sized box at the bbox-centre of the
1124
- // group's (now-hidden) members' pinned coords. The collapse transform
1125
- // redirected their incident edges to `__group_<label>`, so register that id
1126
- // in the position/rect lookups too.
1127
- const collapsedPosByGid = new Map<string, Pt>();
1128
- const collapsedBoxes: Array<{
1129
- label: string;
1130
- lineNumber: number;
1131
- childCount: number;
1132
- x: number;
1133
- y: number;
1134
- }> = [];
1135
- if (collapseInfo)
1136
- for (const label of collapsedGroupLabels) {
1137
- const og = collapseInfo.originalGroups.find((g) => g.label === label);
1138
- if (!og) continue;
1139
- let cx0 = Infinity,
1140
- cy0 = Infinity,
1141
- cx1 = -Infinity,
1142
- cy1 = -Infinity;
1143
- for (const c of og.children) {
1144
- const p = pins.get(c);
1145
- if (!p) continue;
1146
- cx0 = Math.min(cx0, p.x);
1147
- cx1 = Math.max(cx1, p.x);
1148
- cy0 = Math.min(cy0, p.y);
1149
- cy1 = Math.max(cy1, p.y);
1150
- }
1151
- if (!Number.isFinite(cx0)) continue;
1152
- const cx = (cx0 + cx1) / 2;
1153
- const cy = (cy0 + cy1) / 2;
1154
- collapsedPosByGid.set(`__group_${label}`, { x: cx, y: cy });
1155
- collapsedBoxes.push({
1156
- label,
1157
- lineNumber: og.lineNumber,
1158
- childCount:
1159
- collapseInfo.collapsedChildCounts.get(label) ?? og.children.length,
1160
- x: cx,
1161
- y: cy,
1162
- });
1163
- }
1164
- const posOf = (label: string): Pt | undefined =>
1165
- pins.get(label) ?? collapsedPosByGid.get(label);
1166
- const rectOf = (label: string) => {
1167
- const p = posOf(label)!;
1168
- const s = sizes.get(label) ?? { width: NODE_WIDTH, height: NODE_HEIGHT };
1169
- return { x: p.x, y: p.y, w: s.width, h: s.height };
1170
- };
1171
- const nodes = parsed.nodes.map((n) => {
1172
- const r = rectOf(n.label);
1173
- return { label: n.label, x: r.x, y: r.y, width: r.w, height: r.h };
1174
- });
1175
- const edges: BLLayoutEdge[] = parsed.edges.flatMap((e) => {
1176
- const sp = posOf(e.source);
1177
- const tp = posOf(e.target);
1178
- if (!sp || !tp) return [];
1179
- const srcRect = rectOf(e.source);
1180
- const tgtRect = rectOf(e.target);
1181
- const p0 = rectBorderPoint(srcRect, tp);
1182
- const p1 = rectBorderPoint(tgtRect, sp);
1183
- return [
1184
- {
1185
- source: e.source,
1186
- target: e.target,
1187
- ...(e.label !== undefined && { label: e.label }),
1188
- bidirectional: e.bidirectional,
1189
- lineNumber: e.lineNumber,
1190
- points: [p0, p1],
1191
- yOffset: 0,
1192
- parallelCount: 1,
1193
- metadata: e.metadata,
1194
- straight: true,
1195
- },
1196
- ];
1197
- });
1198
- // Fit the canvas around the pinned content with a uniform margin on every
1199
- // side. Crucially, content must never fall off the TOP/LEFT (the viewBox
1200
- // origin is 0,0): if a node was dragged past the margin we shift everything
1201
- // back on-canvas by `max(0, M - min)` — clamped so in-bounds diagrams keep
1202
- // their exact pinned coords (shift 0) and only off-canvas ones are nudged.
1203
- // Flat-group container rects: bbox of the group's members + side/bottom
1204
- // padding, with a label zone reserved at the top (mirrors the renderer).
1205
- const GROUP_PAD = 16;
1206
- const nodeByLabel = new Map(nodes.map((n) => [n.label, n]));
1207
- const groups: BLLayoutResult['groups'][number][] = [];
1208
- for (const grp of parsed.groups) {
1209
- let gx0 = Infinity,
1210
- gy0 = Infinity,
1211
- gx1 = -Infinity,
1212
- gy1 = -Infinity;
1213
- for (const c of grp.children) {
1214
- const n = nodeByLabel.get(c);
1215
- if (!n) continue;
1216
- gx0 = Math.min(gx0, n.x - n.width / 2);
1217
- gx1 = Math.max(gx1, n.x + n.width / 2);
1218
- gy0 = Math.min(gy0, n.y - n.height / 2);
1219
- gy1 = Math.max(gy1, n.y + n.height / 2);
1220
- }
1221
- if (!Number.isFinite(gx0)) continue; // members not pinned / empty group
1222
- const x0 = gx0 - GROUP_PAD;
1223
- const x1 = gx1 + GROUP_PAD;
1224
- const y0 = gy0 - GROUP_LABEL_ZONE;
1225
- const y1 = gy1 + GROUP_PAD;
1226
- groups.push({
1227
- label: grp.label,
1228
- lineNumber: grp.lineNumber,
1229
- x: (x0 + x1) / 2,
1230
- y: (y0 + y1) / 2,
1231
- width: x1 - x0,
1232
- height: y1 - y0,
1233
- collapsed: false,
1234
- childCount: grp.children.length,
1235
- });
1236
- }
1237
- // Collapsed flat groups: a plain box at the members' centre.
1238
- for (const cb of collapsedBoxes) {
1239
- groups.push({
1240
- label: cb.label,
1241
- lineNumber: cb.lineNumber,
1242
- x: cb.x,
1243
- y: cb.y,
1244
- width: NODE_WIDTH,
1245
- height: NODE_HEIGHT,
1246
- collapsed: true,
1247
- childCount: cb.childCount,
1248
- });
1249
- }
1250
-
1251
- const M = 40;
1252
- let minX = Infinity,
1253
- minY = Infinity,
1254
- maxX = -Infinity,
1255
- maxY = -Infinity;
1256
- const acc = (x: number, y: number) => {
1257
- if (x < minX) minX = x;
1258
- if (x > maxX) maxX = x;
1259
- if (y < minY) minY = y;
1260
- if (y > maxY) maxY = y;
1261
- };
1262
- for (const n of nodes) {
1263
- acc(n.x - n.width / 2, n.y - n.height / 2);
1264
- acc(n.x + n.width / 2, n.y + n.height / 2);
1265
- }
1266
- for (const e of edges) for (const p of e.points) acc(p.x, p.y);
1267
- for (const gr of groups) {
1268
- acc(gr.x - gr.width / 2, gr.y - gr.height / 2);
1269
- acc(gr.x + gr.width / 2, gr.y + gr.height / 2);
1270
- }
1271
- // Only correct genuinely off-canvas content — a small tolerance ignores the
1272
- // sub-pixel jitter from rounding coords near the margin, so an already
1273
- // on-canvas diagram keeps its exact pinned positions (no creeping drift).
1274
- const TOL = 2;
1275
- const sx = minX < M - TOL ? M - minX : 0;
1276
- const sy = minY < M - TOL ? M - minY : 0;
1277
- const shifted = sx !== 0 || sy !== 0;
1278
- return {
1279
- nodes: shifted
1280
- ? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
1281
- : nodes,
1282
- edges: shifted
1283
- ? edges.map((e) => ({
1284
- ...e,
1285
- points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
1286
- }))
1287
- : edges,
1288
- groups: shifted
1289
- ? groups.map((gr) => ({ ...gr, x: gr.x + sx, y: gr.y + sy }))
1290
- : groups,
1291
- width: maxX + sx + M,
1292
- height: maxY + sy + M,
1293
- };
1294
- }
1295
-
1296
- function place(cfg: {
1297
- ranker: string;
1298
- nodesep: number;
1299
- ranksep: number;
1300
- seed?: number;
1301
- }): BLLayoutResult {
1302
- const r = cfg.seed === undefined ? null : rng(cfg.seed + 1);
1303
- const ord = <T>(a: readonly T[]): T[] => (r ? shuffle(a, r) : a.slice());
1304
- const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
1305
- g.setGraph({
1306
- rankdir,
1307
- ranker: cfg.ranker,
1308
- nodesep: cfg.nodesep,
1309
- ranksep: cfg.ranksep,
1310
- edgesep: 20,
1311
- marginx: 40,
1312
- marginy: 40,
1313
- });
1314
- g.setDefaultEdgeLabel(() => ({}));
1315
- for (const grp of ord(parsed.groups))
1316
- g.setNode(gid(grp.label), { label: grp.label });
1317
- for (const node of ord(parsed.nodes)) {
1318
- const s = sizes.get(node.label)!;
1319
- g.setNode(node.label, { width: s.width, height: s.height });
1320
- }
1321
- for (const label of collapsedGroupLabels)
1322
- g.setNode(gid(label), { width: NODE_WIDTH, height: NODE_HEIGHT });
1323
- for (const grp of parsed.groups) {
1324
- if (grp.parentGroup && g.hasNode(gid(grp.parentGroup)))
1325
- g.setParent(gid(grp.label), gid(grp.parentGroup));
1326
- for (const c of ord(grp.children)) {
1327
- if (g.hasNode(c)) g.setParent(c, gid(grp.label));
1328
- }
1329
- }
1330
- if (collapseInfo)
1331
- for (const label of collapsedGroupLabels) {
1332
- const og = collapseInfo.originalGroups.find((x) => x.label === label);
1333
- if (
1334
- og?.parentGroup &&
1335
- !collapsedGroupLabels.has(og.parentGroup) &&
1336
- g.hasNode(gid(og.parentGroup))
1337
- )
1338
- g.setParent(gid(label), gid(og.parentGroup));
1339
- }
1340
- for (const e of ord(parsed.edges)) {
1341
- const s = srcRep(e.source);
1342
- const t = tgtRep(e.target);
1343
- if (s !== t && g.hasNode(s) && g.hasNode(t)) {
1344
- // Last-resort label legibility: reserve a virtual label node on the edge
1345
- // so dagre widens the rank gap to fit the (wrapped) label.
1346
- let edgeLabel: object = {};
1347
- if (reserveEdgeLabels && e.label) {
1348
- const m = measureEdgeLabel(e.label, EDGE_LABEL_FONT_SIZE);
1349
- edgeLabel = { width: m.width, height: m.height, labelpos: 'c' };
1350
- }
1351
- g.setEdge(s, t, edgeLabel, edgeKey(e));
1352
- }
1353
- }
1354
- dagre.layout(g);
1355
-
1356
- const nodes = parsed.nodes.map((n) => {
1357
- const p = g.node(n.label);
1358
- return {
1359
- label: n.label,
1360
- x: p.x,
1361
- y: p.y,
1362
- width: p.width,
1363
- height: p.height,
1364
- };
1365
- });
1366
- const groups: BLLayoutResult['groups'][number][] = parsed.groups.map(
1367
- (grp) => {
1368
- const p = g.node(gid(grp.label));
1369
- return {
1370
- label: grp.label,
1371
- lineNumber: grp.lineNumber,
1372
- x: p.x,
1373
- y: p.y,
1374
- width: p.width,
1375
- height: p.height,
1376
- collapsed: false,
1377
- childCount: grp.children.length,
1378
- };
1379
- }
1380
- );
1381
- for (const label of collapsedGroupLabels) {
1382
- const p = g.node(gid(label));
1383
- groups.push({
1384
- label,
1385
- lineNumber: 0,
1386
- x: p.x,
1387
- y: p.y,
1388
- width: p.width,
1389
- height: p.height,
1390
- collapsed: true,
1391
- childCount: collapseInfo?.collapsedChildCounts.get(label) ?? 0,
1392
- });
1393
- }
1394
- // Boundary rects for expanded groups — used to clip group-incident edges.
1395
- const groupRectByGid = new Map<
1396
- string,
1397
- { x: number; y: number; w: number; h: number }
1398
- >();
1399
- for (const grp of parsed.groups) {
1400
- const p = g.node(gid(grp.label));
1401
- if (p)
1402
- groupRectByGid.set(gid(grp.label), {
1403
- x: p.x,
1404
- y: p.y,
1405
- w: p.width,
1406
- h: p.height,
1407
- });
1408
- }
1409
- const clipPointsToGroups = (
1410
- pts: Pt[],
1411
- source: string,
1412
- target: string
1413
- ): Pt[] => {
1414
- if (pts.length < 2) return pts;
1415
- let out = pts;
1416
- const srcRect = groupRectByGid.get(source);
1417
- if (srcRect) {
1418
- let k = 0;
1419
- while (k < out.length - 1 && isInsideRect(out[k]!, srcRect)) k++;
1420
- out = [rectBorderPoint(srcRect, out[k]!), ...out.slice(k)];
1421
- }
1422
- const tgtRect = groupRectByGid.get(target);
1423
- if (tgtRect) {
1424
- let k = out.length - 1;
1425
- while (k > 0 && isInsideRect(out[k]!, tgtRect)) k--;
1426
- out = [...out.slice(0, k + 1), rectBorderPoint(tgtRect, out[k]!)];
1427
- }
1428
- return out;
1429
- };
1430
- const edges: BLLayoutEdge[] = parsed.edges.flatMap((e) => {
1431
- const s = srcRep(e.source);
1432
- const t = tgtRep(e.target);
1433
- const key = edgeKey(e);
1434
- if (s === t || !g.hasEdge(s, t, key)) return [];
1435
- const ed = g.edge(s, t, key) as { points?: Pt[] };
1436
- const points = clipPointsToGroups(ed?.points ?? [], e.source, e.target);
1437
- return [
1438
- {
1439
- source: e.source,
1440
- target: e.target,
1441
- ...(e.label !== undefined && { label: e.label }),
1442
- bidirectional: e.bidirectional,
1443
- lineNumber: e.lineNumber,
1444
- points,
1445
- yOffset: 0,
1446
- parallelCount: 1,
1447
- metadata: e.metadata,
1448
- },
1449
- ];
1450
- });
1451
- const gg = g.graph() as { width?: number; height?: number };
1452
- return {
1453
- nodes,
1454
- edges,
1455
- groups,
1456
- width: gg.width ?? 800,
1457
- height: gg.height ?? 600,
1458
- } as BLLayoutResult;
1459
- }
1460
-
1461
- // Pinned mode short-circuits the entire search.
1462
- if (allPinned) return placePinned(pinned!);
1463
-
1464
- const n = parsed.nodes.length;
1465
- // ~500ms budget: search a larger pool, then refine the top few exactly.
1466
- const seedCount =
1467
- opts?.seeds ?? (n <= 12 ? 80 : n <= 22 ? 40 : n <= 35 ? 22 : 10);
1468
- const REFINE_K = opts?.refineK ?? 6;
1469
- const lambda = opts?.lambda ?? DEFAULT_LAMBDA;
1470
- const prev = opts?.previousPositions;
1471
-
1472
- // Candidate configs: every (ranker × spacing) combo + seed-shuffles of the
1473
- // default. Diverse candidates lower the crossing floor; seed-shuffles vary
1474
- // dagre's within-layer ordering.
1475
- const RANKERS = ['network-simplex', 'tight-tree', 'longest-path'];
1476
- const SPACINGS = [
1477
- { nodesep: 50, ranksep: 60 },
1478
- { nodesep: 34, ranksep: 46 },
1479
- { nodesep: 66, ranksep: 82 },
1480
- ];
1481
- const configs: {
1482
- ranker: string;
1483
- nodesep: number;
1484
- ranksep: number;
1485
- seed?: number;
1486
- }[] = [];
1487
- for (const ranker of RANKERS)
1488
- for (const sp of SPACINGS) configs.push({ ranker, ...sp });
1489
- for (let s = 0; s < seedCount; s++)
1490
- configs.push({
1491
- ranker: 'network-simplex',
1492
- nodesep: 50,
1493
- ranksep: 60,
1494
- seed: s,
1495
- });
1496
-
1497
- // Honest "badness" — every kind of line-in-the-wrong-place counts equally:
1498
- // X true crossings + O overlap runs (lines stepping on each other)
1499
- // + P edges piercing unrelated node boxes.
1500
- // (A line through a node and two lines sharing a path are crossings too.)
1501
- // `floor` lets callers skip the expensive O/P passes once X alone already
1502
- // exceeds the best badness found so far (it can't win, return Infinity).
1503
- const badness = (lay: BLLayoutResult, floor: number): number => {
1504
- const x = countSplineCrossings(lay, floor);
1505
- if (x > floor) return Infinity;
1506
- return (
1507
- x +
1508
- countEdgeOverlaps(lay) +
1509
- countEdgeNodePierces(lay) +
1510
- countGroupOverlaps(lay)
1511
- );
1512
- };
1513
-
1514
- // Objective: badness dominates (×1e6, strictly fewer wins); ties broken by
1515
- // total edge length (positioning) + stability drift (only when prev given).
1516
- const objective = (lay: BLLayoutResult, viol: number) =>
1517
- viol * 1e6 + edgeLength(lay) + lambda * meanDrift(lay, prev) * 10;
1518
-
1519
- // Progress is reported over the two dominant phases: building the dagre
1520
- // candidate pool, then exact-scoring the top few. `refineK` is clamped below,
1521
- // so estimate the total here for a smooth bar.
1522
- const progressTotal =
1523
- configs.length + Math.min(opts?.refineK ?? 6, configs.length);
1524
- let progressDone = 0;
1525
- const step = async (phase: string): Promise<void> => {
1526
- if (!onProgress) return;
1527
- onProgress(++progressDone, progressTotal, phase);
1528
- const now = performance.now();
1529
- if (
1530
- now - searchStart > YIELD_AFTER_MS &&
1531
- now - lastYield > YIELD_EVERY_MS
1532
- ) {
1533
- lastYield = now;
1534
- await tick();
1535
- }
1536
- };
1537
-
1538
- // Build the candidate pool.
1539
- const pool: BLLayoutResult[] = [];
1540
- for (const cfg of configs) {
1541
- try {
1542
- pool.push(place(cfg));
1543
- } catch {
1544
- /* some rankers choke on odd graphs */
1545
- }
1546
- await step('Optimizing layout');
1547
- }
1548
- if (!pool.length)
1549
- return place({ ranker: 'network-simplex', nodesep: 50, ranksep: 60 });
1550
-
1551
- // Home-grown layered candidates (flat graphs only). These own the
1552
- // crossing-minimization stage AND route back-edges around the periphery, so
1553
- // they can reach layouts below dagre's ordering+routing floor (e.g. the
1554
- // pirate-fleet K2,2). Their peripheral back-edges are curved loops that the
1555
- // cheap straight-segment ranker mis-scores, so they bypass stage-1 and are
1556
- // ALWAYS exact-scored in stage 2. Best-effort: never block the dagre pool.
1557
- let layered: BLLayoutResult[] = [];
1558
- try {
1559
- layered = layeredCandidates(parsed, sizes);
1560
- } catch {
1561
- /* ignore */
1562
- }
1563
-
1564
- // Tier-banded grouped candidates. The dagre path treats `[Group]` containers
1565
- // as compound clusters with no tier ORDER and no cycle-aware routing, so
1566
- // tiered architecture diagrams collide bands side-by-side and balloon on
1567
- // back-edges. This generator orders the groups into disjoint rank bands and
1568
- // routes against-tier edges around the periphery. Additive — kept only on a
1569
- // strict badness win. Skipped when any group is collapsed (the generator
1570
- // models fully-expanded groups only).
1571
- if (parsed.groups.length > 0 && collapsedGroupLabels.size === 0) {
1572
- try {
1573
- layered = layered.concat(groupedTierCandidates(parsed, sizes));
1574
- } catch {
1575
- /* ignore */
1576
- }
1577
- }
1578
-
1579
- // Stage 1: rank the dagre pool with the cheap straight-segment counter — a
1580
- // cheap proxy to pick which candidates are worth the expensive exact scoring.
1581
- // Widen REFINE_K a little since the proxy only sees crossings, not O/P.
1582
- // Pre-score each candidate ONCE (countCrossingsFast is O(E²)) into a key map,
1583
- // then sort by the stored key — the comparator otherwise recomputes the score
1584
- // for both operands on every comparison (O(C log C) calls vs C). The score is a
1585
- // pure function of the layout, so the resulting order is identical.
1586
- const fastKey = new Map<BLLayoutResult, number>();
1587
- for (const lay of pool)
1588
- fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
1589
- pool.sort((a, b) => fastKey.get(a)! - fastKey.get(b)!);
1590
- const refineK = Math.min(REFINE_K, pool.length);
1591
-
1592
- // Stage 2: exact-score the top-K dagre candidates on the FULL badness (X+O+P)
1593
- // and pick the best — so the placement search avoids overlaps and node-pierces,
1594
- // not just crossings.
1595
- let best = pool[0]!;
1596
- let bestObj = Infinity;
1597
- let bestBad = Infinity;
1598
- const consider = (lay: BLLayoutResult): void => {
1599
- const bad = badness(lay, bestBad);
1600
- if (bad === Infinity) return;
1601
- const sc = objective(lay, bad);
1602
- if (sc < bestObj) {
1603
- bestObj = sc;
1604
- bestBad = bad;
1605
- best = lay;
1606
- }
1607
- };
1608
- for (const lay of pool.slice(0, refineK)) {
1609
- consider(lay);
1610
- await step('Refining layout');
1611
- }
1612
-
1613
- // Adaptive escalation: a still-high badness after the base seed budget means
1614
- // the graph is genuinely hard — dense layouts (e.g. the marketplace) need many
1615
- // more random restarts to stumble onto a low-crossing layer ordering, and the
1616
- // good ordering is rare enough that refining the existing pool can't find it
1617
- // (only generating fresh seeds can). Spend an extra seed batch — but ONLY when
1618
- // the result is actually bad, so the easy 0-badness majority of the corpus
1619
- // never pays the latency. Bounded by node count to keep the worst case ~1s.
1620
- if (bestBad >= ESCALATE_THRESHOLD && n <= ESCALATE_MAX_N) {
1621
- const extra: BLLayoutResult[] = [];
1622
- for (let s = seedCount; s < seedCount + ESCALATE_SEEDS; s++) {
1623
- try {
1624
- extra.push(
1625
- place({
1626
- ranker: 'network-simplex',
1627
- nodesep: 50,
1628
- ranksep: 60,
1629
- seed: s,
1630
- })
1631
- );
1632
- } catch {
1633
- /* ignore choking rankers */
1634
- }
1635
- }
1636
- const extraKey = new Map<BLLayoutResult, number>();
1637
- for (const lay of extra)
1638
- extraKey.set(lay, objective(lay, countCrossingsFast(lay)));
1639
- extra.sort((a, b) => extraKey.get(a)! - extraKey.get(b)!);
1640
- for (const lay of extra.slice(0, ESCALATE_REFINE)) consider(lay);
1641
- }
1642
-
1643
- // Layered candidates (and their better-routed, de-pierced variants) replace
1644
- // the dagre winner ONLY on a STRICT total-badness reduction — never on an
1645
- // edge-length tiebreak. They're few, so de-piercing each is cheap.
1646
- for (const lay of layered) {
1647
- const variants = [lay];
1648
- const dp = deroutePierces(lay);
1649
- if (dp !== lay) variants.push(dp);
1650
- for (const v of variants) {
1651
- const bad = badness(v, bestBad - 1);
1652
- if (bad < bestBad) {
1653
- bestBad = bad;
1654
- best = v;
1655
- }
1656
- }
1657
- }
1658
-
1659
- // Feed the objective one more BETTER-ROUTED alternative: bend the current
1660
- // winner's edges around any node they still pierce. Kept only if it strictly
1661
- // lowers total badness (a detour that trades a pierce for a crossing/overlap
1662
- // is rejected).
1663
- if (bestBad > 0) {
1664
- const rerouted = deroutePierces(best);
1665
- if (rerouted !== best && badness(rerouted, bestBad - 1) < bestBad)
1666
- best = rerouted;
1667
- }
1668
-
1669
- // Better-placed alternative: if the winner still has overlapping group bands
1670
- // (dagre wedged a small group into a tight channel), push the bands apart along
1671
- // the cross-axis. Kept only on strict total-badness drop.
1672
- if (bestBad > 0 && countGroupOverlaps(best) > 0) {
1673
- const separated = separateGroupBands(best, parsed);
1674
- if (separated !== best && badness(separated, bestBad - 1) < bestBad)
1675
- best = separated;
1676
- }
1677
- return best;
1678
- }