@diagrammo/dgmo 0.47.0 → 0.48.1

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 (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. 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
- }