@diagrammo/dgmo 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auto.js +1 -1
- package/dist/auto.mjs +1 -1
- package/dist/element.js +1 -1
- package/dist/element.mjs +1 -1
- package/package.json +19 -70
- 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/analyzer.ts
DELETED
|
@@ -1,1183 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// PERT Analyzer — forward/backward pass + critical path + project μ/σ
|
|
3
|
-
// ============================================================
|
|
4
|
-
//
|
|
5
|
-
// Phase 1 scope: M-world analytical PERT only. Phase 2 wires Monte
|
|
6
|
-
// Carlo into `monteCarloResult`.
|
|
7
|
-
//
|
|
8
|
-
// TBD-poison-downstream semantics: activities with a null duration
|
|
9
|
-
// propagate null through every descendant's ES/EF/LS/LF/slack; if any
|
|
10
|
-
// TBD is upstream of the project end, projectMu and projectSigma are
|
|
11
|
-
// also null. Cycles emit a diagnostic naming at least one offending
|
|
12
|
-
// activity (AC2.5).
|
|
13
|
-
|
|
14
|
-
import { makeDgmoError } from '../diagnostics';
|
|
15
|
-
import type { DgmoError } from '../diagnostics';
|
|
16
|
-
import type { Duration, DurationUnit } from '../gantt/types';
|
|
17
|
-
import type {
|
|
18
|
-
Anchor,
|
|
19
|
-
CaptionRow,
|
|
20
|
-
ParsedPert,
|
|
21
|
-
PertActivity,
|
|
22
|
-
PertEdge,
|
|
23
|
-
PertGroup,
|
|
24
|
-
ResolvedActivity,
|
|
25
|
-
ResolvedGroup,
|
|
26
|
-
ResolvedPert,
|
|
27
|
-
MonteCarloResult,
|
|
28
|
-
TornadoSwing,
|
|
29
|
-
} from './types';
|
|
30
|
-
import type { DurationEstimate } from './internal';
|
|
31
|
-
import {
|
|
32
|
-
addCalendarDays,
|
|
33
|
-
resolveConfidence,
|
|
34
|
-
unitToDays,
|
|
35
|
-
CONFIDENCE_TABLE,
|
|
36
|
-
} from './internal';
|
|
37
|
-
import { simulateCanonical, type ExpandedActivity } from './monte-carlo';
|
|
38
|
-
|
|
39
|
-
// ============================================================
|
|
40
|
-
// Duration arithmetic helpers
|
|
41
|
-
// ============================================================
|
|
42
|
-
|
|
43
|
-
/** Convert any Duration to a canonical `timeUnit` amount. */
|
|
44
|
-
const UNIT_TO_DAYS: Record<DurationUnit, number> = {
|
|
45
|
-
min: 1 / (60 * 24),
|
|
46
|
-
h: 1 / 24,
|
|
47
|
-
d: 1,
|
|
48
|
-
bd: 1, // PERT has no calendar; bd ≈ d for analytical purposes
|
|
49
|
-
w: 7,
|
|
50
|
-
m: 30,
|
|
51
|
-
q: 90,
|
|
52
|
-
y: 365,
|
|
53
|
-
s: 14, // fallback when sprint-length isn't authored; the analyzer
|
|
54
|
-
// overrides this from `options.sprintLength` when in sprint mode.
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
function unitDays(unit: DurationUnit, sprintDaysOverride?: number): number {
|
|
58
|
-
if (unit === 's' && sprintDaysOverride !== undefined) {
|
|
59
|
-
return sprintDaysOverride;
|
|
60
|
-
}
|
|
61
|
-
return UNIT_TO_DAYS[unit];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function toDays(d: Duration, sprintDaysOverride?: number): number {
|
|
65
|
-
return d.amount * unitDays(d.unit, sprintDaysOverride);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function fromDays(
|
|
69
|
-
days: number,
|
|
70
|
-
unit: DurationUnit,
|
|
71
|
-
sprintDaysOverride?: number
|
|
72
|
-
): number {
|
|
73
|
-
return days / unitDays(unit, sprintDaysOverride);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function resolveSprintDays(opts: {
|
|
77
|
-
sprintMode: 'auto' | 'explicit' | null;
|
|
78
|
-
sprintLength: Duration | null;
|
|
79
|
-
}): number | undefined {
|
|
80
|
-
if (!opts.sprintMode || !opts.sprintLength) return undefined;
|
|
81
|
-
return opts.sprintLength.amount * UNIT_TO_DAYS[opts.sprintLength.unit];
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ============================================================
|
|
85
|
-
// Estimate expansion (M-only heuristic + Beta-PERT μ/σ)
|
|
86
|
-
// ============================================================
|
|
87
|
-
|
|
88
|
-
interface ExpandedEstimate {
|
|
89
|
-
/** Optimistic, in canonical days. */
|
|
90
|
-
o: number;
|
|
91
|
-
/** Most-likely, in canonical days. */
|
|
92
|
-
m: number;
|
|
93
|
-
/** Pessimistic, in canonical days. */
|
|
94
|
-
p: number;
|
|
95
|
-
/** Beta-PERT mean = (O + 4M + P) / 6. */
|
|
96
|
-
mean: number;
|
|
97
|
-
/** Beta-PERT std dev = (P − O) / 6. */
|
|
98
|
-
sigma: number;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function expandEstimate(
|
|
102
|
-
estimate: DurationEstimate,
|
|
103
|
-
diagramConfidence: string,
|
|
104
|
-
activityConfidence: string | undefined,
|
|
105
|
-
sprintDays?: number
|
|
106
|
-
): ExpandedEstimate {
|
|
107
|
-
const o = toDays(estimate.o, sprintDays);
|
|
108
|
-
const m = toDays(estimate.m, sprintDays);
|
|
109
|
-
const p = toDays(estimate.p, sprintDays);
|
|
110
|
-
|
|
111
|
-
// Heuristic-pending sentinel: parser set `mOnly = true` when only a
|
|
112
|
-
// single M token was given. Expand using confidence factors.
|
|
113
|
-
let oFinal = o;
|
|
114
|
-
let pFinal = p;
|
|
115
|
-
if (estimate.mOnly) {
|
|
116
|
-
const fromActivity = activityConfidence
|
|
117
|
-
? resolveConfidence(activityConfidence)
|
|
118
|
-
: null;
|
|
119
|
-
const factors =
|
|
120
|
-
fromActivity ??
|
|
121
|
-
resolveConfidence(diagramConfidence) ??
|
|
122
|
-
CONFIDENCE_TABLE.medium;
|
|
123
|
-
oFinal = m * factors.oFactor;
|
|
124
|
-
pFinal = m * factors.pFactor;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const mean = (oFinal + 4 * m + pFinal) / 6;
|
|
128
|
-
const sigma = (pFinal - oFinal) / 6;
|
|
129
|
-
return { o: oFinal, m, p: pFinal, mean, sigma };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ============================================================
|
|
133
|
-
// analyzePert
|
|
134
|
-
// ============================================================
|
|
135
|
-
|
|
136
|
-
export function analyzePert(parsed: ParsedPert): ResolvedPert {
|
|
137
|
-
const diagnostics: DgmoError[] = [...parsed.diagnostics];
|
|
138
|
-
const error = (line: number, msg: string): void => {
|
|
139
|
-
diagnostics.push(makeDgmoError(line, msg, 'error'));
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const activities = parsed.activities;
|
|
143
|
-
const edges = parsed.edges;
|
|
144
|
-
const warn = (line: number, msg: string): void => {
|
|
145
|
-
diagnostics.push(makeDgmoError(line, msg, 'warning'));
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
// Sprint mode override: when the diagram opts into sprint mode, the
|
|
149
|
-
// `s` unit means "one sprint" worth of canonical days as configured
|
|
150
|
-
// by `sprint-length`. Without this, every `s` defaults to the 14-day
|
|
151
|
-
// fallback, which silently disagrees with the user's `sprint-length`
|
|
152
|
-
// directive.
|
|
153
|
-
const sprintDays = resolveSprintDays(parsed.options);
|
|
154
|
-
|
|
155
|
-
// Build successor/predecessor maps keyed by activity id, plus parallel
|
|
156
|
-
// maps keyed-by-edge-direction so the forward/backward pass can read
|
|
157
|
-
// dep-type and lag for each (source, target) link.
|
|
158
|
-
const successors = new Map<string, string[]>();
|
|
159
|
-
const predecessors = new Map<string, string[]>();
|
|
160
|
-
const incomingEdges = new Map<string, PertEdge[]>();
|
|
161
|
-
const outgoingEdges = new Map<string, PertEdge[]>();
|
|
162
|
-
for (const a of activities) {
|
|
163
|
-
successors.set(a.id, []);
|
|
164
|
-
predecessors.set(a.id, []);
|
|
165
|
-
incomingEdges.set(a.id, []);
|
|
166
|
-
outgoingEdges.set(a.id, []);
|
|
167
|
-
}
|
|
168
|
-
for (const e of edges) {
|
|
169
|
-
successors.get(e.source)?.push(e.target);
|
|
170
|
-
predecessors.get(e.target)?.push(e.source);
|
|
171
|
-
incomingEdges.get(e.target)?.push(e);
|
|
172
|
-
outgoingEdges.get(e.source)?.push(e);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Lead-exceeds-duration validation. For an FS edge with negative lag
|
|
176
|
-
// (lead) of magnitude L, if L > predecessor's duration, the constraint
|
|
177
|
-
// implies B.ES < A.ES — logically impossible without an SS/SF edge.
|
|
178
|
-
// Surface as a warning so the user can correct it; analyzer still
|
|
179
|
-
// computes a schedule (the warning is informational).
|
|
180
|
-
for (const e of edges) {
|
|
181
|
-
if (!e.lag || e.lag.amount >= 0) continue;
|
|
182
|
-
const src = activities.find((a) => a.id === e.source);
|
|
183
|
-
if (!src?.duration) continue;
|
|
184
|
-
const leadDays = -toDays(e.lag, sprintDays);
|
|
185
|
-
const srcDurDays = toDays(src.duration.m, sprintDays);
|
|
186
|
-
if (e.type === 'FS' && leadDays > srcDurDays) {
|
|
187
|
-
warn(
|
|
188
|
-
e.lineNumber,
|
|
189
|
-
`Lead (${-e.lag.amount}${e.lag.unit}) exceeds predecessor "${src.name}" duration — ` +
|
|
190
|
-
`the FS edge implies the successor starts before the predecessor.`
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Topological sort (Kahn's algorithm). On cycle, emit diagnostic.
|
|
196
|
-
const topo: string[] = [];
|
|
197
|
-
const inDegree = new Map<string, number>();
|
|
198
|
-
for (const a of activities)
|
|
199
|
-
inDegree.set(a.id, predecessors.get(a.id)!.length);
|
|
200
|
-
const queue: string[] = [];
|
|
201
|
-
for (const [id, deg] of inDegree) if (deg === 0) queue.push(id);
|
|
202
|
-
|
|
203
|
-
while (queue.length > 0) {
|
|
204
|
-
const id = queue.shift()!;
|
|
205
|
-
topo.push(id);
|
|
206
|
-
for (const next of successors.get(id) ?? []) {
|
|
207
|
-
const deg = (inDegree.get(next) ?? 0) - 1;
|
|
208
|
-
inDegree.set(next, deg);
|
|
209
|
-
if (deg === 0) queue.push(next);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (topo.length < activities.length) {
|
|
214
|
-
// Surviving in-degree > 0 means cycle. Pick the first surviving id.
|
|
215
|
-
const culprit = activities.find((a) => (inDegree.get(a.id) ?? 0) > 0);
|
|
216
|
-
if (culprit) {
|
|
217
|
-
error(culprit.lineNumber, `Cycle detected involving "${culprit.name}".`);
|
|
218
|
-
} else {
|
|
219
|
-
error(0, `Cycle detected in PERT graph.`);
|
|
220
|
-
}
|
|
221
|
-
// Bail out — analysis on a cyclic graph is meaningless.
|
|
222
|
-
return emptyResolved(parsed, diagnostics);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Expand all estimates up-front (canonical days).
|
|
226
|
-
const expandedById = new Map<string, ExpandedEstimate | null>();
|
|
227
|
-
for (const a of activities) {
|
|
228
|
-
if (a.duration === null) {
|
|
229
|
-
expandedById.set(a.id, null);
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
expandedById.set(
|
|
233
|
-
a.id,
|
|
234
|
-
expandEstimate(
|
|
235
|
-
a.duration,
|
|
236
|
-
parsed.options.confidence,
|
|
237
|
-
a.confidence,
|
|
238
|
-
sprintDays
|
|
239
|
-
)
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Compute the poisoned set: any activity transitively downstream of a
|
|
244
|
-
// TBD activity. Run BFS from TBD nodes outward.
|
|
245
|
-
const poisoned = new Set<string>();
|
|
246
|
-
for (const a of activities) {
|
|
247
|
-
if (a.duration === null) {
|
|
248
|
-
const stack = [a.id];
|
|
249
|
-
poisoned.add(a.id);
|
|
250
|
-
while (stack.length > 0) {
|
|
251
|
-
const id = stack.pop()!;
|
|
252
|
-
for (const next of successors.get(id) ?? []) {
|
|
253
|
-
if (!poisoned.has(next)) {
|
|
254
|
-
poisoned.add(next);
|
|
255
|
-
stack.push(next);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Forward pass: ES/EF for each non-poisoned activity, in topo order.
|
|
263
|
-
// Each incoming edge contributes a lower bound according to its type:
|
|
264
|
-
// FS: B.ES ≥ A.EF + lag (constrains ES)
|
|
265
|
-
// SS: B.ES ≥ A.ES + lag (constrains ES)
|
|
266
|
-
// FF: B.EF ≥ A.EF + lag (constrains EF)
|
|
267
|
-
// SF: B.EF ≥ A.ES + lag (constrains EF)
|
|
268
|
-
// ES then EF are picked so EF − ES = duration, and both bounds hold.
|
|
269
|
-
const es = new Map<string, number | null>();
|
|
270
|
-
const ef = new Map<string, number | null>();
|
|
271
|
-
for (const id of topo) {
|
|
272
|
-
if (poisoned.has(id)) {
|
|
273
|
-
es.set(id, null);
|
|
274
|
-
ef.set(id, null);
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
const expanded = expandedById.get(id)!;
|
|
278
|
-
const dur = expanded.mean;
|
|
279
|
-
|
|
280
|
-
let esLower = 0;
|
|
281
|
-
let efLower = -Infinity;
|
|
282
|
-
for (const edge of incomingEdges.get(id) ?? []) {
|
|
283
|
-
const aEs = es.get(edge.source);
|
|
284
|
-
const aEf = ef.get(edge.source);
|
|
285
|
-
if (aEs == null || aEf == null) continue;
|
|
286
|
-
const lagDays = edge.lag ? toDays(edge.lag, sprintDays) : 0;
|
|
287
|
-
switch (edge.type) {
|
|
288
|
-
case 'FS':
|
|
289
|
-
esLower = Math.max(esLower, aEf + lagDays);
|
|
290
|
-
break;
|
|
291
|
-
case 'SS':
|
|
292
|
-
esLower = Math.max(esLower, aEs + lagDays);
|
|
293
|
-
break;
|
|
294
|
-
case 'FF':
|
|
295
|
-
efLower = Math.max(efLower, aEf + lagDays);
|
|
296
|
-
break;
|
|
297
|
-
case 'SF':
|
|
298
|
-
efLower = Math.max(efLower, aEs + lagDays);
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
// Pin EF = ES + dur, then bump ES forward if EF-side constraints
|
|
303
|
-
// demand a later finish than ES + dur supports.
|
|
304
|
-
let esVal = esLower;
|
|
305
|
-
let efVal = esVal + dur;
|
|
306
|
-
if (efLower > efVal) {
|
|
307
|
-
efVal = efLower;
|
|
308
|
-
esVal = efVal - dur;
|
|
309
|
-
}
|
|
310
|
-
es.set(id, esVal);
|
|
311
|
-
ef.set(id, efVal);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Project μ = max(EF) over all non-poisoned activities. Pure FS graphs
|
|
315
|
-
// have monotonic EF along edges so terminals always carry the project
|
|
316
|
-
// end, but SS/FF/SF edges break that invariant — a non-terminal
|
|
317
|
-
// predecessor can finish after its SS-successor. Considering every
|
|
318
|
-
// activity gives the right answer for any mix of dep types.
|
|
319
|
-
// If ANY terminal is poisoned, projectMu becomes null — the actual
|
|
320
|
-
// project end is unknowable until TBDs are estimated. Per AC2.3 +
|
|
321
|
-
// AC3.3a: poisoned terminals invalidate the analytical projectMu.
|
|
322
|
-
let projectMuDays: number | null = null;
|
|
323
|
-
let endId: string | null = null;
|
|
324
|
-
let anyTerminalPoisoned = false;
|
|
325
|
-
for (const a of activities) {
|
|
326
|
-
const isTerminal = (successors.get(a.id) ?? []).length === 0;
|
|
327
|
-
if (poisoned.has(a.id)) {
|
|
328
|
-
if (isTerminal) anyTerminalPoisoned = true;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
const efVal = ef.get(a.id);
|
|
332
|
-
if (efVal != null && (projectMuDays === null || efVal > projectMuDays)) {
|
|
333
|
-
projectMuDays = efVal;
|
|
334
|
-
endId = a.id;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
if (anyTerminalPoisoned) {
|
|
338
|
-
projectMuDays = null;
|
|
339
|
-
endId = null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Backward pass: LS/LF.
|
|
343
|
-
const ls = new Map<string, number | null>();
|
|
344
|
-
const lf = new Map<string, number | null>();
|
|
345
|
-
// Initialize all non-poisoned activities' LF to projectMu (or to their
|
|
346
|
-
// own EF when they are dead-ends with smaller EF — that gives them
|
|
347
|
-
// slack equal to projectMu - EF). Standard PERT convention is LF =
|
|
348
|
-
// projectMu for terminal nodes, then back-propagate via min.
|
|
349
|
-
for (const a of activities) {
|
|
350
|
-
if (poisoned.has(a.id) || projectMuDays === null) {
|
|
351
|
-
ls.set(a.id, null);
|
|
352
|
-
lf.set(a.id, null);
|
|
353
|
-
} else {
|
|
354
|
-
lf.set(a.id, projectMuDays);
|
|
355
|
-
const exp = expandedById.get(a.id)!;
|
|
356
|
-
ls.set(a.id, projectMuDays - exp.mean);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// Walk topo in reverse. Each outgoing edge contributes an upper bound:
|
|
360
|
-
// FS: A.LF ≤ B.LS − lag
|
|
361
|
-
// SS: A.LS ≤ B.LS − lag
|
|
362
|
-
// FF: A.LF ≤ B.LF − lag
|
|
363
|
-
// SF: A.LS ≤ B.LF − lag
|
|
364
|
-
// LS then LF are picked so LF − LS = duration, and both bounds hold.
|
|
365
|
-
for (let i = topo.length - 1; i >= 0; i--) {
|
|
366
|
-
// In-bounds by loop guard.
|
|
367
|
-
const id = topo[i]!;
|
|
368
|
-
if (poisoned.has(id) || projectMuDays === null) continue;
|
|
369
|
-
const out = outgoingEdges.get(id) ?? [];
|
|
370
|
-
if (out.length === 0) continue; // already initialized to projectMu
|
|
371
|
-
|
|
372
|
-
const exp = expandedById.get(id)!;
|
|
373
|
-
const dur = exp.mean;
|
|
374
|
-
|
|
375
|
-
let lfUpper = Infinity;
|
|
376
|
-
let lsUpper = Infinity;
|
|
377
|
-
for (const edge of out) {
|
|
378
|
-
const bLs = ls.get(edge.target);
|
|
379
|
-
const bLf = lf.get(edge.target);
|
|
380
|
-
if (bLs == null || bLf == null) continue;
|
|
381
|
-
const lagDays = edge.lag ? toDays(edge.lag, sprintDays) : 0;
|
|
382
|
-
switch (edge.type) {
|
|
383
|
-
case 'FS':
|
|
384
|
-
lfUpper = Math.min(lfUpper, bLs - lagDays);
|
|
385
|
-
break;
|
|
386
|
-
case 'SS':
|
|
387
|
-
lsUpper = Math.min(lsUpper, bLs - lagDays);
|
|
388
|
-
break;
|
|
389
|
-
case 'FF':
|
|
390
|
-
lfUpper = Math.min(lfUpper, bLf - lagDays);
|
|
391
|
-
break;
|
|
392
|
-
case 'SF':
|
|
393
|
-
lsUpper = Math.min(lsUpper, bLf - lagDays);
|
|
394
|
-
break;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
let lfVal = lfUpper;
|
|
398
|
-
let lsVal = lfVal - dur;
|
|
399
|
-
if (lsUpper < lsVal) {
|
|
400
|
-
lsVal = lsUpper;
|
|
401
|
-
lfVal = lsVal + dur;
|
|
402
|
-
}
|
|
403
|
-
lf.set(id, lfVal);
|
|
404
|
-
ls.set(id, lsVal);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Critical path = walk backward from `endId` taking the predecessor
|
|
408
|
-
// with maximum EF (ties broken by predecessor source-order). This
|
|
409
|
-
// gives a single chain through the M-world longest path.
|
|
410
|
-
const criticalPath: string[] = [];
|
|
411
|
-
if (endId !== null) {
|
|
412
|
-
let cursor: string | null = endId;
|
|
413
|
-
while (cursor !== null) {
|
|
414
|
-
criticalPath.unshift(cursor);
|
|
415
|
-
const preds: string[] = predecessors.get(cursor) ?? [];
|
|
416
|
-
if (preds.length === 0) break;
|
|
417
|
-
let bestPred: string | null = null;
|
|
418
|
-
let bestEf = -Infinity;
|
|
419
|
-
for (const pid of preds) {
|
|
420
|
-
const efp = ef.get(pid);
|
|
421
|
-
if (efp != null && efp > bestEf) {
|
|
422
|
-
bestEf = efp;
|
|
423
|
-
bestPred = pid;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
cursor = bestPred;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const criticalSet = new Set(criticalPath);
|
|
430
|
-
|
|
431
|
-
// Project σ: sqrt(sum of variances on critical path).
|
|
432
|
-
let projectSigmaDays: number | null = null;
|
|
433
|
-
if (projectMuDays !== null && criticalPath.length > 0) {
|
|
434
|
-
let varSum = 0;
|
|
435
|
-
for (const id of criticalPath) {
|
|
436
|
-
const exp = expandedById.get(id);
|
|
437
|
-
if (exp) varSum += exp.sigma * exp.sigma;
|
|
438
|
-
}
|
|
439
|
-
projectSigmaDays = Math.sqrt(varSum);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Render activity records (convert canonical days back to options.timeUnit).
|
|
443
|
-
const unit = parsed.options.timeUnit;
|
|
444
|
-
const resolvedActivities: ResolvedActivity[] = activities.map((a) => {
|
|
445
|
-
const exp = expandedById.get(a.id);
|
|
446
|
-
const slackDays =
|
|
447
|
-
ls.get(a.id) !== null && es.get(a.id) !== null
|
|
448
|
-
? (ls.get(a.id) as number) - (es.get(a.id) as number)
|
|
449
|
-
: null;
|
|
450
|
-
return {
|
|
451
|
-
activity: a,
|
|
452
|
-
es: nullableToUnit(es.get(a.id) ?? null, unit, sprintDays),
|
|
453
|
-
ef: nullableToUnit(ef.get(a.id) ?? null, unit, sprintDays),
|
|
454
|
-
ls: nullableToUnit(ls.get(a.id) ?? null, unit, sprintDays),
|
|
455
|
-
lf: nullableToUnit(lf.get(a.id) ?? null, unit, sprintDays),
|
|
456
|
-
slack: nullableToUnit(slackDays, unit, sprintDays),
|
|
457
|
-
isCriticalPath: criticalSet.has(a.id),
|
|
458
|
-
mu: exp ? fromDays(exp.mean, unit, sprintDays) : null,
|
|
459
|
-
sigma: exp ? fromDays(exp.sigma, unit, sprintDays) : null,
|
|
460
|
-
criticality: null,
|
|
461
|
-
isAuthored: a.duration !== null && !a.duration.mOnly && !a.isMilestone,
|
|
462
|
-
};
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
// Resolved groups: μ/σ rolled up along a path through the group. When
|
|
466
|
-
// MC is on, the rollup uses the modal-longest-path observed across
|
|
467
|
-
// trials; otherwise the M-world critical path.
|
|
468
|
-
let monteCarloResult: MonteCarloResult | null = null;
|
|
469
|
-
|
|
470
|
-
// Auto-derive mode from data: monte-carlo when at least one
|
|
471
|
-
// non-milestone activity carries a duration. M-only activities count —
|
|
472
|
-
// the parser fills O and P from `default-confidence`, and analytical
|
|
473
|
-
// mode already uses those spreads to compute project σ.
|
|
474
|
-
const dataDrivenMC = activities.some((a) => hasDuration(a));
|
|
475
|
-
// Trials clamp: nonsense percentiles from low-N samples → fall back
|
|
476
|
-
// to analytical and surface the reason in the caption.
|
|
477
|
-
const trialsClamped = dataDrivenMC && parsed.options.trials < 100;
|
|
478
|
-
let mode: 'monte-carlo' | 'analytical' =
|
|
479
|
-
dataDrivenMC && !trialsClamped ? 'monte-carlo' : 'analytical';
|
|
480
|
-
|
|
481
|
-
if (mode === 'monte-carlo') {
|
|
482
|
-
const allTerminalsPoisoned = activities
|
|
483
|
-
.filter((a) => (successors.get(a.id) ?? []).length === 0)
|
|
484
|
-
.every((a) => poisoned.has(a.id));
|
|
485
|
-
if (allTerminalsPoisoned && activities.length > 0) {
|
|
486
|
-
// Silently downgrade — the TBD-fallback caption already names
|
|
487
|
-
// the unestimated activities. Erroring would block render even
|
|
488
|
-
// when the user didn't ask for MC explicitly (auto-derive).
|
|
489
|
-
mode = 'analytical';
|
|
490
|
-
} else {
|
|
491
|
-
const expandedArr: ExpandedActivity[] = [];
|
|
492
|
-
for (const a of activities) {
|
|
493
|
-
const exp = expandedById.get(a.id);
|
|
494
|
-
if (exp) expandedArr.push({ id: a.id, o: exp.o, m: exp.m, p: exp.p });
|
|
495
|
-
}
|
|
496
|
-
// Build a preliminary ResolvedPert just for the simulator's graph
|
|
497
|
-
// shape. The simulator uses only `activities` (for ids) and `edges`.
|
|
498
|
-
const prelim: ResolvedPert = {
|
|
499
|
-
options: parsed.options,
|
|
500
|
-
activities: resolvedActivities,
|
|
501
|
-
edges: edges.slice(),
|
|
502
|
-
groups: [],
|
|
503
|
-
tagGroups: parsed.tagGroups.slice(),
|
|
504
|
-
mode: 'monte-carlo',
|
|
505
|
-
summaryRows: null,
|
|
506
|
-
projectSubtitle: null,
|
|
507
|
-
projectMu: null,
|
|
508
|
-
projectSigma: null,
|
|
509
|
-
criticalPath,
|
|
510
|
-
projectStart: null,
|
|
511
|
-
monteCarloResult: null,
|
|
512
|
-
expandedActivities: expandedArr,
|
|
513
|
-
diagnostics: [],
|
|
514
|
-
error: null,
|
|
515
|
-
};
|
|
516
|
-
monteCarloResult = simulateCanonical(prelim, expandedArr, {
|
|
517
|
-
trials: parsed.options.trials,
|
|
518
|
-
seed: parsed.options.seed,
|
|
519
|
-
});
|
|
520
|
-
// Populate per-activity criticality on the resolved activities.
|
|
521
|
-
for (const ra of resolvedActivities) {
|
|
522
|
-
if (poisoned.has(ra.activity.id)) continue;
|
|
523
|
-
const c = monteCarloResult.criticalityByActivity[ra.activity.id];
|
|
524
|
-
ra.criticality = typeof c === 'number' ? c : null;
|
|
525
|
-
}
|
|
526
|
-
// True tornado swings — for each activity, hold every other at μ
|
|
527
|
-
// and re-run the deterministic forward pass twice (at O, then at P)
|
|
528
|
-
// to measure how much the project end-date moves. Stored in the MC
|
|
529
|
-
// result so the renderer can paint two-sided bars.
|
|
530
|
-
monteCarloResult.tornadoSwings = computeTornadoSwings(
|
|
531
|
-
activities,
|
|
532
|
-
topo,
|
|
533
|
-
incomingEdges,
|
|
534
|
-
expandedById,
|
|
535
|
-
poisoned,
|
|
536
|
-
monteCarloResult.criticalityByActivity,
|
|
537
|
-
sprintDays
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// For hammock rollup: use MC-derived modal-longest-path when MC is on.
|
|
543
|
-
const rollupSet =
|
|
544
|
-
monteCarloResult && monteCarloResult.modalCriticalPath.length > 0
|
|
545
|
-
? new Set(monteCarloResult.modalCriticalPath)
|
|
546
|
-
: criticalSet;
|
|
547
|
-
const resolvedById = new Map(
|
|
548
|
-
resolvedActivities.map((r) => [r.activity.id, r])
|
|
549
|
-
);
|
|
550
|
-
const resolvedGroups: ResolvedGroup[] = parsed.groups.map((g) =>
|
|
551
|
-
rollupGroup(g, expandedById, resolvedById, rollupSet, unit, sprintDays)
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
// Always populate the public expanded-activities cache so Workers
|
|
555
|
-
// (Phase 3b) can re-run the simulator without needing the original
|
|
556
|
-
// ParsedPert. TBDs are omitted; the simulator already treats absent
|
|
557
|
-
// entries as zero-duration.
|
|
558
|
-
const publicExpanded: ExpandedActivity[] = [];
|
|
559
|
-
for (const a of activities) {
|
|
560
|
-
const exp = expandedById.get(a.id);
|
|
561
|
-
if (exp) publicExpanded.push({ id: a.id, o: exp.o, m: exp.m, p: exp.p });
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const projectMuOut =
|
|
565
|
-
projectMuDays === null ? null : fromDays(projectMuDays, unit, sprintDays);
|
|
566
|
-
const projectSigmaOut =
|
|
567
|
-
projectSigmaDays === null
|
|
568
|
-
? null
|
|
569
|
-
: fromDays(projectSigmaDays, unit, sprintDays);
|
|
570
|
-
|
|
571
|
-
// Derive projectStart from the optional anchor. Renderer reads this
|
|
572
|
-
// directly — no mode-specific logic in the presentation layer.
|
|
573
|
-
// forward → literal start-date
|
|
574
|
-
// backward → end-date − projectMu (in calendar days), rounded once
|
|
575
|
-
// no anchor or backward+TBD upstream → null
|
|
576
|
-
let projectStart: string | null = null;
|
|
577
|
-
const anchor = parsed.options.anchor;
|
|
578
|
-
if (anchor !== null) {
|
|
579
|
-
if (anchor.kind === 'forward') {
|
|
580
|
-
projectStart = anchor.date;
|
|
581
|
-
} else if (anchor.kind === 'backward' && projectMuDays !== null) {
|
|
582
|
-
projectStart = addCalendarDays(anchor.date, -projectMuDays);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const summaryRows = buildSummary({
|
|
587
|
-
mode,
|
|
588
|
-
projectMu: projectMuOut,
|
|
589
|
-
projectSigma: projectSigmaOut,
|
|
590
|
-
unit,
|
|
591
|
-
parsedActivities: activities,
|
|
592
|
-
monteCarloResult,
|
|
593
|
-
trialsClamped,
|
|
594
|
-
anchor: parsed.options.anchor,
|
|
595
|
-
today: parsed.options.today,
|
|
596
|
-
...(sprintDays !== undefined && { sprintDays }),
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
const projectSubtitle = buildProjectSubtitle({
|
|
600
|
-
projectMu: projectMuOut,
|
|
601
|
-
projectSigma: projectSigmaOut,
|
|
602
|
-
unit,
|
|
603
|
-
anchor: parsed.options.anchor,
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
return {
|
|
607
|
-
options: parsed.options,
|
|
608
|
-
activities: resolvedActivities,
|
|
609
|
-
edges: edges.slice(),
|
|
610
|
-
groups: resolvedGroups,
|
|
611
|
-
tagGroups: parsed.tagGroups.slice(),
|
|
612
|
-
mode,
|
|
613
|
-
summaryRows,
|
|
614
|
-
projectSubtitle,
|
|
615
|
-
projectMu: projectMuOut,
|
|
616
|
-
projectSigma: projectSigmaOut,
|
|
617
|
-
criticalPath,
|
|
618
|
-
projectStart,
|
|
619
|
-
monteCarloResult,
|
|
620
|
-
expandedActivities: publicExpanded,
|
|
621
|
-
diagnostics,
|
|
622
|
-
error: parsed.error ?? firstFatal(diagnostics),
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* True iff the activity has a duration we can simulate — either an
|
|
628
|
-
* explicit O/M/P triple or an M-only value (whose O/P are filled from
|
|
629
|
-
* `default-confidence`). Milestones report false (zero-duration
|
|
630
|
-
* sentinels); zero-duration M-only activities also report false since
|
|
631
|
-
* factor × 0 = 0 → no spread for MC to simulate.
|
|
632
|
-
*/
|
|
633
|
-
function hasDuration(a: PertActivity): boolean {
|
|
634
|
-
if (a.duration === null || a.isMilestone) return false;
|
|
635
|
-
if (a.duration.mOnly) return a.duration.m.amount > 0;
|
|
636
|
-
return true;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Type-aware deterministic forward pass: project end (max EF across
|
|
641
|
-
* activities) in canonical days, given a duration per activity.
|
|
642
|
-
* Mirrors the edge-aware schedule math in `analyzePert` so SS / FF /
|
|
643
|
-
* SF / lag are honored. Poisoned activities contribute 0 days but
|
|
644
|
-
* still propagate edges (we treat them as zero-duration sentinels).
|
|
645
|
-
*/
|
|
646
|
-
function computeProjectEndDays(
|
|
647
|
-
activities: readonly PertActivity[],
|
|
648
|
-
topo: string[],
|
|
649
|
-
incomingEdges: Map<string, PertEdge[]>,
|
|
650
|
-
durations: Map<string, number>,
|
|
651
|
-
poisoned: Set<string>,
|
|
652
|
-
sprintDays: number | undefined
|
|
653
|
-
): number {
|
|
654
|
-
const es = new Map<string, number>();
|
|
655
|
-
const ef = new Map<string, number>();
|
|
656
|
-
for (const id of topo) {
|
|
657
|
-
const dur = poisoned.has(id) ? 0 : (durations.get(id) ?? 0);
|
|
658
|
-
let esLower = 0;
|
|
659
|
-
let efLower = -Infinity;
|
|
660
|
-
for (const edge of incomingEdges.get(id) ?? []) {
|
|
661
|
-
const aEs = es.get(edge.source) ?? 0;
|
|
662
|
-
const aEf = ef.get(edge.source) ?? 0;
|
|
663
|
-
const lag = edge.lag ? toDays(edge.lag, sprintDays) : 0;
|
|
664
|
-
switch (edge.type) {
|
|
665
|
-
case 'FS':
|
|
666
|
-
esLower = Math.max(esLower, aEf + lag);
|
|
667
|
-
break;
|
|
668
|
-
case 'SS':
|
|
669
|
-
esLower = Math.max(esLower, aEs + lag);
|
|
670
|
-
break;
|
|
671
|
-
case 'FF':
|
|
672
|
-
efLower = Math.max(efLower, aEf + lag);
|
|
673
|
-
break;
|
|
674
|
-
case 'SF':
|
|
675
|
-
efLower = Math.max(efLower, aEs + lag);
|
|
676
|
-
break;
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
let esVal = esLower;
|
|
680
|
-
let efVal = esVal + dur;
|
|
681
|
-
if (efLower > efVal) {
|
|
682
|
-
efVal = efLower;
|
|
683
|
-
esVal = efVal - dur;
|
|
684
|
-
}
|
|
685
|
-
es.set(id, esVal);
|
|
686
|
-
ef.set(id, efVal);
|
|
687
|
-
}
|
|
688
|
-
let projectEnd = 0;
|
|
689
|
-
for (const a of activities) {
|
|
690
|
-
const e = ef.get(a.id) ?? 0;
|
|
691
|
-
if (e > projectEnd) projectEnd = e;
|
|
692
|
-
}
|
|
693
|
-
return projectEnd;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Build true two-sided tornado swings. For each activity:
|
|
698
|
-
* 1. Hold every other activity at its μ
|
|
699
|
-
* 2. Run the forward pass once with this activity = O → low end
|
|
700
|
-
* 3. Run again with this activity = P → high end
|
|
701
|
-
* 4. lowSwing = baseline - low end, highSwing = high end - baseline
|
|
702
|
-
*
|
|
703
|
-
* Returns an array sorted descending by total swing. Milestones,
|
|
704
|
-
* poisoned activities, and activities with zero swing in both
|
|
705
|
-
* directions are excluded.
|
|
706
|
-
*/
|
|
707
|
-
function computeTornadoSwings(
|
|
708
|
-
activities: readonly PertActivity[],
|
|
709
|
-
topo: string[],
|
|
710
|
-
incomingEdges: Map<string, PertEdge[]>,
|
|
711
|
-
expandedById: Map<string, ExpandedEstimate | null>,
|
|
712
|
-
poisoned: Set<string>,
|
|
713
|
-
criticalityByActivity: Record<string, number>,
|
|
714
|
-
sprintDays?: number
|
|
715
|
-
): TornadoSwing[] {
|
|
716
|
-
const meansById = new Map<string, number>();
|
|
717
|
-
for (const a of activities) {
|
|
718
|
-
if (poisoned.has(a.id)) continue;
|
|
719
|
-
const exp = expandedById.get(a.id);
|
|
720
|
-
if (exp) meansById.set(a.id, exp.mean);
|
|
721
|
-
}
|
|
722
|
-
const baseline = computeProjectEndDays(
|
|
723
|
-
activities,
|
|
724
|
-
topo,
|
|
725
|
-
incomingEdges,
|
|
726
|
-
meansById,
|
|
727
|
-
poisoned,
|
|
728
|
-
sprintDays
|
|
729
|
-
);
|
|
730
|
-
const swings: TornadoSwing[] = [];
|
|
731
|
-
for (const a of activities) {
|
|
732
|
-
if (a.isMilestone || a.duration === null || poisoned.has(a.id)) continue;
|
|
733
|
-
const exp = expandedById.get(a.id);
|
|
734
|
-
if (!exp) continue;
|
|
735
|
-
// O ≈ M means zero-width swing — skip the work AND skip the row.
|
|
736
|
-
if (exp.o === exp.m && exp.p === exp.m) continue;
|
|
737
|
-
|
|
738
|
-
const lowMap = new Map(meansById);
|
|
739
|
-
lowMap.set(a.id, exp.o);
|
|
740
|
-
const lowEnd = computeProjectEndDays(
|
|
741
|
-
activities,
|
|
742
|
-
topo,
|
|
743
|
-
incomingEdges,
|
|
744
|
-
lowMap,
|
|
745
|
-
poisoned,
|
|
746
|
-
sprintDays
|
|
747
|
-
);
|
|
748
|
-
|
|
749
|
-
const highMap = new Map(meansById);
|
|
750
|
-
highMap.set(a.id, exp.p);
|
|
751
|
-
const highEnd = computeProjectEndDays(
|
|
752
|
-
activities,
|
|
753
|
-
topo,
|
|
754
|
-
incomingEdges,
|
|
755
|
-
highMap,
|
|
756
|
-
poisoned,
|
|
757
|
-
sprintDays
|
|
758
|
-
);
|
|
759
|
-
|
|
760
|
-
const lowSwing = Math.max(0, baseline - lowEnd);
|
|
761
|
-
const highSwing = Math.max(0, highEnd - baseline);
|
|
762
|
-
if (lowSwing === 0 && highSwing === 0) continue;
|
|
763
|
-
const c = criticalityByActivity[a.id];
|
|
764
|
-
swings.push({
|
|
765
|
-
id: a.id,
|
|
766
|
-
name: a.name,
|
|
767
|
-
lowSwing,
|
|
768
|
-
highSwing,
|
|
769
|
-
criticality: typeof c === 'number' ? c : null,
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
swings.sort((a, b) => b.lowSwing + b.highSwing - (a.lowSwing + a.highSwing));
|
|
773
|
-
return swings;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// ============================================================
|
|
777
|
-
// Helpers
|
|
778
|
-
// ============================================================
|
|
779
|
-
|
|
780
|
-
function nullableToUnit(
|
|
781
|
-
value: number | null,
|
|
782
|
-
unit: DurationUnit,
|
|
783
|
-
sprintDays?: number
|
|
784
|
-
): number | null {
|
|
785
|
-
return value === null ? null : fromDays(value, unit, sprintDays);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
function firstFatal(diagnostics: DgmoError[]): string | null {
|
|
789
|
-
const f = diagnostics.find((d) => d.severity === 'error');
|
|
790
|
-
return f ? f.message : null;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function rollupGroup(
|
|
794
|
-
group: PertGroup,
|
|
795
|
-
expandedById: Map<string, ExpandedEstimate | null>,
|
|
796
|
-
resolvedById: Map<string, ResolvedActivity>,
|
|
797
|
-
criticalSet: Set<string>,
|
|
798
|
-
unit: DurationUnit,
|
|
799
|
-
sprintDays?: number
|
|
800
|
-
): ResolvedGroup {
|
|
801
|
-
let muDays = 0;
|
|
802
|
-
let varDays = 0;
|
|
803
|
-
let usable = false;
|
|
804
|
-
for (const id of group.activityIds) {
|
|
805
|
-
if (!criticalSet.has(id)) continue;
|
|
806
|
-
const exp = expandedById.get(id);
|
|
807
|
-
if (!exp) continue;
|
|
808
|
-
muDays += exp.mean;
|
|
809
|
-
varDays += exp.sigma * exp.sigma;
|
|
810
|
-
usable = true;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Schedule envelope across member activities (in source unit, not days).
|
|
814
|
-
let es: number | null = null;
|
|
815
|
-
let ef: number | null = null;
|
|
816
|
-
let ls: number | null = null;
|
|
817
|
-
let lf: number | null = null;
|
|
818
|
-
let criticality: number | null = null;
|
|
819
|
-
for (const id of group.activityIds) {
|
|
820
|
-
const r = resolvedById.get(id);
|
|
821
|
-
if (!r) continue;
|
|
822
|
-
if (r.es !== null) es = es === null ? r.es : Math.min(es, r.es);
|
|
823
|
-
if (r.ef !== null) ef = ef === null ? r.ef : Math.max(ef, r.ef);
|
|
824
|
-
if (r.ls !== null) ls = ls === null ? r.ls : Math.min(ls, r.ls);
|
|
825
|
-
if (r.lf !== null) lf = lf === null ? r.lf : Math.max(lf, r.lf);
|
|
826
|
-
if (r.criticality !== null) {
|
|
827
|
-
criticality =
|
|
828
|
-
criticality === null
|
|
829
|
-
? r.criticality
|
|
830
|
-
: Math.max(criticality, r.criticality);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
const slack = ls !== null && es !== null ? ls - es : null;
|
|
834
|
-
|
|
835
|
-
return {
|
|
836
|
-
group,
|
|
837
|
-
rolledMu: usable ? fromDays(muDays, unit, sprintDays) : null,
|
|
838
|
-
rolledSigma: usable ? fromDays(Math.sqrt(varDays), unit, sprintDays) : null,
|
|
839
|
-
entries: [],
|
|
840
|
-
exits: [],
|
|
841
|
-
es,
|
|
842
|
-
ef,
|
|
843
|
-
ls,
|
|
844
|
-
lf,
|
|
845
|
-
slack,
|
|
846
|
-
criticality,
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ============================================================
|
|
851
|
-
// Caption (project-stats summary) builder
|
|
852
|
-
// ============================================================
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Build the project-stats caption emitted as `ResolvedPert.summaryRows`.
|
|
856
|
-
* Each `CaptionRow` is one bullet in the rendered caption box. Returns
|
|
857
|
-
* `null` when the analyzer produced no output (e.g. cycle bailout).
|
|
858
|
-
*/
|
|
859
|
-
export interface BuildSummaryInput {
|
|
860
|
-
mode: 'monte-carlo' | 'analytical';
|
|
861
|
-
projectMu: number | null;
|
|
862
|
-
projectSigma: number | null;
|
|
863
|
-
unit: DurationUnit;
|
|
864
|
-
parsedActivities: readonly PertActivity[];
|
|
865
|
-
monteCarloResult: MonteCarloResult | null;
|
|
866
|
-
trialsClamped: boolean;
|
|
867
|
-
/**
|
|
868
|
-
* Date anchor — when set, "Expected duration" becomes a date and
|
|
869
|
-
* Monte-Carlo percentiles render as ISO dates instead of durations.
|
|
870
|
-
* Forward → finish-date rows; backward → latest-safe-start rows.
|
|
871
|
-
*/
|
|
872
|
-
anchor?: Anchor;
|
|
873
|
-
/**
|
|
874
|
-
* Parse-time "today" (ISO YYYY-MM-DD). Used to flag backward-mode
|
|
875
|
-
* latest-safe-start rows that have already passed. Empty string is
|
|
876
|
-
* treated as "unknown" — no past flagging.
|
|
877
|
-
*/
|
|
878
|
-
today: string;
|
|
879
|
-
/**
|
|
880
|
-
* Days-per-sprint when sprint mode is active. Used to convert
|
|
881
|
-
* Monte-Carlo percentile durations (canonical days) into the
|
|
882
|
-
* displayed unit when `unit === 's'`.
|
|
883
|
-
*/
|
|
884
|
-
sprintDays?: number;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Round a fractional canonical-days duration toward whichever direction
|
|
889
|
-
* pushes the resulting date further from "now":
|
|
890
|
-
* - forward → ceil (the finish lands later, more conservative)
|
|
891
|
-
* - backward → ceil (the latest-safe start lands earlier, more
|
|
892
|
-
* conservative — `end_date − ceil(d)` is smaller)
|
|
893
|
-
*
|
|
894
|
-
* Both modes ceil because callers apply opposite signs (`+` vs `−`).
|
|
895
|
-
* Keeping the rule in one helper avoids drift between caption math
|
|
896
|
-
* and S-curve math.
|
|
897
|
-
*/
|
|
898
|
-
function roundConservative(
|
|
899
|
-
durationDays: number,
|
|
900
|
-
_mode: 'forward' | 'backward'
|
|
901
|
-
): number {
|
|
902
|
-
return Math.ceil(durationDays);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
|
|
906
|
-
const {
|
|
907
|
-
mode,
|
|
908
|
-
projectMu,
|
|
909
|
-
projectSigma,
|
|
910
|
-
unit,
|
|
911
|
-
parsedActivities,
|
|
912
|
-
monteCarloResult,
|
|
913
|
-
trialsClamped,
|
|
914
|
-
sprintDays,
|
|
915
|
-
today,
|
|
916
|
-
} = input;
|
|
917
|
-
const anchor = input.anchor ?? null;
|
|
918
|
-
|
|
919
|
-
if (parsedActivities.length === 0) return null;
|
|
920
|
-
|
|
921
|
-
// Indeterminate projectMu — TBD upstream of the sink.
|
|
922
|
-
if (projectMu === null) {
|
|
923
|
-
// Backward + TBD: spec §13A.12 mandates `?` placeholders for both
|
|
924
|
-
// Expected duration AND each percentile latest-safe start so the
|
|
925
|
-
// caption shape stays parallel to the feasible case (one top row +
|
|
926
|
-
// three percentile sub-rows).
|
|
927
|
-
if (anchor?.kind === 'backward') {
|
|
928
|
-
return [
|
|
929
|
-
{ text: 'Expected duration: ?', level: 0 },
|
|
930
|
-
{ text: 'P50 latest-safe start: ?', level: 0 },
|
|
931
|
-
{ text: 'P80 latest-safe start: ?', level: 0 },
|
|
932
|
-
{ text: 'P95 latest-safe start: ?', level: 0 },
|
|
933
|
-
];
|
|
934
|
-
}
|
|
935
|
-
// Forward / unanchored: existing wording — names the unestimated
|
|
936
|
-
// activities so the author knows what to fill in.
|
|
937
|
-
const tbdCount = parsedActivities.filter(
|
|
938
|
-
(a) => a.duration === null && !a.isMilestone
|
|
939
|
-
).length;
|
|
940
|
-
return [
|
|
941
|
-
{
|
|
942
|
-
text: `Expected duration unknown — ${tbdCount} ${
|
|
943
|
-
tbdCount === 1 ? 'activity has' : 'activities have'
|
|
944
|
-
} no estimate.`,
|
|
945
|
-
level: 0,
|
|
946
|
-
},
|
|
947
|
-
];
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const rows: CaptionRow[] = [];
|
|
951
|
-
const mc = mode === 'monte-carlo' && monteCarloResult !== null;
|
|
952
|
-
const sigmaPositive = projectSigma !== null && projectSigma > 0;
|
|
953
|
-
const showMcDetail = mc && sigmaPositive;
|
|
954
|
-
|
|
955
|
-
// 1. Expected duration / finish / start — fold σ into a "(± X)"
|
|
956
|
-
// parenthetical when MC ran with σ > 0. Reads more naturally than a
|
|
957
|
-
// separate "Standard deviation:" bullet; for a roughly-normal
|
|
958
|
-
// distribution ±1σ covers ~68% of outcomes.
|
|
959
|
-
const sigmaParen = showMcDetail
|
|
960
|
-
? ` (± ${roundForCaption(projectSigma!)} ${pluralizeUnit(projectSigma!, unit)})`
|
|
961
|
-
: '';
|
|
962
|
-
if (anchor?.kind === 'forward') {
|
|
963
|
-
const projectMuDays = projectMu * unitToDays(unit);
|
|
964
|
-
rows.push({
|
|
965
|
-
text: `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)}${sigmaParen}.`,
|
|
966
|
-
level: 0,
|
|
967
|
-
});
|
|
968
|
-
} else if (anchor?.kind === 'backward') {
|
|
969
|
-
const projectMuDays = projectMu * unitToDays(unit);
|
|
970
|
-
rows.push({
|
|
971
|
-
text: `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)}${sigmaParen}.`,
|
|
972
|
-
level: 0,
|
|
973
|
-
});
|
|
974
|
-
} else {
|
|
975
|
-
const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
|
|
976
|
-
rows.push({
|
|
977
|
-
text: `Expected duration: ${muStr}${sigmaParen}.`,
|
|
978
|
-
level: 0,
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// 2. Percentiles — uniform shape per spec §13A.10/§13A.12:
|
|
983
|
-
// - Forward: P{X} finish: <date>
|
|
984
|
-
// - Backward: P{X} latest-safe start: <date> [(latest-safe start has passed)]
|
|
985
|
-
// - Unanchored: P{X}: <duration>
|
|
986
|
-
// Sub-rows (level 1) so they indent under the preceding "Expected …"
|
|
987
|
-
// row, matching the existing visual rhythm.
|
|
988
|
-
if (showMcDetail) {
|
|
989
|
-
const percentiles: Array<{ pct: 50 | 80 | 95; days: number }> = [
|
|
990
|
-
{ pct: 50, days: monteCarloResult!.p50 },
|
|
991
|
-
{ pct: 80, days: monteCarloResult!.p80 },
|
|
992
|
-
{ pct: 95, days: monteCarloResult!.p95 },
|
|
993
|
-
];
|
|
994
|
-
if (anchor?.kind === 'forward') {
|
|
995
|
-
for (const { pct, days } of percentiles) {
|
|
996
|
-
const offsetDays = roundConservative(days, 'forward');
|
|
997
|
-
const date = addCalendarDays(anchor.date, offsetDays);
|
|
998
|
-
rows.push({ text: `P${pct} finish: ${date}.`, level: 1 });
|
|
999
|
-
}
|
|
1000
|
-
} else if (anchor?.kind === 'backward') {
|
|
1001
|
-
for (const { pct, days } of percentiles) {
|
|
1002
|
-
const offsetDays = roundConservative(days, 'backward');
|
|
1003
|
-
const date = addCalendarDays(anchor.date, -offsetDays);
|
|
1004
|
-
// ISO YYYY-MM-DD strings are lexicographically sortable, so a
|
|
1005
|
-
// string compare is the correct past-date check here.
|
|
1006
|
-
const isPast = today.length > 0 && date < today;
|
|
1007
|
-
const suffix = isPast ? ' (latest-safe start has passed)' : '';
|
|
1008
|
-
const row: CaptionRow = {
|
|
1009
|
-
text: `P${pct} latest-safe start: ${date}${suffix}`,
|
|
1010
|
-
level: 1,
|
|
1011
|
-
};
|
|
1012
|
-
if (isPast) row.isPast = true;
|
|
1013
|
-
rows.push(row);
|
|
1014
|
-
}
|
|
1015
|
-
} else {
|
|
1016
|
-
for (const { pct, days } of percentiles) {
|
|
1017
|
-
const v = fromDays(days, unit, sprintDays);
|
|
1018
|
-
rows.push({ text: `P${pct}: ${formatPercentile(v, unit)}.`, level: 1 });
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// 3. Zero-variance fallback (replaces percentile rows when O=M=P
|
|
1024
|
-
// across every authored activity).
|
|
1025
|
-
if (mc && projectSigma === 0) {
|
|
1026
|
-
const filtered: CaptionRow[] = rows.filter((r) =>
|
|
1027
|
-
r.text.startsWith('Expected ')
|
|
1028
|
-
);
|
|
1029
|
-
filtered.push({
|
|
1030
|
-
text: '(No variance in estimates — all activities have O = M = P.)',
|
|
1031
|
-
level: 0,
|
|
1032
|
-
});
|
|
1033
|
-
return filtered;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// 4. Trials caveat (mode auto-derived to MC then clamped back).
|
|
1037
|
-
if (trialsClamped) {
|
|
1038
|
-
rows.push({
|
|
1039
|
-
text: 'Insufficient trials configured (`trials` < 100) — falling back to deterministic analysis.',
|
|
1040
|
-
level: 0,
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
return rows;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* Build the one-line project subtitle rendered under the diagram title.
|
|
1049
|
-
* Distinct from `buildSummary` because the subtitle sits in the title
|
|
1050
|
-
* region (always visible, independent of the Analysis-row toggle) and
|
|
1051
|
-
* collapses the σ + duration restatement into a single bullet-free line.
|
|
1052
|
-
*
|
|
1053
|
-
* Shape per mode (§13A.7):
|
|
1054
|
-
* - Forward: `Expected finish: <date> · ≈ <μ> <unit> of work (± <σ>)`
|
|
1055
|
-
* - Backward: `Expected start: <date> · ≈ <μ> <unit> lead time (± <σ>)`
|
|
1056
|
-
* - Unanchored: `≈ <μ> <unit> (± <σ>)`
|
|
1057
|
-
*
|
|
1058
|
-
* Returns null when projectMu is indeterminate (TBD upstream) AND no
|
|
1059
|
-
* anchor is present — there's nothing useful to say. With an anchor we
|
|
1060
|
-
* still render `Expected finish: ? · ≈ ? <unit>` to preserve the slot.
|
|
1061
|
-
*/
|
|
1062
|
-
export function buildProjectSubtitle(input: {
|
|
1063
|
-
projectMu: number | null;
|
|
1064
|
-
projectSigma: number | null;
|
|
1065
|
-
unit: DurationUnit;
|
|
1066
|
-
anchor?: Anchor;
|
|
1067
|
-
}): string | null {
|
|
1068
|
-
const anchor = input.anchor ?? null;
|
|
1069
|
-
const { projectMu, projectSigma, unit } = input;
|
|
1070
|
-
|
|
1071
|
-
const sigmaPositive = projectSigma !== null && projectSigma > 0;
|
|
1072
|
-
const sigmaParen = sigmaPositive
|
|
1073
|
-
? ` (± ${roundForCaption(projectSigma!)})`
|
|
1074
|
-
: '';
|
|
1075
|
-
|
|
1076
|
-
if (projectMu === null) {
|
|
1077
|
-
// Anchored + TBD: keep the framing prefix, mark the math as ?.
|
|
1078
|
-
if (anchor?.kind === 'forward') {
|
|
1079
|
-
return `Expected finish: ? · ≈ ? ${pluralizeUnit(2, unit)} of work`;
|
|
1080
|
-
}
|
|
1081
|
-
if (anchor?.kind === 'backward') {
|
|
1082
|
-
return `Expected start: ? · ≈ ? ${pluralizeUnit(2, unit)} lead time`;
|
|
1083
|
-
}
|
|
1084
|
-
// Unanchored + TBD: surface that the total is unknown. The per-node
|
|
1085
|
-
// `?` placeholders convey which activities are missing estimates.
|
|
1086
|
-
return `≈ ? ${pluralizeUnit(2, unit)}`;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
|
|
1090
|
-
|
|
1091
|
-
if (anchor?.kind === 'forward') {
|
|
1092
|
-
const projectMuDays = projectMu * unitToDays(unit);
|
|
1093
|
-
return `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)} · ≈ ${muStr} of work${sigmaParen}`;
|
|
1094
|
-
}
|
|
1095
|
-
if (anchor?.kind === 'backward') {
|
|
1096
|
-
const projectMuDays = projectMu * unitToDays(unit);
|
|
1097
|
-
return `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)} · ≈ ${muStr} lead time${sigmaParen}`;
|
|
1098
|
-
}
|
|
1099
|
-
return `≈ ${muStr}${sigmaParen}`;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
function roundForCaption(n: number): string {
|
|
1103
|
-
let rounded: number;
|
|
1104
|
-
const abs = Math.abs(n);
|
|
1105
|
-
if (abs < 10) rounded = Math.round(n * 100) / 100;
|
|
1106
|
-
else if (abs < 100) rounded = Math.round(n * 10) / 10;
|
|
1107
|
-
else rounded = Math.round(n);
|
|
1108
|
-
// Trim trailing zeros after the decimal: 29.20 → 29.2, 29.00 → 29.
|
|
1109
|
-
const str = rounded.toString();
|
|
1110
|
-
return str.includes('.') ? str.replace(/\.?0+$/, '') : str;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const UNIT_WORDS: Record<DurationUnit, [string, string]> = {
|
|
1114
|
-
min: ['minute', 'minutes'],
|
|
1115
|
-
h: ['hour', 'hours'],
|
|
1116
|
-
d: ['day', 'days'],
|
|
1117
|
-
bd: ['business day', 'business days'],
|
|
1118
|
-
w: ['week', 'weeks'],
|
|
1119
|
-
m: ['month', 'months'],
|
|
1120
|
-
q: ['quarter', 'quarters'],
|
|
1121
|
-
y: ['year', 'years'],
|
|
1122
|
-
s: ['sprint', 'sprints'],
|
|
1123
|
-
};
|
|
1124
|
-
|
|
1125
|
-
function pluralizeUnit(value: number, unit: DurationUnit): string {
|
|
1126
|
-
const [singular, plural] = UNIT_WORDS[unit];
|
|
1127
|
-
return roundForCaption(value) === '1' ? singular : plural;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
function formatPercentile(value: number, unit: DurationUnit): string {
|
|
1131
|
-
return `${roundForCaption(value)} ${pluralizeUnit(value, unit)}`;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
function emptyResolved(
|
|
1135
|
-
parsed: ParsedPert,
|
|
1136
|
-
diagnostics: DgmoError[]
|
|
1137
|
-
): ResolvedPert {
|
|
1138
|
-
return {
|
|
1139
|
-
options: parsed.options,
|
|
1140
|
-
activities: parsed.activities.map((a) => ({
|
|
1141
|
-
activity: a,
|
|
1142
|
-
es: null,
|
|
1143
|
-
ef: null,
|
|
1144
|
-
ls: null,
|
|
1145
|
-
lf: null,
|
|
1146
|
-
slack: null,
|
|
1147
|
-
isCriticalPath: false,
|
|
1148
|
-
mu: null,
|
|
1149
|
-
sigma: null,
|
|
1150
|
-
criticality: null,
|
|
1151
|
-
isAuthored: false,
|
|
1152
|
-
})),
|
|
1153
|
-
edges: parsed.edges.slice(),
|
|
1154
|
-
groups: parsed.groups.map((g) => ({
|
|
1155
|
-
group: g,
|
|
1156
|
-
rolledMu: null,
|
|
1157
|
-
rolledSigma: null,
|
|
1158
|
-
entries: [],
|
|
1159
|
-
exits: [],
|
|
1160
|
-
es: null,
|
|
1161
|
-
ef: null,
|
|
1162
|
-
ls: null,
|
|
1163
|
-
lf: null,
|
|
1164
|
-
slack: null,
|
|
1165
|
-
criticality: null,
|
|
1166
|
-
})),
|
|
1167
|
-
tagGroups: parsed.tagGroups.slice(),
|
|
1168
|
-
mode: 'analytical',
|
|
1169
|
-
summaryRows: null,
|
|
1170
|
-
projectSubtitle: null,
|
|
1171
|
-
projectMu: null,
|
|
1172
|
-
projectSigma: null,
|
|
1173
|
-
criticalPath: [],
|
|
1174
|
-
projectStart: null,
|
|
1175
|
-
monteCarloResult: null,
|
|
1176
|
-
expandedActivities: [],
|
|
1177
|
-
diagnostics,
|
|
1178
|
-
error: firstFatal(diagnostics) ?? parsed.error,
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// Re-export `_unused` markers so lint doesn't complain (PertEdge is read above).
|
|
1183
|
-
export type { PertEdge };
|