@elabs-ai/components-process 4.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/core/index.d.ts +1029 -0
  4. package/dist/core/index.js +1553 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/process-worker.js +462 -0
  7. package/dist/core/process-worker.js.map +1 -0
  8. package/dist/index.d.ts +1153 -0
  9. package/dist/index.js +3146 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/test/index.d.ts +196 -0
  12. package/dist/test/index.js +527 -0
  13. package/dist/test/index.js.map +1 -0
  14. package/package.json +80 -0
  15. package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
  16. package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
  17. package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
  18. package/src/abstraction-controls/abstraction-controls.tsx +288 -0
  19. package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
  20. package/src/abstraction-controls/auto-abstraction.ts +128 -0
  21. package/src/abstraction-controls/index.ts +4 -0
  22. package/src/core/abstract-graph.test.ts +209 -0
  23. package/src/core/abstract-graph.ts +407 -0
  24. package/src/core/adapters/csv.test.ts +131 -0
  25. package/src/core/adapters/csv.ts +146 -0
  26. package/src/core/adapters/flat.test.ts +149 -0
  27. package/src/core/adapters/flat.ts +168 -0
  28. package/src/core/aggregate-performance.test.ts +208 -0
  29. package/src/core/aggregate-performance.ts +200 -0
  30. package/src/core/detect-rework.test.ts +134 -0
  31. package/src/core/detect-rework.ts +100 -0
  32. package/src/core/discover-graph.test.ts +378 -0
  33. package/src/core/discover-graph.ts +202 -0
  34. package/src/core/duration-stats.test.ts +116 -0
  35. package/src/core/duration-stats.ts +162 -0
  36. package/src/core/event-log.test.ts +224 -0
  37. package/src/core/event-log.ts +244 -0
  38. package/src/core/extract-variants.test.ts +126 -0
  39. package/src/core/extract-variants.ts +140 -0
  40. package/src/core/filter-log.test.ts +193 -0
  41. package/src/core/filter-log.ts +215 -0
  42. package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
  43. package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
  44. package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
  45. package/src/core/fixtures/order-to-cash-small.json +200 -0
  46. package/src/core/fixtures/synthetic-log.test.ts +109 -0
  47. package/src/core/fixtures/synthetic-log.ts +167 -0
  48. package/src/core/index.ts +118 -0
  49. package/src/core/reconcile-graph.test.ts +175 -0
  50. package/src/core/reconcile-graph.ts +107 -0
  51. package/src/core/scale.test.ts +80 -0
  52. package/src/core/scale.ts +100 -0
  53. package/src/core/types.ts +151 -0
  54. package/src/core/worker/create-process-worker.test.ts +255 -0
  55. package/src/core/worker/create-process-worker.ts +211 -0
  56. package/src/core/worker/process-worker.ts +80 -0
  57. package/src/index.ts +29 -0
  58. package/src/metric-layer-switch/index.ts +6 -0
  59. package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
  60. package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
  61. package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
  62. package/src/process-explorer.stories.tsx +392 -0
  63. package/src/process-kpi-strip/index.ts +6 -0
  64. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
  65. package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
  66. package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
  67. package/src/process-map/index.ts +13 -0
  68. package/src/process-map/map-model.test.ts +326 -0
  69. package/src/process-map/map-model.ts +873 -0
  70. package/src/process-map/process-activity-node.tsx +200 -0
  71. package/src/process-map/process-map-context.ts +71 -0
  72. package/src/process-map/process-map.stories.tsx +673 -0
  73. package/src/process-map/process-map.test.tsx +523 -0
  74. package/src/process-map/process-map.tsx +979 -0
  75. package/src/process-map/process-transition-edge.test.tsx +160 -0
  76. package/src/process-map/process-transition-edge.tsx +151 -0
  77. package/src/process-map/use-process-layout.test.tsx +265 -0
  78. package/src/process-map/use-process-layout.ts +315 -0
  79. package/src/test/contract.test.ts +99 -0
  80. package/src/test/contract.ts +118 -0
  81. package/src/test/doubles.test.tsx +51 -0
  82. package/src/test/doubles.tsx +82 -0
  83. package/src/test/index.ts +34 -0
  84. package/src/test/primitives.tsx +35 -0
  85. package/src/use-process-explorer/index.ts +8 -0
  86. package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
  87. package/src/use-process-explorer/use-process-explorer.ts +540 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * map-model — the pure `ProcessGraph → React Flow nodes/edges` mapping (RM-051).
3
+ *
4
+ * Everything the process map DECIDES about a graph lives here, and nothing here renders:
5
+ * no React, no DOM, no engine calls. That is deliberate and load-bearing in three ways.
6
+ *
7
+ * 1. **The canvas and the accessible `TableView` twin read ONE model.** Both surfaces are
8
+ * built from the same {@link ProcessMapModel}, so a number can never drift between the
9
+ * picture and the table a screen-reader user is actually given.
10
+ * 2. **Encoding is testable without a browser.** Stroke widths, saturations, printed
11
+ * labels and selection states are plain data here, so a unit test can assert the
12
+ * encoding rules instead of screenshotting them.
13
+ * 3. **Layout can be cached independently of metric.** The model changes whenever the
14
+ * metric changes; the STRUCTURE key (see {@link processGraphStructureKey}) does not, so
15
+ * `useProcessLayout` reuses positions across a metric switch.
16
+ *
17
+ * ## The encoding contract (WCAG 1.4.1 — colour is never the only channel)
18
+ *
19
+ * Two flow-layer defects (#321 edge colour collapsing on a low-chroma palette, #297 edge
20
+ * colour not refreshing on a theme switch) mean edge COLOUR cannot be trusted to separate
21
+ * two values. This module is designed so it never has to be:
22
+ *
23
+ * - an **edge** metric reaches the reader through `weight` (stroke width, the
24
+ * `[1.5, 8]` px linear min-max clamp `computeEdgeWeightScale` applies) AND through a
25
+ * printed `label` pill — colour (`value`/`valueDomain`) is the third channel, never the
26
+ * first;
27
+ * - a **node** metric reaches the reader through a printed value AND a bar whose LENGTH is
28
+ * the same fraction the fill saturation encodes — saturation alone would be colour-only;
29
+ * - **self-loop**, **back-edge** and each **selection** state carry a shape, dash or
30
+ * opacity change, not a hue change.
31
+ *
32
+ * Render any story in greyscale and two different metric values are still distinguishable.
33
+ */
34
+ // Deep module imports rather than the `../core` barrel ON PURPOSE. `/core` builds in its
35
+ // own tsup pass because it must stay engine-free (ADR 0034), and `createProcessWorker`'s
36
+ // `new URL("./process-worker.ts", import.meta.url)` literal only resolves next to
37
+ // `dist/core/index.js`. Reaching through the barrel would put that literal on the trunk
38
+ // bundle's tree-shaking mercy; naming the four modules the map actually needs takes it off
39
+ // the table entirely. Same package, so this is an ordinary relative import.
40
+ import type { Edge, Node } from "@xyflow/react";
41
+ import { performanceValue } from "../core/aggregate-performance";
42
+ import { EDGE_KEY_SEPARATOR } from "../core/discover-graph";
43
+ import { minMax } from "../core/scale";
44
+ import type { ReworkStats } from "../core/detect-rework";
45
+ import type { FilterSpec } from "../core/filter-log";
46
+ import type {
47
+ ActivityStats,
48
+ FrequencyMode,
49
+ PerformanceAgg,
50
+ ProcessGraph,
51
+ TransitionStats,
52
+ } from "../core/types";
53
+
54
+ // ── Public vocabulary ────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * What a node or an edge is painted with: a frequency reading of the discovered counts,
58
+ * or an aggregate of the measured durations.
59
+ */
60
+ export type ProcessMetric = FrequencyMode | PerformanceAgg;
61
+
62
+ /** The three metric slots a process map paints from. */
63
+ export interface ProcessMetricSpec {
64
+ /** Drives node saturation, the node's meter bar and the node's printed value. */
65
+ node: ProcessMetric;
66
+ /** Drives edge stroke width and the edge's printed label pill. */
67
+ edge: ProcessMetric;
68
+ /** Optional second reading, printed beside the first on both nodes and edges. */
69
+ secondary?: ProcessMetric;
70
+ }
71
+
72
+ /** Which half of the graph a selection points at. */
73
+ export type ProcessSelectionKind = "activity" | "transition";
74
+
75
+ /**
76
+ * A coordinated selection. `id` is the ACTIVITY NAME for `"activity"` and the
77
+ * `source`+separator+`target` edge key (see {@link processEdgeId}) for `"transition"` —
78
+ * the same keys `/core` uses, so a selection round-trips through `filterLog` and a URL
79
+ * unchanged.
80
+ */
81
+ export interface ProcessSelection {
82
+ kind: ProcessSelectionKind;
83
+ id: string;
84
+ }
85
+
86
+ /**
87
+ * What the map's filter menu emits. A strict subset of `/core`'s `FilterSpec`, so a
88
+ * consumer can hand it straight to `filterLog` with no translation layer — which is the
89
+ * point: an intent the map invents and nothing can consume is not an intent.
90
+ */
91
+ export type ProcessFilterIntent = Extract<
92
+ FilterSpec,
93
+ { kind: "with" } | { kind: "without" } | { kind: "startsWith" } | { kind: "endsWith" }
94
+ >;
95
+
96
+ /** The four intents the map's own menu offers, in menu order. */
97
+ export const PROCESS_FILTER_INTENT_KINDS = ["with", "without", "startsWith", "endsWith"] as const;
98
+
99
+ /** Human wording for each intent, used by the menu's VISIBLE item text. */
100
+ export const PROCESS_FILTER_INTENT_LABELS: Readonly<Record<ProcessFilterIntent["kind"], string>> =
101
+ Object.freeze({
102
+ with: "Keep cases containing",
103
+ without: "Keep cases without",
104
+ startsWith: "Keep cases starting with",
105
+ endsWith: "Keep cases ending with",
106
+ });
107
+
108
+ /**
109
+ * The locale message key for each intent's ACCESSIBLE name (#346), one activity's block at
110
+ * a time — the visible text alone ({@link PROCESS_FILTER_INTENT_LABELS}) is not unique for
111
+ * a transition's menu, which offers the same four intents once per endpoint: eight items,
112
+ * four distinct visible strings, each doubled. Each message takes an `{activity}` var, so
113
+ * the accessible name states which activity the item filters by even though the visible
114
+ * text (kept unsuffixed, so the menu stays visually compact) does not.
115
+ */
116
+ export const PROCESS_FILTER_INTENT_MESSAGE_KEYS: Readonly<
117
+ Record<ProcessFilterIntent["kind"], string>
118
+ > = Object.freeze({
119
+ with: "process.map.filterIntentWith",
120
+ without: "process.map.filterIntentWithout",
121
+ startsWith: "process.map.filterIntentStartsWith",
122
+ endsWith: "process.map.filterIntentEndsWith",
123
+ });
124
+
125
+ /**
126
+ * How an element relates to the current selection AND the active filter.
127
+ *
128
+ * - `"selected"` — the element the reader picked.
129
+ * - `"associated"` — ordinary; either nothing is selected, or this element touches the
130
+ * selection, and no active filter excludes it.
131
+ * - `"excluded"` — outside the selection's neighbourhood, or dropped by the active filter;
132
+ * dimmed, but still fully operable. Clicking an excluded element is how a reader filters
133
+ * it back in, so it is never `aria-disabled` — see {@link resolveSelectionState}. The
134
+ * dimming is a REDUNDANT cue: `activityAriaLabel`/`transitionAriaLabel` already append the
135
+ * word "excluded" to the element's accessible name, so the state reaches assistive
136
+ * technology through real text, not only through opacity.
137
+ */
138
+ export type ProcessSelectionState = "selected" | "associated" | "excluded";
139
+
140
+ /**
141
+ * The ONE ghosting rung a filter-excluded element dims against, shared by every call site
142
+ * that owns a non-text mark: the activity node's meter fill, the transition edge's stroke
143
+ * (`ProcessTransitionEdge`'s wrapping `<g>`), and — via `labelProps.className` — the
144
+ * transition's own label pill's frame (`FlowWeightedEdge`/`FlowSelfLoopEdge`). Before #351/
145
+ * #352 this was TWO separate literals (`opacity-35` on the activity node's whole subtree,
146
+ * `EXCLUDED_OPACITY` on the edge) and the pill had no ghost treatment at all.
147
+ *
148
+ * **This value dims marks, never text ink.** `ProcessActivityNode` and the pill's own label
149
+ * text are deliberately excluded from it — measured (see #352's follow-up), a `0.35` alpha
150
+ * over EITHER's own already-modest undimmed contrast (5.97:1–6.08:1 for the eyebrow, higher
151
+ * for the title/label) composites well under the WCAG 4.5:1 floor in both reference themes.
152
+ * Ghosted TEXT stays at full ink; the excluded state instead reaches the reader through the
153
+ * activity node's `border-strong` boundary swap and the pill's dashed frame — non-opacity,
154
+ * non-colour-alone channels (WCAG 1.4.1) — plus the accessible name's own appended
155
+ * "excluded" word, same as before.
156
+ */
157
+ export const GHOST_OPACITY = 0.35;
158
+
159
+ /**
160
+ * Per-element selection/filter states, sparse — an id with no entry defaults through
161
+ * {@link resolveSelectionState}'s own rules. Keyed exactly like {@link ProcessSelection}:
162
+ * activity name for `activities`, {@link processEdgeId} for `transitions`.
163
+ *
164
+ * `variants` is carried here for API symmetry with the other two namespaces even though
165
+ * `ProcessMap` does not read it (the variant LIST narrows rather than ghosts — decision
166
+ * `RM-052-tristate-decision.md` §3); a future variant-explorer view may.
167
+ */
168
+ export interface ProcessSelectionStates {
169
+ activities?: Readonly<Record<string, ProcessSelectionState>>;
170
+ transitions?: Readonly<Record<string, ProcessSelectionState>>;
171
+ variants?: Readonly<Record<string, ProcessSelectionState>>;
172
+ }
173
+
174
+ // ── Node / edge data ─────────────────────────────────────────────────────────
175
+
176
+ /** `data` carried by every {@link ProcessMapNode}. */
177
+ export interface ProcessActivityNodeData extends Record<string, unknown> {
178
+ /** Activity name — the node identity and its visible title. */
179
+ title: string;
180
+ /** What the primary number MEANS ("Cases", "Median duration"), shown as the eyebrow. */
181
+ metricLabel: string;
182
+ /** The primary metric, already formatted for display. */
183
+ primaryLabel: string;
184
+ /** The primary metric as a number, for the meter and for tests. */
185
+ primaryValue: number;
186
+ /** The secondary metric, already formatted. Absent when `metric.secondary` is unset. */
187
+ secondaryLabel?: string;
188
+ /** `primaryValue` as a `0..1` fraction of the graph's node-metric domain. */
189
+ saturation: number;
190
+ isStart: boolean;
191
+ isEnd: boolean;
192
+ /** Repeat executions of this activity across the log; omitted when no rework data. */
193
+ reworkCount?: number;
194
+ selectionState: ProcessSelectionState;
195
+ }
196
+
197
+ /** `data` carried by every {@link ProcessMapEdge}. */
198
+ export interface ProcessTransitionEdgeData extends Record<string, unknown> {
199
+ source: string;
200
+ target: string;
201
+ /** Drives stroke width through `computeEdgeWeightScale`'s `[1.5, 8]` px min-max clamp. */
202
+ weight: number;
203
+ /** Drives stroke colour — the THIRD channel, never the first. */
204
+ value: number;
205
+ /** `[min, max]` the colour ramp interpolates `value` across. */
206
+ valueDomain: [number, number];
207
+ /** The printed pill — the second, colour-free channel for the same measure as `weight`. */
208
+ label: string;
209
+ secondaryLabel?: string;
210
+ isSelfLoop: boolean;
211
+ isBackEdge: boolean;
212
+ selectionState: ProcessSelectionState;
213
+ }
214
+
215
+ /** A process-map activity node. Register as `nodeTypes={{ "process-activity": … }}`. */
216
+ export type ProcessMapNode = Node<ProcessActivityNodeData, "process-activity">;
217
+ /** A process-map transition edge. Register as `edgeTypes={{ "process-transition": … }}`. */
218
+ export type ProcessMapEdge = Edge<ProcessTransitionEdgeData, "process-transition">;
219
+
220
+ /** One row of the accessible `TableView` twin — activities half. */
221
+ export interface ProcessActivityRow {
222
+ id: string;
223
+ title: string;
224
+ primaryLabel: string;
225
+ secondaryLabel?: string;
226
+ reworkCount?: number;
227
+ role: string;
228
+ selectionState: ProcessSelectionState;
229
+ }
230
+
231
+ /** One row of the accessible `TableView` twin — transitions half. */
232
+ export interface ProcessTransitionRow {
233
+ id: string;
234
+ source: string;
235
+ target: string;
236
+ primaryLabel: string;
237
+ secondaryLabel?: string;
238
+ shape: string;
239
+ selectionState: ProcessSelectionState;
240
+ }
241
+
242
+ /** Everything the canvas and the table are both rendered from. */
243
+ export interface ProcessMapModel {
244
+ nodes: ProcessMapNode[];
245
+ edges: ProcessMapEdge[];
246
+ /** `[min, max]` of the node metric across the graph. */
247
+ nodeDomain: [number, number];
248
+ /** `[min, max]` of the edge metric across the graph — the `Legend` scale's domain. */
249
+ edgeDomain: [number, number];
250
+ /** What the node metric means, e.g. "Cases". */
251
+ nodeMetricLabel: string;
252
+ /** What the edge metric means, e.g. "Transitions". */
253
+ edgeMetricLabel: string;
254
+ activityRows: ProcessActivityRow[];
255
+ transitionRows: ProcessTransitionRow[];
256
+ /** Format one edge-metric value the way the map prints it — used by the `Legend`. */
257
+ formatEdgeValue: (value: number) => string;
258
+ /**
259
+ * How many activities/transitions the active selection or filter has excluded (#375) —
260
+ * derived once here so the canvas branch, the table branch and any future consumer read
261
+ * one number instead of recomputing it three times, and so the summary is unit-testable
262
+ * without a render.
263
+ */
264
+ excludedCounts: ProcessMapExcludedCounts;
265
+ }
266
+
267
+ /** See {@link ProcessMapModel.excludedCounts}. */
268
+ export interface ProcessMapExcludedCounts {
269
+ activities: number;
270
+ totalActivities: number;
271
+ transitions: number;
272
+ totalTransitions: number;
273
+ }
274
+
275
+ // ── Metric resolution ────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * The frequency readings an ACTIVITY can actually answer from `ActivityStats`.
279
+ *
280
+ * `relative_antecedent` / `relative_consequent` are edge-only readings (they are shares of
281
+ * a source's or a target's traffic — a node has no antecedent), and `max_repetitions`
282
+ * needs the per-case maximum, which discovery does not retain. Rather than invent a
283
+ * number for those three, {@link resolveActivityFrequencyMode} maps them onto the nearest
284
+ * reading the data supports and the RESOLVED mode is what labels the value — so the map
285
+ * never prints one measure under another measure's name.
286
+ */
287
+ export type ActivityFrequencyMode = Extract<
288
+ FrequencyMode,
289
+ "absolute" | "absolute_case" | "relative" | "relative_case"
290
+ >;
291
+
292
+ /** See {@link ActivityFrequencyMode} — a total, documented, tested resolution. */
293
+ export function resolveActivityFrequencyMode(mode: FrequencyMode): ActivityFrequencyMode {
294
+ switch (mode) {
295
+ case "absolute_case":
296
+ return "absolute_case";
297
+ case "relative":
298
+ return "relative";
299
+ case "relative_case":
300
+ case "relative_antecedent":
301
+ case "relative_consequent":
302
+ return "relative_case";
303
+ case "max_repetitions":
304
+ case "absolute":
305
+ default:
306
+ return "absolute";
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The frequency readings a TRANSITION can answer from `TransitionStats`.
312
+ *
313
+ * Only `max_repetitions` is unsupported — the per-case maximum is not retained by
314
+ * discovery — and it resolves to `absolute`, the count it is a maximum of.
315
+ */
316
+ export type TransitionFrequencyMode = Exclude<FrequencyMode, "max_repetitions">;
317
+
318
+ /** See {@link TransitionFrequencyMode}. */
319
+ export function resolveTransitionFrequencyMode(mode: FrequencyMode): TransitionFrequencyMode {
320
+ return mode === "max_repetitions" ? "absolute" : mode;
321
+ }
322
+
323
+ const PERFORMANCE_AGGS = new Set<string>([
324
+ "median",
325
+ "mean",
326
+ "min",
327
+ "max",
328
+ "sum",
329
+ "p90",
330
+ "trimmed_mean",
331
+ ]);
332
+
333
+ /** Whether a metric names a duration aggregate rather than a frequency reading. */
334
+ export function isPerformanceMetric(metric: ProcessMetric): metric is PerformanceAgg {
335
+ return PERFORMANCE_AGGS.has(metric);
336
+ }
337
+
338
+ const PERFORMANCE_AGG_LABELS: Readonly<Record<PerformanceAgg, string>> = Object.freeze({
339
+ median: "Median duration",
340
+ mean: "Mean duration",
341
+ min: "Fastest",
342
+ max: "Slowest",
343
+ sum: "Total duration",
344
+ p90: "90th percentile duration",
345
+ trimmed_mean: "Trimmed mean duration",
346
+ });
347
+
348
+ const ACTIVITY_FREQUENCY_LABELS: Readonly<Record<ActivityFrequencyMode, string>> = Object.freeze({
349
+ absolute: "Occurrences",
350
+ absolute_case: "Cases",
351
+ relative: "Share of events",
352
+ relative_case: "Share of cases",
353
+ });
354
+
355
+ const TRANSITION_FREQUENCY_LABELS: Readonly<Record<TransitionFrequencyMode, string>> =
356
+ Object.freeze({
357
+ absolute: "Transitions",
358
+ absolute_case: "Cases",
359
+ relative: "Share of transitions",
360
+ relative_case: "Share of cases",
361
+ relative_antecedent: "Share of source traffic",
362
+ relative_consequent: "Share of target traffic",
363
+ });
364
+
365
+ /** What a node's metric is CALLED once resolved — the eyebrow and the a11y name use it. */
366
+ export function nodeMetricLabel(metric: ProcessMetric): string {
367
+ return isPerformanceMetric(metric)
368
+ ? PERFORMANCE_AGG_LABELS[metric]
369
+ : ACTIVITY_FREQUENCY_LABELS[resolveActivityFrequencyMode(metric)];
370
+ }
371
+
372
+ /** What an edge's metric is CALLED once resolved. */
373
+ export function edgeMetricLabel(metric: ProcessMetric): string {
374
+ return isPerformanceMetric(metric)
375
+ ? PERFORMANCE_AGG_LABELS[metric]
376
+ : TRANSITION_FREQUENCY_LABELS[resolveTransitionFrequencyMode(metric)];
377
+ }
378
+
379
+ /** Whether a resolved reading is a `0..1` share rather than a count. */
380
+ function isShare(metric: ProcessMetric): boolean {
381
+ if (isPerformanceMetric(metric)) return false;
382
+ const resolved = resolveTransitionFrequencyMode(metric);
383
+ return resolved !== "absolute" && resolved !== "absolute_case";
384
+ }
385
+
386
+ // ── Metric values ────────────────────────────────────────────────────────────
387
+
388
+ /** The node metric of one activity, as a plain number. Pure and total. */
389
+ export function activityMetricValue(
390
+ activity: ActivityStats,
391
+ metric: ProcessMetric,
392
+ totals: ProcessGraph["totals"],
393
+ ): number {
394
+ if (isPerformanceMetric(metric)) return performanceValue(activity.duration, metric);
395
+ switch (resolveActivityFrequencyMode(metric)) {
396
+ case "absolute_case":
397
+ return activity.cases;
398
+ case "relative":
399
+ return totals.events === 0 ? 0 : activity.instances / totals.events;
400
+ case "relative_case":
401
+ return totals.cases === 0 ? 0 : activity.cases / totals.cases;
402
+ case "absolute":
403
+ default:
404
+ return activity.instances;
405
+ }
406
+ }
407
+
408
+ /** Denominators the two per-endpoint edge shares need, computed once per graph. */
409
+ export interface ProcessEdgeDenominators {
410
+ /** Total of every transition count in the graph. */
411
+ total: number;
412
+ /** Activity → total count of the transitions LEAVING it. */
413
+ outgoing: Map<string, number>;
414
+ /** Activity → total count of the transitions ENTERING it. */
415
+ incoming: Map<string, number>;
416
+ }
417
+
418
+ /** Build the per-graph denominators the antecedent/consequent shares divide by. */
419
+ export function processEdgeDenominators(
420
+ transitions: readonly TransitionStats[],
421
+ ): ProcessEdgeDenominators {
422
+ const outgoing = new Map<string, number>();
423
+ const incoming = new Map<string, number>();
424
+ let total = 0;
425
+ for (const t of transitions) {
426
+ total += t.count;
427
+ outgoing.set(t.source, (outgoing.get(t.source) ?? 0) + t.count);
428
+ incoming.set(t.target, (incoming.get(t.target) ?? 0) + t.count);
429
+ }
430
+ return { total, outgoing, incoming };
431
+ }
432
+
433
+ function ratio(numerator: number, denominator: number): number {
434
+ return denominator === 0 ? 0 : numerator / denominator;
435
+ }
436
+
437
+ /** The edge metric of one transition, as a plain number. Pure and total. */
438
+ export function transitionMetricValue(
439
+ transition: TransitionStats,
440
+ metric: ProcessMetric,
441
+ totals: ProcessGraph["totals"],
442
+ denominators: ProcessEdgeDenominators,
443
+ ): number {
444
+ if (isPerformanceMetric(metric)) return performanceValue(transition.duration, metric);
445
+ switch (resolveTransitionFrequencyMode(metric)) {
446
+ case "absolute_case":
447
+ return transition.caseCount;
448
+ case "relative":
449
+ return ratio(transition.count, denominators.total);
450
+ case "relative_case":
451
+ return ratio(transition.caseCount, totals.cases);
452
+ case "relative_antecedent":
453
+ return ratio(transition.count, denominators.outgoing.get(transition.source) ?? 0);
454
+ case "relative_consequent":
455
+ return ratio(transition.count, denominators.incoming.get(transition.target) ?? 0);
456
+ case "absolute":
457
+ default:
458
+ return transition.count;
459
+ }
460
+ }
461
+
462
+ // ── Formatting ───────────────────────────────────────────────────────────────
463
+
464
+ /** Milliseconds per unit, largest first — the ladder {@link formatDurationMs} walks. */
465
+ const DURATION_LADDER: readonly (readonly [number, string])[] = [
466
+ [86_400_000, "d"],
467
+ [3_600_000, "h"],
468
+ [60_000, "min"],
469
+ [1_000, "s"],
470
+ ];
471
+
472
+ /**
473
+ * A duration in milliseconds, spoken in the largest unit that leaves a number at or above
474
+ * one.
475
+ *
476
+ * One decimal below 10 and none above, so "3.4 d" and "18 h" both read as measurements
477
+ * rather than as false precision. A non-finite or negative input answers an em dash — a
478
+ * process map has legitimate holes (an activity that never followed anything), and a
479
+ * printed `NaN` is worse than a printed dash.
480
+ */
481
+ export function formatDurationMs(ms: number): string {
482
+ if (!Number.isFinite(ms) || ms < 0) return "—";
483
+ if (ms === 0) return "0 s";
484
+ for (const [size, unit] of DURATION_LADDER) {
485
+ if (ms >= size) {
486
+ const scaled = ms / size;
487
+ return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)} ${unit}`;
488
+ }
489
+ }
490
+ return `${Math.round(ms)} ms`;
491
+ }
492
+
493
+ /** A count, grouped by the runtime locale; a share, as a percentage with one decimal. */
494
+ export function formatMetricValue(value: number, metric: ProcessMetric): string {
495
+ if (isPerformanceMetric(metric)) return formatDurationMs(value);
496
+ if (!Number.isFinite(value)) return "—";
497
+ if (isShare(metric)) return `${(value * 100).toFixed(1)}%`;
498
+ return value.toLocaleString();
499
+ }
500
+
501
+ // ── Identity ─────────────────────────────────────────────────────────────────
502
+
503
+ /**
504
+ * The stable id of a transition: source, {@link EDGE_KEY_SEPARATOR}, target — keyed
505
+ * exactly the way `/core` keys it. A selection, a performance-layer lookup and a React
506
+ * Flow edge therefore all name the same edge with the same string, with no translation.
507
+ */
508
+ export function processEdgeId(source: string, target: string): string {
509
+ return `${source}${EDGE_KEY_SEPARATOR}${target}`;
510
+ }
511
+
512
+ /**
513
+ * A cheap, order-independent key over the graph's STRUCTURE — node ids and edge ids only,
514
+ * never a metric. `useProcessLayout` caches on it, which is what lets a metric switch
515
+ * reuse the existing positions instead of re-running dagre.
516
+ */
517
+ export function processGraphStructureKey(graph: ProcessGraph): string {
518
+ const activities = graph.activities.map((a) => a.id).sort();
519
+ const transitions = graph.transitions.map((t) => processEdgeId(t.source, t.target)).sort();
520
+ return `${activities.join(EDGE_KEY_SEPARATOR)}|${transitions.join(EDGE_KEY_SEPARATOR)}`;
521
+ }
522
+
523
+ // ── Selection ────────────────────────────────────────────────────────────────
524
+
525
+ /** The activities and transitions a selection keeps in play. */
526
+ export interface ProcessSelectionNeighbourhood {
527
+ activities: Set<string>;
528
+ transitions: Set<string>;
529
+ }
530
+
531
+ /**
532
+ * Which activities and transitions a selection keeps in play.
533
+ *
534
+ * Selecting an ACTIVITY keeps that activity, every activity directly connected to it, and
535
+ * every edge incident to it. Selecting a TRANSITION keeps its two endpoints and itself.
536
+ * Everything else becomes `"excluded"` — dimmed, but never `aria-disabled` (see
537
+ * {@link resolveSelectionState}).
538
+ */
539
+ export function selectionNeighbourhood(
540
+ graph: ProcessGraph,
541
+ selection: ProcessSelection | null | undefined,
542
+ ): ProcessSelectionNeighbourhood | null {
543
+ if (!selection) return null;
544
+ const activities = new Set<string>();
545
+ const transitions = new Set<string>();
546
+ if (selection.kind === "activity") {
547
+ activities.add(selection.id);
548
+ for (const t of graph.transitions) {
549
+ if (t.source !== selection.id && t.target !== selection.id) continue;
550
+ transitions.add(processEdgeId(t.source, t.target));
551
+ activities.add(t.source);
552
+ activities.add(t.target);
553
+ }
554
+ return { activities, transitions };
555
+ }
556
+ for (const t of graph.transitions) {
557
+ if (processEdgeId(t.source, t.target) !== selection.id) continue;
558
+ transitions.add(selection.id);
559
+ activities.add(t.source);
560
+ activities.add(t.target);
561
+ }
562
+ return { activities, transitions };
563
+ }
564
+
565
+ /**
566
+ * Resolve one element's tri-state {@link ProcessSelectionState} from BOTH the coordinated
567
+ * click selection and the active filter's per-element states — the two channels
568
+ * `ProcessMap` renders together. Five rules, in precedence order (RM-052 round 2, #227):
569
+ *
570
+ * 1. **The element IS the click target** → `"selected"`. This wins over everything else,
571
+ * including an active filter's own `"excluded"` — clicking an excluded element is how a
572
+ * reader re-focuses it, so the click must always win.
573
+ * 2. **`states` already says `"excluded"`** → `"excluded"`. This must be checked BEFORE
574
+ * rule 3: a filter-excluded element stays excluded even when it happens to sit inside
575
+ * the neighbourhood of whatever is currently click-selected — the filter's exclusion is
576
+ * not something a click's neighbourhood should be able to override.
577
+ * 3. **A click target exists and this element sits outside its neighbourhood** →
578
+ * `"excluded"`.
579
+ * 4. **`states` carries an entry for this element** → that entry (`"associated"` or
580
+ * `"selected"` from a filter's own point of view — reserved for a future filter shape
581
+ * that can name a "primary" match; today filters only ever produce `"excluded"`
582
+ * entries, which rule 2 already handles).
583
+ * 5. **Otherwise** → `"associated"`, the default.
584
+ */
585
+ export function resolveSelectionState(
586
+ id: string,
587
+ kind: ProcessSelectionKind,
588
+ selection: ProcessSelection | null | undefined,
589
+ neighbourhood: ProcessSelectionNeighbourhood | null,
590
+ states?: ProcessSelectionStates,
591
+ ): ProcessSelectionState {
592
+ if (selection && selection.kind === kind && selection.id === id) return "selected";
593
+
594
+ const namespace = kind === "activity" ? states?.activities : states?.transitions;
595
+ const filterState = namespace?.[id];
596
+
597
+ if (filterState === "excluded") return "excluded";
598
+
599
+ if (selection && neighbourhood) {
600
+ const kept = kind === "activity" ? neighbourhood.activities : neighbourhood.transitions;
601
+ if (!kept.has(id)) return "excluded";
602
+ }
603
+
604
+ if (filterState !== undefined) return filterState;
605
+
606
+ return "associated";
607
+ }
608
+
609
+ // ── The model ────────────────────────────────────────────────────────────────
610
+
611
+ /** Inputs to {@link buildProcessMapModel}. */
612
+ export interface BuildProcessMapModelOptions {
613
+ graph: ProcessGraph;
614
+ metric: ProcessMetricSpec;
615
+ /** Optional rework tallies (from `/core`'s `detectRework`) for the node badge. */
616
+ rework?: ReworkStats;
617
+ selection?: ProcessSelection | null;
618
+ /**
619
+ * Per-element states an active filter contributes (RM-052 round 2, #227) — merged with
620
+ * the click `selection` by {@link resolveSelectionState}. Omitting it reproduces the
621
+ * pre-filter behaviour exactly: every element resolves through rules 1/3/5 alone.
622
+ */
623
+ selectionStates?: ProcessSelectionStates;
624
+ /**
625
+ * Edge ids `layoutFlow` reported as running against the layout direction. Supplied on
626
+ * the SECOND pass, after a layout exists — the first pass has no ranks to read, so
627
+ * every edge starts `isBackEdge: false` exactly as `discoverGraph` leaves it.
628
+ */
629
+ backEdgeIds?: ReadonlySet<string>;
630
+ }
631
+
632
+ /**
633
+ * Map a discovered (or abstracted) graph plus a metric choice into everything both the
634
+ * canvas and the table render. Pure: same inputs, same model, no clock and no DOM.
635
+ */
636
+ export function buildProcessMapModel({
637
+ graph,
638
+ metric,
639
+ rework,
640
+ selection,
641
+ selectionStates,
642
+ backEdgeIds,
643
+ }: BuildProcessMapModelOptions): ProcessMapModel {
644
+ const neighbourhood = selectionNeighbourhood(graph, selection);
645
+ const denominators = processEdgeDenominators(graph.transitions);
646
+
647
+ const nodeValues = graph.activities.map((a) => activityMetricValue(a, metric.node, graph.totals));
648
+ const nodeDomain = minMax(nodeValues);
649
+ const edgeValues = graph.transitions.map((t) =>
650
+ transitionMetricValue(t, metric.edge, graph.totals, denominators),
651
+ );
652
+ const edgeDomain = minMax(edgeValues);
653
+
654
+ const resolvedNodeMetricLabel = nodeMetricLabel(metric.node);
655
+ const resolvedEdgeMetricLabel = edgeMetricLabel(metric.edge);
656
+ const nodeSpan = nodeDomain[1] - nodeDomain[0];
657
+
658
+ const nodes: ProcessMapNode[] = graph.activities.map((activity, index) => {
659
+ const primaryValue = nodeValues[index] as number;
660
+ const secondaryLabel =
661
+ metric.secondary === undefined
662
+ ? undefined
663
+ : formatMetricValue(
664
+ activityMetricValue(activity, metric.secondary, graph.totals),
665
+ metric.secondary,
666
+ );
667
+ const reworkEntry = rework?.perActivity[activity.id];
668
+ const reworkCount =
669
+ reworkEntry === undefined ? undefined : reworkEntry.selfLoops + reworkEntry.loops;
670
+ const selectionState = resolveSelectionState(
671
+ activity.id,
672
+ "activity",
673
+ selection,
674
+ neighbourhood,
675
+ selectionStates,
676
+ );
677
+ const data: ProcessActivityNodeData = {
678
+ title: activity.label || activity.id,
679
+ metricLabel: resolvedNodeMetricLabel,
680
+ primaryLabel: formatMetricValue(primaryValue, metric.node),
681
+ primaryValue,
682
+ secondaryLabel,
683
+ // A single-activity graph (or a flat metric) has no domain to sit in; half
684
+ // saturation is the honest answer — "no comparison available" — rather than a
685
+ // full-strength fill claiming this is the busiest node in a set of one.
686
+ saturation: nodeSpan === 0 ? 0.5 : (primaryValue - nodeDomain[0]) / nodeSpan,
687
+ isStart: activity.isStart,
688
+ isEnd: activity.isEnd,
689
+ reworkCount,
690
+ selectionState,
691
+ };
692
+ return {
693
+ id: activity.id,
694
+ type: "process-activity",
695
+ position: { x: 0, y: 0 },
696
+ data,
697
+ draggable: false,
698
+ // React Flow reads a node's accessible name from the node OBJECT, not from the
699
+ // component — the same seam `withWeightedEdgeAria` exists for on the edge side
700
+ // (#285). Without this the node announces only its id and the metric the map
701
+ // exists to show reaches no assistive technology.
702
+ ariaLabel: activityAriaLabel(data),
703
+ // No `aria-disabled` here even when `selectionState === "excluded"` — an excluded
704
+ // node stays fully operable (clicking it is how a reader filters it back in), and
705
+ // `activityAriaLabel` already appends the word "excluded" to its accessible name, so
706
+ // assistive technology gets the state as real text rather than a lie about
707
+ // disablement. See `ProcessSelectionState`'s own doc comment.
708
+ domAttributes: {
709
+ "data-selection": selectionState,
710
+ "data-activity": activity.id,
711
+ } as ProcessMapNode["domAttributes"],
712
+ };
713
+ });
714
+
715
+ const edges: ProcessMapEdge[] = graph.transitions.map((transition, index) => {
716
+ const value = edgeValues[index] as number;
717
+ const id = processEdgeId(transition.source, transition.target);
718
+ const secondaryLabel =
719
+ metric.secondary === undefined
720
+ ? undefined
721
+ : formatMetricValue(
722
+ transitionMetricValue(transition, metric.secondary, graph.totals, denominators),
723
+ metric.secondary,
724
+ );
725
+ const selectionState = resolveSelectionState(
726
+ id,
727
+ "transition",
728
+ selection,
729
+ neighbourhood,
730
+ selectionStates,
731
+ );
732
+ const data: ProcessTransitionEdgeData = {
733
+ source: transition.source,
734
+ target: transition.target,
735
+ weight: value,
736
+ value,
737
+ valueDomain: edgeDomain,
738
+ label: formatMetricValue(value, metric.edge),
739
+ secondaryLabel,
740
+ isSelfLoop: transition.isSelfLoop,
741
+ isBackEdge: backEdgeIds?.has(id) ?? transition.isBackEdge,
742
+ selectionState,
743
+ };
744
+ return {
745
+ id,
746
+ source: transition.source,
747
+ target: transition.target,
748
+ type: "process-transition",
749
+ data,
750
+ ariaLabel: transitionAriaLabel(data, resolvedEdgeMetricLabel),
751
+ };
752
+ });
753
+
754
+ const activityRows: ProcessActivityRow[] = nodes.map((node) => ({
755
+ id: node.id,
756
+ title: node.data.title,
757
+ primaryLabel: node.data.primaryLabel,
758
+ secondaryLabel: node.data.secondaryLabel,
759
+ reworkCount: node.data.reworkCount,
760
+ role: activityRole(node.data),
761
+ selectionState: node.data.selectionState,
762
+ }));
763
+
764
+ const transitionRows: ProcessTransitionRow[] = edges.map((edge) => {
765
+ const data = edge.data as ProcessTransitionEdgeData;
766
+ return {
767
+ id: edge.id,
768
+ source: data.source,
769
+ target: data.target,
770
+ primaryLabel: data.label,
771
+ secondaryLabel: data.secondaryLabel,
772
+ shape: transitionShape(data),
773
+ selectionState: data.selectionState,
774
+ };
775
+ });
776
+
777
+ const excludedCounts: ProcessMapExcludedCounts = {
778
+ activities: nodes.reduce((n, node) => n + (node.data.selectionState === "excluded" ? 1 : 0), 0),
779
+ totalActivities: nodes.length,
780
+ transitions: edges.reduce(
781
+ (n, edge) =>
782
+ n + ((edge.data as ProcessTransitionEdgeData).selectionState === "excluded" ? 1 : 0),
783
+ 0,
784
+ ),
785
+ totalTransitions: edges.length,
786
+ };
787
+
788
+ return {
789
+ nodes,
790
+ edges,
791
+ nodeDomain,
792
+ edgeDomain,
793
+ nodeMetricLabel: resolvedNodeMetricLabel,
794
+ edgeMetricLabel: resolvedEdgeMetricLabel,
795
+ activityRows,
796
+ transitionRows,
797
+ formatEdgeValue: (value: number) => formatMetricValue(value, metric.edge),
798
+ excludedCounts,
799
+ };
800
+ }
801
+
802
+ /**
803
+ * The word for an activity's position in the process — printed in the table's own column
804
+ * and folded into the node's accessible name, so "this is where cases start" survives
805
+ * without the start/end glyph.
806
+ */
807
+ export function activityRole(data: ProcessActivityNodeData): string {
808
+ if (data.isStart && data.isEnd) return "Start and end";
809
+ if (data.isStart) return "Start";
810
+ if (data.isEnd) return "End";
811
+ return "Step";
812
+ }
813
+
814
+ /** The word for an edge's shape — the non-colour channel, said in text. */
815
+ export function transitionShape(data: ProcessTransitionEdgeData): string {
816
+ if (data.isSelfLoop) return "Self-loop";
817
+ if (data.isBackEdge) return "Back edge";
818
+ return "Forward";
819
+ }
820
+
821
+ /**
822
+ * The CANONICAL (English, un-localized) word for a row's selection/filter state (#373) —
823
+ * this module is pure data shaping with no locale seam of its own, so it cannot resolve a
824
+ * translation. It stays exported for a non-React caller (tests, a console, a non-locale
825
+ * context). The State column's own rendered CELLS resolve the localized text instead, via
826
+ * {@link PROCESS_SELECTION_STATE_MESSAGE_KEYS} + `t()` at the render site
827
+ * (`process-map.tsx`) — see that key map's own comment (#413 review). `"associated"` is the
828
+ * ordinary case and prints nothing, so the column reads as a marker for the two states that
829
+ * matter, not as noise repeated on every row.
830
+ */
831
+ export function selectionStateLabel(state: ProcessSelectionState): string {
832
+ if (state === "selected") return "Selected";
833
+ if (state === "excluded") return "Excluded";
834
+ return "";
835
+ }
836
+
837
+ /**
838
+ * The locale message key for each NON-ORDINARY selection/filter state (#413 review,
839
+ * PRRT_kwDOT6D7ts6gJX2C) — the State column's cells are the table twin's own
840
+ * screen-reader channel for row state (#373), so leaving them printing
841
+ * {@link selectionStateLabel}'s bare English word left a non-English `LocaleProvider`
842
+ * with untranslated visible AND accessible content. `"associated"` has no entry: it is the
843
+ * ordinary case and the column prints nothing for it, same as {@link selectionStateLabel}.
844
+ */
845
+ export const PROCESS_SELECTION_STATE_MESSAGE_KEYS: Readonly<
846
+ Partial<Record<ProcessSelectionState, string>>
847
+ > = Object.freeze({
848
+ selected: "process.map.stateSelected",
849
+ excluded: "process.map.stateExcluded",
850
+ });
851
+
852
+ /** The accessible name of one activity node. */
853
+ export function activityAriaLabel(data: ProcessActivityNodeData): string {
854
+ const parts = [`${data.title} — ${activityRole(data).toLowerCase()}`];
855
+ parts.push(`${data.metricLabel} ${data.primaryLabel}`);
856
+ if (data.secondaryLabel) parts.push(data.secondaryLabel);
857
+ if (data.reworkCount) parts.push(`${data.reworkCount} repeated executions`);
858
+ if (data.selectionState !== "associated") parts.push(data.selectionState);
859
+ return parts.join(", ");
860
+ }
861
+
862
+ /** The accessible name of one transition edge. */
863
+ export function transitionAriaLabel(data: ProcessTransitionEdgeData, metricLabel: string): string {
864
+ const parts = [
865
+ data.isSelfLoop
866
+ ? `Self-loop on ${data.source}`
867
+ : `${data.isBackEdge ? "Back edge" : "Transition"} from ${data.source} to ${data.target}`,
868
+ `${metricLabel} ${data.label}`,
869
+ ];
870
+ if (data.secondaryLabel) parts.push(data.secondaryLabel);
871
+ if (data.selectionState !== "associated") parts.push(data.selectionState);
872
+ return parts.join(", ");
873
+ }