@diagrammo/dgmo 0.47.0 → 0.48.1

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