@transitrix/diagrams 1.0.0 → 1.1.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 (315) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +47 -47
  3. package/dist/__tests__/edge-path.test.js +8 -0
  4. package/dist/activities/cpm.js +11 -2
  5. package/dist/activities/gantt.js +6 -3
  6. package/dist/activities/types.d.ts +1 -0
  7. package/dist/activities/validate.js +3 -3
  8. package/dist/activity-card/__tests__/example.test.js +20 -1
  9. package/dist/activity-card/__tests__/layout.test.js +102 -11
  10. package/dist/activity-card/__tests__/resolver.test.js +161 -7
  11. package/dist/activity-card/index.d.ts +1 -1
  12. package/dist/activity-card/layout.d.ts +1 -1
  13. package/dist/activity-card/layout.js +220 -103
  14. package/dist/activity-card/resolver.js +115 -28
  15. package/dist/activity-card/types.d.ts +150 -27
  16. package/dist/activity-card/validate.js +3 -0
  17. package/dist/actor/__tests__/validate.test.js +63 -31
  18. package/dist/actor/index.d.ts +1 -1
  19. package/dist/actor/index.js +1 -1
  20. package/dist/actor/types.d.ts +7 -3
  21. package/dist/actor/types.js +17 -3
  22. package/dist/actor/validate.js +55 -26
  23. package/dist/assertion/types.d.ts +3 -1
  24. package/dist/assertion/types.js +1 -1
  25. package/dist/change/__tests__/validate.test.js +40 -34
  26. package/dist/change/index.d.ts +1 -0
  27. package/dist/change/types.d.ts +1 -1
  28. package/dist/change/types.js +2 -2
  29. package/dist/change/validate.d.ts +6 -1
  30. package/dist/change/validate.js +68 -41
  31. package/dist/compliance/__tests__/classify.test.js +34 -0
  32. package/dist/compliance/__tests__/coverage-metric.test.js +146 -46
  33. package/dist/compliance/__tests__/html.test.js +2 -2
  34. package/dist/compliance/__tests__/impact.test.js +509 -6
  35. package/dist/compliance/classify.d.ts +6 -0
  36. package/dist/compliance/classify.js +9 -1
  37. package/dist/compliance/coverage-metric.d.ts +31 -11
  38. package/dist/compliance/coverage-metric.js +149 -39
  39. package/dist/compliance/html.js +78 -81
  40. package/dist/compliance/impact.d.ts +100 -16
  41. package/dist/compliance/impact.js +272 -28
  42. package/dist/compliance/index.d.ts +2 -2
  43. package/dist/compliance/index.js +1 -1
  44. package/dist/compliance/markdown.js +1 -0
  45. package/dist/compliance/types.d.ts +25 -6
  46. package/dist/edge-path.d.ts +8 -4
  47. package/dist/edge-path.js +11 -6
  48. package/dist/factor/__tests__/validate.test.js +75 -46
  49. package/dist/factor/index.d.ts +3 -2
  50. package/dist/factor/index.js +1 -1
  51. package/dist/factor/types.d.ts +5 -8
  52. package/dist/factor/types.js +10 -5
  53. package/dist/factor/validate.d.ts +6 -1
  54. package/dist/factor/validate.js +68 -50
  55. package/dist/fgca/__tests__/chain-table.test.js +2 -2
  56. package/dist/fgca/__tests__/layout.test.js +11 -11
  57. package/dist/fgca/__tests__/preview-layout.test.js +7 -7
  58. package/dist/fgca/__tests__/resolver.test.js +185 -0
  59. package/dist/fgca/chain-table.d.ts +1 -1
  60. package/dist/fgca/chain-table.js +4 -4
  61. package/dist/fgca/index.d.ts +4 -2
  62. package/dist/fgca/index.js +2 -1
  63. package/dist/fgca/layout.d.ts +2 -2
  64. package/dist/fgca/layout.js +9 -9
  65. package/dist/fgca/nodeTypes.d.ts +1 -1
  66. package/dist/fgca/nodeTypes.js +2 -2
  67. package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
  68. package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
  69. package/dist/fgca/parse-canonical.js +15 -10
  70. package/dist/fgca/preview-layout.d.ts +1 -1
  71. package/dist/fgca/preview-layout.js +4 -4
  72. package/dist/fgca/resolver.d.ts +49 -0
  73. package/dist/fgca/resolver.js +126 -0
  74. package/dist/fgca/types.d.ts +11 -11
  75. package/dist/fgca/types.js +2 -2
  76. package/dist/fgca/validate.d.ts +2 -2
  77. package/dist/fgca/validate.js +17 -14
  78. package/dist/goals/index.d.ts +1 -1
  79. package/dist/index.d.ts +5 -0
  80. package/dist/index.js +5 -0
  81. package/dist/process-blueprint/__tests__/layout.test.js +37 -0
  82. package/dist/process-blueprint/index.d.ts +1 -1
  83. package/dist/process-blueprint/layout.js +66 -35
  84. package/dist/process-blueprint/types.d.ts +36 -1
  85. package/dist/scenarios/index.d.ts +1 -1
  86. package/dist/scenarios/types.d.ts +4 -4
  87. package/dist/stakeholder/__tests__/validate.test.js +49 -47
  88. package/dist/stakeholder/index.d.ts +1 -0
  89. package/dist/stakeholder/types.d.ts +3 -4
  90. package/dist/stakeholder/types.js +2 -3
  91. package/dist/stakeholder/validate.d.ts +6 -1
  92. package/dist/stakeholder/validate.js +71 -50
  93. package/dist/target-state/__tests__/validate.test.js +43 -27
  94. package/dist/target-state/index.d.ts +2 -0
  95. package/dist/target-state/index.js +1 -0
  96. package/dist/target-state/types.d.ts +6 -1
  97. package/dist/target-state/types.js +8 -7
  98. package/dist/target-state/validate.d.ts +6 -1
  99. package/dist/target-state/validate.js +69 -45
  100. package/dist/theme/themes.js +71 -69
  101. package/dist/theme/tokens.d.ts +7 -1
  102. package/dist/theme/tokens.js +3 -1
  103. package/dist/webview/__tests__/entry-notations.test.js +2 -0
  104. package/dist/webview/__tests__/entry.test.js +11 -11
  105. package/dist/webview/__tests__/render-process.test.js +463 -0
  106. package/dist/webview/__tests__/render-util.test.d.ts +1 -0
  107. package/dist/webview/__tests__/render-util.test.js +54 -0
  108. package/dist/webview/entry.d.ts +1 -1
  109. package/dist/webview/entry.js +9 -5
  110. package/dist/webview/render-activities.d.ts +24 -2
  111. package/dist/webview/render-activities.js +92 -57
  112. package/dist/webview/render-activity-card.d.ts +11 -1
  113. package/dist/webview/render-activity-card.js +115 -79
  114. package/dist/webview/render-blocks.d.ts +24 -1
  115. package/dist/webview/render-blocks.js +58 -24
  116. package/dist/webview/render-fgca.d.ts +31 -0
  117. package/dist/webview/render-fgca.js +30 -19
  118. package/dist/webview/render-goals.d.ts +27 -1
  119. package/dist/webview/render-goals.js +42 -32
  120. package/dist/webview/render-process-blueprint.d.ts +11 -1
  121. package/dist/webview/render-process-blueprint.js +118 -53
  122. package/dist/webview/render-process.d.ts +95 -0
  123. package/dist/webview/render-process.js +392 -0
  124. package/dist/webview/render-scenarios.js +2 -2
  125. package/dist/webview/render-util.d.ts +19 -6
  126. package/dist/webview/render-util.js +23 -15
  127. package/package.json +54 -40
  128. package/src/__tests__/edge-path.test.ts +58 -46
  129. package/src/__tests__/scope.test.ts +27 -27
  130. package/src/__tests__/yaml-normalize.test.ts +158 -158
  131. package/src/activities/__tests__/cpm.test.ts +164 -164
  132. package/src/activities/__tests__/gantt.test.ts +245 -245
  133. package/src/activities/__tests__/layout.test.ts +112 -112
  134. package/src/activities/__tests__/validate.test.ts +409 -409
  135. package/src/activities/cpm.ts +107 -95
  136. package/src/activities/gantt.ts +277 -276
  137. package/src/activities/index.ts +28 -28
  138. package/src/activities/layout.ts +112 -112
  139. package/src/activities/types.ts +149 -148
  140. package/src/activities/validate.ts +357 -357
  141. package/src/activity-card/__tests__/example.test.ts +85 -66
  142. package/src/activity-card/__tests__/layout.test.ts +169 -63
  143. package/src/activity-card/__tests__/resolver.test.ts +330 -161
  144. package/src/activity-card/__tests__/validate.test.ts +131 -131
  145. package/src/activity-card/index.ts +38 -32
  146. package/src/activity-card/layout.ts +324 -194
  147. package/src/activity-card/resolver.ts +422 -337
  148. package/src/activity-card/types.ts +376 -237
  149. package/src/activity-card/validate.ts +162 -158
  150. package/src/actor/__tests__/validate.test.ts +70 -31
  151. package/src/actor/index.ts +1 -1
  152. package/src/actor/types.ts +21 -7
  153. package/src/actor/validate.ts +59 -27
  154. package/src/applications/__tests__/validate.test.ts +256 -256
  155. package/src/applications/index.ts +11 -11
  156. package/src/applications/types.ts +43 -43
  157. package/src/applications/validate.ts +131 -131
  158. package/src/assertion/__tests__/example.test.ts +37 -37
  159. package/src/assertion/__tests__/validate.test.ts +142 -142
  160. package/src/assertion/index.ts +12 -12
  161. package/src/assertion/types.ts +60 -58
  162. package/src/assertion/validate.ts +145 -145
  163. package/src/blocks/__tests__/layout.test.ts +169 -169
  164. package/src/blocks/__tests__/validate.test.ts +260 -260
  165. package/src/blocks/index.ts +17 -17
  166. package/src/blocks/layout.ts +186 -186
  167. package/src/blocks/types.ts +62 -62
  168. package/src/blocks/validate.ts +206 -206
  169. package/src/capability-map/__tests__/validate.test.ts +238 -238
  170. package/src/capability-map/index.ts +8 -8
  171. package/src/capability-map/types.ts +29 -29
  172. package/src/capability-map/validate.ts +127 -127
  173. package/src/change/__tests__/validate.test.ts +43 -34
  174. package/src/change/index.ts +1 -0
  175. package/src/change/types.ts +5 -3
  176. package/src/change/validate.ts +75 -43
  177. package/src/compliance/__tests__/classify.test.ts +64 -27
  178. package/src/compliance/__tests__/confidence.test.ts +98 -98
  179. package/src/compliance/__tests__/coverage-metric.test.ts +300 -188
  180. package/src/compliance/__tests__/demo-scenario.test.ts +273 -273
  181. package/src/compliance/__tests__/example.test.ts +57 -57
  182. package/src/compliance/__tests__/gap-report.test.ts +128 -128
  183. package/src/compliance/__tests__/html.test.ts +141 -141
  184. package/src/compliance/__tests__/impact.test.ts +1059 -481
  185. package/src/compliance/__tests__/markdown.test.ts +94 -94
  186. package/src/compliance/__tests__/views.test.ts +64 -64
  187. package/src/compliance/classify.ts +100 -84
  188. package/src/compliance/confidence.ts +69 -69
  189. package/src/compliance/coverage-metric.ts +404 -263
  190. package/src/compliance/gap-report.ts +94 -94
  191. package/src/compliance/html.ts +291 -295
  192. package/src/compliance/impact.ts +979 -677
  193. package/src/compliance/index.ts +57 -56
  194. package/src/compliance/markdown.ts +136 -135
  195. package/src/compliance/reverse-index.ts +33 -33
  196. package/src/compliance/types.ts +136 -120
  197. package/src/compliance/views.ts +46 -46
  198. package/src/compliance-matrix/__tests__/build.test.ts +200 -200
  199. package/src/compliance-matrix/__tests__/example.test.ts +57 -57
  200. package/src/compliance-matrix/index.ts +11 -11
  201. package/src/compliance-matrix/types.ts +102 -102
  202. package/src/edge-path.ts +50 -44
  203. package/src/factor/__tests__/validate.test.ts +81 -46
  204. package/src/factor/index.ts +3 -7
  205. package/src/factor/types.ts +15 -19
  206. package/src/factor/validate.ts +77 -51
  207. package/src/fgca/__tests__/chain-table.test.ts +172 -172
  208. package/src/fgca/__tests__/layout.test.ts +179 -179
  209. package/src/fgca/__tests__/parse-canonical.test.ts +248 -248
  210. package/src/fgca/__tests__/preview-layout.test.ts +124 -124
  211. package/src/fgca/__tests__/resolver.test.ts +209 -0
  212. package/src/fgca/__tests__/validate.test.ts +142 -142
  213. package/src/fgca/chain-table.ts +225 -225
  214. package/src/fgca/index.ts +47 -45
  215. package/src/fgca/layout.ts +204 -204
  216. package/src/fgca/nodeTypes.ts +11 -11
  217. package/src/fgca/nodes/FGCAActivityNode.tsx +65 -65
  218. package/src/fgca/nodes/FGCAChangeNode.tsx +64 -64
  219. package/src/fgca/nodes/FGCAFactorNode.tsx +64 -64
  220. package/src/fgca/nodes/FGCAGoalNode.tsx +72 -72
  221. package/src/fgca/parse-canonical.ts +405 -400
  222. package/src/fgca/preview-layout.ts +217 -217
  223. package/src/fgca/resolver.ts +159 -0
  224. package/src/fgca/types.ts +50 -50
  225. package/src/fgca/validate.ts +129 -126
  226. package/src/geometry.ts +9 -9
  227. package/src/goals/__tests__/layout.test.ts +175 -175
  228. package/src/goals/__tests__/mutations.test.ts +111 -111
  229. package/src/goals/__tests__/parse-canonical.test.ts +145 -145
  230. package/src/goals/__tests__/validate.test.ts +202 -202
  231. package/src/goals/index.ts +20 -21
  232. package/src/goals/layout.ts +172 -172
  233. package/src/goals/mutations.ts +123 -123
  234. package/src/goals/parse-canonical.ts +286 -286
  235. package/src/goals/types.ts +85 -85
  236. package/src/goals/validate.ts +136 -136
  237. package/src/index.ts +28 -23
  238. package/src/process-blueprint/__tests__/compliance-lane.test.ts +320 -320
  239. package/src/process-blueprint/__tests__/layout.test.ts +240 -195
  240. package/src/process-blueprint/__tests__/validate.test.ts +310 -310
  241. package/src/process-blueprint/index.ts +32 -31
  242. package/src/process-blueprint/layout.ts +564 -531
  243. package/src/process-blueprint/types.ts +278 -242
  244. package/src/process-blueprint/validate.ts +181 -181
  245. package/src/process-map/__tests__/validate.test.ts +223 -223
  246. package/src/process-map/index.ts +10 -10
  247. package/src/process-map/types.ts +36 -36
  248. package/src/process-map/validate.ts +135 -135
  249. package/src/products/__tests__/validate.test.ts +239 -239
  250. package/src/products/index.ts +9 -9
  251. package/src/products/types.ts +31 -31
  252. package/src/products/validate.ts +112 -112
  253. package/src/repo-validate/__tests__/validate-repo.test.ts +149 -149
  254. package/src/repo-validate/index.ts +2 -2
  255. package/src/repo-validate/types.ts +50 -50
  256. package/src/repo-validate/validate-repo.ts +196 -196
  257. package/src/requirement/__tests__/example.test.ts +24 -24
  258. package/src/requirement/__tests__/validate.test.ts +96 -96
  259. package/src/requirement/index.ts +4 -4
  260. package/src/requirement/types.ts +35 -35
  261. package/src/requirement/validate.ts +92 -92
  262. package/src/scenarios/__tests__/validate.test.ts +209 -209
  263. package/src/scenarios/index.ts +15 -15
  264. package/src/scenarios/types.ts +37 -37
  265. package/src/scenarios/validate.ts +122 -122
  266. package/src/schema-version.ts +16 -16
  267. package/src/scope.ts +46 -46
  268. package/src/stakeholder/__tests__/validate.test.ts +51 -47
  269. package/src/stakeholder/index.ts +1 -0
  270. package/src/stakeholder/types.ts +7 -7
  271. package/src/stakeholder/validate.ts +79 -52
  272. package/src/target-state/__tests__/validate.test.ts +48 -27
  273. package/src/target-state/index.ts +2 -0
  274. package/src/target-state/types.ts +11 -7
  275. package/src/target-state/validate.ts +76 -46
  276. package/src/theme/index.ts +4 -4
  277. package/src/theme/themes.ts +239 -237
  278. package/src/theme/tokens.ts +135 -133
  279. package/src/typed-id.ts +56 -56
  280. package/src/validation-types.ts +36 -36
  281. package/src/webview/__tests__/entry-notations.test.ts +79 -77
  282. package/src/webview/__tests__/entry.test.ts +64 -64
  283. package/src/webview/__tests__/render-goals.test.ts +72 -72
  284. package/src/webview/__tests__/render-process.test.ts +538 -0
  285. package/src/webview/__tests__/render-util.test.ts +66 -0
  286. package/src/webview/entry.ts +298 -292
  287. package/src/webview/render-activities.ts +111 -69
  288. package/src/webview/render-activity-card.ts +275 -234
  289. package/src/webview/render-blocks.ts +78 -28
  290. package/src/webview/render-fgca.ts +53 -28
  291. package/src/webview/render-goals.ts +133 -110
  292. package/src/webview/render-process-blueprint.ts +281 -207
  293. package/src/webview/render-process.ts +507 -0
  294. package/src/webview/render-scenarios.ts +3 -3
  295. package/src/webview/render-util.ts +27 -14
  296. package/src/webview/styles.css +322 -323
  297. package/src/yaml-normalize.ts +56 -56
  298. package/dist/element-envelope.d.ts +0 -15
  299. package/dist/element-envelope.js +0 -55
  300. package/dist/issues/__tests__/layout.test.js +0 -62
  301. package/dist/issues/__tests__/validate.test.js +0 -82
  302. package/dist/issues/index.d.ts +0 -4
  303. package/dist/issues/index.js +0 -2
  304. package/dist/issues/layout.d.ts +0 -10
  305. package/dist/issues/layout.js +0 -82
  306. package/dist/issues/types.d.ts +0 -61
  307. package/dist/issues/types.js +0 -6
  308. package/dist/issues/validate.d.ts +0 -3
  309. package/dist/issues/validate.js +0 -143
  310. package/dist/webview/transitrix-render.css +0 -323
  311. package/dist/webview/transitrix-render.js +0 -6452
  312. package/dist/webview/transitrix-render.js.map +0 -7
  313. package/src/element-envelope.ts +0 -76
  314. /package/dist/{issues/__tests__/layout.test.d.ts → fgca/__tests__/resolver.test.d.ts} +0 -0
  315. /package/dist/{issues/__tests__/validate.test.d.ts → webview/__tests__/render-process.test.d.ts} +0 -0
@@ -1,531 +1,564 @@
1
- import type {
2
- AspectCategory,
3
- AspectEntry,
4
- AspectPill,
5
- AspectRow,
6
- ComplianceChip,
7
- ComplianceDecoration,
8
- ComplianceLaneAssertion,
9
- ComplianceLaneInput,
10
- ComplianceLaneRequirement,
11
- ComplianceRow,
12
- ProcessBlueprintLayoutOptions,
13
- LegendCell,
14
- ProcessBlueprintFile,
15
- ProcessBlueprintLayout,
16
- Stage,
17
- StageHeaderCell,
18
- StageTextCell,
19
- } from './types.js';
20
-
21
- const ASPECT_CATEGORIES: AspectCategory[] = ['systems', 'actors', 'equipment', 'information_entities'];
22
-
23
- const ASPECT_LABEL: Record<AspectCategory, string> = {
24
- systems: 'Systems',
25
- actors: 'Actors',
26
- equipment: 'Equipment',
27
- information_entities: 'Information',
28
- };
29
-
30
- // The compliance lane is opt-in, so its two options have no meaningful default
31
- // (absent = lane disabled). Every other option is a sizing value that always
32
- // resolves to a number — keep those `Required`, but exclude the compliance pair
33
- // rather than forcing them to a non-optional type they can't satisfy.
34
- type SizingOptionKeys = Exclude<
35
- keyof ProcessBlueprintLayoutOptions,
36
- 'complianceLane' | 'complianceInput'
37
- >;
38
-
39
- /**
40
- * Options after defaults are applied: every sizing field is guaranteed present;
41
- * the compliance lane fields stay optional (undefined = lane disabled).
42
- */
43
- type ResolvedLayoutOptions = Required<Pick<ProcessBlueprintLayoutOptions, SizingOptionKeys>> &
44
- Pick<ProcessBlueprintLayoutOptions, 'complianceLane' | 'complianceInput'>;
45
-
46
- const DEFAULTS = {
47
- legendColumnWidth: 140,
48
- stageColumnWidth: 220,
49
- stageHeaderHeight: 40,
50
- goalRowHeight: 56,
51
- resultRowHeight: 56,
52
- aspectRowMinHeight: 60,
53
- pillHeight: 28,
54
- pillGap: 4,
55
- cellPadding: 8,
56
- textLineHeight: 15,
57
- textCharWidth: 6.2,
58
- cellTextPadX: 10,
59
- cellTextPadY: 10,
60
- maxTextLines: 6,
61
- } satisfies Required<Pick<ProcessBlueprintLayoutOptions, SizingOptionKeys>>;
62
-
63
- interface RawPill {
64
- category: AspectCategory;
65
- entryIndex: number;
66
- name: string;
67
- id?: string;
68
- startStageIndex: number;
69
- endStageIndex: number;
70
- }
71
-
72
- function resolveOptions(options: ProcessBlueprintLayoutOptions | undefined): ResolvedLayoutOptions {
73
- return { ...DEFAULTS, ...(options ?? {}) };
74
- }
75
-
76
- /**
77
- * Greedy word-wrap to a max characters-per-line budget. Words longer than the
78
- * budget are hard-split; if the result exceeds `maxLines`, the last kept line
79
- * is truncated with an ellipsis so a single pathological cell can't grow the
80
- * whole row unbounded.
81
- */
82
- function wrapText(text: string, maxChars: number, maxLines: number): string[] {
83
- const trimmed = (text ?? '').trim();
84
- if (trimmed.length === 0) return [];
85
- const budget = Math.max(1, maxChars);
86
-
87
- const words = trimmed.split(/\s+/);
88
- const lines: string[] = [];
89
- let cur = '';
90
- const flush = (): void => {
91
- if (cur.length > 0) {
92
- lines.push(cur);
93
- cur = '';
94
- }
95
- };
96
-
97
- for (const word of words) {
98
- if (word.length > budget) {
99
- // Token wider than the cell: hard-break it across lines.
100
- flush();
101
- let rest = word;
102
- while (rest.length > budget) {
103
- lines.push(rest.slice(0, budget));
104
- rest = rest.slice(budget);
105
- }
106
- cur = rest;
107
- continue;
108
- }
109
- const candidate = cur.length > 0 ? `${cur} ${word}` : word;
110
- if (candidate.length > budget) {
111
- flush();
112
- cur = word;
113
- } else {
114
- cur = candidate;
115
- }
116
- }
117
- flush();
118
-
119
- if (lines.length > maxLines) {
120
- const capped = lines.slice(0, maxLines);
121
- const last = capped[maxLines - 1];
122
- const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
123
- capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
124
- return capped;
125
- }
126
- return lines;
127
- }
128
-
129
- function buildStageIndex(stages: Stage[]): Map<string, number> {
130
- const idx = new Map<string, number>();
131
- for (let i = 0; i < stages.length; i++) {
132
- const sid = typeof stages[i]?.id === 'string' ? stages[i].id.trim() : '';
133
- if (sid.length > 0 && !idx.has(sid)) {
134
- idx.set(sid, i);
135
- }
136
- }
137
- return idx;
138
- }
139
-
140
- function pillsForEntry(
141
- category: AspectCategory,
142
- entryIndex: number,
143
- entry: AspectEntry,
144
- stageIndexById: Map<string, number>,
145
- ): RawPill[] {
146
- if (!Array.isArray(entry.stages)) return [];
147
-
148
- const idxs: number[] = [];
149
- const seen = new Set<number>();
150
- for (const ref of entry.stages) {
151
- if (typeof ref !== 'string') continue;
152
- const i = stageIndexById.get(ref.trim());
153
- if (i === undefined) continue;
154
- if (seen.has(i)) continue;
155
- seen.add(i);
156
- idxs.push(i);
157
- }
158
- idxs.sort((a, b) => a - b);
159
-
160
- const pills: RawPill[] = [];
161
- let runStart: number | null = null;
162
- let runEnd: number | null = null;
163
- for (const i of idxs) {
164
- if (runStart === null || runEnd === null) {
165
- runStart = i;
166
- runEnd = i;
167
- continue;
168
- }
169
- if (i === runEnd + 1) {
170
- runEnd = i;
171
- } else {
172
- pills.push({
173
- category,
174
- entryIndex,
175
- name: entry.name,
176
- id: entry.id,
177
- startStageIndex: runStart,
178
- endStageIndex: runEnd,
179
- });
180
- runStart = i;
181
- runEnd = i;
182
- }
183
- }
184
- if (runStart !== null && runEnd !== null) {
185
- pills.push({
186
- category,
187
- entryIndex,
188
- name: entry.name,
189
- id: entry.id,
190
- startStageIndex: runStart,
191
- endStageIndex: runEnd,
192
- });
193
- }
194
- return pills;
195
- }
196
-
197
- function packPillsIntoSlots(pills: RawPill[]): { slot: number[]; maxSlot: number } {
198
- // Greedy interval scheduling: stable order by startStageIndex.
199
- const order = pills
200
- .map((_, i) => i)
201
- .sort((a, b) => {
202
- const ds = pills[a].startStageIndex - pills[b].startStageIndex;
203
- if (ds !== 0) return ds;
204
- return pills[a].endStageIndex - pills[b].endStageIndex;
205
- });
206
-
207
- const slotEndByLevel: number[] = []; // slotEndByLevel[s] = last endStageIndex placed in slot s
208
- const slot = new Array<number>(pills.length).fill(0);
209
- let maxSlot = 0;
210
-
211
- for (const i of order) {
212
- const p = pills[i];
213
- let placed = false;
214
- for (let s = 0; s < slotEndByLevel.length; s++) {
215
- if (slotEndByLevel[s] < p.startStageIndex) {
216
- slot[i] = s;
217
- slotEndByLevel[s] = p.endStageIndex;
218
- placed = true;
219
- break;
220
- }
221
- }
222
- if (!placed) {
223
- slot[i] = slotEndByLevel.length;
224
- slotEndByLevel.push(p.endStageIndex);
225
- }
226
- if (slot[i] > maxSlot) maxSlot = slot[i];
227
- }
228
-
229
- return { slot, maxSlot };
230
- }
231
-
232
- // ── Compliance lane derivation ────────────────────────────────────────────────
233
-
234
- /** ISO 8601 date comparison helper — no Date parsing, string comparison is valid for YYYY-MM-DD. */
235
- function deadlineStatus(
236
- deadline: string | undefined,
237
- today: string,
238
- ): 'past_due' | 'in_force' | 'upcoming' | 'none' {
239
- if (!deadline) return 'none';
240
- if (deadline < today) return 'past_due';
241
- const daysAway = Math.round(
242
- (new Date(deadline).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24),
243
- );
244
- return daysAway <= 30 ? 'in_force' : 'upcoming';
245
- }
246
-
247
- /**
248
- * Derive the laid-out compliance row for a process blueprint.
249
- *
250
- * For each stage, gathers the laws that bind that stage (via the
251
- * assertion → requirement → derived_from chain), applies the jurisdiction
252
- * filter, and computes the three orthogonal decorations per law chip.
253
- */
254
- function deriveComplianceRow(
255
- stages: Stage[],
256
- stageIndexById: Map<string, number>,
257
- input: ComplianceLaneInput,
258
- opts: ResolvedLayoutOptions,
259
- yStart: number,
260
- ): ComplianceRow | undefined {
261
- const {
262
- complianceLane: laneConfig,
263
- legendColumnWidth,
264
- stageColumnWidth,
265
- cellPadding,
266
- pillHeight,
267
- pillGap,
268
- aspectRowMinHeight,
269
- } = opts;
270
-
271
- if (!laneConfig?.enabled) return undefined;
272
-
273
- const today = laneConfig.referenceDate ?? new Date().toISOString().slice(0, 10);
274
- const filterJurisdictions = laneConfig.jurisdictions?.length ? new Set(laneConfig.jurisdictions) : null;
275
-
276
- // Build requirement index: id → requirement.
277
- const reqById = new Map<string, ComplianceLaneRequirement>();
278
- for (const r of input.requirements) {
279
- if (r?.id) reqById.set(r.id, r);
280
- }
281
-
282
- // Per-stage accumulation: stageIdx → lawId → { worstStatus, deadline }
283
- type LawAccumulator = { gap: boolean; deadline: string | undefined };
284
- const stageAccum = new Map<number, Map<string, LawAccumulator>>();
285
-
286
- for (const a of input.assertions) {
287
- if (!a || typeof a !== 'object') continue;
288
- const realisedVia: string[] = Array.isArray(a.realised_via) ? a.realised_via : [];
289
- if (realisedVia.length === 0) continue;
290
-
291
- const req = reqById.get(a.about);
292
- if (!req) continue;
293
-
294
- const lawIds: string[] = Array.isArray(req.derived_from)
295
- ? (req.derived_from as string[]).filter((x): x is string => typeof x === 'string')
296
- : [];
297
- if (lawIds.length === 0) continue;
298
-
299
- for (const stageRef of realisedVia) {
300
- if (typeof stageRef !== 'string') continue;
301
- const idx = stageIndexById.get(stageRef.trim());
302
- if (idx === undefined) continue;
303
-
304
- for (const lawId of lawIds) {
305
- // Apply jurisdiction filter.
306
- if (filterJurisdictions) {
307
- const jur = input.codexJurisdictions?.[lawId];
308
- if (!jur || !filterJurisdictions.has(jur)) continue;
309
- }
310
-
311
- if (!stageAccum.has(idx)) stageAccum.set(idx, new Map());
312
- const lawMap = stageAccum.get(idx)!;
313
- const existing = lawMap.get(lawId);
314
- const isGap = a.status === 'non_compliant' || a.status === 'partial';
315
- if (!existing) {
316
- lawMap.set(lawId, { gap: isGap, deadline: req.deadline });
317
- } else {
318
- existing.gap = existing.gap || isGap;
319
- // Keep the earlier deadline (more urgent).
320
- if (req.deadline && (!existing.deadline || req.deadline < existing.deadline)) {
321
- existing.deadline = req.deadline;
322
- }
323
- }
324
- }
325
- }
326
- }
327
-
328
- if (stageAccum.size === 0) return undefined;
329
-
330
- // Determine how many slots (rows) per stage.
331
- const slotsPerStage = new Map<number, number>();
332
- for (const [idx, lawMap] of stageAccum) {
333
- slotsPerStage.set(idx, lawMap.size);
334
- }
335
- const maxSlots = Math.max(0, ...slotsPerStage.values());
336
- if (maxSlots === 0) return undefined;
337
-
338
- const contentHeight = maxSlots * (pillHeight + pillGap) - pillGap;
339
- const rowHeight = Math.max(aspectRowMinHeight, contentHeight + 2 * cellPadding);
340
-
341
- const chips: ComplianceChip[] = [];
342
-
343
- for (const [stageIdx, lawMap] of stageAccum) {
344
- const prevLawIds = new Set<string>(laneConfig.previousSnapshot?.[stages[stageIdx]?.id ?? ''] ?? []);
345
- let slot = 0;
346
-
347
- for (const [lawId, accum] of lawMap) {
348
- const decorations: ComplianceDecoration[] = [];
349
- if (!prevLawIds.has(lawId)) decorations.push('new');
350
- if (accum.gap) {
351
- decorations.push('gap');
352
- const ds = deadlineStatus(accum.deadline, today);
353
- if (ds !== 'none') decorations.push('deadline');
354
- }
355
-
356
- const x = legendColumnWidth + stageIdx * stageColumnWidth + cellPadding;
357
- const width = stageColumnWidth - 2 * cellPadding;
358
- const y = yStart + cellPadding + slot * (pillHeight + pillGap);
359
- chips.push({ stageIndex: stageIdx, lawId, decorations, x, y, width, height: pillHeight });
360
- slot++;
361
- }
362
- }
363
-
364
- return { y: yStart, height: rowHeight, chips };
365
- }
366
-
367
- export function layoutProcessBlueprint(
368
- file: ProcessBlueprintFile,
369
- options?: ProcessBlueprintLayoutOptions,
370
- ): ProcessBlueprintLayout {
371
- const opts = resolveOptions(options);
372
-
373
- const pb = file.process_blueprint;
374
- const stages = Array.isArray(pb?.stages) ? pb.stages : [];
375
- const stageIndexById = buildStageIndex(stages);
376
-
377
- // Stage headers
378
- const stageHeaders: StageHeaderCell[] = stages.map((s, i) => ({
379
- stageIndex: i,
380
- id: s?.id ?? '',
381
- name: s?.name ?? '',
382
- x: opts.legendColumnWidth + i * opts.stageColumnWidth,
383
- y: 0,
384
- width: opts.stageColumnWidth,
385
- height: opts.stageHeaderHeight,
386
- }));
387
-
388
- // Goal + result rows. Text is word-wrapped to the column width, and each row
389
- // grows to fit the tallest cell so nothing is clipped.
390
- const maxCharsPerLine = Math.max(
391
- 4,
392
- Math.floor((opts.stageColumnWidth - 2 * opts.cellTextPadX) / opts.textCharWidth),
393
- );
394
- const wrap = (text: string): string[] => wrapText(text, maxCharsPerLine, opts.maxTextLines);
395
- const rowHeightFor = (lineCounts: number[], minHeight: number): number => {
396
- const maxLines = Math.max(1, ...lineCounts);
397
- return Math.max(minHeight, maxLines * opts.textLineHeight + 2 * opts.cellTextPadY);
398
- };
399
-
400
- const goalLines = stages.map(s => wrap(s?.goal ?? ''));
401
- const resultLines = stages.map(s => wrap(s?.result ?? ''));
402
-
403
- const goalRowY = opts.stageHeaderHeight;
404
- const goalRowHeight = rowHeightFor(goalLines.map(l => l.length), opts.goalRowHeight);
405
- const resultRowY = goalRowY + goalRowHeight;
406
- const resultRowHeight = rowHeightFor(resultLines.map(l => l.length), opts.resultRowHeight);
407
-
408
- const goalCells: StageTextCell[] = stages.map((s, i) => ({
409
- stageIndex: i,
410
- text: s?.goal ?? '',
411
- lines: goalLines[i],
412
- x: opts.legendColumnWidth + i * opts.stageColumnWidth,
413
- y: goalRowY,
414
- width: opts.stageColumnWidth,
415
- height: goalRowHeight,
416
- }));
417
-
418
- const resultCells: StageTextCell[] = stages.map((s, i) => ({
419
- stageIndex: i,
420
- text: s?.result ?? '',
421
- lines: resultLines[i],
422
- x: opts.legendColumnWidth + i * opts.stageColumnWidth,
423
- y: resultRowY,
424
- width: opts.stageColumnWidth,
425
- height: resultRowHeight,
426
- }));
427
-
428
- const aspectsStartY = resultRowY + resultRowHeight;
429
-
430
- // Aspect rows: only categories with at least one entry, in fixed order.
431
- const aspectRows: AspectRow[] = [];
432
- let aspectCursorY = aspectsStartY;
433
-
434
- for (const category of ASPECT_CATEGORIES) {
435
- const arr = pb?.[category];
436
- if (!Array.isArray(arr) || arr.length === 0) continue;
437
-
438
- const rawPills: RawPill[] = [];
439
- for (let i = 0; i < arr.length; i++) {
440
- const entry = arr[i];
441
- if (!entry || typeof entry !== 'object') continue;
442
- rawPills.push(...pillsForEntry(category, i, entry as AspectEntry, stageIndexById));
443
- }
444
-
445
- // If every entry in this category had no resolvable stage refs, drop the row.
446
- if (rawPills.length === 0) continue;
447
-
448
- const { slot, maxSlot } = packPillsIntoSlots(rawPills);
449
- const contentHeight = (maxSlot + 1) * (opts.pillHeight + opts.pillGap) - opts.pillGap;
450
- const rowHeight = Math.max(
451
- opts.aspectRowMinHeight,
452
- contentHeight + 2 * opts.cellPadding,
453
- );
454
-
455
- const pills: AspectPill[] = rawPills.map((p, i) => {
456
- const x = opts.legendColumnWidth + p.startStageIndex * opts.stageColumnWidth + opts.cellPadding;
457
- const width =
458
- (p.endStageIndex - p.startStageIndex + 1) * opts.stageColumnWidth - 2 * opts.cellPadding;
459
- const y = aspectCursorY + opts.cellPadding + slot[i] * (opts.pillHeight + opts.pillGap);
460
- return {
461
- category: p.category,
462
- entryIndex: p.entryIndex,
463
- name: p.name,
464
- id: p.id,
465
- startStageIndex: p.startStageIndex,
466
- endStageIndex: p.endStageIndex,
467
- x,
468
- y,
469
- width,
470
- height: opts.pillHeight,
471
- };
472
- });
473
-
474
- aspectRows.push({
475
- category,
476
- y: aspectCursorY,
477
- height: rowHeight,
478
- pills,
479
- });
480
-
481
- aspectCursorY += rowHeight;
482
- }
483
-
484
- // Compliance lane — derived from assertions/requirements, rendered after aspect rows.
485
- let complianceRow: import('./types.js').ComplianceRow | undefined;
486
- if (opts.complianceLane?.enabled && opts.complianceInput) {
487
- complianceRow = deriveComplianceRow(
488
- stages,
489
- stageIndexById,
490
- opts.complianceInput,
491
- opts,
492
- aspectCursorY,
493
- );
494
- if (complianceRow) {
495
- aspectCursorY += complianceRow.height;
496
- }
497
- }
498
-
499
- // Legend (left column labels): one entry per row.
500
- const legend: LegendCell[] = [
501
- { kind: 'goal', label: 'Goal', y: goalRowY, height: goalRowHeight },
502
- { kind: 'result', label: 'Result', y: resultRowY, height: resultRowHeight },
503
- ...aspectRows.map<LegendCell>(row => ({
504
- kind: 'aspect',
505
- category: row.category,
506
- label: ASPECT_LABEL[row.category],
507
- y: row.y,
508
- height: row.height,
509
- })),
510
- ...(complianceRow
511
- ? [{ kind: 'compliance' as const, label: 'Compliance', y: complianceRow.y, height: complianceRow.height }]
512
- : []),
513
- ];
514
-
515
- const totalWidth = opts.legendColumnWidth + stages.length * opts.stageColumnWidth;
516
- const totalHeight = aspectCursorY;
517
-
518
- return {
519
- bounds: { x: 0, y: 0, width: totalWidth, height: totalHeight },
520
- legendColumnWidth: opts.legendColumnWidth,
521
- stageColumnWidth: opts.stageColumnWidth,
522
- textLineHeight: opts.textLineHeight,
523
- cellTextPadX: opts.cellTextPadX,
524
- legend,
525
- stageHeaders,
526
- goalCells,
527
- resultCells,
528
- aspectRows,
529
- complianceRow,
530
- };
531
- }
1
+ import type {
2
+ AspectCategory,
3
+ AspectEntry,
4
+ AspectPill,
5
+ AspectRow,
6
+ ComplianceChip,
7
+ ComplianceDecoration,
8
+ ComplianceLaneAssertion,
9
+ ComplianceLaneInput,
10
+ ComplianceLaneRequirement,
11
+ ComplianceRow,
12
+ ProcessBlueprintLayoutOptions,
13
+ LegendCell,
14
+ ProcessBlueprintFile,
15
+ ProcessBlueprintLayout,
16
+ RowId,
17
+ Stage,
18
+ StageHeaderCell,
19
+ StageTextCell,
20
+ } from './types.js';
21
+
22
+ const ASPECT_CATEGORIES: AspectCategory[] = ['systems', 'actors', 'equipment', 'information_entities'];
23
+
24
+ const ASPECT_LABEL: Record<AspectCategory, string> = {
25
+ systems: 'Systems',
26
+ actors: 'Actors',
27
+ equipment: 'Equipment',
28
+ information_entities: 'Information',
29
+ };
30
+
31
+ // Sizing options always resolve to a number; the non-sizing options are opt-in
32
+ // (absent = feature disabled) and must stay optional in the resolved type.
33
+ type SizingOptionKeys = Exclude<
34
+ keyof ProcessBlueprintLayoutOptions,
35
+ 'complianceLane' | 'complianceInput' | 'visibleAspects' | 'visibleRows' | 'visibleStages'
36
+ >;
37
+
38
+ /**
39
+ * Options after defaults are applied: every sizing field is guaranteed present;
40
+ * the opt-in feature fields stay optional (undefined = feature disabled).
41
+ */
42
+ type ResolvedLayoutOptions = Required<Pick<ProcessBlueprintLayoutOptions, SizingOptionKeys>> &
43
+ Pick<ProcessBlueprintLayoutOptions, 'complianceLane' | 'complianceInput' | 'visibleAspects' | 'visibleRows' | 'visibleStages'>;
44
+
45
+ const DEFAULTS = {
46
+ legendColumnWidth: 140,
47
+ stageColumnWidth: 220,
48
+ stageHeaderHeight: 40,
49
+ goalRowHeight: 56,
50
+ resultRowHeight: 56,
51
+ aspectRowMinHeight: 60,
52
+ pillHeight: 40,
53
+ pillGap: 4,
54
+ cellPadding: 8,
55
+ // 17px gives comfortable breathing room between wrapped lines at typical
56
+ // webview font sizes (VS Code uses ~13–14px body, SVG text-secondary ≈ same).
57
+ textLineHeight: 17,
58
+ // 7.5px per char is a conservative estimate that causes wrap to trigger
59
+ // before text visually overflows the cell border.
60
+ textCharWidth: 7.5,
61
+ cellTextPadX: 10,
62
+ cellTextPadY: 10,
63
+ // No hard truncation: rows grow to fit all wrapped lines.
64
+ maxTextLines: 100,
65
+ } satisfies Required<Pick<ProcessBlueprintLayoutOptions, SizingOptionKeys>>;
66
+
67
+ interface RawPill {
68
+ category: AspectCategory;
69
+ entryIndex: number;
70
+ name: string;
71
+ id?: string;
72
+ startStageIndex: number;
73
+ endStageIndex: number;
74
+ }
75
+
76
+ function resolveOptions(options: ProcessBlueprintLayoutOptions | undefined): ResolvedLayoutOptions {
77
+ return { ...DEFAULTS, ...(options ?? {}) };
78
+ }
79
+
80
+ /**
81
+ * Greedy word-wrap to a max characters-per-line budget. Words longer than the
82
+ * budget are hard-split; if the result exceeds `maxLines`, the last kept line
83
+ * is truncated with an ellipsis so a single pathological cell can't grow the
84
+ * whole row unbounded.
85
+ */
86
+ function wrapText(text: string, maxChars: number, maxLines: number): string[] {
87
+ const trimmed = (text ?? '').trim();
88
+ if (trimmed.length === 0) return [];
89
+ const budget = Math.max(1, maxChars);
90
+
91
+ const words = trimmed.split(/\s+/);
92
+ const lines: string[] = [];
93
+ let cur = '';
94
+ const flush = (): void => {
95
+ if (cur.length > 0) {
96
+ lines.push(cur);
97
+ cur = '';
98
+ }
99
+ };
100
+
101
+ for (const word of words) {
102
+ if (word.length > budget) {
103
+ // Token wider than the cell: hard-break it across lines.
104
+ flush();
105
+ let rest = word;
106
+ while (rest.length > budget) {
107
+ lines.push(rest.slice(0, budget));
108
+ rest = rest.slice(budget);
109
+ }
110
+ cur = rest;
111
+ continue;
112
+ }
113
+ const candidate = cur.length > 0 ? `${cur} ${word}` : word;
114
+ if (candidate.length > budget) {
115
+ flush();
116
+ cur = word;
117
+ } else {
118
+ cur = candidate;
119
+ }
120
+ }
121
+ flush();
122
+
123
+ if (lines.length > maxLines) {
124
+ const capped = lines.slice(0, maxLines);
125
+ const last = capped[maxLines - 1];
126
+ const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
127
+ capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
128
+ return capped;
129
+ }
130
+ return lines;
131
+ }
132
+
133
+ function buildStageIndex(stages: Stage[]): Map<string, number> {
134
+ const idx = new Map<string, number>();
135
+ for (let i = 0; i < stages.length; i++) {
136
+ const sid = typeof stages[i]?.id === 'string' ? stages[i].id.trim() : '';
137
+ if (sid.length > 0 && !idx.has(sid)) {
138
+ idx.set(sid, i);
139
+ }
140
+ }
141
+ return idx;
142
+ }
143
+
144
+ function pillsForEntry(
145
+ category: AspectCategory,
146
+ entryIndex: number,
147
+ entry: AspectEntry,
148
+ stageIndexById: Map<string, number>,
149
+ ): RawPill[] {
150
+ if (!Array.isArray(entry.stages)) return [];
151
+
152
+ const idxs: number[] = [];
153
+ const seen = new Set<number>();
154
+ for (const ref of entry.stages) {
155
+ if (typeof ref !== 'string') continue;
156
+ const i = stageIndexById.get(ref.trim());
157
+ if (i === undefined) continue;
158
+ if (seen.has(i)) continue;
159
+ seen.add(i);
160
+ idxs.push(i);
161
+ }
162
+ idxs.sort((a, b) => a - b);
163
+
164
+ const pills: RawPill[] = [];
165
+ let runStart: number | null = null;
166
+ let runEnd: number | null = null;
167
+ for (const i of idxs) {
168
+ if (runStart === null || runEnd === null) {
169
+ runStart = i;
170
+ runEnd = i;
171
+ continue;
172
+ }
173
+ if (i === runEnd + 1) {
174
+ runEnd = i;
175
+ } else {
176
+ pills.push({
177
+ category,
178
+ entryIndex,
179
+ name: entry.name,
180
+ id: entry.id,
181
+ startStageIndex: runStart,
182
+ endStageIndex: runEnd,
183
+ });
184
+ runStart = i;
185
+ runEnd = i;
186
+ }
187
+ }
188
+ if (runStart !== null && runEnd !== null) {
189
+ pills.push({
190
+ category,
191
+ entryIndex,
192
+ name: entry.name,
193
+ id: entry.id,
194
+ startStageIndex: runStart,
195
+ endStageIndex: runEnd,
196
+ });
197
+ }
198
+ return pills;
199
+ }
200
+
201
+ function packPillsIntoSlots(pills: RawPill[]): { slot: number[]; maxSlot: number } {
202
+ // Greedy interval scheduling: stable order by startStageIndex.
203
+ const order = pills
204
+ .map((_, i) => i)
205
+ .sort((a, b) => {
206
+ const ds = pills[a].startStageIndex - pills[b].startStageIndex;
207
+ if (ds !== 0) return ds;
208
+ return pills[a].endStageIndex - pills[b].endStageIndex;
209
+ });
210
+
211
+ const slotEndByLevel: number[] = []; // slotEndByLevel[s] = last endStageIndex placed in slot s
212
+ const slot = new Array<number>(pills.length).fill(0);
213
+ let maxSlot = 0;
214
+
215
+ for (const i of order) {
216
+ const p = pills[i];
217
+ let placed = false;
218
+ for (let s = 0; s < slotEndByLevel.length; s++) {
219
+ if (slotEndByLevel[s] < p.startStageIndex) {
220
+ slot[i] = s;
221
+ slotEndByLevel[s] = p.endStageIndex;
222
+ placed = true;
223
+ break;
224
+ }
225
+ }
226
+ if (!placed) {
227
+ slot[i] = slotEndByLevel.length;
228
+ slotEndByLevel.push(p.endStageIndex);
229
+ }
230
+ if (slot[i] > maxSlot) maxSlot = slot[i];
231
+ }
232
+
233
+ return { slot, maxSlot };
234
+ }
235
+
236
+ // ── Compliance lane derivation ────────────────────────────────────────────────
237
+
238
+ /** ISO 8601 date comparison helper — no Date parsing, string comparison is valid for YYYY-MM-DD. */
239
+ function deadlineStatus(
240
+ deadline: string | undefined,
241
+ today: string,
242
+ ): 'past_due' | 'in_force' | 'upcoming' | 'none' {
243
+ if (!deadline) return 'none';
244
+ if (deadline < today) return 'past_due';
245
+ const daysAway = Math.round(
246
+ (new Date(deadline).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24),
247
+ );
248
+ return daysAway <= 30 ? 'in_force' : 'upcoming';
249
+ }
250
+
251
+ /**
252
+ * Derive the laid-out compliance row for a process blueprint.
253
+ *
254
+ * For each stage, gathers the laws that bind that stage (via the
255
+ * assertion → requirement → derived_from chain), applies the jurisdiction
256
+ * filter, and computes the three orthogonal decorations per law chip.
257
+ */
258
+ function deriveComplianceRow(
259
+ stages: Stage[],
260
+ stageIndexById: Map<string, number>,
261
+ input: ComplianceLaneInput,
262
+ opts: ResolvedLayoutOptions,
263
+ yStart: number,
264
+ ): ComplianceRow | undefined {
265
+ const {
266
+ complianceLane: laneConfig,
267
+ legendColumnWidth,
268
+ stageColumnWidth,
269
+ cellPadding,
270
+ pillHeight,
271
+ pillGap,
272
+ aspectRowMinHeight,
273
+ } = opts;
274
+
275
+ if (!laneConfig?.enabled) return undefined;
276
+
277
+ const today = laneConfig.referenceDate ?? new Date().toISOString().slice(0, 10);
278
+ const filterJurisdictions = laneConfig.jurisdictions?.length ? new Set(laneConfig.jurisdictions) : null;
279
+
280
+ // Build requirement index: id → requirement.
281
+ const reqById = new Map<string, ComplianceLaneRequirement>();
282
+ for (const r of input.requirements) {
283
+ if (r?.id) reqById.set(r.id, r);
284
+ }
285
+
286
+ // Per-stage accumulation: stageIdx → lawId → { worstStatus, deadline }
287
+ type LawAccumulator = { gap: boolean; deadline: string | undefined };
288
+ const stageAccum = new Map<number, Map<string, LawAccumulator>>();
289
+
290
+ for (const a of input.assertions) {
291
+ if (!a || typeof a !== 'object') continue;
292
+ const realisedVia: string[] = Array.isArray(a.realised_via) ? a.realised_via : [];
293
+ if (realisedVia.length === 0) continue;
294
+
295
+ const req = reqById.get(a.about);
296
+ if (!req) continue;
297
+
298
+ const lawIds: string[] = Array.isArray(req.derived_from)
299
+ ? (req.derived_from as string[]).filter((x): x is string => typeof x === 'string')
300
+ : [];
301
+ if (lawIds.length === 0) continue;
302
+
303
+ for (const stageRef of realisedVia) {
304
+ if (typeof stageRef !== 'string') continue;
305
+ const idx = stageIndexById.get(stageRef.trim());
306
+ if (idx === undefined) continue;
307
+
308
+ for (const lawId of lawIds) {
309
+ // Apply jurisdiction filter.
310
+ if (filterJurisdictions) {
311
+ const jur = input.codexJurisdictions?.[lawId];
312
+ if (!jur || !filterJurisdictions.has(jur)) continue;
313
+ }
314
+
315
+ if (!stageAccum.has(idx)) stageAccum.set(idx, new Map());
316
+ const lawMap = stageAccum.get(idx)!;
317
+ const existing = lawMap.get(lawId);
318
+ const isGap = a.status === 'non_compliant' || a.status === 'partial';
319
+ if (!existing) {
320
+ lawMap.set(lawId, { gap: isGap, deadline: req.deadline });
321
+ } else {
322
+ existing.gap = existing.gap || isGap;
323
+ // Keep the earlier deadline (more urgent).
324
+ if (req.deadline && (!existing.deadline || req.deadline < existing.deadline)) {
325
+ existing.deadline = req.deadline;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ if (stageAccum.size === 0) return undefined;
333
+
334
+ // Determine how many slots (rows) per stage.
335
+ const slotsPerStage = new Map<number, number>();
336
+ for (const [idx, lawMap] of stageAccum) {
337
+ slotsPerStage.set(idx, lawMap.size);
338
+ }
339
+ const maxSlots = Math.max(0, ...slotsPerStage.values());
340
+ if (maxSlots === 0) return undefined;
341
+
342
+ const contentHeight = maxSlots * (pillHeight + pillGap) - pillGap;
343
+ const rowHeight = Math.max(aspectRowMinHeight, contentHeight + 2 * cellPadding);
344
+
345
+ const chips: ComplianceChip[] = [];
346
+
347
+ for (const [stageIdx, lawMap] of stageAccum) {
348
+ const prevLawIds = new Set<string>(laneConfig.previousSnapshot?.[stages[stageIdx]?.id ?? ''] ?? []);
349
+ let slot = 0;
350
+
351
+ for (const [lawId, accum] of lawMap) {
352
+ const decorations: ComplianceDecoration[] = [];
353
+ if (!prevLawIds.has(lawId)) decorations.push('new');
354
+ if (accum.gap) {
355
+ decorations.push('gap');
356
+ const ds = deadlineStatus(accum.deadline, today);
357
+ if (ds !== 'none') decorations.push('deadline');
358
+ }
359
+
360
+ const x = legendColumnWidth + stageIdx * stageColumnWidth + cellPadding;
361
+ const width = stageColumnWidth - 2 * cellPadding;
362
+ const y = yStart + cellPadding + slot * (pillHeight + pillGap);
363
+ chips.push({ stageIndex: stageIdx, lawId, decorations, x, y, width, height: pillHeight });
364
+ slot++;
365
+ }
366
+ }
367
+
368
+ return { y: yStart, height: rowHeight, chips };
369
+ }
370
+
371
+ export function layoutProcessBlueprint(
372
+ file: ProcessBlueprintFile,
373
+ options?: ProcessBlueprintLayoutOptions,
374
+ ): ProcessBlueprintLayout {
375
+ const opts = resolveOptions(options);
376
+
377
+ const pb = file.process_blueprint;
378
+
379
+ // Filter stages by visibleStages if set
380
+ const allStages = Array.isArray(pb?.stages) ? pb.stages : [];
381
+ const stages = opts.visibleStages
382
+ ? allStages.filter(s => s?.id != null && opts.visibleStages!.includes(s.id))
383
+ : allStages;
384
+ const stageIndexById = buildStageIndex(stages);
385
+
386
+ // Row visibility from visibleRows
387
+ const visRows = opts.visibleRows;
388
+ const showGoal = !visRows || (visRows as string[]).includes('goal');
389
+ const showResult = !visRows || (visRows as string[]).includes('result');
390
+ const effectiveVisibleAspects: AspectCategory[] | undefined = visRows
391
+ ? (ASPECT_CATEGORIES.filter(c => (visRows as string[]).includes(c)) as AspectCategory[])
392
+ : opts.visibleAspects;
393
+ const effectiveComplianceEnabled =
394
+ opts.complianceLane?.enabled === true &&
395
+ (!visRows || (visRows as string[]).includes('compliance'));
396
+
397
+ // Stage headers
398
+ const stageHeaders: StageHeaderCell[] = stages.map((s, i) => ({
399
+ stageIndex: i,
400
+ id: s?.id ?? '',
401
+ name: s?.name ?? '',
402
+ x: opts.legendColumnWidth + i * opts.stageColumnWidth,
403
+ y: 0,
404
+ width: opts.stageColumnWidth,
405
+ height: opts.stageHeaderHeight,
406
+ }));
407
+
408
+ // Goal + result rows. Text is word-wrapped to the column width, and each row
409
+ // grows to fit the tallest cell so nothing is clipped.
410
+ const maxCharsPerLine = Math.max(
411
+ 4,
412
+ Math.floor((opts.stageColumnWidth - 2 * opts.cellTextPadX) / opts.textCharWidth),
413
+ );
414
+ const wrap = (text: string): string[] => wrapText(text, maxCharsPerLine, opts.maxTextLines);
415
+ const rowHeightFor = (lineCounts: number[], minHeight: number): number => {
416
+ const maxLines = Math.max(1, ...lineCounts);
417
+ return Math.max(minHeight, maxLines * opts.textLineHeight + 2 * opts.cellTextPadY);
418
+ };
419
+
420
+ const goalLines = stages.map(s => wrap(s?.goal ?? ''));
421
+ const resultLines = stages.map(s => wrap(s?.result ?? ''));
422
+
423
+ let cursorY = opts.stageHeaderHeight;
424
+ let goalRowY = 0, goalRowHeight = 0;
425
+ let resultRowY = 0, resultRowHeight = 0;
426
+ const goalCells: StageTextCell[] = [];
427
+ const resultCells: StageTextCell[] = [];
428
+
429
+ if (showGoal) {
430
+ goalRowY = cursorY;
431
+ goalRowHeight = rowHeightFor(goalLines.map(l => l.length), opts.goalRowHeight);
432
+ goalCells.push(...stages.map((s, i) => ({
433
+ stageIndex: i, text: s?.goal ?? '', lines: goalLines[i],
434
+ x: opts.legendColumnWidth + i * opts.stageColumnWidth, y: goalRowY,
435
+ width: opts.stageColumnWidth, height: goalRowHeight,
436
+ })));
437
+ cursorY += goalRowHeight;
438
+ }
439
+ if (showResult) {
440
+ resultRowY = cursorY;
441
+ resultRowHeight = rowHeightFor(resultLines.map(l => l.length), opts.resultRowHeight);
442
+ resultCells.push(...stages.map((s, i) => ({
443
+ stageIndex: i, text: s?.result ?? '', lines: resultLines[i],
444
+ x: opts.legendColumnWidth + i * opts.stageColumnWidth, y: resultRowY,
445
+ width: opts.stageColumnWidth, height: resultRowHeight,
446
+ })));
447
+ cursorY += resultRowHeight;
448
+ }
449
+ const aspectsStartY = cursorY;
450
+
451
+ // Aspect rows: only categories with at least one entry, in fixed order.
452
+ // When effectiveVisibleAspects is set, skip categories not in the list.
453
+ const aspectCategoriesToRender =
454
+ effectiveVisibleAspects !== undefined
455
+ ? ASPECT_CATEGORIES.filter(c => effectiveVisibleAspects.includes(c))
456
+ : ASPECT_CATEGORIES;
457
+
458
+ const aspectRows: AspectRow[] = [];
459
+ let aspectCursorY = aspectsStartY;
460
+
461
+ for (const category of aspectCategoriesToRender) {
462
+ const arr = pb?.[category];
463
+ if (!Array.isArray(arr) || arr.length === 0) continue;
464
+
465
+ const rawPills: RawPill[] = [];
466
+ for (let i = 0; i < arr.length; i++) {
467
+ const entry = arr[i];
468
+ if (!entry || typeof entry !== 'object') continue;
469
+ rawPills.push(...pillsForEntry(category, i, entry as AspectEntry, stageIndexById));
470
+ }
471
+
472
+ // If every entry in this category had no resolvable stage refs, drop the row.
473
+ if (rawPills.length === 0) continue;
474
+
475
+ const { slot, maxSlot } = packPillsIntoSlots(rawPills);
476
+ const contentHeight = (maxSlot + 1) * (opts.pillHeight + opts.pillGap) - opts.pillGap;
477
+ const rowHeight = Math.max(
478
+ opts.aspectRowMinHeight,
479
+ contentHeight + 2 * opts.cellPadding,
480
+ );
481
+
482
+ const pills: AspectPill[] = rawPills.map((p, i) => {
483
+ const x = opts.legendColumnWidth + p.startStageIndex * opts.stageColumnWidth + opts.cellPadding;
484
+ const width =
485
+ (p.endStageIndex - p.startStageIndex + 1) * opts.stageColumnWidth - 2 * opts.cellPadding;
486
+ const y = aspectCursorY + opts.cellPadding + slot[i] * (opts.pillHeight + opts.pillGap);
487
+ const pillMaxChars = Math.max(4, Math.floor((width - 2 * opts.cellPadding) / opts.textCharWidth));
488
+ const nameWrapped = wrapText(p.name, pillMaxChars, 2);
489
+ // Show id as second line only when the name fits on one line; otherwise
490
+ // use both lines for the name so it isn't truncated.
491
+ const lines: string[] = p.id && nameWrapped.length <= 1
492
+ ? [...nameWrapped, p.id].slice(0, 2)
493
+ : nameWrapped;
494
+ return {
495
+ category: p.category,
496
+ entryIndex: p.entryIndex,
497
+ name: p.name,
498
+ id: p.id,
499
+ startStageIndex: p.startStageIndex,
500
+ endStageIndex: p.endStageIndex,
501
+ x,
502
+ y,
503
+ width,
504
+ height: opts.pillHeight,
505
+ lines,
506
+ };
507
+ });
508
+
509
+ aspectRows.push({
510
+ category,
511
+ y: aspectCursorY,
512
+ height: rowHeight,
513
+ pills,
514
+ });
515
+
516
+ aspectCursorY += rowHeight;
517
+ }
518
+
519
+ // Compliance lane — derived from assertions/requirements, rendered after aspect rows.
520
+ let complianceRow: import('./types.js').ComplianceRow | undefined;
521
+ if (effectiveComplianceEnabled && opts.complianceInput) {
522
+ complianceRow = deriveComplianceRow(
523
+ stages,
524
+ stageIndexById,
525
+ opts.complianceInput,
526
+ opts,
527
+ aspectCursorY,
528
+ );
529
+ if (complianceRow) {
530
+ aspectCursorY += complianceRow.height;
531
+ }
532
+ }
533
+
534
+ // Legend (left column labels): one entry per row.
535
+ const legend: LegendCell[] = [
536
+ ...(showGoal ? [{ kind: 'goal' as const, label: 'Goal', y: goalRowY, height: goalRowHeight }] : []),
537
+ ...(showResult ? [{ kind: 'result' as const, label: 'Result', y: resultRowY, height: resultRowHeight }] : []),
538
+ ...aspectRows.map<LegendCell>(row => ({
539
+ kind: 'aspect',
540
+ category: row.category,
541
+ label: ASPECT_LABEL[row.category],
542
+ y: row.y,
543
+ height: row.height,
544
+ })),
545
+ ...(complianceRow ? [{ kind: 'compliance' as const, label: 'Compliance', y: complianceRow.y, height: complianceRow.height }] : []),
546
+ ];
547
+
548
+ const totalWidth = opts.legendColumnWidth + stages.length * opts.stageColumnWidth;
549
+ const totalHeight = aspectCursorY;
550
+
551
+ return {
552
+ bounds: { x: 0, y: 0, width: totalWidth, height: totalHeight },
553
+ legendColumnWidth: opts.legendColumnWidth,
554
+ stageColumnWidth: opts.stageColumnWidth,
555
+ textLineHeight: opts.textLineHeight,
556
+ cellTextPadX: opts.cellTextPadX,
557
+ legend,
558
+ stageHeaders,
559
+ goalCells,
560
+ resultCells,
561
+ aspectRows,
562
+ complianceRow,
563
+ };
564
+ }