@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,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
- }