@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,971 +0,0 @@
1
- // ============================================================
2
- // PERT Layout — dagre wrapper
3
- // ============================================================
4
- //
5
- // Pure functions; no UI state. The diagrammo-app holds expansion state
6
- // in its own store and feeds an override map into `relayoutPert`.
7
-
8
- import dagre from '@dagrejs/dagre';
9
- import type {
10
- ResolvedPert,
11
- LayoutResult,
12
- PertLayoutNode,
13
- PertLayoutGroup,
14
- PertDirection,
15
- } from './types';
16
- import type { LayoutOverrides } from './internal';
17
- import {
18
- formatDuration,
19
- formatScheduleValue,
20
- formatSprintCell,
21
- formatSlackValue,
22
- } from './internal';
23
- import { measureText } from '../utils/text-measure';
24
-
25
- // Textbook 3×3 PERT/CPM box: top row [ES | dur | EF], middle row
26
- // [name spanning all three columns], bottom row [LS | slack | LF].
27
- // Heights are fixed; widths are computed from cell content via
28
- // `computeNodeSizing` so a non-date diagram (where ES/EF are short
29
- // duration labels) renders narrower than a date-anchored diagram
30
- // (where those cells hold a 10-char `YYYY-MM-DD`).
31
- const DEFAULT_NODE_HEIGHT = 90;
32
- const MILESTONE_NODE_HEIGHT = DEFAULT_NODE_HEIGHT;
33
- const COLLAPSED_GROUP_HEIGHT = 90;
34
- const DIAGRAM_PADDING = 20;
35
- const GROUP_PADDING = 18;
36
- // Top padding reserves space for the centered group label band — matches
37
- // org's `CONTAINER_HEADER_HEIGHT`. See `docs/architecture/diagram-visual-
38
- // conventions.md` §2 ("Reserved header band").
39
- const GROUP_TOP_PADDING = 28;
40
-
41
- // Swim-lane layout constants. When a diagram has groups, members are
42
- // placed in horizontal (LR) or vertical (TB) bands so group rectangles
43
- // never overlap, even when cross-group edges scatter members across ranks.
44
- const SWIMLANE_SLOT_SEP = 40;
45
- const SWIMLANE_GAP = 24;
46
-
47
- // Cell-sizing constants — see `computeNodeSizing` below.
48
- const NODE_CELL_FONT_SIZE = 11;
49
- const NODE_NAME_FONT_SIZE = 13;
50
- // Milestone name renders at 12pt with a leading `◆ ` glyph (see renderer).
51
- const MILESTONE_NAME_FONT_SIZE = 12;
52
- const CELL_PAD_X = 8;
53
- const NAME_PAD_X = 6;
54
- // Anchor icon + gap (renderer reserves space to the left of the name
55
- // when the activity is the forward source / backward sink).
56
- const NAME_PIN_WIDTH = 13 + 4;
57
- const MIN_CELL_WIDTH = 30;
58
- const MIN_NODE_WIDTH = 120;
59
- const MAX_NODE_WIDTH = 280;
60
- const MIN_MILESTONE_WIDTH = 80;
61
- const MAX_MILESTONE_WIDTH = 160;
62
-
63
- /**
64
- * Per-diagram node geometry. All non-milestone activity nodes (and any
65
- * collapsed-group cards) share `activityWidth` so the lane reads as a
66
- * uniform grid; `outerColW`/`midColW` distribute that width so the
67
- * outer cells (ES/EF/LS/LF) fit their longest displayed value and the
68
- * middle cells (dur/slack) fit theirs. Milestones get their own width.
69
- */
70
- export interface NodeSizing {
71
- activityWidth: number;
72
- milestoneWidth: number;
73
- outerColW: number;
74
- midColW: number;
75
- }
76
-
77
- /**
78
- * Walk every resolved activity, format each cell with the same fn the
79
- * renderer uses, and return the column/node widths needed to hold the
80
- * longest cell text (plus the longest name, capped at MAX_NODE_WIDTH —
81
- * names beyond that truncate with ellipsis at draw time). Sprint /
82
- * date / duration modes each have a characteristic cell-width profile,
83
- * so the diagram tightens automatically when ES/EF only hold "5w" vs.
84
- * "2026-05-15".
85
- */
86
- export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
87
- const unit = resolved.options.timeUnit;
88
- const sprintMode = resolved.options.sprintMode;
89
- const sprintNumber = resolved.options.sprintNumber ?? 1;
90
- const projectStart = resolved.projectStart;
91
-
92
- const fmtSchedule = (v: number | null, isTbd: boolean): string =>
93
- sprintMode
94
- ? formatSprintCell(v, sprintNumber, isTbd ? '?' : null)
95
- : formatScheduleValue(v, projectStart, unit, isTbd ? '?' : null);
96
- const fmtSlack = (v: number | null, isTbd: boolean): string =>
97
- formatSlackValue(v, projectStart, unit, isTbd ? '?' : null);
98
- const fmtDur = (v: number | null, isTbd: boolean): string =>
99
- formatDuration(v, unit, isTbd ? '?' : null);
100
-
101
- // Track the widest rendered pixel width per cell category — measured at
102
- // the exact font sizes the renderer draws with so reserved width
103
- // matches drawn text.
104
- let maxOuterW = 0;
105
- let maxMidW = 0;
106
- let maxNameW = 0;
107
- let maxMilestoneTopW = 0;
108
- let maxMilestoneSlackW = 0;
109
- let maxMilestoneNameW = 0;
110
-
111
- for (const r of resolved.activities) {
112
- const isTbd = r.es === null;
113
- if (r.activity.isMilestone) {
114
- const dateStr = fmtSchedule(r.es, isTbd);
115
- const slackStr = fmtSlack(r.slack, isTbd);
116
- const slackHidden = !isTbd && /^0[a-z]?$/.test(slackStr);
117
- maxMilestoneTopW = Math.max(
118
- maxMilestoneTopW,
119
- measureText(dateStr, NODE_CELL_FONT_SIZE)
120
- );
121
- if (!slackHidden) {
122
- maxMilestoneSlackW = Math.max(
123
- maxMilestoneSlackW,
124
- measureText(slackStr, NODE_CELL_FONT_SIZE)
125
- );
126
- }
127
- // The milestone glyph prefix `◆ ` precedes the name.
128
- maxMilestoneNameW = Math.max(
129
- maxMilestoneNameW,
130
- measureText(`◆ ${r.activity.name}`, MILESTONE_NAME_FONT_SIZE)
131
- );
132
- continue;
133
- }
134
- const esStr = fmtSchedule(r.es, isTbd);
135
- const efStr = fmtSchedule(r.ef, isTbd);
136
- const lsStr = fmtSchedule(r.ls, isTbd);
137
- const lfStr = fmtSchedule(r.lf, isTbd);
138
- const durStr = fmtDur(r.mu, isTbd);
139
- const slackStr = fmtSlack(r.slack, isTbd);
140
- maxOuterW = Math.max(
141
- maxOuterW,
142
- measureText(esStr, NODE_CELL_FONT_SIZE),
143
- measureText(efStr, NODE_CELL_FONT_SIZE),
144
- measureText(lsStr, NODE_CELL_FONT_SIZE),
145
- measureText(lfStr, NODE_CELL_FONT_SIZE)
146
- );
147
- maxMidW = Math.max(
148
- maxMidW,
149
- measureText(durStr, NODE_CELL_FONT_SIZE),
150
- measureText(slackStr, NODE_CELL_FONT_SIZE)
151
- );
152
- maxNameW = Math.max(
153
- maxNameW,
154
- measureText(r.activity.name, NODE_NAME_FONT_SIZE)
155
- );
156
- }
157
-
158
- // Also account for any collapsed-group rolled-up label width — the
159
- // renderer draws those with the same textbook-card chrome, so their
160
- // name needs to fit in the shared `activityWidth`.
161
- for (const rg of resolved.groups) {
162
- maxNameW = Math.max(
163
- maxNameW,
164
- measureText(rg.group.name, NODE_NAME_FONT_SIZE)
165
- );
166
- }
167
-
168
- const outerCell = Math.ceil(maxOuterW) + 2 * CELL_PAD_X;
169
- const midCell = Math.ceil(maxMidW) + 2 * CELL_PAD_X;
170
- const outerNeeded = Math.max(MIN_CELL_WIDTH, outerCell);
171
- const midNeeded = Math.max(MIN_CELL_WIDTH, midCell);
172
- const cellsTotalW = 2 * outerNeeded + midNeeded;
173
-
174
- // The name dictates a lower bound too — anchor-pinned cards reserve
175
- // a NAME_PIN_WIDTH on the left, so size for the worst case.
176
- const nameTextW = Math.ceil(maxNameW);
177
- const nameTotalW = nameTextW + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
178
-
179
- const activityWidth = Math.max(
180
- MIN_NODE_WIDTH,
181
- Math.min(MAX_NODE_WIDTH, Math.max(cellsTotalW, nameTotalW))
182
- );
183
-
184
- // Distribute the chosen activityWidth across columns. The ES/EF/LS/LF
185
- // pillars must always be the same dimension, so any slack between
186
- // `cellsTotalW` and `activityWidth` (from MIN_NODE_WIDTH inflation or
187
- // a long name) goes to the outer columns. The middle stays at its
188
- // natural need so the dur/slack cells don't grow needlessly wide. If
189
- // mid's natural need ever exceeds the outer share, fall back to three
190
- // equal columns so the middle never reads as the widest cell.
191
- let midColW = midNeeded;
192
- let outerColW = (activityWidth - midColW) / 2;
193
- if (midColW > outerColW) {
194
- outerColW = activityWidth / 3;
195
- midColW = activityWidth - 2 * outerColW;
196
- }
197
-
198
- // Milestones are independent: a one-cell-tall date row, a name row,
199
- // and (optionally) a slack row. Width should fit whichever is widest.
200
- const mTop = Math.ceil(maxMilestoneTopW) + 2 * CELL_PAD_X;
201
- const mSlack =
202
- maxMilestoneSlackW > 0 ? Math.ceil(maxMilestoneSlackW) + 2 * CELL_PAD_X : 0;
203
- // Milestone name renders at 12pt with anchor-pin reserve.
204
- const mName = Math.ceil(maxMilestoneNameW) + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
205
- const milestoneWidth = Math.max(
206
- MIN_MILESTONE_WIDTH,
207
- Math.min(MAX_MILESTONE_WIDTH, Math.max(mTop, mSlack, mName))
208
- );
209
-
210
- return { activityWidth, milestoneWidth, outerColW, midColW };
211
- }
212
-
213
- function nodeDimensions(
214
- resolved: ResolvedPert,
215
- id: string,
216
- sizing: NodeSizing,
217
- overrides?: LayoutOverrides
218
- ): { width: number; height: number } {
219
- if (overrides?.[id]) {
220
- return { width: overrides[id].width, height: overrides[id].height };
221
- }
222
- const r = resolved.activities.find((a) => a.activity.id === id);
223
- if (r?.activity.isMilestone) {
224
- return { width: sizing.milestoneWidth, height: MILESTONE_NODE_HEIGHT };
225
- }
226
- return { width: sizing.activityWidth, height: DEFAULT_NODE_HEIGHT };
227
- }
228
-
229
- export function layoutPert(resolved: ResolvedPert): LayoutResult {
230
- return relayoutPert(resolved, {});
231
- }
232
-
233
- export function relayoutPert(
234
- resolved: ResolvedPert,
235
- overrides: LayoutOverrides,
236
- collapsedGroupIds: ReadonlySet<string> = new Set()
237
- ): LayoutResult {
238
- if (resolved.activities.length === 0) {
239
- return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
240
- }
241
-
242
- // Build member-activity → collapsed-group lookup so we can replace
243
- // member activities with a single virtual dagre node per group.
244
- const memberToGroup = new Map<string, string>();
245
- for (const rg of resolved.groups) {
246
- if (collapsedGroupIds.has(rg.group.id)) {
247
- for (const aid of rg.group.activityIds) {
248
- memberToGroup.set(aid, rg.group.id);
249
- }
250
- }
251
- }
252
- const dagreId = (id: string): string => memberToGroup.get(id) ?? id;
253
-
254
- const g = new dagre.graphlib.Graph();
255
- g.setGraph({
256
- rankdir: resolved.options.direction,
257
- nodesep: 50,
258
- ranksep: 60,
259
- edgesep: 18,
260
- marginx: DIAGRAM_PADDING,
261
- marginy: DIAGRAM_PADDING,
262
- });
263
- g.setDefaultEdgeLabel(() => ({}));
264
-
265
- const sizing = computeNodeSizing(resolved);
266
-
267
- // Add real activity nodes (skipping members of collapsed groups).
268
- for (const r of resolved.activities) {
269
- if (memberToGroup.has(r.activity.id)) continue;
270
- const { width, height } = nodeDimensions(
271
- resolved,
272
- r.activity.id,
273
- sizing,
274
- overrides
275
- );
276
- g.setNode(r.activity.id, { width, height });
277
- }
278
- // Add one virtual node per collapsed group — uses the same shared
279
- // activity width so the rolled-up envelope lines up with siblings.
280
- for (const rg of resolved.groups) {
281
- if (collapsedGroupIds.has(rg.group.id)) {
282
- g.setNode(rg.group.id, {
283
- width: sizing.activityWidth,
284
- height: COLLAPSED_GROUP_HEIGHT,
285
- });
286
- }
287
- }
288
-
289
- // Add edges; rewrite endpoints that land on collapsed members,
290
- // dedupe, and drop edges that fold to a self-loop (internal-only
291
- // edges of a collapsed group).
292
- const seenEdges = new Set<string>();
293
- for (const e of resolved.edges) {
294
- const src = dagreId(e.source);
295
- const tgt = dagreId(e.target);
296
- if (src === tgt) continue;
297
- const k = `${src}|${tgt}`;
298
- if (seenEdges.has(k)) continue;
299
- seenEdges.add(k);
300
- g.setEdge(src, tgt, {});
301
- }
302
-
303
- dagre.layout(g);
304
-
305
- // When the diagram has any expanded group, switch to swim-lane layout:
306
- // ungrouped activities merge into the largest group's lane (the
307
- // "spine") so they share the same row as the spine's members, and any
308
- // additional groups get their own horizontal band below — guarantees
309
- // group rectangles never overlap and cross-group edges read as
310
- // explicit lane transitions. Falls back to per-rank criticality
311
- // centering when there are no groups (or all collapsed).
312
- const swimApplied = applySwimLanes(
313
- g,
314
- resolved,
315
- memberToGroup,
316
- collapsedGroupIds
317
- );
318
- if (!swimApplied) {
319
- // Pull critical-path activities toward the vertical center of each
320
- // rank. Dagre orders nodes within a rank to minimize edge crossings,
321
- // which is criticality-blind — so the dominant path drifts wherever
322
- // dagre chose. After layout, swap y-positions per rank so the
323
- // highest-criticality node sits in the middle and lower-criticality
324
- // ones fan out to the edges.
325
- centerByCriticality(g, resolved, memberToGroup, collapsedGroupIds);
326
- // Criticality centering is crossing-blind — it can undo dagre's
327
- // barycenter ordering and reintroduce edge crossings. Run a greedy
328
- // adjacent-swap sweep that keeps any swap that reduces total edge
329
- // crossings, preserving criticality-at-center where it doesn't
330
- // conflict.
331
- reduceCrossings(g, resolved.options.direction);
332
- }
333
-
334
- // Output nodes — only un-collapsed activities (virtual group nodes
335
- // are rendered through the `groups` channel, not here).
336
- const nodes: PertLayoutNode[] = resolved.activities
337
- .filter((r) => !memberToGroup.has(r.activity.id))
338
- .map((r) => {
339
- const pos = g.node(r.activity.id);
340
- return {
341
- id: r.activity.id,
342
- x: pos.x,
343
- y: pos.y,
344
- width: pos.width,
345
- height: pos.height,
346
- };
347
- });
348
-
349
- // Output edges — one per dedup'd dagre edge. Endpoints already point
350
- // at virtual group ids when a side is collapsed, which the renderer
351
- // uses verbatim for `data-source` / `data-target`.
352
- const edges = Array.from(seenEdges).map((k) => {
353
- // In-bounds: `seenEdges` keys are always `source|target` (built via join).
354
- const [source, target] = k.split('|') as [string, string];
355
- const data = g.edge(source, target);
356
- return { source, target, points: data?.points ?? [] };
357
- });
358
-
359
- // Pool of every laid-out rect (real activities + virtual group nodes)
360
- // for both diagram-bounds and group-rect math.
361
- const nodeMap = new Map<
362
- string,
363
- { x: number; y: number; width: number; height: number }
364
- >();
365
- for (const n of nodes) nodeMap.set(n.id, n);
366
- for (const rg of resolved.groups) {
367
- if (collapsedGroupIds.has(rg.group.id)) {
368
- const vn = g.node(rg.group.id);
369
- nodeMap.set(rg.group.id, {
370
- x: vn.x,
371
- y: vn.y,
372
- width: vn.width,
373
- height: vn.height,
374
- });
375
- }
376
- }
377
-
378
- const groups: PertLayoutGroup[] = resolved.groups.map((rg) => {
379
- const classification = rg.group.classification ?? 'cluster';
380
- if (collapsedGroupIds.has(rg.group.id)) {
381
- const vn = nodeMap.get(rg.group.id);
382
- if (!vn) {
383
- return {
384
- id: rg.group.id,
385
- x: 0,
386
- y: 0,
387
- width: 0,
388
- height: 0,
389
- classification,
390
- collapsed: true,
391
- };
392
- }
393
- return {
394
- id: rg.group.id,
395
- x: vn.x - vn.width / 2,
396
- y: vn.y - vn.height / 2,
397
- width: vn.width,
398
- height: vn.height,
399
- classification,
400
- collapsed: true,
401
- };
402
- }
403
-
404
- const members = rg.group.activityIds
405
- .map((id) => nodeMap.get(id))
406
- .filter(
407
- (n): n is { x: number; y: number; width: number; height: number } =>
408
- n !== undefined
409
- );
410
-
411
- if (members.length === 0) {
412
- return {
413
- id: rg.group.id,
414
- x: 0,
415
- y: 0,
416
- width: 0,
417
- height: 0,
418
- classification,
419
- };
420
- }
421
-
422
- let minX = Infinity;
423
- let minY = Infinity;
424
- let maxX = -Infinity;
425
- let maxY = -Infinity;
426
- for (const m of members) {
427
- const left = m.x - m.width / 2;
428
- const right = m.x + m.width / 2;
429
- const top = m.y - m.height / 2;
430
- const bottom = m.y + m.height / 2;
431
- if (left < minX) minX = left;
432
- if (right > maxX) maxX = right;
433
- if (top < minY) minY = top;
434
- if (bottom > maxY) maxY = bottom;
435
- }
436
- // Group rectangle is a tight bounding box around its members plus
437
- // label-band + bottom padding. Swim-lane placement already guarantees
438
- // non-overlap by giving each group a distinct slot band, so we don't
439
- // need to extend the rect to the full lane height — that would just
440
- // bake in empty space when the spine lane absorbs ungrouped members
441
- // at ranks the spine doesn't span.
442
- return {
443
- id: rg.group.id,
444
- x: minX - GROUP_PADDING,
445
- y: minY - GROUP_TOP_PADDING,
446
- width: maxX - minX + GROUP_PADDING * 2,
447
- height: maxY - minY + GROUP_TOP_PADDING + GROUP_PADDING,
448
- classification,
449
- };
450
- });
451
-
452
- // Diagram bounds — include both nodes and group rects (collapsed
453
- // groups contribute their virtual-node bounds via `groups`).
454
- let totalW = 0;
455
- let totalH = 0;
456
- for (const n of nodes) {
457
- const right = n.x + n.width / 2;
458
- const bottom = n.y + n.height / 2;
459
- if (right > totalW) totalW = right;
460
- if (bottom > totalH) totalH = bottom;
461
- }
462
- for (const grp of groups) {
463
- if (grp.x + grp.width > totalW) totalW = grp.x + grp.width;
464
- if (grp.y + grp.height > totalH) totalH = grp.y + grp.height;
465
- }
466
-
467
- return {
468
- nodes,
469
- edges,
470
- groups,
471
- width: totalW + DIAGRAM_PADDING,
472
- height: totalH + DIAGRAM_PADDING,
473
- };
474
- }
475
-
476
- // ============================================================
477
- // Swim-lane layout (post-process pass)
478
- // ============================================================
479
- //
480
- // When the diagram declares any group, dagre's rank-only layout will
481
- // scatter group members across ranks that the OTHER group also occupies,
482
- // causing post-hoc bounding rectangles to overlap (the classic "two
483
- // groups crammed into the same column band" failure). To fix this, we
484
- // reassign positions along the slot axis so each group gets its own
485
- // contiguous band along the slot axis. Dagre's rank assignments (rank
486
- // axis) are preserved so the diagram still reads as a topological flow.
487
- //
488
- // Lane order: the SPINE lane (the largest expanded group by member
489
- // count) goes first, with all ungrouped activities + collapsed-group
490
- // cards merged into it so they share the spine's primary row — wreck-
491
- // located, dive-ops member, log-inventory all sit at the same y in LR.
492
- // Other expanded groups stack below in source-declaration order. The
493
- // spine group's rectangle remains tight around its own members (not the
494
- // merged-in ungrouped ones), so ungrouped nodes sit at the spine row
495
- // but outside the rect, in the columns the spine doesn't span.
496
-
497
- function applySwimLanes(
498
- g: any,
499
- resolved: ResolvedPert,
500
- _memberToGroup: Map<string, string>,
501
- collapsedGroupIds: ReadonlySet<string>
502
- ): boolean {
503
- const expanded = resolved.groups.filter(
504
- (rg) => !collapsedGroupIds.has(rg.group.id)
505
- );
506
- if (expanded.length === 0) return false;
507
-
508
- const isLR = resolved.options.direction !== 'TB';
509
- const rankAxis = isLR ? 'x' : 'y';
510
- const slotAxis = isLR ? 'y' : 'x';
511
-
512
- // Spine = largest expanded group; ungrouped activities (and collapsed-
513
- // group cards) all merge into this lane so they ride the spine row.
514
- // Tiebreak by source-declaration order (first largest wins).
515
- let spineIdx = 1;
516
- let spineMembers = 0;
517
- expanded.forEach((rg, i) => {
518
- if (rg.group.activityIds.length > spineMembers) {
519
- spineMembers = rg.group.activityIds.length;
520
- spineIdx = i + 1;
521
- }
522
- });
523
-
524
- // Lane index per node. 1..N = expanded groups in declaration order;
525
- // ungrouped activities + collapsed-group cards get spineIdx so they
526
- // share the band with the spine's members.
527
- const laneOf = new Map<string, number>();
528
- expanded.forEach((rg, i) => {
529
- for (const aid of rg.group.activityIds) laneOf.set(aid, i + 1);
530
- });
531
- for (const id of g.nodes()) {
532
- if (!laneOf.has(id)) laneOf.set(id, spineIdx);
533
- }
534
-
535
- // Track which nodes are "spine members" — group members of the spine
536
- // (NOT the merged-in ungrouped nodes). This drives in-cell ordering:
537
- // spine members sit at the top slots so they form a continuous row.
538
- const spineMemberSet = new Set<string>(
539
- expanded[spineIdx - 1]?.group.activityIds ?? []
540
- );
541
-
542
- // Bucket every dagre node by rank, then by lane within each rank.
543
- const numLanes = expanded.length + 1; // index 0 unused (kept for clarity)
544
- const rankBuckets = new Map<number, Map<number, string[]>>();
545
- for (const id of g.nodes()) {
546
- const node = g.node(id);
547
- if (!node) continue;
548
- const rankKey = Math.round(node[rankAxis] as number);
549
- const laneIdx = laneOf.get(id) ?? spineIdx;
550
- let byLane = rankBuckets.get(rankKey);
551
- if (!byLane) {
552
- byLane = new Map();
553
- rankBuckets.set(rankKey, byLane);
554
- }
555
- if (!byLane.has(laneIdx)) byLane.set(laneIdx, []);
556
- byLane.get(laneIdx)!.push(id);
557
- }
558
-
559
- // Per-lane slot capacity = max members of that lane in any single rank.
560
- const laneSlots = new Array<number>(numLanes).fill(0);
561
- for (const byLane of rankBuckets.values()) {
562
- for (const [li, members] of byLane) {
563
- // In-bounds: laneSlots indexed by li from byLane which was keyed under numLanes.
564
- if (members.length > laneSlots[li]!) laneSlots[li] = members.length;
565
- }
566
- }
567
-
568
- // Slot size along the slot axis. All non-milestone activity / collapsed-
569
- // group nodes share DEFAULT_NODE_HEIGHT in LR; in TB we use the uniform
570
- // activityWidth (first node's width is representative).
571
- const slotSize = isLR
572
- ? DEFAULT_NODE_HEIGHT
573
- : (g.node(g.nodes()[0])?.width ?? DEFAULT_NODE_HEIGHT);
574
-
575
- // Lane sizes (along the slot axis) = LABEL_PAD + slot strip + BOTTOM_PAD
576
- // for any lane with at least one member; empty lanes contribute 0.
577
- const laneSize: number[] = new Array<number>(numLanes).fill(0);
578
- for (let i = 1; i < numLanes; i++) {
579
- // In-bounds by loop guard.
580
- const slots = laneSlots[i]!;
581
- if (slots === 0) {
582
- laneSize[i] = 0;
583
- continue;
584
- }
585
- const slotsExtent =
586
- slots * slotSize + Math.max(0, slots - 1) * SWIMLANE_SLOT_SEP;
587
- laneSize[i] = GROUP_TOP_PADDING + slotsExtent + GROUP_PADDING;
588
- }
589
-
590
- // Lane start offsets along the slot axis (cumulative). Spine goes first
591
- // so the spine row sits at the top of the diagram; other lanes stack
592
- // below in declaration order.
593
- const laneOrder: number[] = [
594
- spineIdx,
595
- ...Array.from({ length: expanded.length }, (_, i) => i + 1).filter(
596
- (i) => i !== spineIdx
597
- ),
598
- ];
599
- const laneStart: number[] = new Array<number>(numLanes).fill(0);
600
- let cursor = DIAGRAM_PADDING;
601
- for (const li of laneOrder) {
602
- laneStart[li] = cursor;
603
- // In-bounds: laneSize is sized numLanes; li drawn from laneOrder which contains valid indices.
604
- if (laneSize[li]! > 0) cursor += laneSize[li]! + SWIMLANE_GAP;
605
- }
606
-
607
- // Criticality lookup — used as a tie-breaker when ordering peers in
608
- // the same cell.
609
- const critOf = (id: string): number => {
610
- const r = resolved.activities.find((a) => a.activity.id === id);
611
- if (r) {
612
- return r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
613
- }
614
- const rg = resolved.groups.find((x) => x.group.id === id);
615
- if (!rg) return 0;
616
- let m = 0;
617
- for (const aid of rg.group.activityIds) {
618
- const ar = resolved.activities.find((a) => a.activity.id === aid);
619
- if (!ar) continue;
620
- const c =
621
- ar.criticality !== null ? ar.criticality : ar.isCriticalPath ? 1 : 0;
622
- if (c > m) m = c;
623
- }
624
- return m;
625
- };
626
-
627
- // Pre-shuffle slot positions for stable tie-breaking.
628
- const oldSlot = new Map<string, number>();
629
- for (const id of g.nodes()) {
630
- oldSlot.set(id, g.node(id)![slotAxis] as number);
631
- }
632
-
633
- // Reposition each node into its (lane, rank) cell — packed from the
634
- // band top so slot 1 stays at the same y across ranks (the "spine
635
- // row"). Spine members win the top slot; ungrouped tag along beneath.
636
- for (const byLane of rankBuckets.values()) {
637
- for (const [li, members] of byLane) {
638
- // In-bounds: laneStart sized numLanes; li drawn from the same lane-index domain.
639
- const slotStripStart = laneStart[li]! + GROUP_TOP_PADDING;
640
-
641
- const sorted = [...members].sort((a, b) => {
642
- // Spine members first (so they own slot 1 — the spine row).
643
- const aSpine = spineMemberSet.has(a) ? 0 : 1;
644
- const bSpine = spineMemberSet.has(b) ? 0 : 1;
645
- if (aSpine !== bSpine) return aSpine - bSpine;
646
- const ca = critOf(a);
647
- const cb = critOf(b);
648
- if (cb !== ca) return cb - ca;
649
- return (oldSlot.get(a) ?? 0) - (oldSlot.get(b) ?? 0);
650
- });
651
-
652
- sorted.forEach((id, idx) => {
653
- const node = g.node(id);
654
- node[slotAxis] =
655
- slotStripStart + slotSize / 2 + idx * (slotSize + SWIMLANE_SLOT_SEP);
656
- });
657
- }
658
- }
659
-
660
- // Re-route every edge with a clean S-curve — almost every node moved
661
- // along the slot axis, so dagre's original control points are now
662
- // routed through empty space at best, through other nodes at worst.
663
- const dir = resolved.options.direction;
664
- for (const e of g.edges()) {
665
- const src = g.node(e.v);
666
- const tgt = g.node(e.w);
667
- if (!src || !tgt) continue;
668
- const data = g.edge(e);
669
- if (!data) continue;
670
- data.points = smoothEdge(src, tgt, dir);
671
- }
672
-
673
- return true;
674
- }
675
-
676
- // ============================================================
677
- // Criticality-aware vertical centering (post-process pass)
678
- // ============================================================
679
- //
680
- // For each dagre rank (column in LR / row in TB), reorder nodes so the
681
- // highest-criticality node occupies the middle slot and lower ones
682
- // fan out symmetrically. Edges are kept in shape but their first/last
683
- // y-coordinates shift to match the new endpoints, with a linear
684
- // interpolation across the polyline so the curve still terminates on
685
- // the new node centers.
686
-
687
- // `@dagrejs/dagre` ships no type definitions, so `g` is typed as `any`
688
- // here — every prop access (`nodes()`, `node(id)`, `edges()`, etc.) is
689
- // untyped within this function.
690
-
691
- function centerByCriticality(
692
- g: any,
693
- resolved: ResolvedPert,
694
- memberToGroup: Map<string, string>,
695
- collapsedGroupIds: ReadonlySet<string>
696
- ): void {
697
- // Look up criticality per laid-out node. For activities we use the
698
- // MC criticality index when present, falling back to binary critical-
699
- // path membership in analytical mode. Collapsed groups inherit the
700
- // max-of-members so they sort with their internal hottest path.
701
- const critById = new Map<string, number>();
702
- for (const r of resolved.activities) {
703
- if (memberToGroup.has(r.activity.id)) continue;
704
- const c = r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
705
- critById.set(r.activity.id, c);
706
- }
707
- for (const rg of resolved.groups) {
708
- if (!collapsedGroupIds.has(rg.group.id)) continue;
709
- let maxC = 0;
710
- for (const aid of rg.group.activityIds) {
711
- const r = resolved.activities.find((x) => x.activity.id === aid);
712
- if (!r) continue;
713
- const c =
714
- r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
715
- if (c > maxC) maxC = c;
716
- }
717
- critById.set(rg.group.id, maxC);
718
- }
719
-
720
- // Bucket nodes by rank. dagre gives every node in a rank an identical
721
- // x (LR) or y (TB) coordinate, so a single rounded key per axis is
722
- // sufficient.
723
- const rankAxis = resolved.options.direction === 'TB' ? 'y' : 'x';
724
- const slotAxis = resolved.options.direction === 'TB' ? 'x' : 'y';
725
-
726
- const buckets = new Map<number, string[]>();
727
- for (const id of g.nodes()) {
728
- const node = g.node(id);
729
- if (!node) continue;
730
- const key = Math.round(node[rankAxis] as number);
731
- if (!buckets.has(key)) buckets.set(key, []);
732
- buckets.get(key)!.push(id);
733
- }
734
-
735
- // For each rank with > 1 member, recompute slot assignments.
736
- // `shifts` records the delta between the old slot value (along
737
- // `slotAxis`) and the new one, so edges can be translated to match.
738
- const shifts = new Map<string, number>();
739
-
740
- for (const ids of buckets.values()) {
741
- if (ids.length < 2) continue;
742
-
743
- // Existing slot positions (sorted ascending = top→bottom in LR).
744
- const slots = ids
745
- .map((id) => g.node(id)![slotAxis] as number)
746
- .sort((a, b) => a - b);
747
-
748
- // Sort ids by criticality descending; tie-break by current slot
749
- // so deterministic & stable when criticalities are equal.
750
- const currentSlot = new Map<string, number>();
751
- for (const id of ids) currentSlot.set(id, g.node(id)![slotAxis] as number);
752
- const sortedDesc = [...ids].sort((a, b) => {
753
- const ca = critById.get(a) ?? 0;
754
- const cb = critById.get(b) ?? 0;
755
- if (cb !== ca) return cb - ca;
756
- return (currentSlot.get(a) ?? 0) - (currentSlot.get(b) ?? 0);
757
- });
758
-
759
- // Middle-out arrangement: highest goes to the middle, then alternate
760
- // above/below for the next-ranked, so the lowest-criticality ends
761
- // up at the outermost edges.
762
- const top: string[] = [];
763
- const bottom: string[] = [];
764
- let middle: string | null = null;
765
- sortedDesc.forEach((id, i) => {
766
- if (i === 0) middle = id;
767
- else if (i % 2 === 1) top.unshift(id);
768
- else bottom.push(id);
769
- });
770
- const newOrder: string[] = middle
771
- ? [...top, middle, ...bottom]
772
- : [...top, ...bottom];
773
-
774
- newOrder.forEach((id, i) => {
775
- const node = g.node(id);
776
- const oldVal = node[slotAxis] as number;
777
- // In-bounds: slots was built with same length as newOrder above.
778
- const newVal = slots[i]!;
779
- shifts.set(id, newVal - oldVal);
780
- (node as Record<string, unknown>)[slotAxis] = newVal;
781
- });
782
- }
783
-
784
- // Re-route every edge whose endpoints moved. Dagre routes edges
785
- // through gaps between same-rank nodes; once we permute nodes within
786
- // a rank those gaps no longer exist where the polyline expected, and
787
- // a linear-shift hack would push control points through other nodes.
788
- // Replace the polyline with a clean 4-control-point smooth curve
789
- // (entry/exit are pulled along the flow axis so `curveBasis` gives a
790
- // gentle horizontal-leaning S, matching dagre's typical edge shape).
791
- if (shifts.size > 0) {
792
- const dir = resolved.options.direction;
793
- for (const e of g.edges()) {
794
- // Only regenerate edges where at least one endpoint moved, OR
795
- // an edge between two ranks where any node moved (so that
796
- // unaffected edges keep dagre's nicer multi-rank routing).
797
- if (!shifts.has(e.v) && !shifts.has(e.w)) continue;
798
- const src = g.node(e.v);
799
- const tgt = g.node(e.w);
800
- if (!src || !tgt) continue;
801
- const data = g.edge(e);
802
- if (!data) continue;
803
- data.points = smoothEdge(src, tgt, dir);
804
- }
805
- }
806
- }
807
-
808
- /**
809
- * Build a 4-control-point polyline that exits the source rectangle
810
- * along the flow axis, enters the target rectangle the same way, and
811
- * smooths under `curveBasis` into a gentle S-curve. Falls back to a
812
- * straight line when source and target are colinear along the flow.
813
- */
814
- function smoothEdge(
815
- src: { x: number; y: number; width: number; height: number },
816
- tgt: { x: number; y: number; width: number; height: number },
817
- direction: PertDirection
818
- ): { x: number; y: number }[] {
819
- if (direction === 'TB') {
820
- const sy = src.y + src.height / 2;
821
- const ty = tgt.y - tgt.height / 2;
822
- const span = ty - sy;
823
- const pad = Math.max(20, Math.abs(span) * 0.3);
824
- return [
825
- { x: src.x, y: sy },
826
- { x: src.x, y: sy + Math.sign(span || 1) * pad },
827
- { x: tgt.x, y: ty - Math.sign(span || 1) * pad },
828
- { x: tgt.x, y: ty },
829
- ];
830
- }
831
- // LR
832
- const sx = src.x + src.width / 2;
833
- const tx = tgt.x - tgt.width / 2;
834
- const span = tx - sx;
835
- const pad = Math.max(20, Math.abs(span) * 0.3);
836
- return [
837
- { x: sx, y: src.y },
838
- { x: sx + Math.sign(span || 1) * pad, y: src.y },
839
- { x: tx - Math.sign(span || 1) * pad, y: tgt.y },
840
- { x: tx, y: tgt.y },
841
- ];
842
- }
843
-
844
- // ============================================================
845
- // Crossing reduction (post-process pass)
846
- // ============================================================
847
- //
848
- // Greedy adjacent-swap sweep that runs after `centerByCriticality` so
849
- // crossings introduced by criticality permutation can be reversed when
850
- // the swap reduces total edge crossings. Edges are modeled as straight
851
- // segments between node centers — geometrically exact for the polyline
852
- // shape `smoothEdge` produces (the curve only deflects along the flow
853
- // axis, not the slot axis, so two edges cross visually iff their slot-
854
- // axis center segments cross). Caps iterations to bound cost.
855
-
856
- function reduceCrossings(g: any, direction: PertDirection): void {
857
- const isLR = direction !== 'TB';
858
- const rankAxis = isLR ? 'x' : 'y';
859
- const slotAxis = isLR ? 'y' : 'x';
860
-
861
- // Bucket nodes by rank.
862
- const buckets = new Map<number, string[]>();
863
- for (const id of g.nodes()) {
864
- const node = g.node(id);
865
- if (!node) continue;
866
- const key = Math.round(node[rankAxis] as number);
867
- if (!buckets.has(key)) buckets.set(key, []);
868
- buckets.get(key)!.push(id);
869
- }
870
-
871
- // Collect edges once — each is a pair of node ids whose current
872
- // positions we read on every count.
873
- const edges: Array<{ v: string; w: string }> = g
874
- .edges()
875
- .map((e: { v: string; w: string }) => ({ v: e.v, w: e.w }));
876
-
877
- const countCrossings = (): number => {
878
- let total = 0;
879
- for (let i = 0; i < edges.length; i++) {
880
- // In-bounds by loop guard.
881
- const a = edges[i]!;
882
- const a1 = g.node(a.v);
883
- const a2 = g.node(a.w);
884
- if (!a1 || !a2) continue;
885
- for (let j = i + 1; j < edges.length; j++) {
886
- // In-bounds by loop guard.
887
- const b = edges[j]!;
888
- // Shared endpoint → segments meet at a node, not a crossing.
889
- if (a.v === b.v || a.v === b.w || a.w === b.v || a.w === b.w) continue;
890
- const b1 = g.node(b.v);
891
- const b2 = g.node(b.w);
892
- if (!b1 || !b2) continue;
893
- if (segmentsCross(a1, a2, b1, b2)) total++;
894
- }
895
- }
896
- return total;
897
- };
898
-
899
- // Cap iterations — bubble-style sweep converges fast on small DAGs,
900
- // but a pathological case shouldn't spend O(rank * pairs) per pass
901
- // indefinitely.
902
- const MAX_ITER = 8;
903
- let baseline = countCrossings();
904
- if (baseline === 0) return;
905
-
906
- for (let iter = 0; iter < MAX_ITER; iter++) {
907
- let improved = false;
908
- for (const ids of buckets.values()) {
909
- if (ids.length < 2) continue;
910
- const sorted = ids
911
- .slice()
912
- .sort(
913
- (a, b) =>
914
- (g.node(a)![slotAxis] as number) - (g.node(b)![slotAxis] as number)
915
- );
916
- for (let i = 0; i < sorted.length - 1; i++) {
917
- const a = sorted[i];
918
- const b = sorted[i + 1];
919
- const an = g.node(a)!;
920
- const bn = g.node(b)!;
921
- const av = an[slotAxis] as number;
922
- const bv = bn[slotAxis] as number;
923
- an[slotAxis] = bv;
924
- bn[slotAxis] = av;
925
- const after = countCrossings();
926
- if (after < baseline) {
927
- baseline = after;
928
- improved = true;
929
- } else {
930
- an[slotAxis] = av;
931
- bn[slotAxis] = bv;
932
- }
933
- }
934
- }
935
- if (!improved || baseline === 0) break;
936
- }
937
-
938
- // Re-route every edge — node slot positions changed, so dagre's
939
- // original control points no longer connect cleanly.
940
- for (const e of g.edges()) {
941
- const src = g.node(e.v);
942
- const tgt = g.node(e.w);
943
- if (!src || !tgt) continue;
944
- const data = g.edge(e);
945
- if (!data) continue;
946
- data.points = smoothEdge(src, tgt, direction);
947
- }
948
- }
949
-
950
- // Standard CCW segment-intersection test. Returns true only for proper
951
- // (non-collinear, non-endpoint-shared) crossings.
952
- function segmentsCross(
953
- a1: { x: number; y: number },
954
- a2: { x: number; y: number },
955
- b1: { x: number; y: number },
956
- b2: { x: number; y: number }
957
- ): boolean {
958
- const ccw = (
959
- p: { x: number; y: number },
960
- q: { x: number; y: number },
961
- r: { x: number; y: number }
962
- ) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
963
- const d1 = ccw(b1, b2, a1);
964
- const d2 = ccw(b1, b2, a2);
965
- const d3 = ccw(a1, a2, b1);
966
- const d4 = ccw(a1, a2, b2);
967
- return (
968
- ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
969
- ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
970
- );
971
- }