@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- 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
|
+
}
|