@diagrammo/dgmo 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -1,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
- }