@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,4009 +0,0 @@
1
- // ============================================================
2
- // PERT Renderer (Phase 1 — static SVG)
3
- // ============================================================
4
- //
5
- // Visual encoding (textbook 3×3 PERT/CPM box):
6
- // - top row [ ES | dur | EF ]
7
- // - middle row [ name spans 3 ]
8
- // - bottom row [ LS | slack | LF ]
9
- // - dashed border for TBD activities (and downstream-of-TBD)
10
- // - criticality encoding (border + 25% fill tint + edge stroke):
11
- // analytical mode (no MC):
12
- // on M-world critical path → `palette.colors.red`
13
- // else → `palette.primary` (default)
14
- // Monte-Carlo mode (per-activity criticality index `c`):
15
- // c ≥ 0.80 → red (very likely on the critical path)
16
- // c ≥ 0.50 → orange (often)
17
- // c ≥ 0.25 → yellow (sometimes — could swing either way)
18
- // c ≥ 0.10 → green (occasionally)
19
- // c ≥ 0.02 → blue (rare but real)
20
- // c < 0.02 → primary (default — effectively never)
21
- // Border + fill share the same intent color (matching the org / infra
22
- // "solid border, 25% muted fill" convention). Internal cell-grid
23
- // lines inherit the same color at low opacity so the card reads as a
24
- // single tinted unit. Edges pick the band from
25
- // min(source.crit, target.crit) and a matching arrowhead marker.
26
- // Collapsed groups inherit the band from the max member criticality
27
- // (or any-member-critical when MC is off).
28
- // - group bounding rects (cluster) and super-edges (hammock when collapsed)
29
- // - data attrs on the `<g>` wrapper of each activity ONLY (per
30
- // CLAUDE.md gotcha: children must NOT carry data attributes)
31
-
32
- import * as d3Selection from 'd3-selection';
33
- import * as d3Shape from 'd3-shape';
34
- import { FONT_FAMILY } from '../fonts';
35
- import type { PaletteColors } from '../palettes';
36
- import {
37
- contrastText,
38
- mix,
39
- shapeFill,
40
- themeBaseBg,
41
- } from '../palettes/color-utils';
42
- import { ScaleContext } from '../utils/scaling';
43
- import {
44
- measureText,
45
- truncateText,
46
- wrapTextToWidth,
47
- } from '../utils/text-measure';
48
- import {
49
- TITLE_FONT_SIZE,
50
- TITLE_FONT_WEIGHT,
51
- TITLE_Y,
52
- CAPTION_FONT_SIZE,
53
- CAPTION_FONT_WEIGHT,
54
- CAPTION_LINE_HEIGHT,
55
- CAPTION_TOP_GAP,
56
- CAPTION_BOX_PADDING_X,
57
- CAPTION_BOX_PADDING_Y,
58
- } from '../utils/title-constants';
59
- import { LEGEND_HEIGHT as LEGEND_HEIGHT_CONST } from '../utils/legend-constants';
60
- import { resolveActiveTagGroup, resolveTagColor } from '../utils/tag-groups';
61
- import { renderIntegratedLegend } from '../utils/legend-integration';
62
- import type {
63
- CaptionRow,
64
- LayoutResult,
65
- PertEdge,
66
- ResolvedActivity,
67
- ResolvedPert,
68
- ScurveData,
69
- ScurveReferenceLine,
70
- } from './types';
71
- import { parsePert } from './parser';
72
- import { analyzePert } from './analyzer';
73
- import { layoutPert, computeNodeSizing } from './layout';
74
- import type { NodeSizing } from './layout';
75
- import {
76
- addCalendarDays,
77
- formatDuration,
78
- formatScheduleValue,
79
- formatSprintCell,
80
- formatSlackValue,
81
- } from './internal';
82
- import type { Duration, DurationUnit } from '../gantt/types';
83
-
84
- // ============================================================
85
- // Constants
86
- // ============================================================
87
-
88
- const DIAGRAM_PADDING = 20;
89
- const NODE_FONT_SIZE = 13;
90
- const NODE_CELL_FONT_SIZE = 11;
91
- // Textbook 3×3 PERT/CPM box. Card rx=6 to match org/infra. The middle
92
- // row holds the name and is taller than the corner cells so the name
93
- // reads as the primary label (mirroring textbook proportions).
94
- const NODE_RADIUS = 6;
95
- // Shared card / group / collapse constants (Story 111.1). The explanatory
96
- // comments below stay with the renderer; the values now live in one module.
97
- import {
98
- NODE_STROKE_WIDTH,
99
- EDGE_STROKE_WIDTH,
100
- CONTAINER_RADIUS,
101
- CONTAINER_LABEL_FONT_SIZE,
102
- CONTAINER_HEADER_HEIGHT,
103
- COLLAPSE_BAR_HEIGHT,
104
- } from '../utils/visual-conventions';
105
-
106
- // Analysis-block chrome (Summary / Activity Risk / Completion / Field
107
- // labels). These sit BELOW the diagram and shouldn't compete with it
108
- // for visual weight. Match the uncolored group-container recipe (§2:
109
- // `mix(surface, bg, 40)`) so the panels read as the same neutral
110
- // containers as PERT group rects. Keep the stroke desaturated so the
111
- // diagram stays the primary visual focus.
112
- function analysisBlockChrome(
113
- palette: PaletteColors,
114
- isDark: boolean
115
- ): { fill: string; stroke: string } {
116
- const surfaceBg = themeBaseBg(palette, isDark);
117
- return {
118
- fill: mix(palette.surface, palette.bg, 40),
119
- stroke: mix(palette.textMuted, surfaceBg, 35),
120
- };
121
- }
122
- const NODE_TOP_ROW_HEIGHT = 26;
123
- const NODE_BOTTOM_ROW_HEIGHT = 26;
124
- // Edge styling: non-critical edges follow `diagram-visual-conventions.md`
125
- // §4 (uniform textMuted stroke + width + arrowhead). Critical-path
126
- // edges deliberately deviate — they use `palette.colors.red` for the
127
- // stroke and a matching red arrowhead because the critical path is the
128
- // central concept of a PERT chart, and a binary `data-critical` attr
129
- // alone left it visually invisible to readers.
130
- const ARROWHEAD_W = 10;
131
- const ARROWHEAD_H = 7;
132
- // Group-rect treatment per §2: neutral surface fill on textMuted stroke,
133
- // solid border, rx=8, top-center 13pt 'bold' label inside a reserved
134
- // 28px header band — exactly matching org's container recipe.
135
- // CONTAINER_RADIUS/LABEL_FONT_SIZE/HEADER_HEIGHT + COLLAPSE_BAR_HEIGHT now
136
- // imported from utils/visual-conventions (Story 111.1). Group-rect treatment
137
- // per §2; collapse-bar height matches org per §3 Pattern A/B.
138
- // Always-on fade applied to bottom-20% (by duration) activity nodes
139
- // so the eye is drawn to the longer, schedule-dominating work first.
140
- // Less aggressive than FADE_OPACITY because these cards still need to
141
- // be readable; this is a hint, not a hide.
142
- const DURATION_FADE_OPACITY = 0.55;
143
- // Anchor glyph dimensions (Lucide `anchor` path, scaled to fit
144
- // next to the 13pt bold name label). Width drives the layout math;
145
- // height is a derived target for vertical centering.
146
- const PIN_ICON_W = 13;
147
- const PIN_ICON_H = 13;
148
-
149
- // Legend pill height — matches the shared `renderLegendD3` legend used
150
- // by Cycle/Mindmap/BoxesAndLines (see `utils/legend-constants.ts`):
151
- // 28px tall, fully-rounded rx, mix-fill against surface. Drives the
152
- // tag-group legend block's reserved height.
153
- const LEGEND_PILL_HEIGHT = LEGEND_HEIGHT_CONST;
154
- // Top gap is the breathing room between the title baseline (or canvas
155
- // top when there's no title) and the pill row. Bottom gap separates
156
- // the pills from the diagram body. Together with LEGEND_PILL_HEIGHT
157
- // they make up the block's reserved height.
158
- const LEGEND_TOP_GAP = 12;
159
- const LEGEND_BOTTOM_GAP = 12;
160
-
161
- // Field-reference legend — a 3×2 mini-card that mirrors the schedule
162
- // cells of the textbook PERT card so readers can map each cell's value
163
- // back to its meaning. Renders inside the Analysis row when both are
164
- // on (stacks below Summary in column 1), or in its own full-width row
165
- // when Analysis is off.
166
- // Header band height inlined here (CAPTION_HEADER_BAND_HEIGHT is
167
- // defined later in the file).
168
- const FIELD_LEGEND_HEADER_BAND_HEIGHT = 26;
169
- // Top + bottom padding inside each cell.
170
- const FIELD_LEGEND_CELL_VPAD = 14;
171
- // Pixel height per row in the field-legend grid given how many text
172
- // lines the longest description in that row wraps to. Used by both
173
- // the layout (to reserve canvas height) and the renderer.
174
- function fieldLegendRowHeight(maxDescLines: number): number {
175
- return (
176
- FIELD_LEGEND_CELL_VPAD * 2 +
177
- FIELD_LEGEND_LABEL_FONT_SIZE +
178
- FIELD_LEGEND_LABEL_DESC_GAP +
179
- maxDescLines * FIELD_LEGEND_DESC_LINE_HEIGHT
180
- );
181
- }
182
- // Pixel width the description text wraps within for the given column
183
- // width (column minus 8px of horizontal padding).
184
- function fieldLegendDescWidth(colW: number): number {
185
- return colW - 8;
186
- }
187
- // Total height the field-legend block needs at the given outer width.
188
- // Accounts for the worst-case description wrap across all 6 cells.
189
- function fieldLegendHeightFor(width: number): number {
190
- const colW = width / 3;
191
- const wrapW = fieldLegendDescWidth(colW);
192
- let maxLines = 1;
193
- for (const cell of FIELD_LEGEND_CELLS) {
194
- const n = wrapTextToWidth(
195
- cell.desc,
196
- FIELD_LEGEND_DESC_FONT_SIZE,
197
- wrapW
198
- ).length;
199
- if (n > maxLines) maxLines = n;
200
- }
201
- return (
202
- FIELD_LEGEND_HEADER_BAND_HEIGHT +
203
- fieldLegendRowHeight(maxLines) * 2 +
204
- CAPTION_BOX_PADDING_Y
205
- );
206
- }
207
- const FIELD_LEGEND_LABEL_FONT_SIZE = 13;
208
- const FIELD_LEGEND_DESC_FONT_SIZE = 13;
209
- const FIELD_LEGEND_DESC_LINE_HEIGHT = 17;
210
- const FIELD_LEGEND_LABEL_DESC_GAP = 4;
211
- // Greedy word-wrap budget per line — calibrated for 11pt Inter at the
212
- // cell width minus padding.
213
- // Six cells, top row then bottom row — same order as the textbook card:
214
- // top: [ ES | dur | EF ]
215
- // bottom: [ LS | slack | LF ]
216
- const FIELD_LEGEND_CELLS: readonly { label: string; desc: string }[] = [
217
- {
218
- label: 'Early Start',
219
- desc: 'earliest this activity can begin once predecessors finish',
220
- },
221
- {
222
- label: 'Duration',
223
- desc: 'expected time needed to complete the work',
224
- },
225
- {
226
- label: 'Early Finish',
227
- desc: 'earliest this activity can be fully completed',
228
- },
229
- {
230
- label: 'Late Start',
231
- desc: 'latest this activity can begin without delaying the project',
232
- },
233
- {
234
- label: 'Slack',
235
- desc: "extra time before this activity's delay pushes the deadline",
236
- },
237
- {
238
- label: 'Late Finish',
239
- desc: 'latest this activity can finish without delaying the project',
240
- },
241
- ];
242
-
243
- interface ScaledNodeConstants {
244
- nodeRadius?: number;
245
- nodeStrokeWidth?: number;
246
- nodeTopRowHeight?: number;
247
- nodeBottomRowHeight?: number;
248
- nodeFontSize?: number;
249
- nodeCellFontSize?: number;
250
- pinIconW?: number;
251
- pinIconH?: number;
252
- }
253
-
254
- interface ScaledGroupConstants extends ScaledNodeConstants {
255
- containerRadius?: number;
256
- containerLabelFontSize?: number;
257
- containerHeaderHeight?: number;
258
- collapseBarHeight?: number;
259
- }
260
-
261
- interface ScaledEdgeConstants {
262
- edgeStrokeWidth?: number;
263
- edgeLabelFontSize?: number;
264
- }
265
-
266
- const lineGenerator = d3Shape
267
- .line<{ x: number; y: number }>()
268
- .x((d) => d.x)
269
- .y((d) => d.y)
270
- .curve(d3Shape.curveBasis);
271
-
272
- // ============================================================
273
- // Criticality bands
274
- // ============================================================
275
-
276
- type Band = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | null;
277
-
278
- /**
279
- * Map a Monte-Carlo criticality index (0–1) to a color band, or `null`
280
- * when the activity is essentially never on the critical path. The
281
- * five-band split visualizes how schedule risk is distributed across
282
- * candidate paths:
283
- * red ≥ 0.80 — very likely critical, plan conservatively
284
- * orange ≥ 0.50 — often critical
285
- * yellow ≥ 0.25 — could swing either way
286
- * green ≥ 0.10 — occasionally
287
- * blue ≥ 0.02 — rare but non-zero
288
- * default — effectively never critical
289
- */
290
- function criticalityBand(c: number | null): Band {
291
- if (c === null) return null;
292
- if (c >= 0.8) return 'red';
293
- if (c >= 0.5) return 'orange';
294
- if (c >= 0.25) return 'yellow';
295
- if (c >= 0.1) return 'green';
296
- if (c >= 0.02) return 'blue';
297
- return null;
298
- }
299
-
300
- function bandColor(
301
- band: Band,
302
- palette: PaletteColors,
303
- fallback: string
304
- ): string {
305
- return band === null ? fallback : palette.colors[band];
306
- }
307
-
308
- function bandArrow(band: Band): string {
309
- return band === null ? 'pert-arrow' : `pert-arrow-${band}`;
310
- }
311
-
312
- // ============================================================
313
- // Public API
314
- // ============================================================
315
-
316
- export interface PertRenderOptions {
317
- /** Optional title rendered above the diagram. */
318
- title?: string | null;
319
- /**
320
- * Optional one-line project subtitle rendered under the title (or in
321
- * the title slot when the title is suppressed via `no-title`). Carries
322
- * the project-level μ ± σ + anchor-derived date(s) so the duration
323
- * stays visible even when the Analysis row is toggled off. Pass `null`
324
- * to suppress (the desktop preview does this — it draws an HTML
325
- * subtitle below the React `<h1>` instead). Typically wired from
326
- * `resolved.projectSubtitle`.
327
- */
328
- subtitle?: string | null;
329
- /** Optional callback for click → editor sync. */
330
- onClickItem?: (lineNumber: number) => void;
331
- /**
332
- * Override container dimensions during export. Treated as a hint:
333
- * the renderer will expand height/width if needed to fit chrome
334
- * (title + backward-anchor annotation + diagram body + caption
335
- * block) so the diagram never clips. Pass `undefined` (or omit) to
336
- * use the auto-computed natural size.
337
- */
338
- exportDims?: { width?: number; height?: number };
339
- /**
340
- * Group ids that should render as a single collapsed surface.
341
- * When set, the renderer:
342
- * - draws the group rect with a solid fill and the rolled-up
343
- * attribute body (μ / σ / slack / ES·EF / LS·LF / criticality)
344
- * - skips every activity node whose `groupId` is in this set
345
- * - skips every edge whose source AND target are both inside a
346
- * collapsed group (i.e. internal-only edges)
347
- */
348
- collapsedGroupIds?: readonly string[];
349
- /**
350
- * Render the 3×2 field-reference mini-card to the right of the
351
- * Summary box. Helps presenters explain what each schedule cell
352
- * (ES / dur / EF / LS / slack / LF) means while reviewing the
353
- * diagram. Off by default; the desktop app turns it on with the
354
- * "Field labels" toggle.
355
- */
356
- showFieldLegend?: boolean;
357
- /**
358
- * Render the tag-group legend inside the SVG, between the title and
359
- * the diagram. Defaults to true so CLI exports and share-link images
360
- * include it; the desktop preview flips it off and renders the legend
361
- * in a sibling native-pixel SVG instead, so the pill text stays at
362
- * intended size even when the diagram SVG gets scale-to-fit'd into the
363
- * panel.
364
- */
365
- showLegend?: boolean;
366
- /**
367
- * Render the project-stats Summary box below the diagram. Defaults
368
- * to true so CLI exports / share-link images keep showing it; the
369
- * desktop app's cog has a "Summary" toggle that flips this off when
370
- * readers want a cleaner chart.
371
- */
372
- showSummary?: boolean;
373
- /**
374
- * Render the Tornado sensitivity chart below the diagram. Reads
375
- * existing Monte-Carlo output (criticality + per-activity sigma)
376
- * and ranks activities by Schedule Sensitivity Index. Off by
377
- * default; the desktop app exposes it as a cog toggle.
378
- * When MC didn't run (analytical mode), the widget renders nothing.
379
- */
380
- showTornado?: boolean;
381
- /**
382
- * Render the S-curve (cumulative completion probability) below the
383
- * diagram. Reads the empirical CDF of Monte-Carlo trial finish times
384
- * — gives readers the full distribution shape, not just three
385
- * percentile dates. Off by default. Silently omits when MC didn't
386
- * run.
387
- */
388
- showScurve?: boolean;
389
- /**
390
- * Programmatic override for the active tag group — wins over
391
- * `options.activeTag` from the parsed source. Used by the desktop
392
- * preview when the user clicks a tag-legend pill: that interaction
393
- * sets the override (without mutating the parsed source) and
394
- * triggers a re-render with the new coloring. Pass `null` (or
395
- * `'none'`) to explicitly suppress tag coloring; omit to fall
396
- * through to the parsed `active-tag` directive.
397
- */
398
- activeTagOverride?: string | null;
399
- /** True when rendering for export — strips collapsed pills and cog from legend. */
400
- exportMode?: boolean;
401
- containerWidth?: number;
402
- }
403
-
404
- export function renderPert(
405
- container: HTMLDivElement,
406
- resolved: ResolvedPert,
407
- layout: LayoutResult,
408
- palette: PaletteColors,
409
- isDark: boolean,
410
- options: PertRenderOptions = {}
411
- ): void {
412
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
413
-
414
- const effectiveTitle = options.title;
415
- const effectiveSubtitle = options.subtitle ?? null;
416
- const titleHeight = effectiveTitle ? 80 : effectiveSubtitle ? 50 : 0;
417
-
418
- const anchorAnnotation = anchorAnnotationText(resolved);
419
-
420
- const collapsedSet = new Set(options.collapsedGroupIds ?? []);
421
- const captionRows = resolved.error !== null ? null : resolved.summaryRows;
422
- const captionBullets: CaptionBullet[] =
423
- captionRows !== null && captionRows.length > 0
424
- ? bulletizeCaption(captionRows)
425
- : [];
426
- if (anchorAnnotation) {
427
- captionBullets.push({
428
- text: anchorAnnotation,
429
- level: 0,
430
- italic: true,
431
- });
432
- }
433
- const analysisLayer = computeAnalysisLayer(resolved, captionBullets, {
434
- showSummary: options.showSummary ?? true,
435
- showTornado: options.showTornado ?? false,
436
- showScurve: options.showScurve ?? false,
437
- showFieldLegend: options.showFieldLegend ?? false,
438
- });
439
- const standaloneFieldLegendWidthForExport = layout.width;
440
- const analysisBlockHeight = analysisLayer.analysisHasContent
441
- ? CAPTION_TOP_GAP + analysisLayer.analysisRowHeight
442
- : 0;
443
- const fieldLegendBlockHeight = analysisLayer.fieldLegendStandalone
444
- ? CAPTION_TOP_GAP +
445
- fieldLegendHeightFor(standaloneFieldLegendWidthForExport)
446
- : 0;
447
- const showLegend = options.showLegend ?? true;
448
- const tagLegendActive = resolveActiveTagGroup(
449
- resolved.tagGroups,
450
- resolved.options.activeTag,
451
- options.activeTagOverride
452
- );
453
- const showTagLegend = showLegend && resolved.tagGroups.length > 0;
454
- const legendBlockHeight = showTagLegend
455
- ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
456
- : 0;
457
-
458
- const naturalChartWidth = layout.width + DIAGRAM_PADDING * 2;
459
- const minAnalysisRowW = analysisLayer.analysisHasContent
460
- ? analysisLayer.minContentWidth + 2 * DIAGRAM_PADDING
461
- : 0;
462
- const naturalWidth = Math.max(naturalChartWidth, minAnalysisRowW);
463
- const naturalHeight =
464
- layout.height +
465
- DIAGRAM_PADDING * 2 +
466
- titleHeight +
467
- legendBlockHeight +
468
- analysisBlockHeight +
469
- fieldLegendBlockHeight;
470
- const exportWidth = Math.max(
471
- options.exportDims?.width ?? naturalWidth,
472
- naturalWidth
473
- );
474
- const exportHeight = Math.max(
475
- options.exportDims?.height ?? naturalHeight,
476
- naturalHeight
477
- );
478
- if (exportWidth <= 0 || exportHeight <= 0) return;
479
-
480
- const ctx = options.exportDims
481
- ? ScaleContext.identity()
482
- : options.containerWidth != null
483
- ? ScaleContext.from(options.containerWidth, naturalWidth)
484
- : ScaleContext.identity();
485
-
486
- const sDiagramPad = ctx.aesthetic(DIAGRAM_PADDING);
487
- const sTitleHeight = ctx.aesthetic(titleHeight);
488
- const sTitleFontSize = ctx.text(TITLE_FONT_SIZE);
489
- const sTitleY = ctx.aesthetic(TITLE_Y);
490
- const sSubtitleFontSize = ctx.text(13);
491
- const sLegendTopGap = ctx.aesthetic(LEGEND_TOP_GAP);
492
- const sLegendBottomGap = ctx.aesthetic(LEGEND_BOTTOM_GAP);
493
- const sLegendPillHeight = ctx.structural(LEGEND_PILL_HEIGHT);
494
- const sLegendBlockHeight = showTagLegend
495
- ? sLegendTopGap + sLegendPillHeight + sLegendBottomGap
496
- : 0;
497
- const sNodeRadius = ctx.structural(NODE_RADIUS);
498
- const sNodeStrokeWidth = ctx.structural(NODE_STROKE_WIDTH);
499
- const sNodeFontSize = ctx.text(NODE_FONT_SIZE);
500
- const sNodeCellFontSize = ctx.text(NODE_CELL_FONT_SIZE);
501
- const sNodeTopRowHeight = ctx.structural(NODE_TOP_ROW_HEIGHT);
502
- const sNodeBottomRowHeight = ctx.structural(NODE_BOTTOM_ROW_HEIGHT);
503
- const sEdgeStrokeWidth = ctx.structural(EDGE_STROKE_WIDTH);
504
- const sArrowheadW = ctx.structural(ARROWHEAD_W);
505
- const sArrowheadH = ctx.structural(ARROWHEAD_H);
506
- const sContainerRadius = ctx.structural(CONTAINER_RADIUS);
507
- const sContainerLabelFontSize = ctx.text(CONTAINER_LABEL_FONT_SIZE);
508
- const sContainerHeaderHeight = ctx.structural(CONTAINER_HEADER_HEIGHT);
509
- const sCollapseBarHeight = ctx.structural(COLLAPSE_BAR_HEIGHT);
510
- const sPinIconW = ctx.structural(PIN_ICON_W);
511
- const sPinIconH = ctx.structural(PIN_ICON_H);
512
-
513
- const scaledWidth = layout.width + sDiagramPad * 2;
514
- const scaledHeight =
515
- layout.height +
516
- sDiagramPad * 2 +
517
- sTitleHeight +
518
- sLegendBlockHeight +
519
- analysisBlockHeight +
520
- fieldLegendBlockHeight;
521
- const svgW = ctx.isBelowFloor ? exportWidth : scaledWidth;
522
- const svgH = ctx.isBelowFloor ? exportHeight : scaledHeight;
523
-
524
- const svg = d3Selection
525
- .select(container)
526
- .append('svg')
527
- .attr('width', ctx.isBelowFloor ? '100%' : svgW)
528
- .attr('height', svgH)
529
- .attr('viewBox', `0 0 ${svgW} ${svgH}`)
530
- .attr('preserveAspectRatio', 'xMidYMin meet')
531
- .style('font-family', FONT_FAMILY);
532
-
533
- const defs = svg.append('defs');
534
- buildArrowheads(defs, palette, sArrowheadW, sArrowheadH);
535
-
536
- if (effectiveTitle) {
537
- svg
538
- .append('text')
539
- .attr('class', 'pert-title chart-title')
540
- .attr('x', svgW / 2)
541
- .attr('y', sTitleY)
542
- .attr('text-anchor', 'middle')
543
- .attr('fill', palette.text)
544
- .attr('font-size', sTitleFontSize)
545
- .attr('font-weight', TITLE_FONT_WEIGHT)
546
- .text(effectiveTitle);
547
- }
548
- if (effectiveSubtitle) {
549
- const subtitleY = effectiveTitle ? sTitleY + ctx.aesthetic(26) : sTitleY;
550
- svg
551
- .append('text')
552
- .attr('class', 'pert-subtitle')
553
- .attr('x', svgW / 2)
554
- .attr('y', subtitleY)
555
- .attr('text-anchor', 'middle')
556
- .attr('fill', palette.textMuted)
557
- .attr('font-size', sSubtitleFontSize)
558
- .attr('font-weight', 400)
559
- .text(effectiveSubtitle);
560
- }
561
-
562
- const offsetX = Math.max(sDiagramPad, (svgW - layout.width) / 2);
563
- const offsetY = sDiagramPad + sTitleHeight + sLegendBlockHeight;
564
-
565
- if (showTagLegend) {
566
- const tagLegendY = sDiagramPad + sTitleHeight + sLegendTopGap;
567
- renderTagLegendRow(svg, resolved, palette, isDark, {
568
- x: 0,
569
- y: tagLegendY,
570
- width: svgW,
571
- activeGroup: tagLegendActive,
572
- ...(options.exportMode !== undefined && {
573
- exportMode: options.exportMode,
574
- }),
575
- });
576
- }
577
-
578
- const root = svg
579
- .append('g')
580
- .attr('transform', `translate(${offsetX}, ${offsetY})`);
581
-
582
- const sizing = computeNodeSizing(resolved);
583
- renderGroups(root, resolved, layout, palette, isDark, collapsedSet, sizing, {
584
- nodeRadius: sNodeRadius,
585
- nodeStrokeWidth: sNodeStrokeWidth,
586
- containerRadius: sContainerRadius,
587
- containerLabelFontSize: sContainerLabelFontSize,
588
- containerHeaderHeight: sContainerHeaderHeight,
589
- collapseBarHeight: sCollapseBarHeight,
590
- nodeTopRowHeight: sNodeTopRowHeight,
591
- nodeBottomRowHeight: sNodeBottomRowHeight,
592
- nodeFontSize: sNodeFontSize,
593
- nodeCellFontSize: sNodeCellFontSize,
594
- pinIconW: sPinIconW,
595
- pinIconH: sPinIconH,
596
- });
597
- renderEdges(root, resolved, layout, palette, collapsedSet, {
598
- edgeStrokeWidth: sEdgeStrokeWidth,
599
- edgeLabelFontSize: ctx.text(10),
600
- });
601
- renderNodes(
602
- root,
603
- defs,
604
- resolved,
605
- layout,
606
- palette,
607
- isDark,
608
- sizing,
609
- options.onClickItem,
610
- collapsedSet,
611
- options.activeTagOverride,
612
- {
613
- nodeRadius: sNodeRadius,
614
- nodeStrokeWidth: sNodeStrokeWidth,
615
- nodeTopRowHeight: sNodeTopRowHeight,
616
- nodeBottomRowHeight: sNodeBottomRowHeight,
617
- nodeFontSize: sNodeFontSize,
618
- nodeCellFontSize: sNodeCellFontSize,
619
- pinIconW: sPinIconW,
620
- pinIconH: sPinIconH,
621
- }
622
- );
623
-
624
- paintAnalysisLayer(
625
- svg,
626
- resolved,
627
- palette,
628
- isDark,
629
- analysisLayer,
630
- captionBullets,
631
- sDiagramPad,
632
- offsetY + layout.height,
633
- svgW - 2 * sDiagramPad
634
- );
635
- }
636
-
637
- export function renderPertForExport(
638
- content: string,
639
- theme: 'light' | 'dark' | 'transparent',
640
- palette: PaletteColors,
641
- /**
642
- * Optional parse-time "today" override. Threads through to
643
- * `parsePert({ now })` so the analyzer's backward-mode past-date
644
- * check + the anchor annotation's "(as of YYYY-MM-DD)" suffix stay
645
- * deterministic. Test snapshots pin this; production code omits it.
646
- */
647
- now?: Date
648
- ): string {
649
- const parsed = parsePert(content, {
650
- ...(now !== undefined && { now }),
651
- palette,
652
- });
653
- if (parsed.error || parsed.activities.length === 0) return '';
654
-
655
- const resolved = analyzePert(parsed);
656
- const layout = layoutPert(resolved);
657
- const isDark = theme === 'dark';
658
-
659
- // Mirror renderPert's reservation: 80 for title (subtitle nests below it),
660
- // 50 for subtitle-only, 0 when neither is present. `no-title` suppresses
661
- // the title but the subtitle (project μ ± σ) is project-level and stays.
662
- const hasTitle = !!parsed.title && !resolved.options.noTitle;
663
- const hasSubtitle = resolved.projectSubtitle !== null;
664
- const titleHeight = hasTitle ? 80 : hasSubtitle ? 50 : 0;
665
- // Mirror the bullet-list assembly inside renderPert so exportDims
666
- // matches the natural height (anchor annotation now lives inside
667
- // the caption box as a final italic bullet).
668
- const captionBullets: CaptionBullet[] =
669
- resolved.summaryRows !== null && resolved.summaryRows.length > 0
670
- ? bulletizeCaption(resolved.summaryRows)
671
- : [];
672
- const anchorNote = anchorAnnotationText(resolved);
673
- if (anchorNote) {
674
- captionBullets.push({ text: anchorNote, level: 0, italic: true });
675
- }
676
- const captionBoxHeight =
677
- captionBullets.length > 0
678
- ? captionBullets.length * CAPTION_LINE_HEIGHT +
679
- 2 * CAPTION_BOX_PADDING_Y +
680
- CAPTION_HEADER_BAND_HEIGHT
681
- : 0;
682
- const captionBlockHeight =
683
- captionBullets.length > 0 ? CAPTION_TOP_GAP + captionBoxHeight : 0;
684
- // Mirror renderPert's tag-legend reservation so the offscreen
685
- // container matches the natural canvas height.
686
- const legendBlockHeight =
687
- resolved.tagGroups.length > 0
688
- ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
689
- : 0;
690
- const exportWidth = layout.width + DIAGRAM_PADDING * 2;
691
- const exportHeight =
692
- layout.height +
693
- DIAGRAM_PADDING * 2 +
694
- titleHeight +
695
- legendBlockHeight +
696
- captionBlockHeight;
697
-
698
- const container = document.createElement('div');
699
- container.style.width = `${exportWidth}px`;
700
- container.style.height = `${exportHeight}px`;
701
- container.style.position = 'absolute';
702
- container.style.left = '-9999px';
703
- document.body.appendChild(container);
704
-
705
- try {
706
- renderPert(container, resolved, layout, palette, isDark, {
707
- title: hasTitle ? parsed.title : null,
708
- subtitle: resolved.projectSubtitle,
709
- exportDims: { width: exportWidth, height: exportHeight },
710
- exportMode: true,
711
- });
712
- const svgEl = container.querySelector('svg');
713
- if (!svgEl) return '';
714
- if (theme === 'transparent') svgEl.style.background = 'none';
715
- else svgEl.style.background = palette.bg;
716
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
717
- svgEl.style.fontFamily = FONT_FAMILY;
718
- return svgEl.outerHTML;
719
- } finally {
720
- document.body.removeChild(container);
721
- }
722
- }
723
-
724
- // ============================================================
725
- // Analysis layer (Summary + Tornado + S-curve + Field labels)
726
- // ============================================================
727
- //
728
- // Shared by the inline renderPert path (analysis row below the diagram)
729
- // and the standalone sibling-SVG path used by the desktop preview
730
- // (renderPertAnalysisBlock). The desktop preview renders the layer in
731
- // a separate native-pixel SVG so its text stays readable regardless of
732
- // the scale applied to the main diagram SVG.
733
-
734
- type AnalysisKind = 'summary' | 'tornado' | 'scurve';
735
-
736
- interface AnalysisLayerOptions {
737
- showSummary: boolean;
738
- showTornado: boolean;
739
- showScurve: boolean;
740
- showFieldLegend: boolean;
741
- }
742
-
743
- interface AnalysisLayerState {
744
- // Resolved on/off states (data availability has been considered).
745
- summaryRendered: boolean;
746
- showTornado: boolean;
747
- showScurve: boolean;
748
- fieldLegendInAnalysisRow: boolean;
749
- fieldLegendStandalone: boolean;
750
- analysisHasContent: boolean;
751
-
752
- // Computed widget content (cached so paint matches precompute).
753
- tornadoRows: TornadoRow[];
754
- scurveData: ScurveData | null;
755
- analysisCharts: { kind: AnalysisKind; contentHeight: number }[];
756
-
757
- // Sub-block heights (width-independent).
758
- captionBoxHeight: number;
759
- fieldLegendCol1Height: number;
760
- tornadoBoxHeight: number;
761
- scurveBoxHeight: number;
762
- col1Width: number;
763
- col1Height: number;
764
- analysisRowHeight: number;
765
-
766
- // Minimum content width (excludes outer padding) below which the
767
- // analysis row's chart axes overlap. Caller adds outer padding.
768
- minContentWidth: number;
769
- }
770
-
771
- function computeAnalysisLayer(
772
- resolved: ResolvedPert,
773
- captionBullets: CaptionBullet[],
774
- opts: AnalysisLayerOptions
775
- ): AnalysisLayerState {
776
- const summaryRendered = opts.showSummary && captionBullets.length > 0;
777
- const captionBoxHeight = summaryRendered
778
- ? captionBullets.length * CAPTION_LINE_HEIGHT +
779
- 2 * CAPTION_BOX_PADDING_Y +
780
- CAPTION_HEADER_BAND_HEIGHT
781
- : 0;
782
- // Tornado / S-curve only render when MC ran — analytical mode
783
- // produces no criticality/sigma data.
784
- let tornadoRows = opts.showTornado ? buildTornadoRows(resolved) : [];
785
- const showTornado = tornadoRows.length > 0;
786
- let tornadoBoxHeight = showTornado ? tornadoBoxHeightFor(tornadoRows) : 0;
787
- const scurveData = opts.showScurve ? buildScurveData(resolved) : null;
788
- const showScurve = scurveData !== null;
789
- const scurveBoxHeight = showScurve ? SCURVE_BOX_HEIGHT : 0;
790
-
791
- const fieldLegendInAnalysisRow =
792
- opts.showFieldLegend && (summaryRendered || showTornado || showScurve);
793
- const col1Width = summaryRendered
794
- ? Math.max(
795
- SUMMARY_MIN_W,
796
- Math.min(SUMMARY_MAX_W, captionNaturalWidth(captionBullets))
797
- )
798
- : fieldLegendInAnalysisRow
799
- ? SUMMARY_MAX_W
800
- : 0;
801
- const fieldLegendCol1Height = fieldLegendInAnalysisRow
802
- ? fieldLegendHeightFor(col1Width)
803
- : 0;
804
- const col1Items: number[] = [];
805
- if (summaryRendered) col1Items.push(captionBoxHeight);
806
- if (fieldLegendInAnalysisRow) col1Items.push(fieldLegendCol1Height);
807
- const col1Height = col1Items.length
808
- ? col1Items.reduce((a, b) => a + b, 0) +
809
- (col1Items.length - 1) * COL1_VSTACK_GAP
810
- : 0;
811
-
812
- let analysisRowHeight = Math.max(
813
- col1Height,
814
- scurveBoxHeight,
815
- tornadoBoxHeight
816
- );
817
- // Grow Tornado to fill leftover vertical room when col 1 / S-curve
818
- // are taller than the default-N row count would produce.
819
- if (showTornado) {
820
- const maxRows = tornadoMaxRowsFor(analysisRowHeight);
821
- if (maxRows > tornadoRows.length) {
822
- tornadoRows = buildTornadoRows(resolved, maxRows);
823
- tornadoBoxHeight = tornadoBoxHeightFor(tornadoRows);
824
- analysisRowHeight = Math.max(
825
- col1Height,
826
- scurveBoxHeight,
827
- tornadoBoxHeight
828
- );
829
- }
830
- }
831
-
832
- const analysisCharts: { kind: AnalysisKind; contentHeight: number }[] = [];
833
- if (showTornado)
834
- analysisCharts.push({ kind: 'tornado', contentHeight: tornadoBoxHeight });
835
- if (showScurve)
836
- analysisCharts.push({ kind: 'scurve', contentHeight: scurveBoxHeight });
837
- const analysisHasContent =
838
- summaryRendered || analysisCharts.length > 0 || fieldLegendInAnalysisRow;
839
- const fieldLegendStandalone =
840
- opts.showFieldLegend && !fieldLegendInAnalysisRow;
841
-
842
- // Minimum content width — col1 + sum of chart minimums + gaps.
843
- let minContentWidth = 0;
844
- if (analysisHasContent) {
845
- const col1Used = summaryRendered || fieldLegendInAnalysisRow;
846
- if (col1Used) minContentWidth += col1Width;
847
- for (const w of analysisCharts) {
848
- minContentWidth += w.kind === 'tornado' ? TORNADO_MIN_W : SCURVE_MIN_W;
849
- }
850
- const colCount = (col1Used ? 1 : 0) + analysisCharts.length;
851
- if (colCount > 1) minContentWidth += (colCount - 1) * ANALYSIS_GAP;
852
- }
853
-
854
- return {
855
- summaryRendered,
856
- showTornado,
857
- showScurve,
858
- fieldLegendInAnalysisRow,
859
- fieldLegendStandalone,
860
- analysisHasContent,
861
- tornadoRows,
862
- scurveData,
863
- analysisCharts,
864
- captionBoxHeight,
865
- fieldLegendCol1Height,
866
- tornadoBoxHeight,
867
- scurveBoxHeight,
868
- col1Width,
869
- col1Height,
870
- analysisRowHeight,
871
- minContentWidth,
872
- };
873
- }
874
-
875
- /**
876
- * Total height the analysis layer needs at the given availableWidth.
877
- * Mirrors paintAnalysisLayer's mode selection: row mode when width fits
878
- * the single-row minimum, otherwise greedy-packed multi-row stack mode.
879
- * Returns 0 when nothing renders.
880
- */
881
- function analysisLayerHeightAt(
882
- state: AnalysisLayerState,
883
- availableWidth: number
884
- ): number {
885
- if (!state.analysisHasContent) {
886
- return state.fieldLegendStandalone
887
- ? CAPTION_TOP_GAP + fieldLegendHeightFor(availableWidth)
888
- : 0;
889
- }
890
- if (availableWidth >= state.minContentWidth) {
891
- return CAPTION_TOP_GAP + state.analysisRowHeight;
892
- }
893
- // Stack mode — type-grouped rows; sum per-row max heights.
894
- const rows = packRows(state, availableWidth);
895
- let total = 0;
896
- rows.forEach((row, i) => {
897
- let rowHeight = 0;
898
- for (const item of row) {
899
- rowHeight = Math.max(
900
- rowHeight,
901
- itemContentHeight(state, item.kind, item.paintWidth)
902
- );
903
- }
904
- total += rowHeight;
905
- if (i < rows.length - 1) total += ANALYSIS_GAP;
906
- });
907
- return CAPTION_TOP_GAP + total;
908
- }
909
-
910
- function paintAnalysisLayer(
911
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
912
- resolved: ResolvedPert,
913
- palette: PaletteColors,
914
- isDark: boolean,
915
- state: AnalysisLayerState,
916
- captionBullets: CaptionBullet[],
917
- x: number,
918
- y: number,
919
- availableWidth: number
920
- ): number {
921
- if (!state.analysisHasContent) {
922
- if (!state.fieldLegendStandalone) return 0;
923
- const bandY = y + CAPTION_TOP_GAP;
924
- const h = fieldLegendHeightFor(availableWidth);
925
- renderFieldLegendBlock(svg, {
926
- x,
927
- y: bandY,
928
- width: availableWidth,
929
- height: h,
930
- palette,
931
- isDark,
932
- });
933
- return CAPTION_TOP_GAP + h;
934
- }
935
-
936
- // When the panel is wide enough for a single row, use the original
937
- // side-by-side layout. Otherwise fall back to stack mode so nothing
938
- // overflows horizontally — text stays at native pixel size in both.
939
- if (availableWidth >= state.minContentWidth) {
940
- return paintAnalysisRowMode(
941
- svg,
942
- resolved,
943
- palette,
944
- isDark,
945
- state,
946
- captionBullets,
947
- x,
948
- y,
949
- availableWidth
950
- );
951
- }
952
- return paintAnalysisStackMode(
953
- svg,
954
- resolved,
955
- palette,
956
- isDark,
957
- state,
958
- captionBullets,
959
- x,
960
- y,
961
- availableWidth
962
- );
963
- }
964
-
965
- function paintAnalysisRowMode(
966
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
967
- resolved: ResolvedPert,
968
- palette: PaletteColors,
969
- isDark: boolean,
970
- state: AnalysisLayerState,
971
- captionBullets: CaptionBullet[],
972
- x: number,
973
- y: number,
974
- availableWidth: number
975
- ): number {
976
- const bandY = y + CAPTION_TOP_GAP;
977
- const col1Used = state.summaryRendered || state.fieldLegendInAnalysisRow;
978
- const colCount = (col1Used ? 1 : 0) + state.analysisCharts.length;
979
- const nGaps = Math.max(0, colCount - 1);
980
- const usableWidth = availableWidth - nGaps * ANALYSIS_GAP;
981
- const chartWidth =
982
- state.analysisCharts.length > 0
983
- ? (usableWidth - state.col1Width) / state.analysisCharts.length
984
- : 0;
985
- // Charts first (Tornado then S-curve), then col-1 stack on the
986
- // right so Summary's percentile numbers tie visually to the S-curve.
987
- let cursorX = x;
988
- for (const w of state.analysisCharts) {
989
- const args = {
990
- x: cursorX,
991
- y: bandY,
992
- width: chartWidth,
993
- height: state.analysisRowHeight,
994
- palette,
995
- isDark,
996
- };
997
- if (w.kind === 'tornado') {
998
- renderTornadoBlock(svg, state.tornadoRows, args);
999
- } else {
1000
- // Promote the first caption row ("Expected duration: …") into
1001
- // the S-curve's header band when the Summary card is suppressed,
1002
- // so the project's headline stat still appears somewhere. When
1003
- // Summary IS rendered alongside, the title is omitted to avoid
1004
- // duplicating the same line in two places.
1005
- const scurveTitle =
1006
- !state.summaryRendered && captionBullets.length > 0
1007
- ? // In-bounds by length check.
1008
- captionBullets[0]!.text
1009
- : undefined;
1010
- renderScurveBlock(svg, state.scurveData!, {
1011
- ...args,
1012
- unit: resolved.options.timeUnit,
1013
- ...(scurveTitle !== undefined && { title: scurveTitle }),
1014
- });
1015
- }
1016
- cursorX += chartWidth + ANALYSIS_GAP;
1017
- }
1018
- if (col1Used) {
1019
- let stackY = bandY;
1020
- if (state.summaryRendered) {
1021
- renderCaptionBlock(svg, captionBullets, {
1022
- x: cursorX,
1023
- y: stackY,
1024
- width: state.col1Width,
1025
- height: state.captionBoxHeight,
1026
- palette,
1027
- isDark,
1028
- });
1029
- stackY += state.captionBoxHeight + COL1_VSTACK_GAP;
1030
- }
1031
- if (state.fieldLegendInAnalysisRow) {
1032
- renderFieldLegendBlock(svg, {
1033
- x: cursorX,
1034
- y: stackY,
1035
- width: state.col1Width,
1036
- height: state.fieldLegendCol1Height,
1037
- palette,
1038
- isDark,
1039
- });
1040
- }
1041
- }
1042
- return CAPTION_TOP_GAP + state.analysisRowHeight;
1043
- }
1044
-
1045
- type StackItemKind = 'summary' | 'tornado' | 'scurve' | 'field';
1046
- interface PaintItem {
1047
- kind: StackItemKind;
1048
- paintWidth: number;
1049
- }
1050
-
1051
- // Field labels wraps to any width; this is a "looks OK" floor so it
1052
- // doesn't get squeezed paper-thin alongside another item.
1053
- const FIELD_LEGEND_MIN_W = 220;
1054
-
1055
- // Type-grouped packing. Charts (tornado, scurve) share a row so they
1056
- // stay expressive at narrow widths instead of getting squeezed next to
1057
- // Summary's content-fit box. Texts (summary, field) share their own
1058
- // row with Summary at content-fit and Field labels filling the rest —
1059
- // that puts the leftover width where wrapped descriptions actually
1060
- // benefit from it. Each row falls back to vertical stacking when its
1061
- // side-by-side minimums don't fit the available width.
1062
- function packRows(
1063
- state: AnalysisLayerState,
1064
- availableWidth: number
1065
- ): PaintItem[][] {
1066
- const rows: PaintItem[][] = [];
1067
-
1068
- // ── Charts row ─────────────────────────────────────────────
1069
- const charts = state.analysisCharts;
1070
- if (charts.length === 2) {
1071
- // In-bounds by length === 2 check.
1072
- const minTwo =
1073
- (charts[0]!.kind === 'tornado' ? TORNADO_MIN_W : SCURVE_MIN_W) +
1074
- ANALYSIS_GAP +
1075
- (charts[1]!.kind === 'tornado' ? TORNADO_MIN_W : SCURVE_MIN_W);
1076
- if (availableWidth >= minTwo) {
1077
- const itemW = (availableWidth - ANALYSIS_GAP) / 2;
1078
- rows.push([
1079
- { kind: charts[0]!.kind, paintWidth: itemW },
1080
- { kind: charts[1]!.kind, paintWidth: itemW },
1081
- ]);
1082
- } else {
1083
- for (const c of charts) {
1084
- rows.push([{ kind: c.kind, paintWidth: availableWidth }]);
1085
- }
1086
- }
1087
- } else if (charts.length === 1) {
1088
- // In-bounds by length === 1 check.
1089
- rows.push([{ kind: charts[0]!.kind, paintWidth: availableWidth }]);
1090
- }
1091
-
1092
- // ── Texts row ──────────────────────────────────────────────
1093
- const hasSummary = state.summaryRendered;
1094
- const hasField = state.fieldLegendInAnalysisRow;
1095
- if (hasSummary && hasField) {
1096
- const summaryW = state.col1Width;
1097
- const fieldW = availableWidth - summaryW - ANALYSIS_GAP;
1098
- if (fieldW >= FIELD_LEGEND_MIN_W) {
1099
- rows.push([
1100
- { kind: 'summary', paintWidth: summaryW },
1101
- { kind: 'field', paintWidth: fieldW },
1102
- ]);
1103
- } else {
1104
- rows.push([{ kind: 'summary', paintWidth: summaryW }]);
1105
- rows.push([{ kind: 'field', paintWidth: availableWidth }]);
1106
- }
1107
- } else if (hasSummary) {
1108
- rows.push([{ kind: 'summary', paintWidth: state.col1Width }]);
1109
- } else if (hasField) {
1110
- rows.push([{ kind: 'field', paintWidth: availableWidth }]);
1111
- }
1112
-
1113
- return rows;
1114
- }
1115
-
1116
- function itemContentHeight(
1117
- state: AnalysisLayerState,
1118
- kind: StackItemKind,
1119
- paintWidth: number
1120
- ): number {
1121
- switch (kind) {
1122
- case 'tornado':
1123
- return state.tornadoBoxHeight;
1124
- case 'scurve':
1125
- return state.scurveBoxHeight;
1126
- case 'summary':
1127
- return state.captionBoxHeight;
1128
- case 'field':
1129
- return fieldLegendHeightFor(paintWidth);
1130
- }
1131
- }
1132
-
1133
- function paintAnalysisStackMode(
1134
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1135
- resolved: ResolvedPert,
1136
- palette: PaletteColors,
1137
- isDark: boolean,
1138
- state: AnalysisLayerState,
1139
- captionBullets: CaptionBullet[],
1140
- x: number,
1141
- y: number,
1142
- availableWidth: number
1143
- ): number {
1144
- const rows = packRows(state, availableWidth);
1145
- let cursorY = y + CAPTION_TOP_GAP;
1146
- rows.forEach((row, rowIndex) => {
1147
- const gaps = Math.max(0, row.length - 1) * ANALYSIS_GAP;
1148
- const totalRowW = row.reduce((a, it) => a + it.paintWidth, 0) + gaps;
1149
- // Center a sub-width row inside the available space (e.g. Summary
1150
- // alone at content-fit width sitting in a wide panel).
1151
- const rowOffset = Math.max(0, (availableWidth - totalRowW) / 2);
1152
- let rowHeight = 0;
1153
- for (const item of row) {
1154
- rowHeight = Math.max(
1155
- rowHeight,
1156
- itemContentHeight(state, item.kind, item.paintWidth)
1157
- );
1158
- }
1159
- let cursorX = x + rowOffset;
1160
- for (const item of row) {
1161
- const args = {
1162
- x: cursorX,
1163
- y: cursorY,
1164
- width: item.paintWidth,
1165
- height: rowHeight,
1166
- palette,
1167
- isDark,
1168
- };
1169
- switch (item.kind) {
1170
- case 'tornado':
1171
- renderTornadoBlock(svg, state.tornadoRows, args);
1172
- break;
1173
- case 'scurve': {
1174
- // Same rule as the side-by-side path: promote the headline
1175
- // caption row to the S-curve's header band when Summary is
1176
- // suppressed, otherwise omit to avoid duplication.
1177
- const scurveTitle =
1178
- !state.summaryRendered && captionBullets.length > 0
1179
- ? // In-bounds by length check.
1180
- captionBullets[0]!.text
1181
- : undefined;
1182
- renderScurveBlock(svg, state.scurveData!, {
1183
- ...args,
1184
- unit: resolved.options.timeUnit,
1185
- ...(scurveTitle !== undefined && { title: scurveTitle }),
1186
- });
1187
- break;
1188
- }
1189
- case 'summary':
1190
- renderCaptionBlock(svg, captionBullets, args);
1191
- break;
1192
- case 'field':
1193
- renderFieldLegendBlock(svg, args);
1194
- break;
1195
- }
1196
- cursorX += item.paintWidth + ANALYSIS_GAP;
1197
- }
1198
- cursorY += rowHeight;
1199
- if (rowIndex < rows.length - 1) cursorY += ANALYSIS_GAP;
1200
- });
1201
- return cursorY - y;
1202
- }
1203
-
1204
- /**
1205
- * Measure (without painting) the natural dimensions the analysis layer
1206
- * would consume at the given width. Used by callers that need to lay
1207
- * out the analysis SVG alongside other content — most importantly the
1208
- * desktop preview, which fits diagram + analysis into a fixed panel
1209
- * height by scaling proportionally when natural sizes overflow.
1210
- */
1211
- export function measurePertAnalysisBlock(
1212
- resolved: ResolvedPert,
1213
- width: number,
1214
- options: {
1215
- showSummary?: boolean;
1216
- showTornado?: boolean;
1217
- showScurve?: boolean;
1218
- showFieldLegend?: boolean;
1219
- }
1220
- ): { width: number; height: number } {
1221
- const captionRows = resolved.error !== null ? null : resolved.summaryRows;
1222
- const captionBullets: CaptionBullet[] =
1223
- captionRows !== null && captionRows.length > 0
1224
- ? bulletizeCaption(captionRows)
1225
- : [];
1226
- const anchorAnnotation = anchorAnnotationText(resolved);
1227
- if (anchorAnnotation) {
1228
- captionBullets.push({ text: anchorAnnotation, level: 0, italic: true });
1229
- }
1230
- const state = computeAnalysisLayer(resolved, captionBullets, {
1231
- showSummary: options.showSummary ?? true,
1232
- showTornado: options.showTornado ?? false,
1233
- showScurve: options.showScurve ?? false,
1234
- showFieldLegend: options.showFieldLegend ?? false,
1235
- });
1236
- if (!state.analysisHasContent && !state.fieldLegendStandalone) {
1237
- return { width: 0, height: 0 };
1238
- }
1239
- const itemMinW = Math.max(
1240
- state.summaryRendered ? SUMMARY_MIN_W : 0,
1241
- state.showTornado ? TORNADO_MIN_W : 0,
1242
- state.showScurve ? SCURVE_MIN_W : 0,
1243
- 0
1244
- );
1245
- const w = Math.max(width, itemMinW);
1246
- return { width: w, height: analysisLayerHeightAt(state, w) };
1247
- }
1248
-
1249
- /**
1250
- * Render the PERT analysis layer (Summary + Tornado + S-curve + Field
1251
- * labels) into its own sibling SVG at native pixel size. Used by the
1252
- * desktop preview so the analysis text stays at intended size even when
1253
- * the main diagram SVG is scale-to-fit'd into the panel.
1254
- */
1255
- export function renderPertAnalysisBlock(
1256
- container: HTMLDivElement,
1257
- resolved: ResolvedPert,
1258
- palette: PaletteColors,
1259
- isDark: boolean,
1260
- options: {
1261
- width: number;
1262
- showSummary?: boolean;
1263
- showTornado?: boolean;
1264
- showScurve?: boolean;
1265
- showFieldLegend?: boolean;
1266
- }
1267
- ): void {
1268
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1269
-
1270
- // Mirror renderPert's caption assembly: project-stats bullets +
1271
- // optional anchor-annotation as the closing italic bullet.
1272
- const captionRows = resolved.error !== null ? null : resolved.summaryRows;
1273
- const captionBullets: CaptionBullet[] =
1274
- captionRows !== null && captionRows.length > 0
1275
- ? bulletizeCaption(captionRows)
1276
- : [];
1277
- const anchorAnnotation = anchorAnnotationText(resolved);
1278
- if (anchorAnnotation) {
1279
- captionBullets.push({ text: anchorAnnotation, level: 0, italic: true });
1280
- }
1281
-
1282
- const state = computeAnalysisLayer(resolved, captionBullets, {
1283
- showSummary: options.showSummary ?? true,
1284
- showTornado: options.showTornado ?? false,
1285
- showScurve: options.showScurve ?? false,
1286
- showFieldLegend: options.showFieldLegend ?? false,
1287
- });
1288
-
1289
- if (!state.analysisHasContent && !state.fieldLegendStandalone) return;
1290
-
1291
- // The desktop preview never wants horizontal scrolling — it'd defeat
1292
- // the whole point of pulling Analysis out of the scale-to-fit SVG.
1293
- // Honor options.width as the hard ceiling; paintAnalysisLayer falls
1294
- // back to stack mode when it can't fit a single row at that width.
1295
- // The absolute floor is the widest single item (since stacked items
1296
- // each need their own minimum).
1297
- const itemMinW = Math.max(
1298
- state.summaryRendered ? SUMMARY_MIN_W : 0,
1299
- state.showTornado ? TORNADO_MIN_W : 0,
1300
- state.showScurve ? SCURVE_MIN_W : 0,
1301
- // Field labels has no hard min — it wraps to whatever width it gets.
1302
- 0
1303
- );
1304
- const width = Math.max(options.width, itemMinW);
1305
- const totalHeight = analysisLayerHeightAt(state, width);
1306
- if (totalHeight <= 0 || width <= 0) return;
1307
-
1308
- // overflow visible so block strokes (1.5px wide → 0.75px past rect)
1309
- // don't get clipped at the SVG's viewBox edges. The wrapper around
1310
- // the SVG already supplies canvas-edge padding so the extra stroke
1311
- // pixels sit harmlessly inside that gutter.
1312
- const svg = d3Selection
1313
- .select(container)
1314
- .append('svg')
1315
- .attr('width', width)
1316
- .attr('height', totalHeight)
1317
- .attr('viewBox', `0 0 ${width} ${totalHeight}`)
1318
- .attr('preserveAspectRatio', 'xMidYMin meet')
1319
- .style('font-family', FONT_FAMILY)
1320
- .style('overflow', 'visible');
1321
-
1322
- paintAnalysisLayer(
1323
- svg,
1324
- resolved,
1325
- palette,
1326
- isDark,
1327
- state,
1328
- captionBullets,
1329
- 0,
1330
- 0,
1331
- width
1332
- );
1333
- }
1334
-
1335
- // ============================================================
1336
- // Section: arrowhead defs
1337
- // ============================================================
1338
-
1339
- type Defs = d3Selection.Selection<SVGDefsElement, unknown, null, undefined>;
1340
-
1341
- function buildArrowheads(
1342
- defs: Defs,
1343
- palette: PaletteColors,
1344
- arrowW: number = ARROWHEAD_W,
1345
- arrowH: number = ARROWHEAD_H
1346
- ): void {
1347
- const mk = (id: string, fill: string): void => {
1348
- defs
1349
- .append('marker')
1350
- .attr('id', id)
1351
- .attr('viewBox', `0 0 ${arrowW} ${arrowH}`)
1352
- .attr('refX', arrowW)
1353
- .attr('refY', arrowH / 2)
1354
- .attr('markerWidth', arrowW)
1355
- .attr('markerHeight', arrowH)
1356
- .attr('orient', 'auto')
1357
- .append('polygon')
1358
- .attr('points', `0,0 ${arrowW},${arrowH / 2} 0,${arrowH}`)
1359
- .attr('fill', fill);
1360
- };
1361
- mk('pert-arrow', palette.textMuted);
1362
- mk('pert-arrow-red', palette.colors.red);
1363
- mk('pert-arrow-orange', palette.colors.orange);
1364
- mk('pert-arrow-yellow', palette.colors.yellow);
1365
- mk('pert-arrow-green', palette.colors.green);
1366
- mk('pert-arrow-blue', palette.colors.blue);
1367
- }
1368
-
1369
- // ============================================================
1370
- // Section: groups
1371
- // ============================================================
1372
-
1373
- type RootSel = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
1374
-
1375
- function renderGroups(
1376
- root: RootSel,
1377
- resolved: ResolvedPert,
1378
- layout: LayoutResult,
1379
- palette: PaletteColors,
1380
- isDark: boolean,
1381
- collapsedSet: ReadonlySet<string>,
1382
- sizing: NodeSizing,
1383
- sc: ScaledGroupConstants = {}
1384
- ): void {
1385
- if (layout.groups.length === 0) return;
1386
- const layer = root.append('g').attr('class', 'pert-groups');
1387
- const unit = resolved.options.timeUnit;
1388
- const solid = resolved.options.solidFill === true;
1389
-
1390
- // Container recipe (non-collapsed groups) — see
1391
- // `docs/architecture/diagram-visual-conventions.md` §2.
1392
- // PERT groups don't carry a color, so use the uncolored-group form:
1393
- // a neutral surface-on-bg mix that reads as a soft grey container,
1394
- // matching org's `Data Team` / `Frontend Team` look.
1395
- const containerFill = mix(palette.surface, palette.bg, 40);
1396
- const containerStroke = palette.textMuted;
1397
-
1398
- // Collapsed-group surface — looks like a regular activity card per §3
1399
- // Pattern B. Same fill/stroke/radius as `renderNodes`. Border + fill
1400
- // both switch to the band color when any member activity is on the
1401
- // critical path (or by MC criticality band), so the rolled-up card
1402
- // mirrors the activity-card convention.
1403
-
1404
- const mcOn = resolved.monteCarloResult !== null;
1405
- const groupHasCritical = (groupId: string): boolean =>
1406
- resolved.activities.some(
1407
- (a) => a.activity.groupId === groupId && a.isCriticalPath
1408
- );
1409
-
1410
- const groupBand = (groupId: string, mcCriticality: number | null): Band =>
1411
- mcOn
1412
- ? criticalityBand(mcCriticality)
1413
- : groupHasCritical(groupId)
1414
- ? 'red'
1415
- : null;
1416
-
1417
- for (const grp of layout.groups) {
1418
- if (grp.width <= 0 || grp.height <= 0) continue;
1419
- const resolvedGroup = resolved.groups.find((rg) => rg.group.id === grp.id);
1420
- const label = resolvedGroup?.group.name ?? grp.id;
1421
- const isCollapsed = collapsedSet.has(grp.id);
1422
- const memberBand = groupBand(grp.id, resolvedGroup?.criticality ?? null);
1423
- const memberCritical = groupHasCritical(grp.id);
1424
-
1425
- const g = layer
1426
- .append('g')
1427
- .attr(
1428
- 'class',
1429
- isCollapsed ? 'pert-group pert-group-collapsed' : 'pert-group'
1430
- )
1431
- .attr('data-group-id', grp.id)
1432
- .attr('data-group-toggle', grp.id)
1433
- .attr('data-collapsed', String(isCollapsed))
1434
- .attr('data-line-number', String(resolvedGroup?.group.lineNumber ?? 0))
1435
- .attr('data-critical-path', String(memberCritical))
1436
- .attr('data-criticality-band', memberBand ?? '')
1437
- .style('cursor', 'pointer');
1438
-
1439
- if (isCollapsed) {
1440
- // Render the rolled-up envelope as a textbook card with the
1441
- // group name in the middle band — visually identical to an
1442
- // activity node so users can read it the same way.
1443
- const projectStart = resolved.projectStart;
1444
- const muStr = formatDuration(resolvedGroup?.rolledMu ?? null, unit, '?');
1445
- const slackStr = formatSlackValue(
1446
- resolvedGroup?.slack ?? null,
1447
- projectStart,
1448
- unit,
1449
- '?'
1450
- );
1451
- const esStr = formatScheduleValue(
1452
- resolvedGroup?.es ?? null,
1453
- projectStart,
1454
- unit,
1455
- '?'
1456
- );
1457
- const efStr = formatScheduleValue(
1458
- resolvedGroup?.ef ?? null,
1459
- projectStart,
1460
- unit,
1461
- '?'
1462
- );
1463
- const lsStr = formatScheduleValue(
1464
- resolvedGroup?.ls ?? null,
1465
- projectStart,
1466
- unit,
1467
- '?'
1468
- );
1469
- const lfStr = formatScheduleValue(
1470
- resolvedGroup?.lf ?? null,
1471
- projectStart,
1472
- unit,
1473
- '?'
1474
- );
1475
-
1476
- const cardBaseColor = bandColor(memberBand, palette, palette.primary);
1477
- const cardFill = shapeFill(palette, cardBaseColor, isDark, { solid });
1478
- const cardLabelColor = contrastText(
1479
- cardFill,
1480
- palette.textOnFillLight,
1481
- palette.textOnFillDark
1482
- );
1483
- const sNR = sc.nodeRadius ?? NODE_RADIUS;
1484
- const sCBH = sc.collapseBarHeight ?? COLLAPSE_BAR_HEIGHT;
1485
- drawTextbookCard(g, {
1486
- width: grp.width,
1487
- height: grp.height,
1488
- x: grp.x,
1489
- y: grp.y,
1490
- name: label,
1491
- es: esStr,
1492
- dur: muStr,
1493
- ef: efStr,
1494
- ls: lsStr,
1495
- slack: slackStr,
1496
- lf: lfStr,
1497
- fill: cardFill,
1498
- stroke: cardBaseColor,
1499
- labelColor: cardLabelColor,
1500
- highlightColor: palette.colors.blue,
1501
- outerColW: sizing.outerColW,
1502
- midColW: sizing.midColW,
1503
- sNodeRadius: sc.nodeRadius,
1504
- sNodeStrokeWidth: sc.nodeStrokeWidth,
1505
- sNodeTopRowHeight: sc.nodeTopRowHeight,
1506
- sNodeBottomRowHeight: sc.nodeBottomRowHeight,
1507
- sNodeFontSize: sc.nodeFontSize,
1508
- sNodeCellFontSize: sc.nodeCellFontSize,
1509
- sPinIconW: sc.pinIconW,
1510
- sPinIconH: sc.pinIconH,
1511
- });
1512
-
1513
- const safeGroupId = grp.id.replace(/[^A-Za-z0-9_-]/g, '_');
1514
- const clipId = `pert-group-clip-${safeGroupId}`;
1515
- g.append('clipPath')
1516
- .attr('id', clipId)
1517
- .append('rect')
1518
- .attr('x', grp.x)
1519
- .attr('y', grp.y)
1520
- .attr('width', grp.width)
1521
- .attr('height', grp.height)
1522
- .attr('rx', sNR)
1523
- .attr('ry', sNR);
1524
- g.append('rect')
1525
- .attr('class', 'pert-collapse-bar')
1526
- .attr('x', grp.x)
1527
- .attr('y', grp.y + grp.height - sCBH)
1528
- .attr('width', grp.width)
1529
- .attr('height', sCBH)
1530
- .attr('fill', cardBaseColor)
1531
- .attr('clip-path', `url(#${clipId})`);
1532
- continue;
1533
- }
1534
-
1535
- const sCR = sc.containerRadius ?? CONTAINER_RADIUS;
1536
- const sCLFS = sc.containerLabelFontSize ?? CONTAINER_LABEL_FONT_SIZE;
1537
- const sCHH = sc.containerHeaderHeight ?? CONTAINER_HEADER_HEIGHT;
1538
- const sNSW = sc.nodeStrokeWidth ?? NODE_STROKE_WIDTH;
1539
- g.append('rect')
1540
- .attr('x', grp.x)
1541
- .attr('y', grp.y)
1542
- .attr('width', grp.width)
1543
- .attr('height', grp.height)
1544
- .attr('rx', sCR)
1545
- .attr('ry', sCR)
1546
- .attr('fill', containerFill)
1547
- .attr('stroke', containerStroke)
1548
- .attr('stroke-opacity', 0.35)
1549
- .attr('stroke-width', sNSW);
1550
-
1551
- g.append('text')
1552
- .attr('x', grp.x + grp.width / 2)
1553
- .attr('y', grp.y + sCHH / 2 + sCLFS / 2 - 2)
1554
- .attr('text-anchor', 'middle')
1555
- .attr('font-family', FONT_FAMILY)
1556
- .attr('fill', palette.text)
1557
- .attr('font-size', sCLFS)
1558
- .attr('font-weight', 'bold')
1559
- .text(label);
1560
- }
1561
- }
1562
-
1563
- // ============================================================
1564
- // Section: edges
1565
- // ============================================================
1566
-
1567
- function renderEdges(
1568
- root: RootSel,
1569
- resolved: ResolvedPert,
1570
- layout: LayoutResult,
1571
- palette: PaletteColors,
1572
- collapsedSet: ReadonlySet<string>,
1573
- sc: ScaledEdgeConstants = {}
1574
- ): void {
1575
- const layer = root.append('g').attr('class', 'pert-edges');
1576
- const criticalSet = new Set(resolved.criticalPath);
1577
- const mcOn = resolved.monteCarloResult !== null;
1578
-
1579
- // Index parsed edges by `source->target` so the renderer can look up
1580
- // the dependency type + lag for each layout edge. Super-edges between
1581
- // collapsed groups won't match (their source/target are group ids);
1582
- // they fall back to the unlabeled default render.
1583
- const edgeByKey = new Map<string, PertEdge>();
1584
- for (const e of resolved.edges) {
1585
- edgeByKey.set(`${e.source}->${e.target}`, e);
1586
- }
1587
-
1588
- // Map activity → group for fast lookup so we can suppress edges
1589
- // that live entirely inside a collapsed group.
1590
- const activityGroup = new Map<string, string | undefined>();
1591
- const critById = new Map<string, number | null>();
1592
- for (const a of resolved.activities) {
1593
- activityGroup.set(a.activity.id, a.activity.groupId);
1594
- critById.set(a.activity.id, a.criticality);
1595
- }
1596
- // When a group is collapsed, its id appears as `e.source` / `e.target`
1597
- // on super-edges. Roll up criticality so super-edges pick up the
1598
- // right band: max member.criticality (MC mode) or 1.0 if any member
1599
- // is on the binary critical path (analytical mode).
1600
- for (const rg of resolved.groups) {
1601
- if (!collapsedSet.has(rg.group.id)) continue;
1602
- let anyCritical = false;
1603
- let maxC: number | null = null;
1604
- for (const aid of rg.group.activityIds) {
1605
- if (criticalSet.has(aid)) anyCritical = true;
1606
- const c = critById.get(aid);
1607
- if (typeof c === 'number') {
1608
- maxC = maxC === null ? c : Math.max(maxC, c);
1609
- }
1610
- }
1611
- if (anyCritical) criticalSet.add(rg.group.id);
1612
- critById.set(rg.group.id, maxC);
1613
- }
1614
-
1615
- for (const e of layout.edges) {
1616
- if (e.points.length < 2) continue;
1617
- const srcGroup = activityGroup.get(e.source);
1618
- const tgtGroup = activityGroup.get(e.target);
1619
- if (
1620
- srcGroup &&
1621
- tgtGroup &&
1622
- srcGroup === tgtGroup &&
1623
- collapsedSet.has(srcGroup)
1624
- ) {
1625
- continue; // internal-only edge of a collapsed group
1626
- }
1627
- const isCritical = criticalSet.has(e.source) && criticalSet.has(e.target);
1628
- let band: Band;
1629
- if (mcOn) {
1630
- const sc = critById.get(e.source);
1631
- const tc = critById.get(e.target);
1632
- const minC =
1633
- sc === null || tc === null || sc === undefined || tc === undefined
1634
- ? null
1635
- : Math.min(sc, tc);
1636
- band = criticalityBand(minC);
1637
- // Mirror the node-side fallback: a deterministic critical edge
1638
- // whose endpoints score 0 in MC (e.g., one is a milestone) still
1639
- // belongs to the visual critical chain.
1640
- if (band === null && isCritical) band = 'red';
1641
- } else {
1642
- band = isCritical ? 'red' : null;
1643
- }
1644
- const path = lineGenerator(e.points);
1645
- if (!path) continue;
1646
- const sESW = sc.edgeStrokeWidth ?? EDGE_STROKE_WIDTH;
1647
- const sELFS = sc.edgeLabelFontSize ?? 10;
1648
- layer
1649
- .append('path')
1650
- .attr('class', 'pert-edge')
1651
- .attr('d', path)
1652
- .attr('fill', 'none')
1653
- .attr('stroke', bandColor(band, palette, palette.textMuted))
1654
- .attr('stroke-width', sESW)
1655
- .attr('marker-end', `url(#${bandArrow(band)})`)
1656
- .attr('data-source', e.source)
1657
- .attr('data-target', e.target)
1658
- .attr('data-critical', String(isCritical))
1659
- .attr('data-critical-path', String(isCritical))
1660
- .attr('data-criticality-band', band ?? '');
1661
-
1662
- const parsedEdge = edgeByKey.get(`${e.source}->${e.target}`);
1663
- const labelText = parsedEdge ? formatEdgeLabel(parsedEdge) : null;
1664
- if (labelText) {
1665
- const mid = e.points[Math.floor(e.points.length / 2)]!;
1666
- layer
1667
- .append('text')
1668
- .attr('class', 'pert-edge-label')
1669
- .attr('x', mid.x)
1670
- .attr('y', mid.y - 4)
1671
- .attr('text-anchor', 'middle')
1672
- .attr('fill', palette.textMuted)
1673
- .attr('font-size', sELFS)
1674
- .attr('paint-order', 'stroke')
1675
- .attr('stroke', palette.bg)
1676
- .attr('stroke-width', 3)
1677
- .attr('stroke-linejoin', 'round')
1678
- .attr('data-edge-source', e.source)
1679
- .attr('data-edge-target', e.target)
1680
- .text(labelText);
1681
- }
1682
- }
1683
- }
1684
-
1685
- /**
1686
- * Render an edge label like `SS +2d`, `FF -1d`, or `+2d` (FS-only lag).
1687
- * Returns `null` when the edge is the default FS+0 — those stay clean.
1688
- */
1689
- function formatEdgeLabel(edge: PertEdge): string | null {
1690
- if (edge.type === 'FS' && edge.lag === null) return null;
1691
- const parts: string[] = [];
1692
- if (edge.type !== 'FS') parts.push(edge.type);
1693
- if (edge.lag) {
1694
- const sign = edge.lag.amount >= 0 ? '+' : '-';
1695
- const amount = Math.abs(edge.lag.amount);
1696
- parts.push(`${sign}${amount}${edge.lag.unit}`);
1697
- }
1698
- return parts.join(' ');
1699
- }
1700
-
1701
- // ============================================================
1702
- // Section: nodes
1703
- // ============================================================
1704
-
1705
- function renderNodes(
1706
- root: RootSel,
1707
- _defs: Defs,
1708
- resolved: ResolvedPert,
1709
- layout: LayoutResult,
1710
- palette: PaletteColors,
1711
- isDark: boolean,
1712
- sizing: NodeSizing,
1713
- onClickItem?: (lineNumber: number) => void,
1714
- collapsedSet: ReadonlySet<string> = new Set(),
1715
- activeTagOverride?: string | null,
1716
- sc: ScaledNodeConstants = {}
1717
- ): void {
1718
- const layer = root.append('g').attr('class', 'pert-nodes');
1719
- const byId = new Map(resolved.activities.map((r) => [r.activity.id, r]));
1720
- const tbdSet = new Set<string>(
1721
- resolved.activities.filter((r) => r.es === null).map((r) => r.activity.id)
1722
- );
1723
- const unit = resolved.options.timeUnit;
1724
- const sprintMode = resolved.options.sprintMode;
1725
- const sprintNumber = resolved.options.sprintNumber ?? 1;
1726
- const solid = resolved.options.solidFill === true;
1727
-
1728
- // Active tag group resolution. Programmatic override (e.g. desktop
1729
- // legend click) wins; otherwise the source's `active-tag` directive;
1730
- // otherwise null (collapsed-by-default per spec §1.3).
1731
- const activeTagGroup = resolveActiveTagGroup(
1732
- resolved.tagGroups,
1733
- resolved.options.activeTag,
1734
- activeTagOverride
1735
- );
1736
-
1737
- // Match org / infra default-node treatment:
1738
- // fill = 25% tint of the node's intent color on surface (via shapeFill)
1739
- // stroke = the node's intent color
1740
- // For critical-path / criticality-band nodes the intent color is the band
1741
- // hue (red / orange / yellow); otherwise it's `palette.primary`. The fill
1742
- // therefore tracks the border so a red-bordered card reads as red-tinted,
1743
- // an orange one as orange-tinted, etc. — same convention as org / infra.
1744
- const projectStart = resolved.projectStart;
1745
- // Four formatter roles. Schedule cells become sprint labels (`S5`)
1746
- // when sprint mode is active, dates when anchored to a calendar,
1747
- // numeric durations otherwise. Slack normalizes to days when
1748
- // anchored. Mu/dur is always a duration label regardless of mode.
1749
- const fmtSchedule = (v: number | null, isTbd: boolean): string => {
1750
- if (sprintMode)
1751
- return formatSprintCell(v, sprintNumber, isTbd ? '?' : null);
1752
- return formatScheduleValue(v, projectStart, unit, isTbd ? '?' : null);
1753
- };
1754
- const fmtSlack = (v: number | null, isTbd: boolean): string =>
1755
- formatSlackValue(v, projectStart, unit, isTbd ? '?' : null);
1756
- const fmtDur = (v: number | null, isTbd: boolean): string =>
1757
- formatDuration(v, unit, isTbd ? '?' : null);
1758
-
1759
- const mcOn = resolved.monteCarloResult !== null;
1760
-
1761
- // Duration-rank emphasis: top 20% of (non-milestone, estimated)
1762
- // activities by μ get bold corner cells; bottom 20% fade to
1763
- // DURATION_FADE_OPACITY so the eye is drawn to longer work first.
1764
- // Skipped when the project has fewer than 5 activities — the buckets
1765
- // become noise.
1766
- const { topMuIds, bottomMuIds } = computeDurationEmphasis(
1767
- resolved.activities
1768
- );
1769
-
1770
- // Anchor set — nodes whose label gets an anchor icon prefix
1771
- // and whose anchor-side corner cells (ES+LS for forward, EF+LF for
1772
- // backward) render bold, because those cells carry the user-supplied
1773
- // date directly rather than a derived offset.
1774
- // forward → activities with no predecessors (ES = start-date)
1775
- // backward → activities with no successors (LF = end-date)
1776
- // No anchor → empty set, no pins drawn anywhere.
1777
- const pinnedSet = computeAnchorPinSet(resolved);
1778
- const anchorKind = resolved.options.anchor?.kind ?? null;
1779
-
1780
- for (const node of layout.nodes) {
1781
- const r = byId.get(node.id);
1782
- if (!r) continue;
1783
- if (r.activity.groupId && collapsedSet.has(r.activity.groupId)) continue;
1784
- const isCritical = r.isCriticalPath;
1785
- const isTbd = tbdSet.has(node.id);
1786
- const dashArray = isTbd ? '4,3' : 'none';
1787
- const isTopMu = topMuIds.has(node.id);
1788
- const isBottomMu = bottomMuIds.has(node.id);
1789
-
1790
- // In MC mode, prefer the per-activity criticality band. Fall back
1791
- // to red when the deterministic critical path includes this
1792
- // activity but the MC band is null — ensures milestones (which
1793
- // tend to score 0 in the simulator) and other quirks don't make
1794
- // the caption's "Critical path:" text disagree with the colors.
1795
- let band: Band;
1796
- if (mcOn) {
1797
- band = criticalityBand(r.criticality);
1798
- if (band === null && isCritical) band = 'red';
1799
- } else {
1800
- band = isCritical ? 'red' : null;
1801
- }
1802
-
1803
- const g = layer
1804
- .append('g')
1805
- .attr('class', 'pert-node')
1806
- .attr('transform', `translate(${node.x}, ${node.y})`)
1807
- .attr('data-activity-id', node.id)
1808
- .attr('data-line-number', String(r.activity.lineNumber))
1809
- .attr(
1810
- 'data-group-id',
1811
- r.activity.groupId !== undefined ? r.activity.groupId : ''
1812
- )
1813
- .attr('data-critical-path', String(isCritical))
1814
- .attr('data-criticality-band', band ?? '')
1815
- .attr('data-duration-rank', isTopMu ? 'top' : isBottomMu ? 'bottom' : '');
1816
-
1817
- // Tag metadata as `data-tag-<key>` attributes — drives the
1818
- // app-side legend-hover dimming (CSS-only). Identical contract
1819
- // to org / kanban / gantt.
1820
- if (r.activity.tags) {
1821
- for (const [tagKey, tagValue] of Object.entries(r.activity.tags)) {
1822
- g.attr(`data-tag-${tagKey}`, String(tagValue).toLowerCase());
1823
- }
1824
- }
1825
-
1826
- // Anchored source/sink nodes — the React-layer top legend uses
1827
- // this to fade everything except anchored nodes when "Anchor" is
1828
- // hovered/clicked.
1829
- if (pinnedSet.has(node.id) && anchorKind) {
1830
- g.attr('data-anchor', anchorKind);
1831
- }
1832
-
1833
- if (onClickItem) {
1834
- g.style('cursor', 'pointer').on('click', () =>
1835
- onClickItem(r.activity.lineNumber)
1836
- );
1837
- }
1838
-
1839
- const baseColor = bandColor(band, palette, palette.primary);
1840
- const fill = shapeFill(palette, baseColor, isDark, { solid });
1841
- const labelColor = contrastText(
1842
- fill,
1843
- palette.textOnFillLight,
1844
- palette.textOnFillDark
1845
- );
1846
-
1847
- // Tag-driven color. Activities paint the middle (name) band only
1848
- // so the criticality border stays the dominant signal; milestones
1849
- // paint the entire pill since they have a single shape.
1850
- const tagColor = resolveTagColor(
1851
- r.activity.tags ?? {},
1852
- resolved.tagGroups,
1853
- activeTagGroup
1854
- );
1855
- const hasTagColor = tagColor !== undefined && tagColor !== '#999999';
1856
- const tagBandFill = hasTagColor
1857
- ? shapeFill(palette, tagColor as string, isDark, { solid })
1858
- : undefined;
1859
- const tagLabelColor = hasTagColor
1860
- ? contrastText(
1861
- tagBandFill as string,
1862
- palette.textOnFillLight,
1863
- palette.textOnFillDark
1864
- )
1865
- : undefined;
1866
-
1867
- if (r.activity.isMilestone) {
1868
- // Critical-path milestones have slack ≈ 0; suppressing the slack
1869
- // cell removes a noisy "0d" and a redundant divider since there's
1870
- // no schedule envelope to communicate. Slack is computed as
1871
- // `LS - ES`, which can land on a tiny float instead of exactly 0,
1872
- // so detect via the formatted display ("0", "0d", "0w") — that's
1873
- // what the user would have read in the cell anyway.
1874
- const slackText = fmtSlack(r.slack, isTbd);
1875
- const slackHidden = !isTbd && /^0[a-z]?$/.test(slackText);
1876
- g.attr('data-milestone', 'true');
1877
- if (slackHidden) g.attr('data-milestone-slack-hidden', 'true');
1878
- drawMilestonePill(g, {
1879
- width: node.width,
1880
- height: node.height,
1881
- x: -node.width / 2,
1882
- y: -node.height / 2,
1883
- name: `◆ ${r.activity.name}`,
1884
- date: fmtSchedule(r.es, isTbd),
1885
- slack: slackText,
1886
- slackHidden,
1887
- fill: tagBandFill ?? fill,
1888
- stroke: baseColor,
1889
- labelColor: tagLabelColor ?? labelColor,
1890
- highlightColor: palette.colors.blue,
1891
- dashArray,
1892
- pinned: pinnedSet.has(node.id) ? anchorKind : null,
1893
- sNodeRadius: sc.nodeRadius,
1894
- sNodeStrokeWidth: sc.nodeStrokeWidth,
1895
- sNodeTopRowHeight: sc.nodeTopRowHeight,
1896
- sNodeBottomRowHeight: sc.nodeBottomRowHeight,
1897
- sNodeCellFontSize: sc.nodeCellFontSize,
1898
- sPinIconW: sc.pinIconW,
1899
- sPinIconH: sc.pinIconH,
1900
- });
1901
- continue;
1902
- }
1903
-
1904
- drawTextbookCard(g, {
1905
- width: node.width,
1906
- height: node.height,
1907
- x: -node.width / 2,
1908
- y: -node.height / 2,
1909
- name: r.activity.name,
1910
- es: fmtSchedule(r.es, isTbd),
1911
- dur: fmtDur(r.mu, isTbd),
1912
- ef: fmtSchedule(r.ef, isTbd),
1913
- ls: fmtSchedule(r.ls, isTbd),
1914
- slack: fmtSlack(r.slack, isTbd),
1915
- lf: fmtSchedule(r.lf, isTbd),
1916
- fill,
1917
- stroke: baseColor,
1918
- highlightColor: palette.colors.blue,
1919
- labelColor,
1920
- dashArray,
1921
- emphasis: isTopMu ? 'top' : isBottomMu ? 'bottom' : null,
1922
- pinned: pinnedSet.has(node.id) ? anchorKind : null,
1923
- outerColW: sizing.outerColW,
1924
- midColW: sizing.midColW,
1925
- ...(tagBandFill !== undefined && { midBandFill: tagBandFill }),
1926
- ...(tagLabelColor !== undefined && { midBandLabelColor: tagLabelColor }),
1927
- sNodeRadius: sc.nodeRadius,
1928
- sNodeStrokeWidth: sc.nodeStrokeWidth,
1929
- sNodeTopRowHeight: sc.nodeTopRowHeight,
1930
- sNodeBottomRowHeight: sc.nodeBottomRowHeight,
1931
- sNodeFontSize: sc.nodeFontSize,
1932
- sNodeCellFontSize: sc.nodeCellFontSize,
1933
- sPinIconW: sc.pinIconW,
1934
- sPinIconH: sc.pinIconH,
1935
- });
1936
- }
1937
- }
1938
-
1939
- /**
1940
- * Bucket activities into top-20% / bottom-20% / middle by expected
1941
- * duration (μ). Milestones, TBDs, and zero-μ activities are excluded
1942
- * from the buckets. Skipped (returns empty sets) when fewer than 5
1943
- * activities qualify — the buckets become noise on small projects.
1944
- */
1945
- function computeDurationEmphasis(activities: ResolvedActivity[]): {
1946
- topMuIds: Set<string>;
1947
- bottomMuIds: Set<string>;
1948
- } {
1949
- const ranked = activities
1950
- .filter(
1951
- (r) => !r.activity.isMilestone && r.mu !== null && (r.mu as number) > 0
1952
- )
1953
- .map((r) => ({ id: r.activity.id, mu: r.mu as number }))
1954
- .sort((a, b) => a.mu - b.mu);
1955
- if (ranked.length < 5) {
1956
- return { topMuIds: new Set(), bottomMuIds: new Set() };
1957
- }
1958
- const tierCount = Math.max(1, Math.floor(ranked.length * 0.2));
1959
- const bottomMuIds = new Set(ranked.slice(0, tierCount).map((b) => b.id));
1960
- const topMuIds = new Set(ranked.slice(-tierCount).map((b) => b.id));
1961
- return { topMuIds, bottomMuIds };
1962
- }
1963
-
1964
- // ============================================================
1965
- // Section: textbook 3×3 PERT/CPM card
1966
- // ============================================================
1967
- //
1968
- // ┌──────┬──────┬──────┐
1969
- // │ ES │ dur │ EF │ ← top row
1970
- // ├──────┴──────┴──────┤
1971
- // │ name │ ← middle row (spans full width)
1972
- // ├──────┬──────┬──────┤
1973
- // │ LS │ slack│ LF │ ← bottom row
1974
- // └──────┴──────┴──────┘
1975
- //
1976
- // Used for both individual activity nodes and collapsed-group cards.
1977
-
1978
- interface TextbookCardArgs {
1979
- width: number;
1980
- height: number;
1981
- x: number;
1982
- y: number;
1983
- name: string;
1984
- es: string;
1985
- dur: string;
1986
- ef: string;
1987
- ls: string;
1988
- slack: string;
1989
- lf: string;
1990
- fill: string;
1991
- stroke: string;
1992
- /** Stroke for internal cell-grid lines. Defaults to `stroke`. */
1993
- gridStroke?: string;
1994
- labelColor: string;
1995
- /**
1996
- * Tint applied behind a cell when the field-legend hover-cross-link
1997
- * activates it. Each cell carries `data-field` + a transparent
1998
- * highlight rect; the React layer flips fill-opacity to 0.25.
1999
- */
2000
- highlightColor: string;
2001
- dashArray?: string;
2002
- /**
2003
- * Duration-rank emphasis. Affects ONLY the `dur` cell: 'top' bolds
2004
- * the duration value, 'bottom' fades it to DURATION_FADE_OPACITY.
2005
- * Card border, fill, name, and other cells are unaffected — the
2006
- * signal is precise to "longer / shorter task".
2007
- */
2008
- emphasis?: 'top' | 'bottom' | null;
2009
- /**
2010
- * When set, prefix the middle-row name with a small anchor icon and
2011
- * bold the corner cells that carry the user-supplied anchor date
2012
- * directly. `'forward'` = source node under `start-date` (bold ES + LS);
2013
- * `'backward'` = sink node under `end-date` (bold EF + LF). `null` /
2014
- * undefined leaves the card plain.
2015
- */
2016
- pinned?: 'forward' | 'backward' | null;
2017
- /**
2018
- * Asymmetric column widths. Outer cells (ES/EF/LS/LF) hold the
2019
- * widest content (date strings) and `midColW` shrinks for the
2020
- * narrow dur/slack labels. Sum of (2*outerColW + midColW) === width.
2021
- */
2022
- outerColW: number;
2023
- midColW: number;
2024
- /**
2025
- * Optional fill for the middle (name) band only. When set, painted
2026
- * on top of the base card fill so corner cells keep `fill` and only
2027
- * the name region picks up the tag color. The card border (stroke)
2028
- * is unaffected — it continues to communicate criticality.
2029
- */
2030
- midBandFill?: string;
2031
- /**
2032
- * Label color used for the name when `midBandFill` is set. Computed
2033
- * via `contrastText()` against the tag-tinted band so the name stays
2034
- * readable regardless of palette/theme.
2035
- */
2036
- midBandLabelColor?: string;
2037
- sNodeRadius?: number | undefined;
2038
- sNodeStrokeWidth?: number | undefined;
2039
- sNodeTopRowHeight?: number | undefined;
2040
- sNodeBottomRowHeight?: number | undefined;
2041
- sNodeFontSize?: number | undefined;
2042
- sNodeCellFontSize?: number | undefined;
2043
- sPinIconW?: number | undefined;
2044
- sPinIconH?: number | undefined;
2045
- }
2046
-
2047
- type AnySel = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
2048
-
2049
- function drawTextbookCard(g: AnySel, a: TextbookCardArgs): void {
2050
- const { width: w, height: h, x, y } = a;
2051
- const sNR = a.sNodeRadius ?? NODE_RADIUS;
2052
- const sNSW = a.sNodeStrokeWidth ?? NODE_STROKE_WIDTH;
2053
- const sTRH = a.sNodeTopRowHeight ?? NODE_TOP_ROW_HEIGHT;
2054
- const sBRH = a.sNodeBottomRowHeight ?? NODE_BOTTOM_ROW_HEIGHT;
2055
- const sNFS = a.sNodeFontSize ?? NODE_FONT_SIZE;
2056
- const sNCFS = a.sNodeCellFontSize ?? NODE_CELL_FONT_SIZE;
2057
- const sPIW = a.sPinIconW ?? PIN_ICON_W;
2058
- const sPIH = a.sPinIconH ?? PIN_ICON_H;
2059
- const outerColW = a.outerColW;
2060
- const midColW = a.midColW;
2061
- const topY = y + sTRH;
2062
- const bottomY = y + h - sBRH;
2063
- const colX1 = x + outerColW;
2064
- const colX2 = x + outerColW + midColW;
2065
-
2066
- g.append('rect')
2067
- .attr('x', x)
2068
- .attr('y', y)
2069
- .attr('width', w)
2070
- .attr('height', h)
2071
- .attr('rx', sNR)
2072
- .attr('ry', sNR)
2073
- .attr('fill', a.fill)
2074
- .attr('stroke', a.stroke)
2075
- .attr('stroke-width', sNSW)
2076
- .attr('stroke-dasharray', a.dashArray ?? 'none');
2077
-
2078
- if (a.midBandFill) {
2079
- g.append('rect')
2080
- .attr('x', x)
2081
- .attr('y', topY)
2082
- .attr('width', w)
2083
- .attr('height', bottomY - topY)
2084
- .attr('fill', a.midBandFill)
2085
- .attr('pointer-events', 'none');
2086
- }
2087
-
2088
- const gridColor = a.gridStroke ?? a.stroke;
2089
- const grid = (x1: number, y1: number, x2: number, y2: number): void => {
2090
- g.append('line')
2091
- .attr('x1', x1)
2092
- .attr('y1', y1)
2093
- .attr('x2', x2)
2094
- .attr('y2', y2)
2095
- .attr('stroke', gridColor)
2096
- .attr('stroke-opacity', 0.3)
2097
- .attr('stroke-width', 1);
2098
- };
2099
- grid(x, topY, x + w, topY);
2100
- grid(x, bottomY, x + w, bottomY);
2101
- grid(colX1, y, colX1, topY);
2102
- grid(colX2, y, colX2, topY);
2103
- grid(colX1, bottomY, colX1, y + h);
2104
- grid(colX2, bottomY, colX2, y + h);
2105
-
2106
- const drawCellHighlight = (
2107
- field: string,
2108
- cx: number,
2109
- cy: number,
2110
- cw: number,
2111
- ch: number
2112
- ): void => {
2113
- g.append('rect')
2114
- .attr('class', 'pert-cell-highlight')
2115
- .attr('data-field', field)
2116
- .attr('x', cx)
2117
- .attr('y', cy)
2118
- .attr('width', cw)
2119
- .attr('height', ch)
2120
- .attr('fill', a.highlightColor)
2121
- .attr('fill-opacity', 0)
2122
- .attr('pointer-events', 'none');
2123
- };
2124
- drawCellHighlight('es', x, y, outerColW, sTRH);
2125
- drawCellHighlight('dur', x + outerColW, y, midColW, sTRH);
2126
- drawCellHighlight('ef', x + outerColW + midColW, y, outerColW, sTRH);
2127
- drawCellHighlight('ls', x, bottomY, outerColW, sBRH);
2128
- drawCellHighlight('slack', x + outerColW, bottomY, midColW, sBRH);
2129
- drawCellHighlight('lf', x + outerColW + midColW, bottomY, outerColW, sBRH);
2130
-
2131
- const drawCell = (
2132
- cx: number,
2133
- cy: number,
2134
- text: string,
2135
- weight: 'normal' | 'bold' = 'normal',
2136
- size: number = sNCFS,
2137
- opacity = 1,
2138
- colorOverride?: string
2139
- ): void => {
2140
- const t = g
2141
- .append('text')
2142
- .attr('x', cx)
2143
- .attr('y', cy + size / 2 - 2)
2144
- .attr('text-anchor', 'middle')
2145
- .attr('font-family', FONT_FAMILY)
2146
- .attr('fill', colorOverride ?? a.labelColor)
2147
- .attr('font-size', size)
2148
- .attr('font-weight', weight)
2149
- .text(text);
2150
- if (opacity !== 1) t.attr('opacity', String(opacity));
2151
- };
2152
-
2153
- const topMid = y + sTRH / 2;
2154
- const durWeight: 'normal' | 'bold' = a.emphasis === 'top' ? 'bold' : 'normal';
2155
- const durOpacity = a.emphasis === 'bottom' ? DURATION_FADE_OPACITY : 1;
2156
- const esWeight: 'normal' | 'bold' =
2157
- a.pinned === 'forward' ? 'bold' : 'normal';
2158
- const efWeight: 'normal' | 'bold' =
2159
- a.pinned === 'backward' ? 'bold' : 'normal';
2160
- drawCell(x + outerColW / 2, topMid, a.es, esWeight);
2161
- drawCell(
2162
- x + outerColW + midColW / 2,
2163
- topMid,
2164
- a.dur,
2165
- durWeight,
2166
- sNCFS,
2167
- durOpacity
2168
- );
2169
- drawCell(x + outerColW + midColW + outerColW / 2, topMid, a.ef, efWeight);
2170
-
2171
- const midRowTop = y + sTRH;
2172
- const midRowH = h - sTRH - sBRH;
2173
- const midCenterY = midRowTop + midRowH / 2;
2174
- const NAME_PAD_X = 6;
2175
- const NAME_PIN_GAP = 4;
2176
- const pinReserve = a.pinned ? sPIW + NAME_PIN_GAP : 0;
2177
- const availTextW = Math.max(0, w - 2 * NAME_PAD_X - pinReserve);
2178
- const displayName = truncateText(a.name, sNFS, availTextW);
2179
- const nameColor = a.midBandLabelColor ?? a.labelColor;
2180
- if (a.pinned) {
2181
- drawAnchorPin(g, x + NAME_PAD_X, midCenterY, nameColor, sPIW, sPIH);
2182
- const textAreaLeft = x + NAME_PAD_X + sPIW + NAME_PIN_GAP;
2183
- const textAreaRight = x + w - NAME_PAD_X;
2184
- const textCx = (textAreaLeft + textAreaRight) / 2;
2185
- drawCell(
2186
- textCx,
2187
- midCenterY,
2188
- displayName,
2189
- 'bold',
2190
- sNFS,
2191
- 1,
2192
- a.midBandLabelColor
2193
- );
2194
- } else {
2195
- drawCell(
2196
- x + w / 2,
2197
- midCenterY,
2198
- displayName,
2199
- 'bold',
2200
- sNFS,
2201
- 1,
2202
- a.midBandLabelColor
2203
- );
2204
- }
2205
-
2206
- const botMid = y + h - sBRH / 2;
2207
- const lsWeight: 'normal' | 'bold' = esWeight;
2208
- const lfWeight: 'normal' | 'bold' = efWeight;
2209
- drawCell(x + outerColW / 2, botMid, a.ls, lsWeight);
2210
- drawCell(x + outerColW + midColW / 2, botMid, a.slack);
2211
- drawCell(x + outerColW + midColW + outerColW / 2, botMid, a.lf, lfWeight);
2212
- }
2213
-
2214
- interface MilestonePillArgs {
2215
- width: number;
2216
- height: number;
2217
- x: number;
2218
- y: number;
2219
- name: string;
2220
- date: string;
2221
- slack: string;
2222
- /**
2223
- * When true, suppress the slack cell entirely — both the text and the
2224
- * bottom row divider. Used for zero-slack (critical-path) milestones
2225
- * where "0d" / "0w" would just add noise.
2226
- */
2227
- slackHidden?: boolean;
2228
- fill: string;
2229
- stroke: string;
2230
- labelColor: string;
2231
- /** See `TextbookCardArgs.highlightColor`. */
2232
- highlightColor: string;
2233
- dashArray?: string;
2234
- pinned?: 'forward' | 'backward' | null;
2235
- sNodeRadius?: number | undefined;
2236
- sNodeStrokeWidth?: number | undefined;
2237
- sNodeTopRowHeight?: number | undefined;
2238
- sNodeBottomRowHeight?: number | undefined;
2239
- sNodeCellFontSize?: number | undefined;
2240
- sPinIconW?: number | undefined;
2241
- sPinIconH?: number | undefined;
2242
- }
2243
-
2244
- function drawMilestonePill(g: AnySel, a: MilestonePillArgs): void {
2245
- const { width: w, height: h, x, y } = a;
2246
- const sNR = a.sNodeRadius ?? NODE_RADIUS;
2247
- const sNSW = a.sNodeStrokeWidth ?? NODE_STROKE_WIDTH;
2248
- const topRowH = a.sNodeTopRowHeight ?? NODE_TOP_ROW_HEIGHT;
2249
- const botRowH = a.sNodeBottomRowHeight ?? NODE_BOTTOM_ROW_HEIGHT;
2250
- const sNCFS = a.sNodeCellFontSize ?? NODE_CELL_FONT_SIZE;
2251
- const sPIW = a.sPinIconW ?? PIN_ICON_W;
2252
- const sPIH = a.sPinIconH ?? PIN_ICON_H;
2253
- const topY = y + topRowH;
2254
- const bottomY = y + h - botRowH;
2255
-
2256
- g.append('rect')
2257
- .attr('x', x)
2258
- .attr('y', y)
2259
- .attr('width', w)
2260
- .attr('height', h)
2261
- .attr('rx', sNR)
2262
- .attr('ry', sNR)
2263
- .attr('fill', a.fill)
2264
- .attr('stroke', a.stroke)
2265
- .attr('stroke-width', sNSW)
2266
- .attr('stroke-dasharray', a.dashArray ?? 'none');
2267
-
2268
- // Two horizontal dividers — same low-opacity grid stroke as the
2269
- // textbook card's row separators, so the pill reads as a sibling
2270
- // shape, just narrower.
2271
- const grid = (x1: number, y1: number, x2: number, y2: number): void => {
2272
- g.append('line')
2273
- .attr('x1', x1)
2274
- .attr('y1', y1)
2275
- .attr('x2', x2)
2276
- .attr('y2', y2)
2277
- .attr('stroke', a.stroke)
2278
- .attr('stroke-opacity', 0.3)
2279
- .attr('stroke-width', 1);
2280
- };
2281
- grid(x, topY, x + w, topY);
2282
- if (!a.slackHidden) grid(x, bottomY, x + w, bottomY);
2283
-
2284
- // Per-cell highlight overlays. The pill collapses ES/EF into a single
2285
- // top-row date cell and (when not zero) carries a slack cell at the
2286
- // bottom; hovering "Early Start" / "Early Finish" / "Slack" in the
2287
- // field-legend lights the corresponding row here.
2288
- const addCellHighlight = (
2289
- field: string,
2290
- cx: number,
2291
- cy: number,
2292
- cw: number,
2293
- ch: number
2294
- ): void => {
2295
- g.append('rect')
2296
- .attr('class', 'pert-cell-highlight')
2297
- .attr('data-field', field)
2298
- .attr('x', cx)
2299
- .attr('y', cy)
2300
- .attr('width', cw)
2301
- .attr('height', ch)
2302
- .attr('fill', a.highlightColor)
2303
- .attr('fill-opacity', 0)
2304
- .attr('pointer-events', 'none');
2305
- };
2306
- addCellHighlight('es', x, y, w, topRowH);
2307
- addCellHighlight('ef', x, y, w, topRowH);
2308
- if (!a.slackHidden) {
2309
- addCellHighlight('slack', x, bottomY, w, botRowH);
2310
- }
2311
-
2312
- const drawCenteredText = (
2313
- cx: number,
2314
- cy: number,
2315
- text: string,
2316
- weight: 'normal' | 'bold',
2317
- size: number
2318
- ): void => {
2319
- g.append('text')
2320
- .attr('x', cx)
2321
- .attr('y', cy + size / 2 - 2)
2322
- .attr('text-anchor', 'middle')
2323
- .attr('font-family', FONT_FAMILY)
2324
- .attr('fill', a.labelColor)
2325
- .attr('font-size', size)
2326
- .attr('font-weight', weight)
2327
- .text(text);
2328
- };
2329
-
2330
- // Top: milestone date (single value — ES = EF for any zero-duration
2331
- // activity, so two cells would just repeat).
2332
- drawCenteredText(x + w / 2, y + topRowH / 2, a.date, 'normal', sNCFS);
2333
-
2334
- const midRowTop = y + topRowH;
2335
- const midRowH = h - topRowH - botRowH;
2336
- const midCenterY = midRowTop + midRowH / 2;
2337
- const nameSize = 12;
2338
- const NAME_PAD_X = 6;
2339
- const NAME_PIN_GAP = 4;
2340
- const NAME_LINE_HEIGHT = 14;
2341
-
2342
- let textAreaLeft = x + NAME_PAD_X;
2343
- const textAreaRight = x + w - NAME_PAD_X;
2344
- if (a.pinned) {
2345
- drawAnchorPin(g, x + NAME_PAD_X, midCenterY, a.labelColor, sPIW, sPIH);
2346
- textAreaLeft = x + NAME_PAD_X + sPIW + NAME_PIN_GAP;
2347
- }
2348
- const textCx = (textAreaLeft + textAreaRight) / 2;
2349
- const availW = textAreaRight - textAreaLeft;
2350
- // Hard-break over-long words so a single long token still fits the
2351
- // narrow milestone pill rather than overflowing.
2352
- const lines = wrapTextToWidth(a.name, nameSize, availW, { hardBreak: true });
2353
- const maxLines = Math.max(1, Math.floor(midRowH / NAME_LINE_HEIGHT));
2354
- const visibleLines = lines.slice(0, maxLines);
2355
- if (lines.length > maxLines && visibleLines.length > 0) {
2356
- // More lines than fit — force a trailing ellipsis on the last
2357
- // visible line to signal the truncation, fitting it to the text area.
2358
- const last = visibleLines[visibleLines.length - 1]!;
2359
- const withEllipsis = `${last}…`;
2360
- visibleLines[visibleLines.length - 1] =
2361
- measureText(withEllipsis, nameSize) <= availW
2362
- ? withEllipsis
2363
- : truncateText(last, nameSize, availW);
2364
- }
2365
- const startCy =
2366
- midCenterY - ((visibleLines.length - 1) * NAME_LINE_HEIGHT) / 2;
2367
- visibleLines.forEach((line, i) => {
2368
- drawCenteredText(
2369
- textCx,
2370
- startCy + i * NAME_LINE_HEIGHT,
2371
- line,
2372
- 'bold',
2373
- nameSize
2374
- );
2375
- });
2376
-
2377
- if (!a.slackHidden) {
2378
- drawCenteredText(x + w / 2, y + h - botRowH / 2, a.slack, 'normal', sNCFS);
2379
- }
2380
- }
2381
-
2382
- // ============================================================
2383
- // Helpers
2384
- // ============================================================
2385
-
2386
- /**
2387
- * Render a Lucide-style anchor glyph at `(left, centerY)`, sized to
2388
- * PIN_ICON_W × PIN_ICON_H. Ring + shaft/arms inherit the supplied
2389
- * color so the anchor tracks the node's tint.
2390
- */
2391
- function drawAnchorPin(
2392
- g: AnySel,
2393
- left: number,
2394
- centerY: number,
2395
- color: string,
2396
- pinW: number = PIN_ICON_W,
2397
- pinH: number = PIN_ICON_H
2398
- ): void {
2399
- const scale = pinW / 24;
2400
- const top = centerY - pinH / 2;
2401
- const pin = g
2402
- .append('g')
2403
- .attr('class', 'pert-pin')
2404
- .attr('data-pert-pin', '')
2405
- .attr('transform', `translate(${left}, ${top}) scale(${scale})`);
2406
- // Vertical shaft + curved arms (path) and ring at the top (circle).
2407
- pin
2408
- .append('path')
2409
- .attr('d', 'M12 22V8 M5 12H2a10 10 0 0 0 20 0h-3')
2410
- .attr('fill', 'none')
2411
- .attr('stroke', color)
2412
- .attr('stroke-width', 2)
2413
- .attr('stroke-linecap', 'round')
2414
- .attr('stroke-linejoin', 'round');
2415
- pin
2416
- .append('circle')
2417
- .attr('cx', 12)
2418
- .attr('cy', 5)
2419
- .attr('r', 3)
2420
- .attr('fill', 'none')
2421
- .attr('stroke', color)
2422
- .attr('stroke-width', 2);
2423
- }
2424
-
2425
- /**
2426
- * Build the set of activity ids whose label gets an anchor icon
2427
- * because their card carries a user-supplied anchor date directly:
2428
- * forward → activities with no predecessors (ES = start-date)
2429
- * backward → activities with no successors (LF = end-date)
2430
- * unanchored → empty set
2431
- */
2432
- function computeAnchorPinSet(resolved: ResolvedPert): Set<string> {
2433
- const anchor = resolved.options.anchor;
2434
- if (anchor === null) return new Set();
2435
- // Build incoming/outgoing degree from edges so we don't need an
2436
- // analyzer-side flag.
2437
- const inDeg = new Map<string, number>();
2438
- const outDeg = new Map<string, number>();
2439
- for (const a of resolved.activities) {
2440
- inDeg.set(a.activity.id, 0);
2441
- outDeg.set(a.activity.id, 0);
2442
- }
2443
- for (const e of resolved.edges) {
2444
- outDeg.set(e.source, (outDeg.get(e.source) ?? 0) + 1);
2445
- inDeg.set(e.target, (inDeg.get(e.target) ?? 0) + 1);
2446
- }
2447
- const pinned = new Set<string>();
2448
- for (const a of resolved.activities) {
2449
- if (anchor.kind === 'forward' && (inDeg.get(a.activity.id) ?? 0) === 0) {
2450
- pinned.add(a.activity.id);
2451
- } else if (
2452
- anchor.kind === 'backward' &&
2453
- (outDeg.get(a.activity.id) ?? 0) === 0
2454
- ) {
2455
- pinned.add(a.activity.id);
2456
- }
2457
- }
2458
- return pinned;
2459
- }
2460
-
2461
- /**
2462
- * Build the anchor framing bullet, or `null` when no anchor is set.
2463
- * Surfaces the user-pinned date in plain language; the start/finish
2464
- * percentile bullets above already speak to the other end, so this
2465
- * line stays narrowly focused on the fixed boundary.
2466
- */
2467
- function anchorAnnotationText(resolved: ResolvedPert): string | null {
2468
- const anchor = resolved.options.anchor;
2469
- if (anchor === null) return null;
2470
- const today = resolved.options.today;
2471
- if (anchor.kind === 'forward') {
2472
- return `Forward from start-date ${anchor.date}`;
2473
- }
2474
- // Backward mode — surface the parse-time "today" so shared-link
2475
- // recipients see how fresh the past-date annotations are.
2476
- const asOf = today ? ` (as of ${today})` : '';
2477
- // TBD upstream still needs a hint that schedule cells will render
2478
- // `?` until estimates land, otherwise readers see ?-filled cards
2479
- // under a deadline with no explanation.
2480
- if (resolved.projectStart) {
2481
- return `Backward-anchored from end-date ${anchor.date}${asOf}`;
2482
- }
2483
- return `Backward-anchored from end-date ${anchor.date}${asOf} — upstream activities still need estimates`;
2484
- }
2485
-
2486
- interface CaptionBullet {
2487
- text: string;
2488
- /** 0 = top-level bullet; 1 = sub-bullet (indented). */
2489
- level: number;
2490
- /**
2491
- * When true, render the bullet text in italic. Bullets always carry
2492
- * a `•` glyph regardless; italic is a stylistic accent for the D10
2493
- * anchor framing note at the bottom of the caption box.
2494
- */
2495
- italic?: boolean;
2496
- /**
2497
- * Backward-mode flag carried over from the analyzer's `CaptionRow`.
2498
- * True when the row reports a latest-safe-start date that already
2499
- * precedes `options.today`; the underlying text already carries the
2500
- * `(latest-safe start has passed)` suffix.
2501
- */
2502
- isPast?: boolean;
2503
- }
2504
-
2505
- /**
2506
- * Pass-through adapter from the analyzer's structured `CaptionRow[]`
2507
- * to the renderer's `CaptionBullet[]` shape. The analyzer now emits
2508
- * one row per logical bullet (with `level`/`italic`/`isPast`), so the
2509
- * renderer no longer has to recover bullet structure by splitting on
2510
- * `\n` / `. ` and assertions on `isPast` flow through directly to
2511
- * downstream styling.
2512
- */
2513
- function bulletizeCaption(rows: CaptionRow[]): CaptionBullet[] {
2514
- return rows.map((row) => {
2515
- const out: CaptionBullet = { text: row.text, level: row.level };
2516
- if (row.italic) out.italic = true;
2517
- if (row.isPast) out.isPast = true;
2518
- return out;
2519
- });
2520
- }
2521
-
2522
- interface CaptionBlockArgs {
2523
- x: number;
2524
- y: number;
2525
- width: number;
2526
- height: number;
2527
- palette: PaletteColors;
2528
- isDark: boolean;
2529
- }
2530
-
2531
- /**
2532
- * Render the project-stats caption as a node-styled rectangle below
2533
- * the diagram body. Mirrors the textbook-card recipe: rounded corners,
2534
- * `palette.primary` stroke, 25% tint fill via `shapeFill`. A centered
2535
- * "Summary" header sits above a hairline divider; bullets follow,
2536
- * left-aligned with a `•` glyph and sub-bullets (level 1) indented
2537
- * under the preceding top-level bullet.
2538
- */
2539
- const SUB_BULLET_INDENT = 20;
2540
- // Vertical space the header band reserves: the "Summary" line itself
2541
- // (CAPTION_LINE_HEIGHT) plus a small gap between divider and the first
2542
- // bullet. Used by renderPert / renderPertForExport when sizing the
2543
- // caption box.
2544
- const CAPTION_HEADER_BAND_HEIGHT = CAPTION_LINE_HEIGHT + 8;
2545
-
2546
- /**
2547
- * Estimate the Summary box's natural pixel width given its bullets.
2548
- * Picks the longest bullet (with `• ` glyph + sub-bullet indent) and
2549
- * adds box padding. Used to size the Summary to its content rather
2550
- * than always claiming a fixed share of the Analysis row.
2551
- *
2552
- * 0.55 × CAPTION_FONT_SIZE approximates Inter's average glyph width
2553
- * at 13pt for mixed-case English content. Tighter than the typical
2554
- * 0.6 estimator — works because bullet text is mostly ASCII numerics
2555
- * and short labels.
2556
- */
2557
- function captionNaturalWidth(bullets: CaptionBullet[]): number {
2558
- const charW = CAPTION_FONT_SIZE * 0.55;
2559
- // Header text "Summary" sets a soft floor so a one-bullet caption
2560
- // doesn't end up narrower than its centered header label.
2561
- let max = 'Summary'.length * charW;
2562
- for (const b of bullets) {
2563
- const indent = b.level === 1 ? SUB_BULLET_INDENT : 0;
2564
- const w = indent + `• ${b.text}`.length * charW;
2565
- if (w > max) max = w;
2566
- }
2567
- return Math.ceil(max + 2 * CAPTION_BOX_PADDING_X);
2568
- }
2569
-
2570
- // Tornado widget — Monte-Carlo sensitivity ranking. Renders inside
2571
- // the Analysis row at width determined by the row's column allocation.
2572
- const TORNADO_TOP_N = 10;
2573
- const TORNADO_ROW_HEIGHT = 26;
2574
- const TORNADO_NAME_COL_W = 160;
2575
- const TORNADO_BAR_FONT_SIZE = 13;
2576
- const TORNADO_BAR_HEIGHT = 16;
2577
-
2578
- // Analysis row layout — shared between the inline (CLI / export) path
2579
- // inside renderPert and the standalone sibling-SVG path used by the
2580
- // desktop preview (renderPertAnalysisBlock).
2581
- const SUMMARY_MIN_W = 260;
2582
- const SUMMARY_MAX_W = 420;
2583
- const ANALYSIS_GAP = 16;
2584
- const COL1_VSTACK_GAP = 8;
2585
- // Each chart's minimum readable width — below these axis labels overlap
2586
- // and bars collapse. The canvas widens to honor them when needed.
2587
- const TORNADO_MIN_W = 340;
2588
- const SCURVE_MIN_W = 320;
2589
-
2590
- // S-curve widget — empirical CDF of MC trial finish times.
2591
- const SCURVE_BOX_HEIGHT = 220;
2592
- const SCURVE_PLOT_PADDING_X = 64; // y-axis labels + tick gap (13pt ticks)
2593
- const SCURVE_PLOT_PADDING_RIGHT = 16;
2594
- const SCURVE_PLOT_PADDING_BOTTOM = 44; // x-axis labels + tick gap (13pt ticks)
2595
- const SCURVE_TICK_FONT_SIZE = 13;
2596
- const SCURVE_PERCENTILE_RADIUS = 4;
2597
-
2598
- function renderCaptionBlock(
2599
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2600
- bullets: CaptionBullet[],
2601
- args: CaptionBlockArgs
2602
- ): void {
2603
- const { x, y, width, height, palette, isDark } = args;
2604
- const { fill, stroke: chromeStroke } = analysisBlockChrome(palette, isDark);
2605
- const labelColor = contrastText(
2606
- fill,
2607
- palette.textOnFillLight,
2608
- palette.textOnFillDark
2609
- );
2610
-
2611
- const block = svg
2612
- .append('g')
2613
- .attr('class', 'pert-caption-block')
2614
- .attr('data-pert-caption', '');
2615
-
2616
- block
2617
- .append('rect')
2618
- .attr('class', 'pert-caption-rect')
2619
- .attr('x', x)
2620
- .attr('y', y)
2621
- .attr('width', width)
2622
- .attr('height', height)
2623
- .attr('rx', NODE_RADIUS)
2624
- .attr('ry', NODE_RADIUS)
2625
- .attr('fill', fill)
2626
- .attr('stroke', chromeStroke)
2627
- .attr('stroke-width', NODE_STROKE_WIDTH);
2628
-
2629
- block
2630
- .append('text')
2631
- .attr('class', 'pert-caption-header')
2632
- .attr('x', x + width / 2)
2633
- .attr('y', y + CAPTION_BOX_PADDING_Y + CAPTION_FONT_SIZE)
2634
- .attr('text-anchor', 'middle')
2635
- .attr('fill', labelColor)
2636
- .attr('font-size', CAPTION_FONT_SIZE)
2637
- .attr('font-weight', '700')
2638
- .text('Summary');
2639
-
2640
- const textX = x + CAPTION_BOX_PADDING_X;
2641
- const firstBaselineY =
2642
- y + CAPTION_BOX_PADDING_Y + CAPTION_HEADER_BAND_HEIGHT + CAPTION_FONT_SIZE;
2643
- const text = block
2644
- .append('text')
2645
- .attr('class', 'pert-caption')
2646
- .attr('x', textX)
2647
- .attr('y', firstBaselineY)
2648
- .attr('text-anchor', 'start')
2649
- .attr('fill', labelColor)
2650
- .attr('font-size', CAPTION_FONT_SIZE)
2651
- .attr('font-weight', CAPTION_FONT_WEIGHT);
2652
-
2653
- bullets.forEach((bullet, i) => {
2654
- const indent = bullet.level === 1 ? SUB_BULLET_INDENT : 0;
2655
- const tspan = text
2656
- .append('tspan')
2657
- .attr('x', textX + indent)
2658
- .text(`• ${bullet.text}`);
2659
- if (bullet.italic) tspan.attr('font-style', 'italic');
2660
- if (i > 0) tspan.attr('dy', CAPTION_LINE_HEIGHT);
2661
- });
2662
- }
2663
-
2664
- interface FieldLegendArgs {
2665
- x: number;
2666
- y: number;
2667
- width: number;
2668
- height: number;
2669
- palette: PaletteColors;
2670
- isDark: boolean;
2671
- }
2672
-
2673
- /**
2674
- * Render the 3×2 PERT-field reference card. A neutral-tinted rounded
2675
- * rect with a "Activity card fields" header band on top (mirroring the
2676
- * Summary's typographic idiom) and a 3×2 grid of labeled definitions
2677
- * below — so the cells map 1-to-1 to the schedule cells of every
2678
- * activity card without pretending to be a node themselves.
2679
- *
2680
- * The cell content is vertically centered inside each row, so the
2681
- * legend looks balanced whether it's sized to a tall Summary (lots of
2682
- * bullets) or its compact default height.
2683
- *
2684
- * Cell order follows `drawTextbookCard`:
2685
- * top: [ Early Start | Duration | Early Finish ]
2686
- * bottom: [ Late Start | Slack | Late Finish ]
2687
- */
2688
- interface TagLegendArgs {
2689
- x: number;
2690
- y: number;
2691
- width: number;
2692
- activeGroup: string | null;
2693
- exportMode?: boolean;
2694
- }
2695
-
2696
- /**
2697
- * Render the tag-group legend row using the shared `renderLegendD3`
2698
- * capsule. Inactive groups appear as collapsible pills; the active
2699
- * group expands to show its values + colored dots. Each rendered
2700
- * group <g> is tagged with `data-tag-legend-group="<name>"` so app-side
2701
- * hover wiring can scope dimming the same way org / kanban do.
2702
- */
2703
- function renderTagLegendRow(
2704
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2705
- resolved: ResolvedPert,
2706
- palette: PaletteColors,
2707
- isDark: boolean,
2708
- args: TagLegendArgs
2709
- ): void {
2710
- if (resolved.tagGroups.length === 0) return;
2711
-
2712
- const { x, y, width, activeGroup, exportMode } = args;
2713
- const groups = resolved.tagGroups.map((g) => ({
2714
- name: g.name,
2715
- entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2716
- }));
2717
-
2718
- const block = svg
2719
- .append('g')
2720
- .attr('class', 'pert-tag-legend')
2721
- .attr('transform', `translate(${x}, ${y})`);
2722
-
2723
- renderIntegratedLegend(block, {
2724
- groups,
2725
- mode: exportMode ? 'export' : 'preview',
2726
- palette,
2727
- isDark,
2728
- width,
2729
- activeGroup,
2730
- });
2731
- }
2732
-
2733
- function renderFieldLegendBlock(
2734
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2735
- args: FieldLegendArgs
2736
- ): void {
2737
- const { x, y, width, height, palette, isDark } = args;
2738
- // Field labels is a teaching aid (what each schedule cell means) —
2739
- // visually subordinate to Summary / Tornado / S-curve which carry
2740
- // data. Use a transparent fill + faint hairline border so the block
2741
- // reads as reference chrome, not a panel of its own.
2742
- const baseColor = palette.textMuted;
2743
- const labelColor = contrastText(
2744
- shapeFill(palette, baseColor, isDark),
2745
- palette.textOnFillLight,
2746
- palette.textOnFillDark
2747
- );
2748
-
2749
- // Header band — matches the Summary / Tornado / S-curve treatment so
2750
- // all four Analysis-row widgets share one visual family.
2751
- const headerY = y + CAPTION_BOX_PADDING_Y + CAPTION_FONT_SIZE;
2752
- const colW = width / 3;
2753
- // Grid starts BELOW the header band.
2754
- const gridTop = y + CAPTION_BOX_PADDING_Y + CAPTION_HEADER_BAND_HEIGHT;
2755
- const gridBottom = y + height;
2756
- const rowH = (gridBottom - gridTop) / 2;
2757
-
2758
- const block = svg
2759
- .append('g')
2760
- .attr('class', 'pert-field-legend')
2761
- .attr('data-pert-field-legend', '');
2762
-
2763
- block
2764
- .append('rect')
2765
- .attr('class', 'pert-field-legend-rect')
2766
- .attr('x', x)
2767
- .attr('y', y)
2768
- .attr('width', width)
2769
- .attr('height', height)
2770
- .attr('rx', NODE_RADIUS)
2771
- .attr('ry', NODE_RADIUS)
2772
- .attr('fill', 'none')
2773
- .attr('stroke', baseColor)
2774
- .attr('stroke-opacity', 0.4)
2775
- .attr('stroke-width', 1);
2776
-
2777
- // Centered bold header + hairline divider — matches Summary etc.
2778
- block
2779
- .append('text')
2780
- .attr('class', 'pert-field-legend-header')
2781
- .attr('x', x + width / 2)
2782
- .attr('y', headerY)
2783
- .attr('text-anchor', 'middle')
2784
- .attr('fill', labelColor)
2785
- .attr('font-size', CAPTION_FONT_SIZE)
2786
- .attr('font-weight', '700')
2787
- .text('Field labels');
2788
-
2789
- // Internal grid lines for the 3×2 cell area (matches
2790
- // drawTextbookCard's low-opacity divider pattern).
2791
- const grid = (x1: number, y1: number, x2: number, y2: number): void => {
2792
- block
2793
- .append('line')
2794
- .attr('x1', x1)
2795
- .attr('y1', y1)
2796
- .attr('x2', x2)
2797
- .attr('y2', y2)
2798
- .attr('stroke', baseColor)
2799
- .attr('stroke-opacity', 0.3)
2800
- .attr('stroke-width', 1);
2801
- };
2802
- grid(x, gridTop + rowH, x + width, gridTop + rowH);
2803
- grid(x + colW, gridTop, x + colW, gridBottom);
2804
- grid(x + colW * 2, gridTop, x + colW * 2, gridBottom);
2805
-
2806
- // Field id per cell, indexed in the same order as FIELD_LEGEND_CELLS:
2807
- // top: [ es | dur | ef ]
2808
- // bottom: [ ls | slack | lf ]
2809
- // Used by the React-layer hover handler to find matching cells in
2810
- // both this legend and every node card.
2811
- const FIELD_BY_INDEX = ['es', 'dur', 'ef', 'ls', 'slack', 'lf'] as const;
2812
-
2813
- // Cells — bold label above a wrapped description, both centered
2814
- // vertically inside the row. Each cell sizes its own stack so 2-line
2815
- // and 3-line descriptions still sit centered in the same row height.
2816
- // Each cell is wrapped in a `<g class="pert-field-legend-cell">` so
2817
- // the React-layer hover handler can resolve the cell from any
2818
- // descendant target via `closest()`.
2819
- FIELD_LEGEND_CELLS.forEach((cell, i) => {
2820
- const col = i % 3;
2821
- const row = Math.floor(i / 3);
2822
- const cx = x + col * colW + colW / 2;
2823
- const cellTop = gridTop + row * rowH;
2824
- const field = FIELD_BY_INDEX[i]!;
2825
-
2826
- const cellG = block
2827
- .append('g')
2828
- .attr('class', 'pert-field-legend-cell')
2829
- .attr('data-field', field);
2830
-
2831
- // Hover trigger + highlight overlay in one. Transparent by default;
2832
- // React layer flips fill-opacity to 0.25 on enter. `pointer-events:
2833
- // all` lets it catch hover even when invisible.
2834
- cellG
2835
- .append('rect')
2836
- .attr('class', 'pert-cell-highlight')
2837
- .attr('data-field', field)
2838
- .attr('x', x + col * colW)
2839
- .attr('y', cellTop)
2840
- .attr('width', colW)
2841
- .attr('height', rowH)
2842
- .attr('fill', palette.colors.blue)
2843
- .attr('fill-opacity', 0)
2844
- .attr('pointer-events', 'all');
2845
-
2846
- const descLines = wrapTextToWidth(
2847
- cell.desc,
2848
- FIELD_LEGEND_DESC_FONT_SIZE,
2849
- fieldLegendDescWidth(colW)
2850
- );
2851
- const descBlockHeight =
2852
- FIELD_LEGEND_DESC_FONT_SIZE +
2853
- Math.max(descLines.length - 1, 0) * FIELD_LEGEND_DESC_LINE_HEIGHT;
2854
- const stackHeight =
2855
- FIELD_LEGEND_LABEL_FONT_SIZE +
2856
- FIELD_LEGEND_LABEL_DESC_GAP +
2857
- descBlockHeight;
2858
- const stackTop = cellTop + Math.max((rowH - stackHeight) / 2, 0);
2859
- const labelY = stackTop + FIELD_LEGEND_LABEL_FONT_SIZE;
2860
- const firstDescY =
2861
- labelY + FIELD_LEGEND_LABEL_DESC_GAP + FIELD_LEGEND_DESC_FONT_SIZE;
2862
-
2863
- cellG
2864
- .append('text')
2865
- .attr('class', 'pert-field-legend-label')
2866
- .attr('x', cx)
2867
- .attr('y', labelY)
2868
- .attr('text-anchor', 'middle')
2869
- .attr('font-family', FONT_FAMILY)
2870
- .attr('font-size', FIELD_LEGEND_LABEL_FONT_SIZE)
2871
- .attr('font-weight', 600)
2872
- .attr('fill', labelColor)
2873
- .text(cell.label);
2874
-
2875
- const descText = cellG
2876
- .append('text')
2877
- .attr('class', 'pert-field-legend-desc')
2878
- .attr('x', cx)
2879
- .attr('y', firstDescY)
2880
- .attr('text-anchor', 'middle')
2881
- .attr('font-family', FONT_FAMILY)
2882
- .attr('font-size', FIELD_LEGEND_DESC_FONT_SIZE)
2883
- .attr('fill', labelColor)
2884
- .attr('opacity', 0.85);
2885
- descLines.forEach((line, idx) => {
2886
- const tspan = descText.append('tspan').attr('x', cx).text(line);
2887
- if (idx > 0) tspan.attr('dy', FIELD_LEGEND_DESC_LINE_HEIGHT);
2888
- });
2889
- });
2890
- }
2891
-
2892
- // ============================================================
2893
- // Section: tornado (sensitivity) widget
2894
- // ============================================================
2895
-
2896
- interface TornadoRow {
2897
- id: string;
2898
- name: string;
2899
- /** Days the project finishes EARLIER when this activity = O. */
2900
- lowSwing: number;
2901
- /** Days the project finishes LATER when this activity = P. */
2902
- highSwing: number;
2903
- /** Criticality band drives bar color (red/orange/yellow/green/blue). */
2904
- band: Band;
2905
- }
2906
-
2907
- /**
2908
- * Build the top-N tornado rows from MC output. Reads the pre-computed
2909
- * `tornadoSwings` array on the MonteCarloResult, converts swings from
2910
- * canonical days to the display unit, and assigns criticality bands.
2911
- * Returns an empty array when MC didn't run or no activity has
2912
- * non-zero swing.
2913
- */
2914
- function buildTornadoRows(
2915
- resolved: ResolvedPert,
2916
- maxN: number = TORNADO_TOP_N
2917
- ): TornadoRow[] {
2918
- if (resolved.monteCarloResult === null) return [];
2919
- const swings = resolved.monteCarloResult.tornadoSwings ?? [];
2920
- if (swings.length === 0) return [];
2921
- const sprintDays = resolveSprintDaysFromOptions(resolved.options);
2922
- const unit = resolved.options.timeUnit;
2923
- const rows: TornadoRow[] = swings.map((s) => ({
2924
- id: s.id,
2925
- name: s.name,
2926
- lowSwing: fromDisplayUnit(s.lowSwing, unit, sprintDays),
2927
- highSwing: fromDisplayUnit(s.highSwing, unit, sprintDays),
2928
- band: criticalityBand(s.criticality),
2929
- }));
2930
- return rows.slice(0, Math.max(1, maxN));
2931
- }
2932
-
2933
- // Days → display-unit count (e.g., days → weeks). Inlined here so the
2934
- // renderer doesn't need to import the analyzer's helpers.
2935
- function fromDisplayUnit(
2936
- days: number,
2937
- unit: DurationUnit,
2938
- sprintDays: number | undefined
2939
- ): number {
2940
- const unitDays =
2941
- unit === 's' && sprintDays !== undefined ? sprintDays : DAYS_PER_UNIT[unit];
2942
- return days / unitDays;
2943
- }
2944
-
2945
- function resolveSprintDaysFromOptions(opts: {
2946
- sprintMode: 'auto' | 'explicit' | null;
2947
- sprintLength: Duration | null;
2948
- }): number | undefined {
2949
- if (!opts.sprintMode || !opts.sprintLength) return undefined;
2950
- return opts.sprintLength.amount * DAYS_PER_UNIT[opts.sprintLength.unit];
2951
- }
2952
-
2953
- const DAYS_PER_UNIT: Record<DurationUnit, number> = {
2954
- min: 1 / (60 * 24),
2955
- h: 1 / 24,
2956
- d: 1,
2957
- bd: 1,
2958
- w: 7,
2959
- m: 30,
2960
- q: 90,
2961
- y: 365,
2962
- s: 14,
2963
- };
2964
-
2965
- /**
2966
- * Maximum number of tornado rows that fit inside a box of the given
2967
- * height. Reverses tornadoBoxHeightFor(): subtracts header band and
2968
- * box padding, then floors by row height.
2969
- */
2970
- function tornadoMaxRowsFor(boxHeight: number): number {
2971
- const rowSpace =
2972
- boxHeight - 2 * CAPTION_BOX_PADDING_Y - CAPTION_HEADER_BAND_HEIGHT;
2973
- return Math.max(1, Math.floor(rowSpace / TORNADO_ROW_HEIGHT));
2974
- }
2975
-
2976
- function tornadoBoxHeightFor(rows: TornadoRow[]): number {
2977
- return (
2978
- rows.length * TORNADO_ROW_HEIGHT +
2979
- 2 * CAPTION_BOX_PADDING_Y +
2980
- CAPTION_HEADER_BAND_HEIGHT
2981
- );
2982
- }
2983
-
2984
- interface TornadoBlockArgs {
2985
- x: number;
2986
- y: number;
2987
- width: number;
2988
- height: number;
2989
- palette: PaletteColors;
2990
- isDark: boolean;
2991
- }
2992
-
2993
- function renderTornadoBlock(
2994
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2995
- rows: TornadoRow[],
2996
- args: TornadoBlockArgs
2997
- ): void {
2998
- const { x, y, width, height, palette, isDark } = args;
2999
- const { fill, stroke: chromeStroke } = analysisBlockChrome(palette, isDark);
3000
- const labelColor = contrastText(
3001
- fill,
3002
- palette.textOnFillLight,
3003
- palette.textOnFillDark
3004
- );
3005
-
3006
- const block = svg
3007
- .append('g')
3008
- .attr('class', 'pert-tornado-block')
3009
- .attr('data-pert-tornado', '');
3010
-
3011
- block
3012
- .append('rect')
3013
- .attr('class', 'pert-tornado-rect')
3014
- .attr('x', x)
3015
- .attr('y', y)
3016
- .attr('width', width)
3017
- .attr('height', height)
3018
- .attr('rx', NODE_RADIUS)
3019
- .attr('ry', NODE_RADIUS)
3020
- .attr('fill', fill)
3021
- .attr('stroke', chromeStroke)
3022
- .attr('stroke-width', NODE_STROKE_WIDTH);
3023
-
3024
- block
3025
- .append('text')
3026
- .attr('class', 'pert-tornado-header')
3027
- .attr('x', x + width / 2)
3028
- .attr('y', y + CAPTION_BOX_PADDING_Y + CAPTION_FONT_SIZE)
3029
- .attr('text-anchor', 'middle')
3030
- .attr('fill', labelColor)
3031
- .attr('font-size', CAPTION_FONT_SIZE)
3032
- .attr('font-weight', '700')
3033
- .text('Activity Risk');
3034
-
3035
- // Two-sided bar geometry. Activity name on the far left, then a
3036
- // bidirectional plot area with a vertical zero-line: each row paints
3037
- // a `low` bar growing LEFT (project finishes earlier) and a `high`
3038
- // bar growing RIGHT (project finishes later). Magnitudes sit at the
3039
- // END of each bar — `−low` left of the low bar, `+high` right of the
3040
- // high bar — so the eye binds the number to its own bar instead of
3041
- // parsing a combined cell.
3042
- //
3043
- // The zero-line is positioned asymmetrically so the longest left bar
3044
- // exactly reaches `innerLeft` and the longest right bar exactly
3045
- // reaches `innerRight`. A single uniform pixels-per-unit scale spans
3046
- // both sides — bars on the left and right remain magnitude-comparable
3047
- // — but the plot allocates space proportionally to maxLow vs maxHigh
3048
- // instead of wasting half on the shorter side. With maxLow == maxHigh
3049
- // the line lands dead-center (legacy behavior).
3050
- const fmt = (v: number): string => {
3051
- const r = Math.round(v * 100) / 100;
3052
- return r.toFixed(2).replace(/\.?0+$/, '');
3053
- };
3054
-
3055
- const maxLow = rows.reduce((acc, r) => Math.max(acc, r.lowSwing), 0);
3056
- const maxHigh = rows.reduce((acc, r) => Math.max(acc, r.highSwing), 0);
3057
- const totalRange = maxLow + maxHigh || 1;
3058
- const nameX = x + CAPTION_BOX_PADDING_X;
3059
- const plotLeft = nameX + TORNADO_NAME_COL_W;
3060
- const plotRight = x + width - CAPTION_BOX_PADDING_X;
3061
- // Reserve gutter at each end for the value labels so a max-width
3062
- // bar's end-label has room without bleeding into the box edge. The
3063
- // gutters are sized to the actual longest label on each side rather
3064
- // than a hardcoded width — `+8.54` only needs ~37px, not the legacy
3065
- // 56px reserve. Less wasted gutter = wider plot area = bars that use
3066
- // more of the box. When one side has zero extent (delay-only chart)
3067
- // its gutter collapses to zero.
3068
- const VALUE_GAP = 6;
3069
- // Inter at TORNADO_BAR_FONT_SIZE — digits and `+`/`−` average ~0.55em.
3070
- // Slight over-estimate (0.6) so labels never crash into the box edge
3071
- // even with 1-2px font-rendering variance across platforms.
3072
- const CHAR_W = TORNADO_BAR_FONT_SIZE * 0.6;
3073
- const longestLeftLabelChars = rows.reduce(
3074
- (acc, r) =>
3075
- r.lowSwing > 0 ? Math.max(acc, `−${fmt(r.lowSwing)}`.length) : acc,
3076
- 0
3077
- );
3078
- const longestRightLabelChars = rows.reduce(
3079
- (acc, r) =>
3080
- r.highSwing > 0 ? Math.max(acc, `+${fmt(r.highSwing)}`.length) : acc,
3081
- 0
3082
- );
3083
- const leftGutter =
3084
- longestLeftLabelChars > 0
3085
- ? longestLeftLabelChars * CHAR_W + VALUE_GAP + 2
3086
- : 0;
3087
- const rightGutter =
3088
- longestRightLabelChars > 0
3089
- ? longestRightLabelChars * CHAR_W + VALUE_GAP + 2
3090
- : 0;
3091
- const innerLeft = plotLeft + leftGutter;
3092
- const innerRight = plotRight - rightGutter;
3093
- const plotWidth = Math.max(innerRight - innerLeft, 0);
3094
- const pixelsPerUnit = plotWidth / totalRange;
3095
- const leftWidth = maxLow * pixelsPerUnit;
3096
- const centerX = innerLeft + leftWidth;
3097
- const firstRowY = y + CAPTION_BOX_PADDING_Y + CAPTION_HEADER_BAND_HEIGHT;
3098
-
3099
- // Center baseline axis is drawn AFTER bars below — see end of fn —
3100
- // so it reads as the canonical zero-line instead of getting buried
3101
- // under the bar rects (which span centerX).
3102
-
3103
- rows.forEach((row, i) => {
3104
- const rowY = firstRowY + i * TORNADO_ROW_HEIGHT;
3105
- const labelY =
3106
- rowY + TORNADO_ROW_HEIGHT / 2 + TORNADO_BAR_FONT_SIZE / 2 - 2;
3107
- const barColor = bandColor(row.band, palette, palette.primary);
3108
- const barFill = shapeFill(palette, barColor, isDark);
3109
- const lowW = row.lowSwing * pixelsPerUnit;
3110
- const highW = row.highSwing * pixelsPerUnit;
3111
-
3112
- const rowG = block
3113
- .append('g')
3114
- .attr('class', 'pert-tornado-row')
3115
- .attr('data-activity-id', row.id)
3116
- // Total swing (low + high) in display-units. Consumers (the app's
3117
- // sensitivity-heatmap overlay) read this to map nodes to opacity
3118
- // by leverage without needing a parallel data feed.
3119
- .attr('data-tornado-swing', String(row.lowSwing + row.highSwing));
3120
-
3121
- // Transparent overlay rect spans the entire row, captures pointer
3122
- // events even over whitespace between bars and value labels.
3123
- rowG
3124
- .append('rect')
3125
- .attr('class', 'pert-tornado-row-hit')
3126
- .attr('x', nameX)
3127
- .attr('y', rowY)
3128
- .attr('width', plotRight - nameX)
3129
- .attr('height', TORNADO_ROW_HEIGHT)
3130
- .attr('fill', 'transparent')
3131
- .attr('pointer-events', 'all');
3132
-
3133
- // Activity name (truncate when overlong).
3134
- const truncated =
3135
- row.name.length > 22 ? row.name.slice(0, 21) + '…' : row.name;
3136
- rowG
3137
- .append('text')
3138
- .attr('class', 'pert-tornado-name')
3139
- .attr('x', nameX)
3140
- .attr('y', labelY)
3141
- .attr('text-anchor', 'start')
3142
- .attr('fill', labelColor)
3143
- .attr('font-size', TORNADO_BAR_FONT_SIZE)
3144
- .text(truncated);
3145
-
3146
- // Left bar + its `−low` label at the bar's outer (left) end.
3147
- if (lowW > 0) {
3148
- rowG
3149
- .append('rect')
3150
- .attr('class', 'pert-tornado-bar pert-tornado-bar-low')
3151
- .attr('x', centerX - lowW)
3152
- .attr('y', rowY + (TORNADO_ROW_HEIGHT - TORNADO_BAR_HEIGHT) / 2)
3153
- .attr('width', lowW)
3154
- .attr('height', TORNADO_BAR_HEIGHT)
3155
- .attr('rx', 2)
3156
- .attr('ry', 2)
3157
- .attr('fill', barFill)
3158
- .attr('stroke', barColor)
3159
- .attr('stroke-width', 1);
3160
- rowG
3161
- .append('text')
3162
- .attr('class', 'pert-tornado-value pert-tornado-value-low')
3163
- .attr('x', centerX - lowW - VALUE_GAP)
3164
- .attr('y', labelY)
3165
- .attr('text-anchor', 'end')
3166
- .attr('fill', labelColor)
3167
- .attr('font-size', TORNADO_BAR_FONT_SIZE)
3168
- .text(`−${fmt(row.lowSwing)}`);
3169
- }
3170
-
3171
- // Right bar + its `+high` label at the bar's outer (right) end.
3172
- if (highW > 0) {
3173
- rowG
3174
- .append('rect')
3175
- .attr('class', 'pert-tornado-bar pert-tornado-bar-high')
3176
- .attr('x', centerX)
3177
- .attr('y', rowY + (TORNADO_ROW_HEIGHT - TORNADO_BAR_HEIGHT) / 2)
3178
- .attr('width', highW)
3179
- .attr('height', TORNADO_BAR_HEIGHT)
3180
- .attr('rx', 2)
3181
- .attr('ry', 2)
3182
- .attr('fill', barFill)
3183
- .attr('stroke', barColor)
3184
- .attr('stroke-width', 1);
3185
- rowG
3186
- .append('text')
3187
- .attr('class', 'pert-tornado-value pert-tornado-value-high')
3188
- .attr('x', centerX + highW + VALUE_GAP)
3189
- .attr('y', labelY)
3190
- .attr('text-anchor', 'start')
3191
- .attr('fill', labelColor)
3192
- .attr('font-size', TORNADO_BAR_FONT_SIZE)
3193
- .text(`+${fmt(row.highSwing)}`);
3194
- }
3195
- });
3196
-
3197
- // Zero axis — vertical rule at the bar pivot, drawn AFTER bars so it
3198
- // visibly anchors the eye to the project-end-unchanged baseline.
3199
- // labelColor reads "ink, not data" against the gray block fill.
3200
- if (rows.length > 0) {
3201
- const axisTop = firstRowY;
3202
- const axisBottom = firstRowY + rows.length * TORNADO_ROW_HEIGHT;
3203
- block
3204
- .append('line')
3205
- .attr('class', 'pert-tornado-axis')
3206
- .attr('x1', centerX)
3207
- .attr('x2', centerX)
3208
- .attr('y1', axisTop)
3209
- .attr('y2', axisBottom)
3210
- .attr('stroke', labelColor)
3211
- .attr('stroke-width', 1)
3212
- .attr('opacity', 0.5);
3213
- }
3214
- }
3215
-
3216
- // ============================================================
3217
- // Section: S-curve (completion-probability) widget
3218
- // ============================================================
3219
-
3220
- // `ScurveData` is defined in `./types` (Path B mode-discriminated shape).
3221
- // Previously declared inline here; promoted alongside the
3222
- // backward-anchor framing flip — see tech-spec §13A.12.
3223
-
3224
- /**
3225
- * Build the cumulative-distribution data for the S-curve.
3226
- *
3227
- * Two modes share most of the data; the renderer reads `data.mode` and
3228
- * the `referenceLines[].isPast` flag for the only mode-discriminated
3229
- * styling (dashed strokes for past latest-safe-starts). Forward-mode
3230
- * fields preserve the pre-change semantics so existing snapshots stay
3231
- * stable (AC 12).
3232
- *
3233
- * Forward (no anchor or `start-date`):
3234
- * - x-axis = duration / finish date; y rises 0 → 1.
3235
- * - referenceLines at P50/P80/P95 finishes; `isPast` always `false`.
3236
- *
3237
- * Backward (`end-date`):
3238
- * - x-axis = candidate-start date; y falls 1 → 0.
3239
- * - referenceLines at P50/P80/P95 latest-safe starts;
3240
- * `isPast` flips `true` when the resolved date precedes
3241
- * `options.today`, and the label appends `" (past)"`.
3242
- */
3243
- function buildScurveData(resolved: ResolvedPert): ScurveData | null {
3244
- const mc = resolved.monteCarloResult;
3245
- if (mc === null) return null;
3246
- // Anchor the curve with O–p95 range. Use 2·p50 − p95 as a proxy for
3247
- // the lower tail; when MC reports a degenerate p50/p80/p95 (collapse
3248
- // to one value) the function returns null and the no-variance
3249
- // fallback caption row covers the case.
3250
- const p5 = Math.max(0, 2 * mc.p50 - mc.p95);
3251
- const durationSamples = [p5, mc.p50, mc.p80, mc.p95];
3252
- if (durationSamples.every((v) => v === durationSamples[0])) return null;
3253
-
3254
- const anchor = resolved.options.anchor;
3255
- const mode: 'forward' | 'backward' =
3256
- anchor?.kind === 'backward' ? 'backward' : 'forward';
3257
- // Mode-agnostic y-axis label. The mode-specific reading lives on
3258
- // the x-axis (finish date vs candidate-start date) and in the
3259
- // inline percentile labels; the y-axis is a probability scale in
3260
- // both cases, so a plain-English label reads more cleanly than
3261
- // mathematical notation.
3262
- const yAxisLabel = 'Probability of completion';
3263
- const today = resolved.options.today;
3264
-
3265
- // Deadline canonical days = `end_date − projectStart` for backward
3266
- // mode. Backward also uses this as the right edge of the x-axis
3267
- // (= candidate-start of "the deadline itself" = 0% probability).
3268
- let deadlineDays: number | null = null;
3269
- let deadlineDate: string | null = null;
3270
- if (
3271
- anchor !== null &&
3272
- anchor.kind === 'backward' &&
3273
- resolved.projectStart !== null
3274
- ) {
3275
- const startMs = Date.parse(resolved.projectStart);
3276
- const endMs = Date.parse(anchor.date);
3277
- if (!isNaN(startMs) && !isNaN(endMs)) {
3278
- deadlineDays = (endMs - startMs) / (1000 * 60 * 60 * 24);
3279
- deadlineDate = anchor.date;
3280
- }
3281
- }
3282
-
3283
- // ── Project everything into "canonical x-axis days" ────────────
3284
- //
3285
- // Forward: x-axis days = duration days from projectStart. Sample
3286
- // values, percentiles, and band edges are all already in this
3287
- // space — pass through unchanged.
3288
- //
3289
- // Backward: x-axis days = candidate-start days from projectStart,
3290
- // so a candidate-start date `s` projects to `s − projectStart`
3291
- // canonical days, and the deadline at `end_date` sits at
3292
- // `deadlineDays` canonical days (the right edge). For each duration
3293
- // `d`, the candidate-start that just barely fits is
3294
- // `deadlineDays − d`. P95 (longest duration) → leftmost (earliest
3295
- // start); P5_proxy (shortest) → rightmost (latest candidate start).
3296
- //
3297
- // Two projection helpers: `projectSmooth` keeps fractional days so
3298
- // the curve interpolates smoothly; `projectRounded` applies the
3299
- // same `Math.ceil` rounding the analyzer uses (`roundConservative`)
3300
- // so percentile dots and their date labels match the caption rows
3301
- // byte-for-byte.
3302
- const projectSmooth = (durationDays: number): number =>
3303
- mode === 'backward' && deadlineDays !== null
3304
- ? deadlineDays - durationDays
3305
- : durationDays;
3306
- const projectRounded = (durationDays: number): number =>
3307
- mode === 'backward' && deadlineDays !== null
3308
- ? deadlineDays - Math.ceil(durationDays)
3309
- : durationDays;
3310
-
3311
- // Curve points, ordered ascending by `x`. Forward rises; backward
3312
- // falls. Same MC trial set in both — only the projection flips.
3313
- // Smooth projection (no ceil) so the cubic interpolation between
3314
- // (P50, P80, P95) doesn't step.
3315
- const durationProbPairs: Array<{ d: number; p: number }> = [
3316
- // In-bounds: MC always produces samples; consumers gate on `mc.minDurationDays !== null`.
3317
- { d: durationSamples[0]!, p: 0.05 },
3318
- { d: mc.p50, p: 0.5 },
3319
- { d: mc.p80, p: 0.8 },
3320
- { d: mc.p95, p: 0.95 },
3321
- ];
3322
- const curvePoints = durationProbPairs
3323
- .map(({ d, p }) => ({ x: projectSmooth(d), y: p }))
3324
- .sort((a, b) => a.x - b.x);
3325
-
3326
- const samples = durationSamples.map(projectSmooth).sort((a, b) => a - b);
3327
-
3328
- // Band edges. In backward mode the projection inverts ordering, so
3329
- // we explicitly assign the smaller projected value to `p16Days` and
3330
- // the larger to `p84Days` — the renderer relies on `p16Days ≤
3331
- // p84Days`.
3332
- const p16Proj = projectSmooth(mc.p16);
3333
- const p84Proj = projectSmooth(mc.p84);
3334
- const bandLeft = Math.min(p16Proj, p84Proj);
3335
- const bandRight = Math.max(p16Proj, p84Proj);
3336
-
3337
- // Percentile reference lines — same MC outputs, mode-dependent
3338
- // wording and projection. `durationDays` keeps the original duration
3339
- // so the renderer can paint the "≈ Nw of work" sub-label below each
3340
- // dot in backward mode.
3341
- const referenceLines: ScurveReferenceLine[] = (
3342
- [
3343
- { pct: 50, days: mc.p50 },
3344
- { pct: 80, days: mc.p80 },
3345
- { pct: 95, days: mc.p95 },
3346
- ] as Array<{ pct: 50 | 80 | 95; days: number }>
3347
- ).map(({ pct, days }) => {
3348
- const yFrac = pct / 100;
3349
- if (mode === 'backward' && anchor?.kind === 'backward') {
3350
- const offsetDays = Math.ceil(days);
3351
- const date = addCalendarDays(anchor.date, -offsetDays);
3352
- const isPast = today.length > 0 && date < today;
3353
- const label = `P${pct} latest-safe start${isPast ? ' (past)' : ''}`;
3354
- return {
3355
- x: projectRounded(days),
3356
- y: yFrac,
3357
- durationDays: days,
3358
- label,
3359
- isPast,
3360
- };
3361
- }
3362
- return {
3363
- x: days,
3364
- y: yFrac,
3365
- durationDays: days,
3366
- label: `P${pct} finish`,
3367
- isPast: false,
3368
- };
3369
- });
3370
-
3371
- // Axis bounds. Backward right edge = the deadline itself (the
3372
- // candidate-start = deadline column, where 0% chance remains).
3373
- const xMinDays =
3374
- mode === 'backward' && deadlineDays !== null
3375
- ? deadlineDays - mc.maxDurationDays
3376
- : // In-bounds: MC always produces samples.
3377
- Math.min(durationSamples[0]!, mc.minDurationDays);
3378
- const xMaxDays =
3379
- mode === 'backward' && deadlineDays !== null
3380
- ? deadlineDays
3381
- : Math.max(mc.p95, mc.maxDurationDays);
3382
-
3383
- return {
3384
- mode,
3385
- yAxisLabel,
3386
- curvePoints,
3387
- samples,
3388
- p16Days: bandLeft,
3389
- p84Days: bandRight,
3390
- // Percentile dots/date-labels — `projectRounded` matches the
3391
- // analyzer's `roundConservative` (Math.ceil of the duration), so
3392
- // each dot's date label is byte-identical to the caption's
3393
- // "P{X} latest-safe start: <date>" row.
3394
- p50Days: projectRounded(mc.p50),
3395
- p80Days: projectRounded(mc.p80),
3396
- p95Days: projectRounded(mc.p95),
3397
- xMinDays,
3398
- xMaxDays,
3399
- referenceLines,
3400
- framingNote: anchorAnnotationText(resolved),
3401
- anchorDate: resolved.projectStart,
3402
- deadlineDays,
3403
- deadlineDate,
3404
- };
3405
- }
3406
-
3407
- interface ScurveBlockArgs {
3408
- x: number;
3409
- y: number;
3410
- width: number;
3411
- height: number;
3412
- palette: PaletteColors;
3413
- isDark: boolean;
3414
- unit: DurationUnit;
3415
- // Header text painted in the top band of the block, matching the
3416
- // "Summary" header treatment in renderCaptionBlock. Used to surface
3417
- // the project's headline stat ("Expected duration: 11 days (± 2 days).")
3418
- // so the chart carries its own context now that the Summary card is
3419
- // gone from the desktop preview.
3420
- title?: string;
3421
- }
3422
-
3423
- function renderScurveBlock(
3424
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
3425
- data: ScurveData,
3426
- args: ScurveBlockArgs
3427
- ): void {
3428
- const { x, y, width, height, palette, isDark, unit, title } = args;
3429
- const { fill, stroke: chromeStroke } = analysisBlockChrome(palette, isDark);
3430
- const labelColor = contrastText(
3431
- fill,
3432
- palette.textOnFillLight,
3433
- palette.textOnFillDark
3434
- );
3435
-
3436
- const block = svg
3437
- .append('g')
3438
- .attr('class', 'pert-scurve-block')
3439
- .attr('data-pert-scurve', '');
3440
-
3441
- block
3442
- .append('rect')
3443
- .attr('class', 'pert-scurve-rect')
3444
- .attr('x', x)
3445
- .attr('y', y)
3446
- .attr('width', width)
3447
- .attr('height', height)
3448
- .attr('rx', NODE_RADIUS)
3449
- .attr('ry', NODE_RADIUS)
3450
- .attr('fill', fill)
3451
- .attr('stroke', chromeStroke)
3452
- .attr('stroke-width', NODE_STROKE_WIDTH);
3453
-
3454
- // Header band — mirrors the Summary card's title treatment. Only
3455
- // reserved when a title is provided; the inline renderPert path
3456
- // (which still pairs S-curve with a Summary card) passes none, so
3457
- // the plot keeps its full height there.
3458
- const hasTitle = typeof title === 'string' && title.length > 0;
3459
- if (hasTitle) {
3460
- // Strip the trailing period — the caption-box renders this same
3461
- // string as a sentence-style bullet, but as a bold header it reads
3462
- // cleaner without terminal punctuation.
3463
- const titleText = title!.replace(/\.$/, '');
3464
- block
3465
- .append('text')
3466
- .attr('class', 'pert-scurve-header')
3467
- .attr('x', x + width / 2)
3468
- .attr('y', y + CAPTION_BOX_PADDING_Y + CAPTION_FONT_SIZE)
3469
- .attr('text-anchor', 'middle')
3470
- .attr('fill', labelColor)
3471
- .attr('font-size', CAPTION_FONT_SIZE)
3472
- .attr('font-weight', '700')
3473
- .text(titleText);
3474
- }
3475
-
3476
- // Anchor-framing kept only as a top reservation for the deadline
3477
- // label when the diagram is end-date-anchored. The rotated y-axis
3478
- // title plus the colored P50/P80/P95 dots make the chart self-
3479
- // identifying for the axis story; the header band (when present)
3480
- // names the chart with the project's headline stat.
3481
- const SCURVE_DEADLINE_LABEL_HEIGHT = 16;
3482
- const hasDeadline = data.deadlineDays !== null;
3483
-
3484
- // Plot rect — leave room on the left for y-axis labels, below for
3485
- // x-axis labels, above for the header band (when titled) and the
3486
- // deadline label (when end-date-anchored).
3487
- const plotLeft = x + SCURVE_PLOT_PADDING_X;
3488
- const plotRight = x + width - SCURVE_PLOT_PADDING_RIGHT;
3489
- const plotTop =
3490
- y +
3491
- CAPTION_BOX_PADDING_Y +
3492
- (hasTitle ? CAPTION_HEADER_BAND_HEIGHT : 0) +
3493
- (hasDeadline ? SCURVE_DEADLINE_LABEL_HEIGHT : 0);
3494
- // Percentile metadata (date / duration) now lives inline next to
3495
- // each dot rather than stacked below the plot, so no extra bottom
3496
- // padding is needed for a second line. The x-axis carries only the
3497
- // general-scale ticks.
3498
- const isAnchored = data.anchorDate !== null;
3499
- const plotBottom = y + height - SCURVE_PLOT_PADDING_BOTTOM;
3500
- const plotW = plotRight - plotLeft;
3501
- const plotH = plotBottom - plotTop;
3502
-
3503
- // Tighten the visible x-range to the percentile cluster + a small
3504
- // margin so P50/P80/P95 spread across most of the plot instead of
3505
- // bunching against one edge of a wide-tail MC sample range. Always
3506
- // keep the empirical 68% band [p16Days, p84Days] in frame so the
3507
- // shaded "likely-finish span" reads cleanly. Backward mode keeps
3508
- // the deadline as the right edge (it's the "0% chance" boundary);
3509
- // forward mode pads past the rightmost dot symmetrically.
3510
- const dotPositions = [data.p50Days, data.p80Days, data.p95Days];
3511
- const dotMin = Math.min(...dotPositions);
3512
- const dotMax = Math.max(...dotPositions);
3513
- const dotSpan = Math.max(1, dotMax - dotMin);
3514
- const xMin = Math.min(data.p16Days, dotMin - 0.2 * dotSpan);
3515
- const xMax =
3516
- data.mode === 'backward' && data.deadlineDays !== null
3517
- ? data.deadlineDays
3518
- : Math.max(data.p84Days, dotMax + 0.2 * dotSpan);
3519
- const xRange = xMax - xMin || 1;
3520
- const xScale = (v: number): number =>
3521
- plotLeft + ((v - xMin) / xRange) * plotW;
3522
- const yScale = (frac: number): number => plotBottom - frac * plotH;
3523
-
3524
- // Y-axis gridlines at 0.25/0.5/0.75/1.0 with labels.
3525
- const yTicks = [0, 0.25, 0.5, 0.75, 1];
3526
- for (const t of yTicks) {
3527
- const yy = yScale(t);
3528
- block
3529
- .append('line')
3530
- .attr('class', 'pert-scurve-grid')
3531
- .attr('x1', plotLeft)
3532
- .attr('x2', plotRight)
3533
- .attr('y1', yy)
3534
- .attr('y2', yy)
3535
- .attr('stroke', palette.textMuted)
3536
- .attr('stroke-width', 1)
3537
- .attr('opacity', t === 0 || t === 1 ? 0.4 : 0.15);
3538
- block
3539
- .append('text')
3540
- .attr('class', 'pert-scurve-ytick')
3541
- .attr('x', plotLeft - 6)
3542
- .attr('y', yy + SCURVE_TICK_FONT_SIZE / 3)
3543
- .attr('text-anchor', 'end')
3544
- .attr('fill', labelColor)
3545
- .attr('font-size', SCURVE_TICK_FONT_SIZE)
3546
- .text(`${Math.round(t * 100)}%`);
3547
- }
3548
-
3549
- // Y-axis title — rotated 90° on the far left of the plot, centered
3550
- // vertically. The 0-100% ticks are obviously percentages, but
3551
- // "probability of what?" needs an explicit axis title.
3552
- const yAxisLabelCx = x + 14;
3553
- const yAxisLabelCy = plotTop + plotH / 2;
3554
- block
3555
- .append('text')
3556
- .attr('class', 'pert-scurve-y-axis-title')
3557
- .attr(
3558
- 'transform',
3559
- `translate(${yAxisLabelCx}, ${yAxisLabelCy}) rotate(-90)`
3560
- )
3561
- .attr('text-anchor', 'middle')
3562
- .attr('fill', labelColor)
3563
- .attr('font-size', SCURVE_TICK_FONT_SIZE - 1)
3564
- .attr('font-weight', '600')
3565
- .attr('opacity', 0.85)
3566
- // Plain-English "Probability of completion" — same label in
3567
- // both modes. The mode-specific reading comes from the x-axis
3568
- // (finish date vs candidate-start date) and the inline P50/P80/
3569
- // P95 labels; the y-axis is a probability scale either way.
3570
- // The card header was removed once this title took over, so
3571
- // this is now the only place the chart names itself.
3572
- .text(data.yAxisLabel);
3573
-
3574
- // Empirical 68% band [P16, P84] — same MC trials that produced the
3575
- // P50/P80/P95 dots, so the math story stays consistent: every
3576
- // visual element on the chart comes from one simulation run. The
3577
- // band visually answers "where will the project most likely land?"
3578
- // without assuming the finish-time distribution is normal.
3579
- if (data.p84Days > data.p16Days) {
3580
- const bandLeftRaw = xScale(data.p16Days);
3581
- const bandRightRaw = xScale(data.p84Days);
3582
- const bandLeft = Math.max(plotLeft, bandLeftRaw);
3583
- const bandRight = Math.min(plotRight, bandRightRaw);
3584
- if (bandRight > bandLeft) {
3585
- block
3586
- .append('rect')
3587
- .attr('class', 'pert-scurve-likely-band')
3588
- .attr('x', bandLeft)
3589
- .attr('y', plotTop)
3590
- .attr('width', bandRight - bandLeft)
3591
- .attr('height', plotH)
3592
- .attr('fill', palette.colors.blue)
3593
- .attr('opacity', 0.12);
3594
- }
3595
- }
3596
-
3597
- // Deadline — vertical line at the hard end-date constraint. The
3598
- // label sits ABOVE the 100% gridline (above plotTop) so it doesn't
3599
- // compete with curve labels inside the plot, and the line + label
3600
- // share a distinct purple color so they read as a hard constraint
3601
- // distinct from the colored dashed percentile ticks.
3602
- if (data.deadlineDays !== null && data.deadlineDate !== null) {
3603
- const dx = xScale(data.deadlineDays);
3604
- if (dx >= plotLeft - 1 && dx <= plotRight + 1) {
3605
- const deadlineColor = palette.colors.purple;
3606
- // Label first so it appears above the plot (in the reserved top
3607
- // strip). Mirrors the inward-anchor logic used for percentiles
3608
- // so the label doesn't clip at the plot edges.
3609
- const edgePad = 4;
3610
- let anchor: 'middle' | 'start' | 'end' = 'middle';
3611
- if (dx <= plotLeft + edgePad) anchor = 'start';
3612
- else if (dx >= plotRight - edgePad) anchor = 'end';
3613
- block
3614
- .append('text')
3615
- .attr('class', 'pert-scurve-deadline-label')
3616
- .attr('x', dx)
3617
- .attr('y', plotTop - 4)
3618
- .attr('text-anchor', anchor)
3619
- .attr('fill', deadlineColor)
3620
- .attr('font-size', SCURVE_TICK_FONT_SIZE)
3621
- .attr('font-weight', '700')
3622
- .text(`Deadline · ${formatScurveDate(data.deadlineDate)}`);
3623
- block
3624
- .append('line')
3625
- .attr('class', 'pert-scurve-deadline-line')
3626
- .attr('x1', dx)
3627
- .attr('x2', dx)
3628
- .attr('y1', plotTop)
3629
- .attr('y2', plotBottom)
3630
- .attr('stroke', deadlineColor)
3631
- .attr('stroke-width', 1.5)
3632
- .attr('opacity', 0.9);
3633
- }
3634
- }
3635
-
3636
- // Sigmoid curve through the 4 sample points. Forward mode rises
3637
- // 0.05 → 0.95; backward mode falls 0.95 → 0.05. The pairs come from
3638
- // `buildScurveData`, already projected into the right x-axis space
3639
- // (duration days vs candidate-start days), so this site stays
3640
- // mode-blind.
3641
- const points: { x: number; y: number }[] = data.curvePoints.map((pt) => ({
3642
- x: xScale(pt.x),
3643
- y: yScale(pt.y),
3644
- }));
3645
- const curve = d3Shape
3646
- .line<{ x: number; y: number }>()
3647
- .x((d) => d.x)
3648
- .y((d) => d.y)
3649
- .curve(d3Shape.curveMonotoneX)(points);
3650
- if (curve) {
3651
- // Clip the curve to the plot rect. After the x-axis tightening,
3652
- // the spline's boundary tangent can overshoot below y=0 % at
3653
- // xMin (where the next sample point lies outside the visible
3654
- // range), which leaks a dangling tail past the chart's left
3655
- // edge. Per-S-curve clipPath id keeps multiple PERT diagrams on
3656
- // the same page from sharing the same clip.
3657
- const clipId = `pert-scurve-clip-${Math.random().toString(36).slice(2, 10)}`;
3658
- block
3659
- .append('clipPath')
3660
- .attr('id', clipId)
3661
- .append('rect')
3662
- .attr('x', plotLeft)
3663
- .attr('y', plotTop)
3664
- .attr('width', plotW)
3665
- .attr('height', plotH);
3666
- block
3667
- .append('path')
3668
- .attr('class', 'pert-scurve-line')
3669
- .attr('d', curve)
3670
- .attr('fill', 'none')
3671
- .attr('stroke', palette.colors.red)
3672
- .attr('stroke-width', 2)
3673
- .attr('clip-path', `url(#${clipId})`);
3674
- }
3675
-
3676
- // Percentile dots + dashed verticals + x-axis value labels. Each
3677
- // percentile gets its own color so the eye can match dot → dashed
3678
- // line → x-axis label as a single unit. Yellow/orange/red mirrors
3679
- // the criticality bands used elsewhere in the diagram so colors
3680
- // read consistently across widgets.
3681
- type Band = 'yellow' | 'orange' | 'red';
3682
- // Reference-line `isPast` flag comes from `buildScurveData`'s
3683
- // backward-mode pass (forward mode always emits `false`). Past lines
3684
- // render with a longer dash so they read as "deadline has slipped"
3685
- // at a glance — matches the `(past)` label suffix on the same line.
3686
- // `value` is the x-axis plotting position (mode-projected by
3687
- // `buildScurveData`); `durationDays` is the original duration so
3688
- // the secondary sub-label can still surface "≈ Nw of work" in
3689
- // backward mode, where `value` is a candidate-start position
3690
- // rather than a duration.
3691
- const dots: {
3692
- value: number;
3693
- durationDays: number;
3694
- pct: number;
3695
- label: string;
3696
- band: Band;
3697
- isPast: boolean;
3698
- }[] = [
3699
- {
3700
- value: data.p50Days,
3701
- durationDays: data.referenceLines[0]?.durationDays ?? data.p50Days,
3702
- pct: 0.5,
3703
- label: 'P50',
3704
- band: 'yellow',
3705
- isPast: data.referenceLines[0]?.isPast ?? false,
3706
- },
3707
- {
3708
- value: data.p80Days,
3709
- durationDays: data.referenceLines[1]?.durationDays ?? data.p80Days,
3710
- pct: 0.8,
3711
- label: 'P80',
3712
- band: 'orange',
3713
- isPast: data.referenceLines[1]?.isPast ?? false,
3714
- },
3715
- {
3716
- value: data.p95Days,
3717
- durationDays: data.referenceLines[2]?.durationDays ?? data.p95Days,
3718
- pct: 0.95,
3719
- label: 'P95',
3720
- band: 'red',
3721
- isPast: data.referenceLines[2]?.isPast ?? false,
3722
- },
3723
- ];
3724
- // Inline CSS for the hover swap. Putting it inside the block means
3725
- // the behavior travels with the SVG — saved SVGs viewed in a browser
3726
- // still get the same hover-to-expand affordance. resvg (PNG export)
3727
- // ignores :hover entirely, so PNGs render the terse default just as
3728
- // they did before. The selectors are class-scoped to .pert-scurve-
3729
- // percentile, so the rules can't leak into other diagrams sharing
3730
- // the page.
3731
- block
3732
- .append('style')
3733
- .text(
3734
- '.pert-scurve-percentile-verbose{opacity:0;pointer-events:none}' +
3735
- '.pert-scurve-percentile:hover .pert-scurve-percentile-label{opacity:0}' +
3736
- '.pert-scurve-percentile:hover .pert-scurve-percentile-verbose{opacity:1}'
3737
- );
3738
-
3739
- // Capture the percentile dot x-positions so the x-axis tick pass
3740
- // below can drop any auto-tick that would land directly under the
3741
- // inline value label ("14.7w" auto-tick + "P80 · 14.7w" inline
3742
- // label = duplicated number on the chart).
3743
- const dotXs: number[] = dots.map((d) => xScale(d.value));
3744
-
3745
- for (const d of dots) {
3746
- const cx = xScale(d.value);
3747
- const cy = yScale(d.pct);
3748
- const color = palette.colors[d.band];
3749
- const valueText = isAnchored
3750
- ? formatScurveDate(addCalendarDays(data.anchorDate!, d.value))
3751
- : formatScurveTick(d.durationDays, unit);
3752
- const inlineText = `${d.label} · ${valueText}`;
3753
- // Verbose phrasing revealed on hover. Compressed wordings so the
3754
- // overlay stays narrow enough to fit on the plot at typical widths.
3755
- // The terse label already carries the date/duration, so the verbose
3756
- // omits it in backward mode (where it'd duplicate "P50 · Mar 28").
3757
- // Past dates aren't called out — the terse label already shows the
3758
- // date and the dashed tick line's "4,2" pattern signals past.
3759
- const pctInt = Math.round(d.pct * 100);
3760
- const verboseText =
3761
- data.mode === 'backward'
3762
- ? `${pctInt}% chance of meeting deadline`
3763
- : isAnchored
3764
- ? `${pctInt}% chance to finish by ${valueText}`
3765
- : `${pctInt}% chance within ${valueText}`;
3766
- // Shared placement for terse + verbose: pick the side of the dot
3767
- // that fits the wider element (the verbose phrasing) without
3768
- // overflowing the plot. Sharing the anchor means the hover swap
3769
- // is truly in-place — the terse "P95 · 15.8w" can't sit right of
3770
- // the dot only to have the verbose flip left on hover.
3771
- const verboseCharW = SCURVE_TICK_FONT_SIZE * 0.55;
3772
- const verboseW = verboseText.length * verboseCharW;
3773
- const inlineGap = SCURVE_PERCENTILE_RADIUS + 6;
3774
- const roomRight = plotRight - cx - inlineGap;
3775
- const roomLeft = cx - plotLeft - inlineGap;
3776
- let labelX: number;
3777
- let labelAnchor: 'start' | 'end';
3778
- if (verboseW <= roomRight) {
3779
- labelX = cx + inlineGap;
3780
- labelAnchor = 'start';
3781
- } else if (verboseW <= roomLeft) {
3782
- labelX = cx - inlineGap;
3783
- labelAnchor = 'end';
3784
- } else if (roomRight >= roomLeft) {
3785
- // Verbose wider than either side around the dot — clamp to the
3786
- // plot edge with more room. Terse follows so the hover swap
3787
- // still happens in place (the terse just sits a bit farther
3788
- // from its dot than usual at this edge).
3789
- labelX = plotRight;
3790
- labelAnchor = 'end';
3791
- } else {
3792
- labelX = plotLeft;
3793
- labelAnchor = 'start';
3794
- }
3795
- // Percentile colors (yellow/orange/red) are tuned for filled
3796
- // shapes at 25 % tint. Free-standing on the panel bg they lose
3797
- // contrast — yellow especially, against light themes — so we
3798
- // shift each label color 25 % toward `palette.text` (dark in
3799
- // light theme, light in dark theme) to push it back into the
3800
- // readable band without abandoning its band identity.
3801
- const labelFill = mix(color, palette.text, 25);
3802
-
3803
- // Group wraps tick + dot + hit area + both labels so `:hover` on
3804
- // any part of the cluster triggers the swap.
3805
- const dotGroup = block
3806
- .append('g')
3807
- .attr('class', 'pert-scurve-percentile')
3808
- .attr('data-percentile', d.label);
3809
-
3810
- dotGroup
3811
- .append('line')
3812
- .attr('class', 'pert-scurve-percentile-tick')
3813
- .attr('x1', cx)
3814
- .attr('x2', cx)
3815
- .attr('y1', cy)
3816
- .attr('y2', plotBottom)
3817
- .attr('stroke', color)
3818
- .attr('stroke-width', 1)
3819
- .attr('stroke-dasharray', d.isPast ? '4,2' : '3 3')
3820
- .attr('opacity', 0.7);
3821
- dotGroup
3822
- .append('circle')
3823
- .attr('class', 'pert-scurve-percentile-dot')
3824
- .attr('cx', cx)
3825
- .attr('cy', cy)
3826
- .attr('r', SCURVE_PERCENTILE_RADIUS)
3827
- .attr('fill', color)
3828
- .attr('stroke', fill)
3829
- .attr('stroke-width', 1.5)
3830
- // Mirrored on the parent group so the hover :hover selector can
3831
- // attach to the cluster as a whole; kept here too for back-
3832
- // compat with consumers querying `circle[data-percentile]`.
3833
- .attr('data-percentile', d.label);
3834
- // Generous transparent hit-area — a 4 px dot is too small a target
3835
- // for the hover affordance to feel discoverable. The hit circle
3836
- // sits on top so any pointer within 14 px registers a hover on
3837
- // the parent group.
3838
- dotGroup
3839
- .append('circle')
3840
- .attr('class', 'pert-scurve-percentile-hit')
3841
- .attr('cx', cx)
3842
- .attr('cy', cy)
3843
- .attr('r', 14)
3844
- .attr('fill', 'transparent')
3845
- .attr('pointer-events', 'all');
3846
- // Inline label next to the dot: "P{X} · {date}" when anchored,
3847
- // "P{X} · {duration}" otherwise. The natural y-spacing between
3848
- // P50/P80/P95 (≈ 45 % of plot height) gives each label its own
3849
- // row, so this layout stays legible even when dots are bunched
3850
- // together horizontally (e.g. backward mode with a narrow
3851
- // candidate-start span). Replaces the old "name above + x-axis
3852
- // two-line label below" combo, which collided when dots overlapped.
3853
- dotGroup
3854
- .append('text')
3855
- .attr('class', 'pert-scurve-percentile-label')
3856
- .attr('x', labelX)
3857
- .attr('y', cy + SCURVE_TICK_FONT_SIZE / 3)
3858
- .attr('text-anchor', labelAnchor)
3859
- .attr('fill', labelFill)
3860
- .attr('font-size', SCURVE_TICK_FONT_SIZE)
3861
- .attr('font-weight', '700')
3862
- // Paint-order halo with the panel fill — knocks out the band
3863
- // and dashed gridlines behind each letter so the label reads
3864
- // against the panel bg, not the visual noise behind it.
3865
- .attr('paint-order', 'stroke fill')
3866
- .attr('stroke', fill)
3867
- .attr('stroke-width', 3)
3868
- .attr('stroke-linejoin', 'round')
3869
- .attr('stroke-opacity', 0.95)
3870
- .text(inlineText);
3871
- // Verbose label shares the terse's anchor and x so the hover
3872
- // swap reads as an in-place expansion. Hidden by default via the
3873
- // inline <style> above; revealed when the surrounding group is
3874
- // hovered. Same halo treatment so the longer phrase stays
3875
- // readable across the colored band and the gridlines.
3876
- dotGroup
3877
- .append('text')
3878
- .attr('class', 'pert-scurve-percentile-verbose')
3879
- .attr('x', labelX)
3880
- .attr('y', cy + SCURVE_TICK_FONT_SIZE / 3)
3881
- .attr('text-anchor', labelAnchor)
3882
- .attr('fill', labelFill)
3883
- .attr('font-size', SCURVE_TICK_FONT_SIZE)
3884
- .attr('font-weight', '700')
3885
- .attr('paint-order', 'stroke fill')
3886
- .attr('stroke', fill)
3887
- .attr('stroke-width', 3)
3888
- .attr('stroke-linejoin', 'round')
3889
- .attr('stroke-opacity', 0.95)
3890
- .text(verboseText);
3891
- }
3892
-
3893
- // X-axis ticks: evenly spaced across the x-range. Endpoint labels
3894
- // anchor inward so they don't clip the box. The percentile-collision
3895
- // pass that used to suppress auto-ticks under colored labels is gone
3896
- // now that percentile values live inline next to their dots — the
3897
- // x-axis carries only the "general scale" markers.
3898
- type Tick = { v: number; x: number; anchor: 'start' | 'middle' | 'end' };
3899
- // "Mon DD" dates (~48px) sit between durations (~36px) and ISO
3900
- // dates (~72px). 5 ticks gives a comfortable scale without crowding.
3901
- const N_X_TICKS = isAnchored ? 5 : 6;
3902
- const LABEL_W_EST = isAnchored ? 48 : 36; // "Sep 16" vs "25.4w"
3903
- const TICK_MIN_GAP = 6;
3904
- const footprint = (t: Tick): [number, number] => {
3905
- if (t.anchor === 'start') return [t.x, t.x + LABEL_W_EST];
3906
- if (t.anchor === 'end') return [t.x - LABEL_W_EST, t.x];
3907
- return [t.x - LABEL_W_EST / 2, t.x + LABEL_W_EST / 2];
3908
- };
3909
-
3910
- const all: Tick[] = [];
3911
- for (let i = 0; i < N_X_TICKS; i++) {
3912
- const v = xMin + (xRange * i) / (N_X_TICKS - 1);
3913
- all.push({
3914
- v,
3915
- x: xScale(v),
3916
- anchor: i === 0 ? 'start' : i === N_X_TICKS - 1 ? 'end' : 'middle',
3917
- });
3918
- }
3919
- // Auto-tick suppression near percentile dots — drop any middle tick
3920
- // whose label would visually duplicate the inline "P{X} · {value}"
3921
- // pair (e.g. P80 at exactly 14.7w would otherwise render the same
3922
- // "14.7w" twice). The ~28 px tolerance is roughly half a duration
3923
- // label width, so adjacent-but-distinct values keep their tick.
3924
- const TICK_DOT_PROXIMITY = 28;
3925
- const collidesWithDot = (t: Tick): boolean =>
3926
- dotXs.some((dx) => Math.abs(t.x - dx) < TICK_DOT_PROXIMITY);
3927
- // In-bounds: N_X_TICKS > 0, so `all` has at least one entry.
3928
- const kept: Tick[] = [all[0]!];
3929
- const last = all[all.length - 1]!;
3930
- const lastLeft = footprint(last)[0];
3931
- let rightEdge = footprint(all[0]!)[1];
3932
- for (let i = 1; i < all.length - 1; i++) {
3933
- // In-bounds by loop guard.
3934
- const t = all[i]!;
3935
- if (collidesWithDot(t)) continue;
3936
- const [l, r] = footprint(t);
3937
- if (r + TICK_MIN_GAP > lastLeft) continue;
3938
- if (l - TICK_MIN_GAP < rightEdge) continue;
3939
- kept.push(t);
3940
- rightEdge = r;
3941
- }
3942
- kept.push(last);
3943
-
3944
- for (const t of kept) {
3945
- // Anchored: show calendar date (the durations live on the
3946
- // percentile labels). Unanchored: show duration as before.
3947
- const labelText = isAnchored
3948
- ? formatScurveDate(addCalendarDays(data.anchorDate!, t.v))
3949
- : formatScurveTick(t.v, unit);
3950
- block
3951
- .append('text')
3952
- .attr('class', 'pert-scurve-xtick')
3953
- .attr('x', t.x)
3954
- .attr('y', plotBottom + SCURVE_TICK_FONT_SIZE + 6)
3955
- .attr('text-anchor', t.anchor)
3956
- .attr('fill', labelColor)
3957
- .attr('font-size', SCURVE_TICK_FONT_SIZE)
3958
- .text(labelText);
3959
- }
3960
- }
3961
-
3962
- function formatScurveTick(days: number, unit: DurationUnit): string {
3963
- const value = days / UNIT_TO_DAYS_LOCAL[unit];
3964
- const display = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, '');
3965
- return `${display}${unit}`;
3966
- }
3967
-
3968
- // Compact date format for S-curve x-axis labels ("Sep 16" / "Oct 2").
3969
- // The year is omitted — it already shows up in the subline (e.g.
3970
- // "Deadline: 2026-09-15."), and including it on every tick forces
3971
- // collisions at typical panel widths.
3972
- const SCURVE_MONTH_NAMES = [
3973
- 'Jan',
3974
- 'Feb',
3975
- 'Mar',
3976
- 'Apr',
3977
- 'May',
3978
- 'Jun',
3979
- 'Jul',
3980
- 'Aug',
3981
- 'Sep',
3982
- 'Oct',
3983
- 'Nov',
3984
- 'Dec',
3985
- ];
3986
- function formatScurveDate(iso: string): string {
3987
- const parts = iso.split('-');
3988
- if (parts.length !== 3) return iso;
3989
- // In-bounds by length === 3 check.
3990
- const month = parseInt(parts[1]!, 10) - 1;
3991
- const day = parseInt(parts[2]!, 10);
3992
- if (month < 0 || month > 11 || isNaN(day)) return iso;
3993
- return `${SCURVE_MONTH_NAMES[month]} ${day}`;
3994
- }
3995
-
3996
- const UNIT_TO_DAYS_LOCAL: Record<DurationUnit, number> = {
3997
- min: 1 / (60 * 24),
3998
- h: 1 / 24,
3999
- d: 1,
4000
- bd: 1,
4001
- w: 7,
4002
- m: 30,
4003
- q: 90,
4004
- y: 365,
4005
- s: 14,
4006
- };
4007
-
4008
- // re-export to silence unused-type lint when consumers only want the helper
4009
- export type { ResolvedActivity, Duration };