@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/raci/renderer.ts
DELETED
|
@@ -1,1627 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// RACI / RASCI / DACI — D3 SVG Renderer
|
|
3
|
-
// ============================================================
|
|
4
|
-
//
|
|
5
|
-
// Treatment: color-blocked letters (cell fill = palette tint per
|
|
6
|
-
// marker; letter on top with palette-aware contrast text). One of
|
|
7
|
-
// the three Task-7 prototype options; chosen for v1 because:
|
|
8
|
-
//
|
|
9
|
-
// - At gallery thumbnail scale, the marker palette reads as the
|
|
10
|
-
// dominant signal (matches conventional RACI visual language).
|
|
11
|
-
// - Multi-marker cells (`A R`) stay legible — letters don't pile up.
|
|
12
|
-
// - Empty cells are visually distinct (subtle hairline border) per
|
|
13
|
-
// AC 13's "ready to author" requirement.
|
|
14
|
-
//
|
|
15
|
-
// Locked in `docs/architecture/adr-NNN-raci-renderer-visual.md`
|
|
16
|
-
// (Phase 6 deliverable). Switching to dot-markers or plain letters
|
|
17
|
-
// in a future iteration only changes the cell-paint code path.
|
|
18
|
-
//
|
|
19
|
-
// SVG attribute conventions (per `sequence/renderer.ts:2310-2317`):
|
|
20
|
-
// - Each phase `<g>` carries `data-section`, `data-section-toggle`,
|
|
21
|
-
// `data-line-number`. Children carry `data-line-number` only.
|
|
22
|
-
// - Task rows carry `data-line-number` for editor highlighting.
|
|
23
|
-
// - Cells carry `data-line-number` to the role-assignment line so
|
|
24
|
-
// click → editor cursor jumps to the source.
|
|
25
|
-
|
|
26
|
-
import * as d3Selection from 'd3-selection';
|
|
27
|
-
import { FONT_FAMILY } from '../fonts';
|
|
28
|
-
import {
|
|
29
|
-
TITLE_FONT_SIZE,
|
|
30
|
-
TITLE_FONT_WEIGHT,
|
|
31
|
-
TITLE_Y,
|
|
32
|
-
} from '../utils/title-constants';
|
|
33
|
-
import { contrastText, mix, themeBaseBg } from '../palettes/color-utils';
|
|
34
|
-
import type { PaletteColors } from '../palettes';
|
|
35
|
-
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
36
|
-
import type {
|
|
37
|
-
ParsedRaci,
|
|
38
|
-
RaciMarker,
|
|
39
|
-
RaciPhase,
|
|
40
|
-
RaciTask,
|
|
41
|
-
RaciVariant,
|
|
42
|
-
} from './types';
|
|
43
|
-
import { allTasks } from './parser';
|
|
44
|
-
import { VARIANTS } from './variants';
|
|
45
|
-
import { ScaleContext } from '../utils/scaling';
|
|
46
|
-
import {
|
|
47
|
-
measureText,
|
|
48
|
-
truncateText,
|
|
49
|
-
wrapTextToWidth,
|
|
50
|
-
} from '../utils/text-measure';
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Group `parsed.diagnostics` per task, so the renderer can paint a
|
|
54
|
-
* violation indicator next to each task's label. Diagnostics whose
|
|
55
|
-
* line falls inside a task's [lineNumber, endLineNumber] range are
|
|
56
|
-
* attributed to that task. Out-of-range diagnostics are dropped (chart-
|
|
57
|
-
* level concerns are surfaced by the diagnostics panel, not the canvas).
|
|
58
|
-
*/
|
|
59
|
-
interface DiagnosticEntry {
|
|
60
|
-
line: number;
|
|
61
|
-
message: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function groupDiagnosticsByTask(parsed: ParsedRaci): Map<
|
|
65
|
-
string,
|
|
66
|
-
{
|
|
67
|
-
errors: DiagnosticEntry[];
|
|
68
|
-
warnings: DiagnosticEntry[];
|
|
69
|
-
/** First diagnostic line for click-to-navigate. */
|
|
70
|
-
firstLine: number;
|
|
71
|
-
}
|
|
72
|
-
> {
|
|
73
|
-
const map = new Map<
|
|
74
|
-
string,
|
|
75
|
-
{
|
|
76
|
-
errors: DiagnosticEntry[];
|
|
77
|
-
warnings: DiagnosticEntry[];
|
|
78
|
-
firstLine: number;
|
|
79
|
-
}
|
|
80
|
-
>();
|
|
81
|
-
if (parsed.diagnostics.length === 0) return map;
|
|
82
|
-
|
|
83
|
-
const tasks: RaciTask[] = [];
|
|
84
|
-
for (const t of parsed.tasksWithoutPhase) tasks.push(t);
|
|
85
|
-
for (const p of parsed.phases) for (const t of p.tasks) tasks.push(t);
|
|
86
|
-
|
|
87
|
-
for (const d of parsed.diagnostics) {
|
|
88
|
-
if (d.line <= 0) continue;
|
|
89
|
-
const owner = tasks.find(
|
|
90
|
-
(t) => d.line >= t.lineNumber && d.line <= t.endLineNumber
|
|
91
|
-
);
|
|
92
|
-
if (!owner) continue;
|
|
93
|
-
let bucket = map.get(owner.id);
|
|
94
|
-
if (!bucket) {
|
|
95
|
-
bucket = { errors: [], warnings: [], firstLine: d.line };
|
|
96
|
-
map.set(owner.id, bucket);
|
|
97
|
-
} else if (d.line < bucket.firstLine) {
|
|
98
|
-
bucket.firstLine = d.line;
|
|
99
|
-
}
|
|
100
|
-
const entry = { line: d.line, message: d.message };
|
|
101
|
-
if (d.severity === 'error') bucket.errors.push(entry);
|
|
102
|
-
else bucket.warnings.push(entry);
|
|
103
|
-
}
|
|
104
|
-
return map;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Constraint-rule messages name the task they're about — either as a
|
|
109
|
-
* `Task 'X' …` prefix (most rules) or an `… on task 'X' …` mid-clause
|
|
110
|
-
* (conflicting-markers rule). When rendered right next to the task
|
|
111
|
-
* label, that naming is redundant — strip it so the inline preview
|
|
112
|
-
* reads cleanly (`Has no Responsible assigned.` instead of
|
|
113
|
-
* `Task 'Stand the watch' has no Responsible assigned.`).
|
|
114
|
-
*
|
|
115
|
-
* The full form stays in `parsed.diagnostics` for the bottom
|
|
116
|
-
* diagnostics panel, which lists messages outside their task row.
|
|
117
|
-
*/
|
|
118
|
-
function trimTaskPrefix(message: string): string {
|
|
119
|
-
const prefix = message.match(/^Task '[^']*'\s+(.+)$/);
|
|
120
|
-
if (prefix) {
|
|
121
|
-
// Capture group [1] guaranteed by regex match.
|
|
122
|
-
const rest = prefix[1]!;
|
|
123
|
-
return rest.charAt(0).toUpperCase() + rest.slice(1);
|
|
124
|
-
}
|
|
125
|
-
return message.replace(/\s+on task '[^']*'/, '');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Build an SVG path for a rectangle with only the top corners rounded —
|
|
130
|
-
* matches the kanban-style "column" silhouette where the bottom runs
|
|
131
|
-
* straight to the edge so the column reads as a continuing container.
|
|
132
|
-
*/
|
|
133
|
-
function topRoundedRectPath(
|
|
134
|
-
x: number,
|
|
135
|
-
y: number,
|
|
136
|
-
w: number,
|
|
137
|
-
h: number,
|
|
138
|
-
r: number
|
|
139
|
-
): string {
|
|
140
|
-
const rr = Math.min(r, w / 2, h / 2);
|
|
141
|
-
return (
|
|
142
|
-
`M ${x} ${y + rr}` +
|
|
143
|
-
` A ${rr} ${rr} 0 0 1 ${x + rr} ${y}` +
|
|
144
|
-
` H ${x + w - rr}` +
|
|
145
|
-
` A ${rr} ${rr} 0 0 1 ${x + w} ${y + rr}` +
|
|
146
|
-
` V ${y + h}` +
|
|
147
|
-
` H ${x}` +
|
|
148
|
-
` Z`
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Sort markers into canonical alphabet order for display. */
|
|
153
|
-
function sortByAlphabet(
|
|
154
|
-
markers: ReadonlyArray<RaciMarker>,
|
|
155
|
-
alphabet: ReadonlyArray<RaciMarker>
|
|
156
|
-
): RaciMarker[] {
|
|
157
|
-
const order = new Map<RaciMarker, number>(
|
|
158
|
-
alphabet.map((m, i) => [m, i] as const)
|
|
159
|
-
);
|
|
160
|
-
return [...markers].sort(
|
|
161
|
-
(a, b) => (order.get(a) ?? 99) - (order.get(b) ?? 99)
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── Marker semantic labels (variant-aware) ───────────────────
|
|
166
|
-
//
|
|
167
|
-
// The legend chip shows both the letter and its full meaning.
|
|
168
|
-
// Variant differences are deliberate: in DACI the `A` is "Approver"
|
|
169
|
-
// (not "Accountable") and `C` is "Contributor" (not "Consulted") —
|
|
170
|
-
// teaching the meaning is part of the legend's job.
|
|
171
|
-
export const MARKER_LABELS: Readonly<
|
|
172
|
-
Record<RaciVariant, Readonly<Partial<Record<RaciMarker, string>>>>
|
|
173
|
-
> = {
|
|
174
|
-
raci: { R: 'Responsible', A: 'Accountable', C: 'Consulted', I: 'Informed' },
|
|
175
|
-
rasci: {
|
|
176
|
-
R: 'Responsible',
|
|
177
|
-
A: 'Accountable',
|
|
178
|
-
S: 'Support',
|
|
179
|
-
C: 'Consulted',
|
|
180
|
-
I: 'Informed',
|
|
181
|
-
},
|
|
182
|
-
daci: { D: 'Driver', A: 'Approver', C: 'Contributor', I: 'Informed' },
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// ── Layout constants ─────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
const TITLE_AREA_HEIGHT = 50;
|
|
188
|
-
/** Left/right page margin. */
|
|
189
|
-
const H_MARGIN = 24;
|
|
190
|
-
/** Top breathing room below title. */
|
|
191
|
-
const V_MARGIN = 16;
|
|
192
|
-
/**
|
|
193
|
-
* Marker palette / legend chip height. Matches the in-cell marker
|
|
194
|
-
* slice height (`ROW_HEIGHT - 2 * CELL_PAD = 28`) so legend chips
|
|
195
|
-
* read as the same primitive as the slices inside the matrix.
|
|
196
|
-
*/
|
|
197
|
-
const LEGEND_HEIGHT = 28;
|
|
198
|
-
/** Gap between adjacent legend chips. */
|
|
199
|
-
const LEGEND_CHIP_GAP = 8;
|
|
200
|
-
/** Min chip width when rendering with full marker labels. */
|
|
201
|
-
const LEGEND_CHIP_LABEL_MIN = 96;
|
|
202
|
-
/** Chip width when only the marker letter is shown (compact mode). Square = same as cell height. */
|
|
203
|
-
const LEGEND_LETTER_CHIP_W = 28;
|
|
204
|
-
/** Min horizontal gap between title and right-aligned legend. */
|
|
205
|
-
const TITLE_LEGEND_GAP = 16;
|
|
206
|
-
/** Label font in legend chips. */
|
|
207
|
-
const LEGEND_LABEL_FONT = 12;
|
|
208
|
-
/** Letter font in legend chips. */
|
|
209
|
-
const LEGEND_LETTER_FONT = 14;
|
|
210
|
-
/** Header row that holds the role names. Matches kanban COLUMN_HEADER_HEIGHT. */
|
|
211
|
-
const HEADER_HEIGHT = 36;
|
|
212
|
-
/** Each task row. */
|
|
213
|
-
const ROW_HEIGHT = 36;
|
|
214
|
-
/** Phase divider band height (full-width above the phase's tasks).
|
|
215
|
-
* Sized with breathing room so the per-column collapsed-phase
|
|
216
|
-
* summary chips can match the regular cell geometry without crowding
|
|
217
|
-
* the phase title. */
|
|
218
|
-
const PHASE_HEIGHT = 40;
|
|
219
|
-
/** Width allocated to the task-label column on the left. */
|
|
220
|
-
const TASK_LABEL_MIN = 200;
|
|
221
|
-
const TASK_LABEL_MAX = 360;
|
|
222
|
-
/** Min/max role column width — clamped if too few or too many roles. */
|
|
223
|
-
const ROLE_COL_MIN = 64;
|
|
224
|
-
const ROLE_COL_MAX = 120;
|
|
225
|
-
/** Inter-cell padding. */
|
|
226
|
-
const CELL_PAD = 4;
|
|
227
|
-
/** Extra vertical space added to a task row when it has a description. */
|
|
228
|
-
/** Vertical space per stacked violation message line on a task row. */
|
|
229
|
-
const VIOLATION_LINE_HEIGHT = 14;
|
|
230
|
-
/**
|
|
231
|
-
* Breathing room between the task label and the first line of stacked
|
|
232
|
-
* content (description or violation). Without this the secondary lines
|
|
233
|
-
* sit visually crammed against the task name.
|
|
234
|
-
*/
|
|
235
|
-
const STACK_TOP_GAP = 8;
|
|
236
|
-
/** Description font size — smaller than LABEL_FONT for visual hierarchy. */
|
|
237
|
-
const DESC_FONT = 11;
|
|
238
|
-
/** Per-role column corner radius (matches kanban COLUMN_RADIUS). */
|
|
239
|
-
const COLUMN_RADIUS = 8;
|
|
240
|
-
/**
|
|
241
|
-
* Inset on each side of a role column, producing a 16-px visual gap
|
|
242
|
-
* between columns — matches kanban COLUMN_GAP exactly.
|
|
243
|
-
*/
|
|
244
|
-
const COLUMN_INSET = 8;
|
|
245
|
-
/**
|
|
246
|
-
* Extra space below the last task row, kept inside the column body so
|
|
247
|
-
* the rounded bottom corners read clearly. Mirrors kanban COLUMN_PADDING.
|
|
248
|
-
*/
|
|
249
|
-
const COLUMN_BOTTOM_PAD = 14;
|
|
250
|
-
/** Marker letter font size. */
|
|
251
|
-
const MARKER_FONT = 14;
|
|
252
|
-
const MARKER_FONT_WEIGHT = 600;
|
|
253
|
-
/** Task-label font size. */
|
|
254
|
-
const LABEL_FONT = 13;
|
|
255
|
-
/** Role-header font size. */
|
|
256
|
-
const ROLE_HEADER_FONT = 12;
|
|
257
|
-
/** Phase-bar font size. */
|
|
258
|
-
const PHASE_FONT = 13;
|
|
259
|
-
|
|
260
|
-
// Cell-fill tint percentage of the marker color over surface bg.
|
|
261
|
-
// 25 = the canonical `shapeFill` tint used by every other chart type.
|
|
262
|
-
const TINT_PCT = 25;
|
|
263
|
-
/**
|
|
264
|
-
* Marker rect stroke + radius — matches the codebase's node styling
|
|
265
|
-
* convention (flowchart `NODE_STROKE_WIDTH = 1.5`, kanban
|
|
266
|
-
* `CARD_STROKE_WIDTH = 1.5`, c4 `NODE_STROKE_WIDTH = 1.5`,
|
|
267
|
-
* kanban `CARD_RADIUS = 6`). Keeps RACI markers visually consistent
|
|
268
|
-
* with nodes elsewhere in the diagram language.
|
|
269
|
-
*/
|
|
270
|
-
import { NODE_STROKE_WIDTH } from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
271
|
-
const NODE_RADIUS = 6;
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Map each marker letter to a semantic color drawn from the palette's
|
|
275
|
-
* named accents. The mapping follows the most common convention seen
|
|
276
|
-
* across Atlassian / Smartsheet / Excel template defaults:
|
|
277
|
-
* - R Responsible → green (active doer / "go" signal)
|
|
278
|
-
* - A Accountable → red (owns it / "buck stops here")
|
|
279
|
-
* - C Consulted → yellow
|
|
280
|
-
* - I Informed → gray
|
|
281
|
-
* - S Support → teal (RASCI; close to R-green but cooler)
|
|
282
|
-
* - D Driver → purple (DACI; distinct from A's red)
|
|
283
|
-
*
|
|
284
|
-
* Palette swap → markers swap; semantics stay constant.
|
|
285
|
-
*/
|
|
286
|
-
/**
|
|
287
|
-
* Auto-accent rotation used when the user hasn't given a phase / role
|
|
288
|
-
* an explicit `| color: …`. Cycles through every named accent (gray
|
|
289
|
-
* excluded — reads as "disabled") so users can refer to phases and
|
|
290
|
-
* columns by color name. Marker fills sit on top at full saturation
|
|
291
|
-
* inside small chips, so even a "red column" + Accountable marker
|
|
292
|
-
* read as separate elements.
|
|
293
|
-
*
|
|
294
|
-
* Order alternates warm / cool so adjacent phases / columns don't
|
|
295
|
-
* blur into one another.
|
|
296
|
-
*/
|
|
297
|
-
const AUTO_ACCENTS: ReadonlyArray<keyof PaletteColors['colors']> = [
|
|
298
|
-
'blue',
|
|
299
|
-
'orange',
|
|
300
|
-
'green',
|
|
301
|
-
'purple',
|
|
302
|
-
'yellow',
|
|
303
|
-
'cyan',
|
|
304
|
-
'red',
|
|
305
|
-
'teal',
|
|
306
|
-
];
|
|
307
|
-
|
|
308
|
-
function autoAccent(index: number, palette: PaletteColors): string {
|
|
309
|
-
// In-bounds: (index % AUTO_ACCENTS.length) is always within bounds (AUTO_ACCENTS is non-empty).
|
|
310
|
-
return palette.colors[AUTO_ACCENTS[index % AUTO_ACCENTS.length]!];
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function markerColor(marker: RaciMarker, palette: PaletteColors): string {
|
|
314
|
-
switch (marker) {
|
|
315
|
-
case 'R':
|
|
316
|
-
return palette.colors.green;
|
|
317
|
-
case 'A':
|
|
318
|
-
return palette.colors.red;
|
|
319
|
-
case 'S':
|
|
320
|
-
return palette.colors.teal;
|
|
321
|
-
case 'C':
|
|
322
|
-
return palette.colors.yellow;
|
|
323
|
-
case 'I':
|
|
324
|
-
return palette.colors.gray;
|
|
325
|
-
case 'D':
|
|
326
|
-
return palette.colors.purple;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ── Public render entry points ───────────────────────────────
|
|
331
|
-
|
|
332
|
-
/** Drag-source identity passed to `onMarkerDragStart`. */
|
|
333
|
-
export type RaciDragSource =
|
|
334
|
-
| { kind: 'legend'; marker: RaciMarker }
|
|
335
|
-
| {
|
|
336
|
-
kind: 'cell';
|
|
337
|
-
marker: RaciMarker;
|
|
338
|
-
taskId: string;
|
|
339
|
-
roleId: string;
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
export interface RaciInteractionHandlers {
|
|
343
|
-
onClickLine?: (lineNumber: number) => void;
|
|
344
|
-
/** Fires on `pointerdown` of a legend chip OR an in-cell marker slice. */
|
|
345
|
-
onMarkerDragStart?: (source: RaciDragSource, event: PointerEvent) => void;
|
|
346
|
-
/**
|
|
347
|
-
* Suppress the in-SVG legend strip. The app overlays an HTML legend
|
|
348
|
-
* for native HTML5 drag — SVG `<g> draggable=true` is unreliable.
|
|
349
|
-
* PNG/SVG export keeps the legend by default.
|
|
350
|
-
*/
|
|
351
|
-
hideLegend?: boolean;
|
|
352
|
-
/**
|
|
353
|
-
* Phase ids whose tasks should be hidden. The phase header still
|
|
354
|
-
* renders with a "collapsed" chevron so the user can re-expand.
|
|
355
|
-
*/
|
|
356
|
-
collapsedPhases?: ReadonlySet<string>;
|
|
357
|
-
/**
|
|
358
|
-
* Suppress the in-SVG title. The app paints its own HTML title
|
|
359
|
-
* above the HTML legend bar so the visual order is title → legend
|
|
360
|
-
* → matrix. PNG/SVG export keeps the title by default.
|
|
361
|
-
*/
|
|
362
|
-
hideTitle?: boolean;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Render a RACI / RASCI / DACI matrix into the given DOM container.
|
|
367
|
-
* Layout is computed once and laid out as SVG; no animation.
|
|
368
|
-
*/
|
|
369
|
-
export function renderRaci(
|
|
370
|
-
container: HTMLDivElement,
|
|
371
|
-
parsed: ParsedRaci,
|
|
372
|
-
palette: PaletteColors,
|
|
373
|
-
isDark: boolean,
|
|
374
|
-
handlers?: RaciInteractionHandlers | ((lineNumber: number) => void),
|
|
375
|
-
exportDims?: D3ExportDimensions
|
|
376
|
-
): void {
|
|
377
|
-
// Backwards-compat: previous signature accepted `onClickLine` directly.
|
|
378
|
-
const opts: RaciInteractionHandlers =
|
|
379
|
-
typeof handlers === 'function'
|
|
380
|
-
? { onClickLine: handlers }
|
|
381
|
-
: (handlers ?? {});
|
|
382
|
-
const onClickLine = opts.onClickLine;
|
|
383
|
-
const onMarkerDragStart = opts.onMarkerDragStart;
|
|
384
|
-
const hideLegend = opts.hideLegend ?? false;
|
|
385
|
-
const collapsedPhases = opts.collapsedPhases ?? new Set<string>();
|
|
386
|
-
const hideTitle =
|
|
387
|
-
(opts.hideTitle ?? false) || parsed.options['no-title'] === 'on';
|
|
388
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
389
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
390
|
-
const height = exportDims?.height ?? container.clientHeight;
|
|
391
|
-
if (width <= 0 || height <= 0) return;
|
|
392
|
-
|
|
393
|
-
const tasksAll = [...allTasks(parsed)];
|
|
394
|
-
if (tasksAll.length === 0 && parsed.phases.length === 0) return;
|
|
395
|
-
|
|
396
|
-
const solid = parsed.options['solid-fill'] === 'on';
|
|
397
|
-
const surfaceBg = themeBaseBg(palette, isDark);
|
|
398
|
-
|
|
399
|
-
// --- ScaleContext: differential scaling ---
|
|
400
|
-
const roleCount = Math.max(1, parsed.roles.length);
|
|
401
|
-
const idealWidth = roleCount * ROLE_COL_MAX + TASK_LABEL_MAX + 2 * H_MARGIN;
|
|
402
|
-
const ctx = exportDims
|
|
403
|
-
? ScaleContext.identity()
|
|
404
|
-
: ScaleContext.from(width, idealWidth);
|
|
405
|
-
|
|
406
|
-
const sTitleFontSize = ctx.text(TITLE_FONT_SIZE);
|
|
407
|
-
const sTitleY = ctx.structural(TITLE_Y);
|
|
408
|
-
const sHMargin = ctx.aesthetic(H_MARGIN);
|
|
409
|
-
const sVMargin = ctx.aesthetic(V_MARGIN);
|
|
410
|
-
const sTitleAreaHeight = ctx.structural(TITLE_AREA_HEIGHT);
|
|
411
|
-
const sHeaderHeight = ctx.structural(HEADER_HEIGHT);
|
|
412
|
-
const sRowHeight = ctx.structural(ROW_HEIGHT);
|
|
413
|
-
const sPhaseHeight = ctx.structural(PHASE_HEIGHT);
|
|
414
|
-
const sTaskLabelMin = ctx.structural(TASK_LABEL_MIN);
|
|
415
|
-
const sTaskLabelMax = ctx.structural(TASK_LABEL_MAX);
|
|
416
|
-
const sRoleColMin = ctx.structural(ROLE_COL_MIN);
|
|
417
|
-
const sRoleColMax = ctx.structural(ROLE_COL_MAX);
|
|
418
|
-
const sCellPad = ctx.structural(CELL_PAD);
|
|
419
|
-
const sLabelFont = ctx.text(LABEL_FONT);
|
|
420
|
-
const sRoleHeaderFont = ctx.text(ROLE_HEADER_FONT);
|
|
421
|
-
const sPhaseFont = ctx.text(PHASE_FONT);
|
|
422
|
-
const sMarkerFont = ctx.text(MARKER_FONT);
|
|
423
|
-
const sDescFont = ctx.text(DESC_FONT);
|
|
424
|
-
const sColumnRadius = ctx.structural(COLUMN_RADIUS);
|
|
425
|
-
const sColumnInset = ctx.structural(COLUMN_INSET);
|
|
426
|
-
const sColumnBottomPad = ctx.structural(COLUMN_BOTTOM_PAD);
|
|
427
|
-
const sLegendHeight = ctx.structural(LEGEND_HEIGHT);
|
|
428
|
-
const sLegendChipGap = ctx.structural(LEGEND_CHIP_GAP);
|
|
429
|
-
const sLegendChipLabelMin = ctx.structural(LEGEND_CHIP_LABEL_MIN);
|
|
430
|
-
const sLegendLetterChipW = ctx.structural(LEGEND_LETTER_CHIP_W);
|
|
431
|
-
const sTitleLegendGap = ctx.aesthetic(TITLE_LEGEND_GAP);
|
|
432
|
-
const sLegendLabelFont = ctx.text(LEGEND_LABEL_FONT);
|
|
433
|
-
const sLegendLetterFont = ctx.text(LEGEND_LETTER_FONT);
|
|
434
|
-
const sViolationLineHeight = ctx.structural(VIOLATION_LINE_HEIGHT);
|
|
435
|
-
const sStackTopGap = ctx.structural(STACK_TOP_GAP);
|
|
436
|
-
const sNodeStrokeWidth = ctx.structural(NODE_STROKE_WIDTH);
|
|
437
|
-
const sNodeRadius = ctx.structural(NODE_RADIUS);
|
|
438
|
-
|
|
439
|
-
// ── Column widths ──────────────────────────────────────────
|
|
440
|
-
|
|
441
|
-
const innerWidth = Math.max(0, width - 2 * sHMargin);
|
|
442
|
-
|
|
443
|
-
// Greedy split: prefer sRoleColMin..sRoleColMax, give the rest to
|
|
444
|
-
// the task-label column (clamped to a reasonable range).
|
|
445
|
-
let roleColW = Math.max(
|
|
446
|
-
sRoleColMin,
|
|
447
|
-
Math.min(sRoleColMax, Math.floor((innerWidth - sTaskLabelMin) / roleCount))
|
|
448
|
-
);
|
|
449
|
-
let labelW = innerWidth - roleColW * roleCount;
|
|
450
|
-
if (labelW > sTaskLabelMax) {
|
|
451
|
-
labelW = sTaskLabelMax;
|
|
452
|
-
roleColW = Math.max(
|
|
453
|
-
sRoleColMin,
|
|
454
|
-
Math.floor((innerWidth - labelW) / roleCount)
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
if (labelW < sTaskLabelMin) labelW = sTaskLabelMin;
|
|
458
|
-
|
|
459
|
-
// ── Vertical layout ────────────────────────────────────────
|
|
460
|
-
|
|
461
|
-
// Pre-compute per-task diagnostic buckets so the row-height pass can
|
|
462
|
-
// grow rows that need to fit multiple stacked violation messages.
|
|
463
|
-
const taskDiagnostics = groupDiagnosticsByTask(parsed);
|
|
464
|
-
|
|
465
|
-
const phasedRows: Array<
|
|
466
|
-
| { kind: 'phase'; phase: RaciPhase; collapsed: boolean }
|
|
467
|
-
| { kind: 'task'; task: RaciTask }
|
|
468
|
-
> = [];
|
|
469
|
-
for (const t of parsed.tasksWithoutPhase)
|
|
470
|
-
phasedRows.push({ kind: 'task', task: t });
|
|
471
|
-
for (const p of parsed.phases) {
|
|
472
|
-
const collapsed = collapsedPhases.has(p.id);
|
|
473
|
-
phasedRows.push({ kind: 'phase', phase: p, collapsed });
|
|
474
|
-
if (!collapsed) {
|
|
475
|
-
for (const t of p.tasks) phasedRows.push({ kind: 'task', task: t });
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Vertical stack: [title row + inline legend] → role headers → body.
|
|
480
|
-
// Title and legend share a single row at the top (kanban-style):
|
|
481
|
-
// title left-aligned, legend right-aligned, both vertically centered.
|
|
482
|
-
// When both are hidden (app preview with HTML overlays), the row collapses.
|
|
483
|
-
const hasTopRow = !hideTitle || !hideLegend;
|
|
484
|
-
const titleArea = hasTopRow ? sTitleAreaHeight : sVMargin;
|
|
485
|
-
const legendY = (sTitleAreaHeight - sLegendHeight) / 2;
|
|
486
|
-
const headerY = titleArea + sVMargin;
|
|
487
|
-
const bodyTop = headerY + sHeaderHeight;
|
|
488
|
-
let cursorY = bodyTop;
|
|
489
|
-
const rowYs: number[] = [];
|
|
490
|
-
// Pre-compute wrapped task content once: both the y-cursor pass and
|
|
491
|
-
// the renderer need it, and word-wrapping isn't free.
|
|
492
|
-
const labelMaxW = labelW - 16;
|
|
493
|
-
const taskRowContent = new Map<string, RowContent>();
|
|
494
|
-
for (const row of phasedRows) {
|
|
495
|
-
if (row.kind === 'task') {
|
|
496
|
-
const bucket = taskDiagnostics.get(row.task.id);
|
|
497
|
-
taskRowContent.set(
|
|
498
|
-
row.task.id,
|
|
499
|
-
prepareRowContent(
|
|
500
|
-
row.task,
|
|
501
|
-
bucket,
|
|
502
|
-
labelMaxW,
|
|
503
|
-
sLabelFont,
|
|
504
|
-
sDescFont,
|
|
505
|
-
sRowHeight,
|
|
506
|
-
sStackTopGap,
|
|
507
|
-
sViolationLineHeight
|
|
508
|
-
)
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
for (const row of phasedRows) {
|
|
513
|
-
rowYs.push(cursorY);
|
|
514
|
-
if (row.kind === 'phase') {
|
|
515
|
-
cursorY += sPhaseHeight;
|
|
516
|
-
} else {
|
|
517
|
-
const content = taskRowContent.get(row.task.id);
|
|
518
|
-
cursorY += content?.rowHeight ?? sRowHeight;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
const colBottomY = cursorY + sColumnBottomPad;
|
|
522
|
-
const totalHeight = colBottomY + sVMargin;
|
|
523
|
-
|
|
524
|
-
// Export renders a fixed canvas (e.g. 1200×800); a short matrix would
|
|
525
|
-
// otherwise reserve a tall band of dead space below the last row. Size the
|
|
526
|
-
// export canvas to the content height. The interactive preview keeps the
|
|
527
|
-
// `max(pane, content)` behaviour so a short matrix still fills the pane.
|
|
528
|
-
const svgHeight = exportDims ? totalHeight : Math.max(height, totalHeight);
|
|
529
|
-
|
|
530
|
-
// ── SVG root ───────────────────────────────────────────────
|
|
531
|
-
|
|
532
|
-
const svg = d3Selection
|
|
533
|
-
.select(container)
|
|
534
|
-
.append('svg')
|
|
535
|
-
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
536
|
-
.attr('width', width)
|
|
537
|
-
.attr('height', svgHeight)
|
|
538
|
-
.attr('viewBox', `0 0 ${width} ${svgHeight}`)
|
|
539
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
540
|
-
.attr('font-family', FONT_FAMILY)
|
|
541
|
-
.style('background', 'transparent');
|
|
542
|
-
|
|
543
|
-
if (ctx.isBelowFloor) {
|
|
544
|
-
svg.attr('width', '100%');
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// ── Top row: title (left) + legend (right), inline like kanban ──
|
|
548
|
-
|
|
549
|
-
// Decide legend geometry first so we know how much room is left for the title.
|
|
550
|
-
const variantLabels = MARKER_LABELS[parsed.variant];
|
|
551
|
-
const legendMarkers = (Object.keys(variantLabels) as RaciMarker[]).filter(
|
|
552
|
-
(m) => variantLabels[m] !== undefined
|
|
553
|
-
);
|
|
554
|
-
const numChips = legendMarkers.length;
|
|
555
|
-
const chipGapTotal = sLegendChipGap * Math.max(0, numChips - 1);
|
|
556
|
-
|
|
557
|
-
// Size full-mode chips to actually fit the longest label without truncation.
|
|
558
|
-
// chip = 4 pad + 24 slab + 8 gap + label + 8 right pad.
|
|
559
|
-
const longestLabelPx = legendMarkers.reduce(
|
|
560
|
-
(n, m) =>
|
|
561
|
-
Math.max(n, measureText(variantLabels[m] ?? '', sLegendLabelFont)),
|
|
562
|
-
0
|
|
563
|
-
);
|
|
564
|
-
const fullChipW = Math.max(
|
|
565
|
-
sLegendChipLabelMin,
|
|
566
|
-
Math.ceil(4 + 24 + 8 + longestLabelPx + 8)
|
|
567
|
-
);
|
|
568
|
-
const fullLegendW = numChips * fullChipW + chipGapTotal;
|
|
569
|
-
const letterLegendW = numChips * sLegendLetterChipW + chipGapTotal;
|
|
570
|
-
|
|
571
|
-
// Estimate title pixel width using the shared glyph measurer.
|
|
572
|
-
const titleEstW =
|
|
573
|
-
parsed.title && !hideTitle ? measureText(parsed.title, sTitleFontSize) : 0;
|
|
574
|
-
|
|
575
|
-
// Pick legend mode: full chips if everything fits, else letter-only.
|
|
576
|
-
// If even letters won't fit beside the title, the title is truncated below.
|
|
577
|
-
let legendMode: 'full' | 'letters' = 'full';
|
|
578
|
-
let legendTotalW = fullLegendW;
|
|
579
|
-
let legendChipW = fullChipW;
|
|
580
|
-
if (!hideLegend) {
|
|
581
|
-
const remaining = innerWidth - titleEstW - sTitleLegendGap;
|
|
582
|
-
if (remaining < fullLegendW) {
|
|
583
|
-
legendMode = 'letters';
|
|
584
|
-
legendTotalW = letterLegendW;
|
|
585
|
-
legendChipW = sLegendLetterChipW;
|
|
586
|
-
}
|
|
587
|
-
} else {
|
|
588
|
-
legendTotalW = 0;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const legendX = sHMargin + innerWidth - legendTotalW;
|
|
592
|
-
const titleMaxW = hideLegend
|
|
593
|
-
? innerWidth
|
|
594
|
-
: legendX - sHMargin - sTitleLegendGap;
|
|
595
|
-
|
|
596
|
-
// Title — left-aligned, truncated to whatever room the legend leaves.
|
|
597
|
-
if (parsed.title && !hideTitle) {
|
|
598
|
-
svg
|
|
599
|
-
.append('text')
|
|
600
|
-
.attr('x', sHMargin)
|
|
601
|
-
.attr('y', sTitleY)
|
|
602
|
-
.attr('text-anchor', 'start')
|
|
603
|
-
.attr('font-size', sTitleFontSize)
|
|
604
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
605
|
-
.attr('fill', palette.text)
|
|
606
|
-
.text(truncateText(parsed.title, sTitleFontSize, titleMaxW));
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Legend — right-aligned in the same row.
|
|
610
|
-
if (!hideLegend && numChips > 0) {
|
|
611
|
-
renderLegend(
|
|
612
|
-
svg,
|
|
613
|
-
legendMarkers,
|
|
614
|
-
parsed.variant,
|
|
615
|
-
legendX,
|
|
616
|
-
legendY,
|
|
617
|
-
legendMode,
|
|
618
|
-
legendChipW,
|
|
619
|
-
palette,
|
|
620
|
-
surfaceBg,
|
|
621
|
-
solid,
|
|
622
|
-
onMarkerDragStart,
|
|
623
|
-
sLegendChipGap,
|
|
624
|
-
sLegendHeight,
|
|
625
|
-
sNodeRadius,
|
|
626
|
-
sNodeStrokeWidth,
|
|
627
|
-
sLegendLetterFont,
|
|
628
|
-
sLegendLabelFont
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// ── Per-role columns (kanban-style: rounded body bg + header overlay) ──
|
|
633
|
-
|
|
634
|
-
const roleX = (i: number): number => sHMargin + labelW + i * roleColW;
|
|
635
|
-
|
|
636
|
-
// Column treatment: each role gets its own rounded rect spanning the full
|
|
637
|
-
// body height, with a clearly darker header overlay on top. Drawn before
|
|
638
|
-
// any body content so phase bars and marker cells render above it.
|
|
639
|
-
const colTotalHeight = colBottomY - headerY;
|
|
640
|
-
|
|
641
|
-
// No-phase row bands: when the chart has no [Section] bars carrying
|
|
642
|
-
// visual rhythm, paint a full-width tinted band per task row so each
|
|
643
|
-
// row reads as its own micro-section. Drawn BEFORE columns/cells so
|
|
644
|
-
// column tints stack on top without muddying. A 4-px y-margin keeps
|
|
645
|
-
// adjacent bands from touching.
|
|
646
|
-
if (parsed.phases.length === 0) {
|
|
647
|
-
const bandsG = svg.append('g').attr('class', 'raci-row-bands');
|
|
648
|
-
phasedRows.forEach((row, i) => {
|
|
649
|
-
if (row.kind !== 'task') return;
|
|
650
|
-
const taskIdx = parsed.tasksWithoutPhase.indexOf(row.task);
|
|
651
|
-
if (taskIdx < 0) return;
|
|
652
|
-
// In-bounds: forEach index i is < rowYs.length (rowYs aligned with phasedRows).
|
|
653
|
-
const yTop = rowYs[i]!;
|
|
654
|
-
const rh = taskRowContent.get(row.task.id)?.rowHeight ?? sRowHeight;
|
|
655
|
-
bandsG
|
|
656
|
-
.append('rect')
|
|
657
|
-
.attr('class', 'raci-row-band')
|
|
658
|
-
.attr('data-task-id', row.task.id)
|
|
659
|
-
.attr('x', sHMargin)
|
|
660
|
-
.attr('y', yTop + 2)
|
|
661
|
-
.attr('width', innerWidth)
|
|
662
|
-
.attr('height', rh - 4)
|
|
663
|
-
.attr('rx', 6)
|
|
664
|
-
.attr('fill', mix(autoAccent(taskIdx, palette), surfaceBg, 12));
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Each role gets its own <g class="raci-column" data-role-id> wrapping
|
|
669
|
-
// body + header + name text. This lets the app's sync CSS dim other
|
|
670
|
-
// columns when one is hovered: any descendant getting :hover bubbles
|
|
671
|
-
// up so the wrapper's :hover state fires for the whole column area.
|
|
672
|
-
const columnsG = svg.append('g').attr('class', 'raci-columns');
|
|
673
|
-
parsed.roles.forEach((roleId, i) => {
|
|
674
|
-
const cx = roleX(i) + sColumnInset;
|
|
675
|
-
const cw = roleColW - 2 * sColumnInset;
|
|
676
|
-
// Per-role color from `Cap blue` trailing-token (or `Cap | color: blue`)
|
|
677
|
-
// syntax. When the user provides
|
|
678
|
-
// one, it wins; otherwise rotate through marker-safe accents so
|
|
679
|
-
// each column has a subtle visual identity instead of every column
|
|
680
|
-
// reading as the same neutral gray.
|
|
681
|
-
const roleColor = parsed.roleColors[i] ?? autoAccent(i, palette);
|
|
682
|
-
const bodyFill = mix(roleColor, themeBaseBg(palette, isDark), 16);
|
|
683
|
-
const headerFill = mix(
|
|
684
|
-
roleColor,
|
|
685
|
-
themeBaseBg(palette, isDark),
|
|
686
|
-
30
|
|
687
|
-
);
|
|
688
|
-
const colG = columnsG
|
|
689
|
-
.append('g')
|
|
690
|
-
.attr('class', 'raci-column')
|
|
691
|
-
.attr('data-role-id', roleId);
|
|
692
|
-
colG
|
|
693
|
-
.append('rect')
|
|
694
|
-
.attr('class', 'raci-column-body')
|
|
695
|
-
.attr('x', cx)
|
|
696
|
-
.attr('y', headerY)
|
|
697
|
-
.attr('width', cw)
|
|
698
|
-
.attr('height', colTotalHeight)
|
|
699
|
-
.attr('rx', sColumnRadius)
|
|
700
|
-
.attr('fill', bodyFill);
|
|
701
|
-
// Top-rounded path (flat bottom) — matches kanban COLUMN_HEADER. The
|
|
702
|
-
// header sits flush against the body content below; rounded bottoms
|
|
703
|
-
// would leave a visible seam between header and the first task row.
|
|
704
|
-
colG
|
|
705
|
-
.append('path')
|
|
706
|
-
.attr('class', 'raci-column-header')
|
|
707
|
-
.attr(
|
|
708
|
-
'd',
|
|
709
|
-
topRoundedRectPath(cx, headerY, cw, sHeaderHeight, sColumnRadius)
|
|
710
|
-
)
|
|
711
|
-
.attr('fill', headerFill);
|
|
712
|
-
const cxText = roleX(i) + roleColW / 2;
|
|
713
|
-
colG
|
|
714
|
-
.append('text')
|
|
715
|
-
.attr('class', 'raci-column-label')
|
|
716
|
-
.attr('x', cxText)
|
|
717
|
-
.attr('y', headerY + sHeaderHeight / 2)
|
|
718
|
-
.attr('text-anchor', 'middle')
|
|
719
|
-
.attr('dominant-baseline', 'central')
|
|
720
|
-
.attr('font-size', sRoleHeaderFont)
|
|
721
|
-
.attr('font-weight', 600)
|
|
722
|
-
.attr('fill', palette.text)
|
|
723
|
-
.text(
|
|
724
|
-
truncateText(
|
|
725
|
-
parsed.roleDisplayNames[i] ?? '',
|
|
726
|
-
sRoleHeaderFont,
|
|
727
|
-
roleColW - 2 * sCellPad
|
|
728
|
-
)
|
|
729
|
-
);
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
// ── Body: phases + task rows ───────────────────────────────
|
|
733
|
-
|
|
734
|
-
const hasAnyDiagnostic = taskDiagnostics.size > 0;
|
|
735
|
-
|
|
736
|
-
// Chart-wide collapsed-summary decision. If ANY collapsed phase would
|
|
737
|
-
// need a column whose chip-slice width drops below the readable
|
|
738
|
-
// threshold, suppress summary chips on EVERY collapsed bar — so the
|
|
739
|
-
// user never wonders "why does Filing have data but Settlement
|
|
740
|
-
// doesn't?". Either every collapsed bar shows chips or none do.
|
|
741
|
-
const SUMMARY_MIN_SLICE_W = 22;
|
|
742
|
-
const summaryInsetForCheck = sColumnInset + 4;
|
|
743
|
-
const summaryColBodyWForCheck = roleColW - 2 * summaryInsetForCheck;
|
|
744
|
-
let chartCanShowSummaries = true;
|
|
745
|
-
for (const row of phasedRows) {
|
|
746
|
-
if (row.kind !== 'phase' || !row.collapsed) continue;
|
|
747
|
-
for (const roleId of parsed.roles) {
|
|
748
|
-
const used = new Set<RaciMarker>();
|
|
749
|
-
for (const t of row.phase.tasks) {
|
|
750
|
-
const a = t.roleAssignments.find((r) => r.id === roleId);
|
|
751
|
-
if (!a) continue;
|
|
752
|
-
for (const m of a.markers) used.add(m);
|
|
753
|
-
}
|
|
754
|
-
if (used.size === 0) continue;
|
|
755
|
-
const totalGap = SLICE_GAP * Math.max(0, used.size - 1);
|
|
756
|
-
const sliceW = (summaryColBodyWForCheck - totalGap) / used.size;
|
|
757
|
-
if (sliceW < SUMMARY_MIN_SLICE_W) {
|
|
758
|
-
chartCanShowSummaries = false;
|
|
759
|
-
break;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
if (!chartCanShowSummaries) break;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
for (let i = 0; i < phasedRows.length; i++) {
|
|
766
|
-
// In-bounds by loop guard (i < phasedRows.length); rowYs aligned with phasedRows.
|
|
767
|
-
const row = phasedRows[i]!;
|
|
768
|
-
const y = rowYs[i]!;
|
|
769
|
-
if (row.kind === 'phase') {
|
|
770
|
-
const phaseIdx = parsed.phases.indexOf(row.phase);
|
|
771
|
-
renderPhaseBar(
|
|
772
|
-
svg,
|
|
773
|
-
row.phase,
|
|
774
|
-
sHMargin,
|
|
775
|
-
y,
|
|
776
|
-
innerWidth,
|
|
777
|
-
palette,
|
|
778
|
-
row.collapsed,
|
|
779
|
-
autoAccent(Math.max(0, phaseIdx), palette),
|
|
780
|
-
parsed.roles,
|
|
781
|
-
roleX,
|
|
782
|
-
roleColW,
|
|
783
|
-
parsed.variant,
|
|
784
|
-
surfaceBg,
|
|
785
|
-
solid,
|
|
786
|
-
chartCanShowSummaries,
|
|
787
|
-
sPhaseHeight,
|
|
788
|
-
sPhaseFont,
|
|
789
|
-
sColumnInset,
|
|
790
|
-
sNodeRadius
|
|
791
|
-
);
|
|
792
|
-
} else {
|
|
793
|
-
renderTaskRow(
|
|
794
|
-
svg,
|
|
795
|
-
row.task,
|
|
796
|
-
parsed,
|
|
797
|
-
sHMargin,
|
|
798
|
-
y,
|
|
799
|
-
labelW,
|
|
800
|
-
roleX,
|
|
801
|
-
roleColW,
|
|
802
|
-
palette,
|
|
803
|
-
surfaceBg,
|
|
804
|
-
solid,
|
|
805
|
-
taskDiagnostics,
|
|
806
|
-
hasAnyDiagnostic,
|
|
807
|
-
taskRowContent.get(row.task.id),
|
|
808
|
-
onClickLine,
|
|
809
|
-
onMarkerDragStart,
|
|
810
|
-
sLabelFont,
|
|
811
|
-
sDescFont,
|
|
812
|
-
sCellPad,
|
|
813
|
-
sRowHeight,
|
|
814
|
-
sColumnInset,
|
|
815
|
-
sNodeRadius,
|
|
816
|
-
sNodeStrokeWidth,
|
|
817
|
-
sMarkerFont,
|
|
818
|
-
sLegendLabelFont,
|
|
819
|
-
sStackTopGap,
|
|
820
|
-
sViolationLineHeight
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// ── Empty-state hint when all cells are blank ──────────────
|
|
826
|
-
const anyMarker = tasksAll.some((t) =>
|
|
827
|
-
t.roleAssignments.some((a) => a.markers.length > 0)
|
|
828
|
-
);
|
|
829
|
-
if (!anyMarker) {
|
|
830
|
-
svg
|
|
831
|
-
.append('text')
|
|
832
|
-
.attr('x', width / 2)
|
|
833
|
-
.attr('y', totalHeight - sVMargin - 2)
|
|
834
|
-
.attr('text-anchor', 'middle')
|
|
835
|
-
.attr('font-size', 12)
|
|
836
|
-
.attr('fill', palette.textMuted)
|
|
837
|
-
.text(
|
|
838
|
-
'Drag a marker from the legend onto any cell. Drag a marker out to remove.'
|
|
839
|
-
);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// ── Column hover wiring ──────────────────────────────────────
|
|
843
|
-
//
|
|
844
|
-
// Toggle `data-hover-col` on the SVG and tag the matching column +
|
|
845
|
-
// its cells with `raci-col-active` whenever the pointer is over any
|
|
846
|
-
// element with a `data-role-id` ancestor. The app's sync CSS uses
|
|
847
|
-
// those hooks to dim everything else, so a single role's row of
|
|
848
|
-
// cells pops on hover. Pure-CSS doesn't reach because cells are not
|
|
849
|
-
// descendants of the column group.
|
|
850
|
-
setupColumnHover(svg);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
function setupColumnHover(
|
|
854
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>
|
|
855
|
-
): void {
|
|
856
|
-
const node = svg.node();
|
|
857
|
-
if (!node) return;
|
|
858
|
-
let current: string | null = null;
|
|
859
|
-
|
|
860
|
-
const findRole = (target: EventTarget | null): string | null => {
|
|
861
|
-
let el = target as Element | null;
|
|
862
|
-
while (el && el !== node) {
|
|
863
|
-
const r = el.getAttribute && el.getAttribute('data-role-id');
|
|
864
|
-
if (r) return r;
|
|
865
|
-
el = el.parentElement;
|
|
866
|
-
}
|
|
867
|
-
return null;
|
|
868
|
-
};
|
|
869
|
-
|
|
870
|
-
const set = (role: string | null): void => {
|
|
871
|
-
if (role === current) return;
|
|
872
|
-
if (current) {
|
|
873
|
-
svg
|
|
874
|
-
.selectAll(
|
|
875
|
-
`.raci-cell[data-role-id="${current}"], .raci-column[data-role-id="${current}"]`
|
|
876
|
-
)
|
|
877
|
-
.classed('raci-col-active', false);
|
|
878
|
-
}
|
|
879
|
-
current = role;
|
|
880
|
-
if (role) {
|
|
881
|
-
svg.attr('data-hover-col', role);
|
|
882
|
-
svg
|
|
883
|
-
.selectAll(
|
|
884
|
-
`.raci-cell[data-role-id="${role}"], .raci-column[data-role-id="${role}"]`
|
|
885
|
-
)
|
|
886
|
-
.classed('raci-col-active', true);
|
|
887
|
-
} else {
|
|
888
|
-
svg.attr('data-hover-col', null);
|
|
889
|
-
}
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
svg.on('mouseover', (event: MouseEvent) => set(findRole(event.target)));
|
|
893
|
-
svg.on('mouseleave', () => set(null));
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// ── Legend / palette ────────────────────────────────────────
|
|
897
|
-
|
|
898
|
-
function renderLegend(
|
|
899
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
900
|
-
markers: ReadonlyArray<RaciMarker>,
|
|
901
|
-
variant: RaciVariant,
|
|
902
|
-
x: number,
|
|
903
|
-
y: number,
|
|
904
|
-
mode: 'full' | 'letters',
|
|
905
|
-
chipW: number,
|
|
906
|
-
palette: PaletteColors,
|
|
907
|
-
surfaceBg: string,
|
|
908
|
-
solid: boolean,
|
|
909
|
-
_onMarkerDragStart:
|
|
910
|
-
| ((source: RaciDragSource, e: PointerEvent) => void)
|
|
911
|
-
| undefined,
|
|
912
|
-
sLegendChipGap: number,
|
|
913
|
-
sLegendHeight: number,
|
|
914
|
-
sNodeRadius: number,
|
|
915
|
-
sNodeStrokeWidth: number,
|
|
916
|
-
sLegendLetterFont: number,
|
|
917
|
-
sLegendLabelFont: number
|
|
918
|
-
): void {
|
|
919
|
-
if (markers.length === 0) return;
|
|
920
|
-
const labels = MARKER_LABELS[variant];
|
|
921
|
-
|
|
922
|
-
const legendG = svg
|
|
923
|
-
.append('g')
|
|
924
|
-
.attr('class', 'raci-legend')
|
|
925
|
-
.attr('aria-label', `${variant.toUpperCase()} marker legend`);
|
|
926
|
-
|
|
927
|
-
markers.forEach((marker, i) => {
|
|
928
|
-
const cx = x + i * (chipW + sLegendChipGap);
|
|
929
|
-
const rawColor = markerColor(marker, palette);
|
|
930
|
-
const labelText = labels[marker]!;
|
|
931
|
-
|
|
932
|
-
const chipG = legendG
|
|
933
|
-
.append('g')
|
|
934
|
-
.attr('class', 'raci-legend-chip')
|
|
935
|
-
.attr('data-marker', marker)
|
|
936
|
-
.style('cursor', 'grab');
|
|
937
|
-
|
|
938
|
-
if (mode === 'letters') {
|
|
939
|
-
// Compact: a single colored pill with just the marker letter.
|
|
940
|
-
// The full label moves to a native tooltip so hover still teaches it.
|
|
941
|
-
const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
|
|
942
|
-
const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
|
|
943
|
-
chipG
|
|
944
|
-
.append('rect')
|
|
945
|
-
.attr('x', cx)
|
|
946
|
-
.attr('y', y)
|
|
947
|
-
.attr('width', chipW)
|
|
948
|
-
.attr('height', sLegendHeight)
|
|
949
|
-
.attr('rx', sNodeRadius)
|
|
950
|
-
.attr('fill', fill)
|
|
951
|
-
.attr('stroke', stroke)
|
|
952
|
-
.attr('stroke-width', sNodeStrokeWidth);
|
|
953
|
-
chipG
|
|
954
|
-
.append('text')
|
|
955
|
-
.attr('x', cx + chipW / 2)
|
|
956
|
-
.attr('y', y + sLegendHeight / 2)
|
|
957
|
-
.attr('text-anchor', 'middle')
|
|
958
|
-
.attr('dominant-baseline', 'central')
|
|
959
|
-
.attr('font-size', sLegendLetterFont)
|
|
960
|
-
.attr('font-weight', 700)
|
|
961
|
-
.attr(
|
|
962
|
-
'fill',
|
|
963
|
-
solid
|
|
964
|
-
? contrastText(
|
|
965
|
-
fill,
|
|
966
|
-
palette.textOnFillLight,
|
|
967
|
-
palette.textOnFillDark
|
|
968
|
-
)
|
|
969
|
-
: palette.text
|
|
970
|
-
)
|
|
971
|
-
.text(marker);
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
// Full mode: bordered chip with a letter slab on the left and label text.
|
|
976
|
-
const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
|
|
977
|
-
const stroke = mix(rawColor, surfaceBg, 70);
|
|
978
|
-
|
|
979
|
-
chipG
|
|
980
|
-
.append('rect')
|
|
981
|
-
.attr('x', cx)
|
|
982
|
-
.attr('y', y)
|
|
983
|
-
.attr('width', chipW)
|
|
984
|
-
.attr('height', sLegendHeight)
|
|
985
|
-
.attr('rx', sNodeRadius)
|
|
986
|
-
.attr('fill', fill)
|
|
987
|
-
.attr('stroke', stroke)
|
|
988
|
-
.attr('stroke-width', sNodeStrokeWidth);
|
|
989
|
-
|
|
990
|
-
// Letter slab on the left — tinted (not solid) so it sits in
|
|
991
|
-
// the same visual register as the diagram's marker cells.
|
|
992
|
-
const slabPad = 4;
|
|
993
|
-
const slabW = 24;
|
|
994
|
-
const slabFill = solid
|
|
995
|
-
? mix(rawColor, surfaceBg, 70)
|
|
996
|
-
: mix(rawColor, surfaceBg, 55);
|
|
997
|
-
chipG
|
|
998
|
-
.append('rect')
|
|
999
|
-
.attr('x', cx + slabPad)
|
|
1000
|
-
.attr('y', y + slabPad)
|
|
1001
|
-
.attr('width', slabW)
|
|
1002
|
-
.attr('height', sLegendHeight - 2 * slabPad)
|
|
1003
|
-
.attr('rx', 4)
|
|
1004
|
-
.attr('fill', slabFill);
|
|
1005
|
-
|
|
1006
|
-
chipG
|
|
1007
|
-
.append('text')
|
|
1008
|
-
.attr('x', cx + slabPad + slabW / 2)
|
|
1009
|
-
.attr('y', y + sLegendHeight / 2)
|
|
1010
|
-
.attr('text-anchor', 'middle')
|
|
1011
|
-
.attr('dominant-baseline', 'central')
|
|
1012
|
-
.attr('font-size', sLegendLetterFont)
|
|
1013
|
-
.attr('font-weight', 700)
|
|
1014
|
-
.attr(
|
|
1015
|
-
'fill',
|
|
1016
|
-
solid
|
|
1017
|
-
? contrastText(
|
|
1018
|
-
slabFill,
|
|
1019
|
-
palette.textOnFillLight,
|
|
1020
|
-
palette.textOnFillDark
|
|
1021
|
-
)
|
|
1022
|
-
: palette.text
|
|
1023
|
-
)
|
|
1024
|
-
.text(marker);
|
|
1025
|
-
|
|
1026
|
-
// Full label to the right of the slab
|
|
1027
|
-
chipG
|
|
1028
|
-
.append('text')
|
|
1029
|
-
.attr('x', cx + slabPad + slabW + 8)
|
|
1030
|
-
.attr('y', y + sLegendHeight / 2)
|
|
1031
|
-
.attr('dominant-baseline', 'central')
|
|
1032
|
-
.attr('font-size', sLegendLabelFont)
|
|
1033
|
-
.attr('font-weight', 600)
|
|
1034
|
-
.attr('fill', palette.text)
|
|
1035
|
-
.text(
|
|
1036
|
-
truncateText(
|
|
1037
|
-
labelText,
|
|
1038
|
-
sLegendLabelFont,
|
|
1039
|
-
chipW - slabW - slabPad * 2 - 12
|
|
1040
|
-
)
|
|
1041
|
-
);
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// Drop-in export wrapper used by `d3.ts` renderForExport dispatch.
|
|
1046
|
-
export function renderRaciForExport(
|
|
1047
|
-
container: HTMLDivElement,
|
|
1048
|
-
parsed: ParsedRaci,
|
|
1049
|
-
palette: PaletteColors,
|
|
1050
|
-
isDark: boolean,
|
|
1051
|
-
exportDims?: D3ExportDimensions
|
|
1052
|
-
): void {
|
|
1053
|
-
renderRaci(container, parsed, palette, isDark, {}, exportDims);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
// ── Phase bar ────────────────────────────────────────────────
|
|
1057
|
-
|
|
1058
|
-
function renderPhaseBar(
|
|
1059
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1060
|
-
phase: RaciPhase,
|
|
1061
|
-
x: number,
|
|
1062
|
-
y: number,
|
|
1063
|
-
width: number,
|
|
1064
|
-
palette: PaletteColors,
|
|
1065
|
-
collapsed: boolean,
|
|
1066
|
-
autoColor: string,
|
|
1067
|
-
// Role columns + variant. Supplied so a collapsed phase bar can paint
|
|
1068
|
-
// a per-column marker-union summary in each role column's slot.
|
|
1069
|
-
roles: ReadonlyArray<string>,
|
|
1070
|
-
roleX: (i: number) => number,
|
|
1071
|
-
roleColW: number,
|
|
1072
|
-
variant: RaciVariant,
|
|
1073
|
-
surfaceBg: string,
|
|
1074
|
-
solid: boolean,
|
|
1075
|
-
showSummary: boolean,
|
|
1076
|
-
sPhaseHeight: number,
|
|
1077
|
-
sPhaseFont: number,
|
|
1078
|
-
sColumnInset: number,
|
|
1079
|
-
sNodeRadius: number
|
|
1080
|
-
): void {
|
|
1081
|
-
const phaseG = svg
|
|
1082
|
-
.append('g')
|
|
1083
|
-
.attr('class', 'raci-phase')
|
|
1084
|
-
.attr('data-section', `phase-${phase.id}`)
|
|
1085
|
-
.attr('data-section-toggle', '')
|
|
1086
|
-
.attr('data-line-number', String(phase.lineNumber))
|
|
1087
|
-
.attr('data-phase-id', phase.id)
|
|
1088
|
-
.attr('data-collapsed', collapsed ? '1' : '0')
|
|
1089
|
-
.style('cursor', 'pointer');
|
|
1090
|
-
|
|
1091
|
-
// Phase color rules: explicit `[Voyage] | color: blue` wins; absent
|
|
1092
|
-
// that, fall back to the marker-safe auto-accent so each phase bar
|
|
1093
|
-
// has its own identity.
|
|
1094
|
-
const phaseFill = mix(phase.color ?? autoColor, palette.bg, 30);
|
|
1095
|
-
phaseG
|
|
1096
|
-
.append('rect')
|
|
1097
|
-
.attr('x', x)
|
|
1098
|
-
.attr('y', y + 4)
|
|
1099
|
-
.attr('width', width)
|
|
1100
|
-
.attr('height', sPhaseHeight - 8)
|
|
1101
|
-
.attr('fill', phaseFill)
|
|
1102
|
-
.attr('rx', 4);
|
|
1103
|
-
|
|
1104
|
-
const chevX = x + 12;
|
|
1105
|
-
const chevY = y + sPhaseHeight / 2;
|
|
1106
|
-
phaseG
|
|
1107
|
-
.append('text')
|
|
1108
|
-
.attr('x', chevX)
|
|
1109
|
-
.attr('y', chevY)
|
|
1110
|
-
.attr('text-anchor', 'middle')
|
|
1111
|
-
.attr('dominant-baseline', 'central')
|
|
1112
|
-
.attr('font-family', FONT_FAMILY)
|
|
1113
|
-
.attr('font-size', 9)
|
|
1114
|
-
.attr('fill', palette.text)
|
|
1115
|
-
.attr('opacity', 0.6)
|
|
1116
|
-
.text(collapsed ? '▶' : '▼');
|
|
1117
|
-
|
|
1118
|
-
phaseG
|
|
1119
|
-
.append('text')
|
|
1120
|
-
.attr('x', x + 26)
|
|
1121
|
-
.attr('y', y + (sPhaseHeight - 4) / 2 + 2)
|
|
1122
|
-
.attr('dominant-baseline', 'central')
|
|
1123
|
-
.attr('font-size', sPhaseFont)
|
|
1124
|
-
.attr('font-weight', 600)
|
|
1125
|
-
.attr('fill', palette.text)
|
|
1126
|
-
.text(phase.displayName);
|
|
1127
|
-
|
|
1128
|
-
if (collapsed) {
|
|
1129
|
-
// Show the hidden-task count to the right of the label so users
|
|
1130
|
-
// can see at a glance what's in the rolled-up phase.
|
|
1131
|
-
const taskCount = phase.tasks.length;
|
|
1132
|
-
if (taskCount > 0) {
|
|
1133
|
-
const labelTextWidth = measureText(phase.displayName, sPhaseFont);
|
|
1134
|
-
phaseG
|
|
1135
|
-
.append('text')
|
|
1136
|
-
.attr('x', x + 26 + labelTextWidth + 10)
|
|
1137
|
-
.attr('y', y + (sPhaseHeight - 4) / 2 + 2)
|
|
1138
|
-
.attr('dominant-baseline', 'central')
|
|
1139
|
-
.attr('font-size', sPhaseFont - 1)
|
|
1140
|
-
.attr('font-weight', 500)
|
|
1141
|
-
.attr('fill', palette.textMuted)
|
|
1142
|
-
.text(`${taskCount} ${taskCount === 1 ? 'task' : 'tasks'}`);
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// Chart-wide gate: if any collapsed phase on this chart would have
|
|
1146
|
-
// a column too narrow to render readable chips, suppress on every
|
|
1147
|
-
// collapsed bar — coverage stays consistent.
|
|
1148
|
-
if (!showSummary) return;
|
|
1149
|
-
|
|
1150
|
-
// Per-column marker-union summary. For each role column, walk every
|
|
1151
|
-
// task in this phase, union the markers used in that column, and
|
|
1152
|
-
// paint chips that match the regular cell geometry — same column
|
|
1153
|
-
// inset, same SLICE_GAP, so chips fill the column body and read
|
|
1154
|
-
// as a compressed cell rather than a separate primitive.
|
|
1155
|
-
const alphabet = VARIANTS[variant].alphabet;
|
|
1156
|
-
const summaryInset = sColumnInset + 4;
|
|
1157
|
-
const summaryColBodyW = roleColW - 2 * summaryInset;
|
|
1158
|
-
const SUMMARY_CHIP_H = 24;
|
|
1159
|
-
const SUMMARY_LETTER_FONT = 13;
|
|
1160
|
-
const summaryY = y + (sPhaseHeight - SUMMARY_CHIP_H) / 2;
|
|
1161
|
-
|
|
1162
|
-
roles.forEach((roleId, colIdx) => {
|
|
1163
|
-
const used = new Set<RaciMarker>();
|
|
1164
|
-
for (const t of phase.tasks) {
|
|
1165
|
-
const a = t.roleAssignments.find((r) => r.id === roleId);
|
|
1166
|
-
if (!a) continue;
|
|
1167
|
-
for (const m of a.markers) used.add(m);
|
|
1168
|
-
}
|
|
1169
|
-
if (used.size === 0) return;
|
|
1170
|
-
const ordered = sortByAlphabet([...used], alphabet);
|
|
1171
|
-
const totalGap = SLICE_GAP * Math.max(0, ordered.length - 1);
|
|
1172
|
-
const sliceW = (summaryColBodyW - totalGap) / ordered.length;
|
|
1173
|
-
const startX = roleX(colIdx) + summaryInset;
|
|
1174
|
-
|
|
1175
|
-
ordered.forEach((marker, i) => {
|
|
1176
|
-
const cx = startX + i * (sliceW + SLICE_GAP);
|
|
1177
|
-
const rawColor = markerColor(marker, palette);
|
|
1178
|
-
// Match the cell-marker / legend-chip fill+stroke colors and
|
|
1179
|
-
// corner radius. Stroke width is a touch thinner than
|
|
1180
|
-
// NODE_STROKE_WIDTH because at the smaller summary scale the
|
|
1181
|
-
// full 1.5 reads as too heavy.
|
|
1182
|
-
const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
|
|
1183
|
-
const stroke = solid ? mix(rawColor, surfaceBg, 70) : rawColor;
|
|
1184
|
-
const chipG = phaseG.append('g').attr('class', 'raci-phase-summary');
|
|
1185
|
-
chipG
|
|
1186
|
-
.append('rect')
|
|
1187
|
-
.attr('x', cx)
|
|
1188
|
-
.attr('y', summaryY)
|
|
1189
|
-
.attr('width', sliceW)
|
|
1190
|
-
.attr('height', SUMMARY_CHIP_H)
|
|
1191
|
-
.attr('rx', sNodeRadius)
|
|
1192
|
-
.attr('fill', fill)
|
|
1193
|
-
.attr('stroke', stroke)
|
|
1194
|
-
.attr('stroke-width', 1.25);
|
|
1195
|
-
chipG
|
|
1196
|
-
.append('text')
|
|
1197
|
-
.attr('x', cx + sliceW / 2)
|
|
1198
|
-
.attr('y', summaryY + SUMMARY_CHIP_H / 2)
|
|
1199
|
-
.attr('text-anchor', 'middle')
|
|
1200
|
-
.attr('dominant-baseline', 'central')
|
|
1201
|
-
.attr('font-size', SUMMARY_LETTER_FONT)
|
|
1202
|
-
.attr('font-weight', 700)
|
|
1203
|
-
.attr(
|
|
1204
|
-
'fill',
|
|
1205
|
-
solid
|
|
1206
|
-
? contrastText(
|
|
1207
|
-
fill,
|
|
1208
|
-
palette.textOnFillLight,
|
|
1209
|
-
palette.textOnFillDark
|
|
1210
|
-
)
|
|
1211
|
-
: palette.text
|
|
1212
|
-
)
|
|
1213
|
-
.text(marker);
|
|
1214
|
-
});
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// ── Task row ─────────────────────────────────────────────────
|
|
1220
|
-
|
|
1221
|
-
/** Gap (in px) between adjacent marker slices in a multi-marker cell. */
|
|
1222
|
-
const SLICE_GAP = 4;
|
|
1223
|
-
|
|
1224
|
-
interface TaskDiagnosticBucket {
|
|
1225
|
-
errors: DiagnosticEntry[];
|
|
1226
|
-
warnings: DiagnosticEntry[];
|
|
1227
|
-
firstLine: number;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
function renderTaskRow(
|
|
1231
|
-
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1232
|
-
task: RaciTask,
|
|
1233
|
-
parsed: ParsedRaci,
|
|
1234
|
-
x: number,
|
|
1235
|
-
y: number,
|
|
1236
|
-
labelW: number,
|
|
1237
|
-
roleX: (i: number) => number,
|
|
1238
|
-
roleColW: number,
|
|
1239
|
-
palette: PaletteColors,
|
|
1240
|
-
surfaceBg: string,
|
|
1241
|
-
solid: boolean,
|
|
1242
|
-
taskDiagnostics: Map<string, TaskDiagnosticBucket> | null,
|
|
1243
|
-
_hasAnyDiagnostic: boolean,
|
|
1244
|
-
rowContent: RowContent | undefined,
|
|
1245
|
-
onClickLine: ((lineNumber: number) => void) | undefined,
|
|
1246
|
-
_onMarkerDragStart:
|
|
1247
|
-
| ((source: RaciDragSource, e: PointerEvent) => void)
|
|
1248
|
-
| undefined,
|
|
1249
|
-
sLabelFont: number,
|
|
1250
|
-
sDescFont: number,
|
|
1251
|
-
sCellPad: number,
|
|
1252
|
-
sRowHeight: number,
|
|
1253
|
-
sColumnInset: number,
|
|
1254
|
-
sNodeRadius: number,
|
|
1255
|
-
sNodeStrokeWidth: number,
|
|
1256
|
-
sMarkerFont: number,
|
|
1257
|
-
sLegendLabelFont: number,
|
|
1258
|
-
sStackTopGap: number,
|
|
1259
|
-
sViolationLineHeight: number
|
|
1260
|
-
): void {
|
|
1261
|
-
const rowG = svg
|
|
1262
|
-
.append('g')
|
|
1263
|
-
.attr('class', 'raci-task-row')
|
|
1264
|
-
.attr('data-task-id', task.id)
|
|
1265
|
-
.attr('data-line-number', String(task.lineNumber));
|
|
1266
|
-
|
|
1267
|
-
// No gutter chip — violations now stack on their own lines below the
|
|
1268
|
-
// task label, each colored by severity (red = error, amber = warning).
|
|
1269
|
-
const labelX = x + 8;
|
|
1270
|
-
const labelMaxW = labelW - 16;
|
|
1271
|
-
const content =
|
|
1272
|
-
rowContent ??
|
|
1273
|
-
prepareRowContent(
|
|
1274
|
-
task,
|
|
1275
|
-
taskDiagnostics?.get(task.id),
|
|
1276
|
-
labelMaxW,
|
|
1277
|
-
sLabelFont,
|
|
1278
|
-
sDescFont,
|
|
1279
|
-
sRowHeight,
|
|
1280
|
-
sStackTopGap,
|
|
1281
|
-
sViolationLineHeight
|
|
1282
|
-
);
|
|
1283
|
-
const { nameLines, descLines, violations, rowHeight } = content;
|
|
1284
|
-
|
|
1285
|
-
// Group everything that lives in the label column under a single
|
|
1286
|
-
// wrapper so the sync adapter's hover-dim CSS can target the wrapper
|
|
1287
|
-
// and fire whether the user is over the rect, the name text, the
|
|
1288
|
-
// description, or a violation. Cells stay siblings of this group on
|
|
1289
|
-
// the rowG so they DON'T trigger the row-hover dim.
|
|
1290
|
-
const labelG = rowG.append('g').attr('class', 'raci-task-label-area');
|
|
1291
|
-
|
|
1292
|
-
// Transparent hit-zone over the task-label column, sized to match
|
|
1293
|
-
// the wrapped row height so any pointer drift inside the label
|
|
1294
|
-
// column is captured by the wrapper :hover.
|
|
1295
|
-
labelG
|
|
1296
|
-
.append('rect')
|
|
1297
|
-
.attr('class', 'raci-task-label-zone')
|
|
1298
|
-
.attr('x', x)
|
|
1299
|
-
.attr('y', y)
|
|
1300
|
-
.attr('width', labelW)
|
|
1301
|
-
.attr('height', rowHeight)
|
|
1302
|
-
.attr('fill', 'transparent')
|
|
1303
|
-
.attr('pointer-events', 'all');
|
|
1304
|
-
|
|
1305
|
-
// Task name — wrapped to as many lines as fit without truncating.
|
|
1306
|
-
const nameTopY = y + sLabelFont / 2 + 6;
|
|
1307
|
-
const nameEl = labelG
|
|
1308
|
-
.append('text')
|
|
1309
|
-
.attr('class', 'raci-task-label')
|
|
1310
|
-
.attr('x', labelX)
|
|
1311
|
-
.attr('y', nameTopY)
|
|
1312
|
-
.attr('dominant-baseline', 'central')
|
|
1313
|
-
.attr('font-size', sLabelFont)
|
|
1314
|
-
.attr('fill', palette.text);
|
|
1315
|
-
nameLines.forEach((line, i) => {
|
|
1316
|
-
const tspan = nameEl.append('tspan').attr('x', labelX).text(line);
|
|
1317
|
-
if (i > 0) tspan.attr('dy', NAME_LINE_HEIGHT);
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
let stackY =
|
|
1321
|
-
nameTopY +
|
|
1322
|
-
Math.max(0, nameLines.length - 1) * NAME_LINE_HEIGHT +
|
|
1323
|
-
sLabelFont / 2 +
|
|
1324
|
-
4 +
|
|
1325
|
-
sStackTopGap;
|
|
1326
|
-
|
|
1327
|
-
if (descLines.length > 0) {
|
|
1328
|
-
const descEl = labelG
|
|
1329
|
-
.append('text')
|
|
1330
|
-
.attr('class', 'raci-task-description')
|
|
1331
|
-
.attr('x', labelX)
|
|
1332
|
-
.attr('y', stackY)
|
|
1333
|
-
.attr('dominant-baseline', 'central')
|
|
1334
|
-
.attr('font-size', sDescFont)
|
|
1335
|
-
.attr('fill', palette.textMuted);
|
|
1336
|
-
descLines.forEach((line, i) => {
|
|
1337
|
-
const tspan = descEl.append('tspan').attr('x', labelX).text(line);
|
|
1338
|
-
if (i > 0) tspan.attr('dy', DESC_LINE_HEIGHT);
|
|
1339
|
-
});
|
|
1340
|
-
stackY += descLines.length * DESC_LINE_HEIGHT;
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
for (const v of violations) {
|
|
1344
|
-
const lineColor =
|
|
1345
|
-
v.severity === 'error' ? palette.colors.red : palette.colors.orange;
|
|
1346
|
-
const lineG = labelG
|
|
1347
|
-
.append('g')
|
|
1348
|
-
.attr('class', 'raci-violation-line')
|
|
1349
|
-
.attr('data-severity', v.severity)
|
|
1350
|
-
.style('cursor', onClickLine ? 'pointer' : 'default');
|
|
1351
|
-
|
|
1352
|
-
const violationFont = sLabelFont - 2;
|
|
1353
|
-
const textEl = lineG
|
|
1354
|
-
.append('text')
|
|
1355
|
-
.attr('x', labelX)
|
|
1356
|
-
.attr('y', stackY)
|
|
1357
|
-
.attr('dominant-baseline', 'central')
|
|
1358
|
-
.attr('font-size', violationFont)
|
|
1359
|
-
.attr('fill', lineColor);
|
|
1360
|
-
v.lines.forEach((line, i) => {
|
|
1361
|
-
// Render bold for any quoted spans within this wrapped line.
|
|
1362
|
-
const segs = parseQuotedSegments(line);
|
|
1363
|
-
segs.forEach((seg, segIdx) => {
|
|
1364
|
-
const tspan = textEl.append('tspan').text(seg.text);
|
|
1365
|
-
if (i > 0 && segIdx === 0)
|
|
1366
|
-
tspan.attr('x', labelX).attr('dy', sViolationLineHeight);
|
|
1367
|
-
if (seg.bold) tspan.attr('font-weight', 700);
|
|
1368
|
-
});
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
if (onClickLine) {
|
|
1372
|
-
const targetLine = v.sourceLine;
|
|
1373
|
-
lineG.on('click', (e: Event) => {
|
|
1374
|
-
e.stopPropagation();
|
|
1375
|
-
onClickLine(targetLine);
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
stackY += v.lines.length * sViolationLineHeight;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
// Build a quick lookup: roleId → markers[]
|
|
1382
|
-
const cellByRole = new Map<string, readonly RaciMarker[]>();
|
|
1383
|
-
for (const a of task.roleAssignments) {
|
|
1384
|
-
cellByRole.set(a.id, a.markers);
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
const alphabet = VARIANTS[parsed.variant].alphabet;
|
|
1388
|
-
|
|
1389
|
-
// For each role column, paint a cell
|
|
1390
|
-
parsed.roles.forEach((roleId, idx) => {
|
|
1391
|
-
// Cells sit inside the column body. The column body uses COLUMN_INSET
|
|
1392
|
-
// on each side; add 4 px more so the cell doesn't kiss the column edge.
|
|
1393
|
-
const cellInset = sColumnInset + 4;
|
|
1394
|
-
const cx = roleX(idx) + cellInset;
|
|
1395
|
-
const cy = y + sCellPad;
|
|
1396
|
-
const cw = roleColW - 2 * cellInset;
|
|
1397
|
-
const ch = sRowHeight - 2 * sCellPad;
|
|
1398
|
-
const markersSource = cellByRole.get(roleId) ?? [];
|
|
1399
|
-
// Display markers in canonical alphabet order — RACI cells always
|
|
1400
|
-
// read R A C I left-to-right regardless of source order. Source
|
|
1401
|
-
// ordering still drives the round-trip (mutations preserve it).
|
|
1402
|
-
const markers = sortByAlphabet(markersSource, alphabet);
|
|
1403
|
-
|
|
1404
|
-
const assignment = task.roleAssignments.find((a) => a.id === roleId);
|
|
1405
|
-
const cellG = rowG
|
|
1406
|
-
.append('g')
|
|
1407
|
-
.attr('class', 'raci-cell')
|
|
1408
|
-
.attr('data-task-id', task.id)
|
|
1409
|
-
.attr('data-role-id', roleId);
|
|
1410
|
-
// Cells with an explicit role assignment carry the assignment's line
|
|
1411
|
-
// number so click → cursor jumps to that exact source line. Empty
|
|
1412
|
-
// cells leave the attribute off; walk-up resolution falls back to
|
|
1413
|
-
// the parent task row.
|
|
1414
|
-
if (assignment) {
|
|
1415
|
-
cellG.attr('data-line-number', String(assignment.lineNumber));
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// Full-bounds transparent hit layer covering the column-body area
|
|
1419
|
-
// (NOT the inter-column gap). Without this, empty cells only hit-test
|
|
1420
|
-
// on the marker stroke — drag-and-drop misses the cell body.
|
|
1421
|
-
cellG
|
|
1422
|
-
.append('rect')
|
|
1423
|
-
.attr('class', 'raci-cell-hit')
|
|
1424
|
-
.attr('x', roleX(idx) + sColumnInset)
|
|
1425
|
-
.attr('y', y)
|
|
1426
|
-
.attr('width', roleColW - 2 * sColumnInset)
|
|
1427
|
-
.attr('height', sRowHeight)
|
|
1428
|
-
.attr('fill', 'transparent')
|
|
1429
|
-
.attr('pointer-events', 'all');
|
|
1430
|
-
|
|
1431
|
-
// Build a horizontally-segmented fill: each marker gets its own
|
|
1432
|
-
// tinted slice in canonical order with a small gap between slices.
|
|
1433
|
-
// Each slice is its own draggable handle (`<g>` with data-marker).
|
|
1434
|
-
//
|
|
1435
|
-
// Border styling matches the codebase's node convention
|
|
1436
|
-
// (flowchart NODE_STROKE_WIDTH = 1.5, kanban CARD_STROKE_WIDTH = 1.5,
|
|
1437
|
-
// c4 NODE_STROKE_WIDTH = 1.5; corner radius 6 = kanban CARD_RADIUS).
|
|
1438
|
-
const totalGap = SLICE_GAP * (markers.length - 1);
|
|
1439
|
-
const sliceW = markers.length > 0 ? (cw - totalGap) / markers.length : 0;
|
|
1440
|
-
const variantLabels = MARKER_LABELS[parsed.variant];
|
|
1441
|
-
markers.forEach((m, i) => {
|
|
1442
|
-
const rawColor = markerColor(m, palette);
|
|
1443
|
-
const fill = solid ? rawColor : mix(rawColor, surfaceBg, TINT_PCT);
|
|
1444
|
-
const stroke = rawColor;
|
|
1445
|
-
const sliceX = cx + i * (sliceW + SLICE_GAP);
|
|
1446
|
-
|
|
1447
|
-
const sliceG = cellG
|
|
1448
|
-
.append('g')
|
|
1449
|
-
.attr('class', 'raci-marker-slice')
|
|
1450
|
-
.attr('data-marker', m)
|
|
1451
|
-
.style('cursor', 'grab');
|
|
1452
|
-
|
|
1453
|
-
sliceG
|
|
1454
|
-
.append('rect')
|
|
1455
|
-
.attr('x', sliceX)
|
|
1456
|
-
.attr('y', cy)
|
|
1457
|
-
.attr('width', sliceW)
|
|
1458
|
-
.attr('height', ch)
|
|
1459
|
-
.attr('rx', sNodeRadius)
|
|
1460
|
-
.attr('fill', fill)
|
|
1461
|
-
.attr('stroke', stroke)
|
|
1462
|
-
.attr('stroke-width', sNodeStrokeWidth);
|
|
1463
|
-
|
|
1464
|
-
// When the slice is wide enough, spell out the full marker label
|
|
1465
|
-
// ("Responsible") instead of the bare letter. Same primitive as the
|
|
1466
|
-
// legend chip, so cells and legend read as the same UI element.
|
|
1467
|
-
const fullLabel = variantLabels[m] ?? m;
|
|
1468
|
-
const labelPx = measureText(fullLabel, sLegendLabelFont);
|
|
1469
|
-
const showFullLabel = labelPx + 16 <= sliceW;
|
|
1470
|
-
const textContent = showFullLabel ? fullLabel : m;
|
|
1471
|
-
const textFont = showFullLabel ? sLegendLabelFont : sMarkerFont;
|
|
1472
|
-
|
|
1473
|
-
sliceG
|
|
1474
|
-
.append('text')
|
|
1475
|
-
.attr('x', sliceX + sliceW / 2)
|
|
1476
|
-
.attr('y', cy + ch / 2)
|
|
1477
|
-
.attr('text-anchor', 'middle')
|
|
1478
|
-
.attr('dominant-baseline', 'central')
|
|
1479
|
-
.attr('font-size', textFont)
|
|
1480
|
-
.attr('font-weight', MARKER_FONT_WEIGHT)
|
|
1481
|
-
.attr(
|
|
1482
|
-
'fill',
|
|
1483
|
-
solid
|
|
1484
|
-
? contrastText(
|
|
1485
|
-
fill,
|
|
1486
|
-
palette.textOnFillLight,
|
|
1487
|
-
palette.textOnFillDark
|
|
1488
|
-
)
|
|
1489
|
-
: palette.text
|
|
1490
|
-
)
|
|
1491
|
-
.text(textContent);
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
// Drop-preview rect: sized + rounded to match where a dropped marker
|
|
1495
|
-
// would land. Appended LAST so it paints on top of any marker slices —
|
|
1496
|
-
// otherwise the slice rects in a filled cell fully cover the highlight
|
|
1497
|
-
// and the user gets no visual feedback when dragging onto a cell that
|
|
1498
|
-
// already has assignments. `pointer-events: none` keeps it clear of
|
|
1499
|
-
// hit-testing. Hidden by default; CSS paints it when the cell carries
|
|
1500
|
-
// [data-drop-active="1"].
|
|
1501
|
-
cellG
|
|
1502
|
-
.append('rect')
|
|
1503
|
-
.attr('class', 'raci-cell-drop-preview')
|
|
1504
|
-
.attr('x', cx)
|
|
1505
|
-
.attr('y', cy)
|
|
1506
|
-
.attr('width', cw)
|
|
1507
|
-
.attr('height', ch)
|
|
1508
|
-
.attr('rx', sNodeRadius)
|
|
1509
|
-
.attr('fill', 'transparent')
|
|
1510
|
-
.attr('stroke', 'transparent')
|
|
1511
|
-
.attr('pointer-events', 'none');
|
|
1512
|
-
|
|
1513
|
-
if (onClickLine && assignment) {
|
|
1514
|
-
cellG.on('click', () => onClickLine(assignment.lineNumber));
|
|
1515
|
-
}
|
|
1516
|
-
});
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// ── Helpers ──────────────────────────────────────────────────
|
|
1520
|
-
|
|
1521
|
-
/**
|
|
1522
|
-
* Wrapped text content for a task row plus the height that fits it.
|
|
1523
|
-
* Pre-computed once per task and reused by both the y-cursor layout
|
|
1524
|
-
* pass (which needs the height) and the renderer (which paints the
|
|
1525
|
-
* wrapped lines), so the two stay in sync without duplicating logic.
|
|
1526
|
-
*/
|
|
1527
|
-
interface RowContent {
|
|
1528
|
-
nameLines: string[];
|
|
1529
|
-
descLines: string[];
|
|
1530
|
-
violations: Array<{
|
|
1531
|
-
severity: 'error' | 'warning';
|
|
1532
|
-
sourceLine: number;
|
|
1533
|
-
text: string;
|
|
1534
|
-
lines: string[];
|
|
1535
|
-
}>;
|
|
1536
|
-
rowHeight: number;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
const NAME_LINE_HEIGHT = 16;
|
|
1540
|
-
const DESC_LINE_HEIGHT = 16;
|
|
1541
|
-
|
|
1542
|
-
function prepareRowContent(
|
|
1543
|
-
task: RaciTask,
|
|
1544
|
-
bucket: TaskDiagnosticBucket | undefined,
|
|
1545
|
-
labelMaxW: number,
|
|
1546
|
-
sLabelFont = LABEL_FONT,
|
|
1547
|
-
sDescFont = DESC_FONT,
|
|
1548
|
-
sRowHeight = ROW_HEIGHT,
|
|
1549
|
-
sStackTopGap = STACK_TOP_GAP,
|
|
1550
|
-
sViolationLineHeight = VIOLATION_LINE_HEIGHT
|
|
1551
|
-
): RowContent {
|
|
1552
|
-
const nameLines = wrapTextToWidth(task.displayName, sLabelFont, labelMaxW, {
|
|
1553
|
-
hardBreak: true,
|
|
1554
|
-
});
|
|
1555
|
-
const description = task.description?.trim() ?? '';
|
|
1556
|
-
const descLines =
|
|
1557
|
-
description.length > 0
|
|
1558
|
-
? description
|
|
1559
|
-
.split('\n')
|
|
1560
|
-
.flatMap((line) =>
|
|
1561
|
-
wrapTextToWidth(line, sDescFont, labelMaxW, { hardBreak: true })
|
|
1562
|
-
)
|
|
1563
|
-
: [];
|
|
1564
|
-
const violations: RowContent['violations'] = [];
|
|
1565
|
-
if (bucket) {
|
|
1566
|
-
const collect = (
|
|
1567
|
-
arr: DiagnosticEntry[],
|
|
1568
|
-
severity: 'error' | 'warning'
|
|
1569
|
-
): void => {
|
|
1570
|
-
for (const e of arr) {
|
|
1571
|
-
const text = trimTaskPrefix(e.message);
|
|
1572
|
-
violations.push({
|
|
1573
|
-
severity,
|
|
1574
|
-
sourceLine: e.line,
|
|
1575
|
-
text,
|
|
1576
|
-
lines: wrapTextToWidth(text, sLabelFont - 2, labelMaxW, {
|
|
1577
|
-
hardBreak: true,
|
|
1578
|
-
}),
|
|
1579
|
-
});
|
|
1580
|
-
}
|
|
1581
|
-
};
|
|
1582
|
-
collect(bucket.errors, 'error');
|
|
1583
|
-
collect(bucket.warnings, 'warning');
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
const totalViolationLines = violations.reduce(
|
|
1587
|
-
(n, v) => n + v.lines.length,
|
|
1588
|
-
0
|
|
1589
|
-
);
|
|
1590
|
-
const hasStacked = descLines.length > 0 || violations.length > 0;
|
|
1591
|
-
// ROW_HEIGHT was sized for a one-line label; extra label lines push
|
|
1592
|
-
// every stacked block down by NAME_LINE_HEIGHT.
|
|
1593
|
-
const labelExtra = Math.max(0, nameLines.length - 1) * NAME_LINE_HEIGHT;
|
|
1594
|
-
const rowHeight =
|
|
1595
|
-
sRowHeight +
|
|
1596
|
-
labelExtra +
|
|
1597
|
-
(hasStacked ? sStackTopGap : 0) +
|
|
1598
|
-
descLines.length * DESC_LINE_HEIGHT +
|
|
1599
|
-
totalViolationLines * sViolationLineHeight;
|
|
1600
|
-
|
|
1601
|
-
return { nameLines, descLines, violations, rowHeight };
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
/**
|
|
1605
|
-
* Split a diagnostic message at single-quoted spans so the renderer can
|
|
1606
|
-
* paint the quoted name (role / task) as a bold tspan with no quotes.
|
|
1607
|
-
* Plain-text contexts (tooltip, bottom panel) keep the quotes — they're
|
|
1608
|
-
* the only emphasis available there.
|
|
1609
|
-
*/
|
|
1610
|
-
type Segment = { text: string; bold: boolean };
|
|
1611
|
-
|
|
1612
|
-
function parseQuotedSegments(message: string): Segment[] {
|
|
1613
|
-
const out: Segment[] = [];
|
|
1614
|
-
const re = /'([^']+)'/g;
|
|
1615
|
-
let last = 0;
|
|
1616
|
-
let m: RegExpExecArray | null;
|
|
1617
|
-
while ((m = re.exec(message)) !== null) {
|
|
1618
|
-
if (m.index > last)
|
|
1619
|
-
out.push({ text: message.slice(last, m.index), bold: false });
|
|
1620
|
-
// Capture group [1] guaranteed by the regex pattern.
|
|
1621
|
-
out.push({ text: m[1]!, bold: true });
|
|
1622
|
-
last = m.index + m[0].length;
|
|
1623
|
-
}
|
|
1624
|
-
if (last < message.length)
|
|
1625
|
-
out.push({ text: message.slice(last), bold: false });
|
|
1626
|
-
return out;
|
|
1627
|
-
}
|