@diagrammo/dgmo 0.47.0 → 0.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advanced.js +11 -11
- package/dist/auto.js +16 -16
- package/dist/auto.mjs +41 -12
- package/dist/block.js +4 -4
- package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
- package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
- package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
- package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
- package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
- package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
- package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
- package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
- package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
- package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
- package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
- package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
- package/dist/cli.cjs +15 -15
- package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
- package/dist/element.js +16 -16
- package/dist/element.mjs +41 -12
- package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
- package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
- package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
- package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
- package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
- package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
- package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
- package/package.json +23 -71
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/pert/renderer.ts
DELETED
|
@@ -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 };
|