@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,979 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessMap — the directly-follows map every process-mining session opens on (RM-051).
5
+ *
6
+ * ## What it is
7
+ *
8
+ * A composition, not a component library. Every mark on the canvas comes from
9
+ * `@elabs-ai/components-flow`: the frame is `CanvasShell`, the layout is `layoutFlow`
10
+ * (dagre), the controls are `ZoomControls`, the overview is `FlowMiniMap`, the key is
11
+ * `Legend variant="scale"`, and the nodes and edges are `FlowNode` / `FlowWeightedEdge` /
12
+ * `FlowSelfLoopEdge` wrapped by this package's two thin process-shaped components. What
13
+ * lives HERE is the domain reading — which number a node prints, which edges a selection
14
+ * keeps, what a right-click means — and nothing else.
15
+ *
16
+ * ## Two inputs, one model
17
+ *
18
+ * Pass a `graph` you already discovered, or pass a `log` and let the map run
19
+ * `discoverGraph` (and `detectRework`) internally — the convenience path for a story, a
20
+ * prototype or a screen that has no explorer around it yet. `abstraction` runs
21
+ * `abstractGraph` over whichever of the two you gave. All three are memoized on their own
22
+ * inputs, so a metric change never re-derives the graph.
23
+ *
24
+ * ## Why the layout does not twitch
25
+ *
26
+ * `useProcessLayout` caches dagre's output on the graph's STRUCTURE (see
27
+ * `processGraphStructureKey`) rather than on the model, so switching the metric,
28
+ * selecting an activity or hovering a path re-renders without moving a single node. The
29
+ * observable proof is `layoutRuns`, which a test asserts against.
30
+ *
31
+ * ## Accessibility
32
+ *
33
+ * - Nodes are React Flow's own focusable elements, and `applyPositions` emits them SORTED
34
+ * BY THEIR LAID-OUT POSITION (top-to-bottom then left-to-right for `TB`, the transpose
35
+ * for `LR`) rather than in model order — so DOM order, and therefore `Tab` order, is
36
+ * layout order. `Enter`/`Space` selects via React Flow's `elementSelectionKeys`
37
+ * handling, not a re-implementation.
38
+ * - Edges are NOT separate tab stops (`edgesFocusable={false}`). Every edge already
39
+ * carries a focusable, named label pill, so leaving the edge `<g>` focusable too put
40
+ * two stops on every arrow and buried the activities behind them. Keys pressed on a
41
+ * pill still reach the map: `EdgeLabelRenderer` is a React portal, so its events bubble
42
+ * up the REACT tree through `ProcessTransitionEdge`, which knows its own edge id and
43
+ * hands it to `onEdgeKey` (`ProcessMapEdgeKeyContext`).
44
+ * - `F` opens the filter-intent menu for whatever is focused, falling back to the current
45
+ * selection. The same menu is reachable with the mouse by right-clicking a node or an
46
+ * edge, and with neither by the always-present "Filter…" button — a keyboard shortcut
47
+ * that is the ONLY way to reach a menu is not an affordance.
48
+ * - `tableView` renders the identical numbers as two `Table`s. It shares
49
+ * `map-model.ts`'s formatting, so the twin can never drift from the canvas.
50
+ */
51
+ import {
52
+ useCallback,
53
+ useEffect,
54
+ useMemo,
55
+ useRef,
56
+ useState,
57
+ type HTMLAttributes,
58
+ type KeyboardEvent as ReactKeyboardEvent,
59
+ type MouseEvent as ReactMouseEvent,
60
+ } from "react";
61
+ import { Filter } from "lucide-react";
62
+ import type { NodeChange } from "@xyflow/react";
63
+ import {
64
+ Button,
65
+ DropdownMenu,
66
+ DropdownMenuContent,
67
+ DropdownMenuItem,
68
+ DropdownMenuLabel,
69
+ DropdownMenuSeparator,
70
+ DropdownMenuTrigger,
71
+ StatePanel,
72
+ Table,
73
+ TableBody,
74
+ TableCaption,
75
+ TableCell,
76
+ TableHead,
77
+ TableHeader,
78
+ TableRow,
79
+ useLocale,
80
+ } from "@elabs-ai/components-ui";
81
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
82
+ import {
83
+ CanvasShell,
84
+ FlowMiniMap,
85
+ Legend,
86
+ ZoomControls,
87
+ type FlowLayoutDirection,
88
+ } from "@elabs-ai/components-flow";
89
+ import { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
90
+ import { detectRework, type ReworkStats } from "../core/detect-rework";
91
+ import { discoverGraph } from "../core/discover-graph";
92
+ import type { EventLog, ProcessGraph } from "../core/types";
93
+ import {
94
+ buildProcessMapModel,
95
+ processGraphStructureKey,
96
+ PROCESS_FILTER_INTENT_KINDS,
97
+ PROCESS_FILTER_INTENT_LABELS,
98
+ PROCESS_FILTER_INTENT_MESSAGE_KEYS,
99
+ PROCESS_SELECTION_STATE_MESSAGE_KEYS,
100
+ type ProcessFilterIntent,
101
+ type ProcessMapEdge,
102
+ type ProcessMapModel,
103
+ type ProcessMapNode,
104
+ type ProcessMetricSpec,
105
+ type ProcessSelection,
106
+ type ProcessSelectionState,
107
+ type ProcessSelectionStates,
108
+ } from "./map-model";
109
+ import { ProcessActivityNode } from "./process-activity-node";
110
+ import { ProcessTransitionEdge } from "./process-transition-edge";
111
+ import {
112
+ EMPTY_PROCESS_MAP_HOVER,
113
+ ProcessMapEdgeKeyContext,
114
+ ProcessMapHoverContext,
115
+ type ProcessMapHoverState,
116
+ } from "./process-map-context";
117
+ import {
118
+ PROCESS_MAP_NODE_MOTION_CLASS,
119
+ useProcessLayout,
120
+ type UseProcessLayoutResult,
121
+ } from "./use-process-layout";
122
+
123
+ /**
124
+ * Registered once at module scope. React Flow re-creates every node when this object's
125
+ * identity changes, so an inline literal would remount the whole canvas each render.
126
+ */
127
+ const NODE_TYPES = { "process-activity": ProcessActivityNode };
128
+ const EDGE_TYPES = { "process-transition": ProcessTransitionEdge };
129
+
130
+ /**
131
+ * The zoom the map refuses to open below, however big the process is.
132
+ *
133
+ * A discovered log is routinely wider than any pane, and a fit that shows ALL of it shows
134
+ * none of it: the 24-activity fixture frames at 0.28, where the activity name (14 px on
135
+ * the card) prints at 3.9 px and the card is a grey smudge. The floor is set from the
136
+ * card's own type rather than from a graph size: at 0.75 the name renders at 10.5 px and
137
+ * the secondary line (12 px) at 9 px, which is the smallest either stays a word.
138
+ *
139
+ * It is the OPENING zoom only — {@link MIN_ZOOM} still lets the reader pull all the way
140
+ * back to an overview, and the minimap says where in the process the pane is sitting. A
141
+ * fit clamped by this floor is anchored on the START of the process, not its middle;
142
+ * `CanvasShell` does that (see `anchorToStartWhenClamped`).
143
+ */
144
+ export const PROCESS_MAP_LEGIBLE_ZOOM = 0.75;
145
+
146
+ /**
147
+ * How the canvas re-frames itself after a layout.
148
+ *
149
+ * `padding` is generous because the edge label pills are portalled OUTSIDE the SVG and so
150
+ * contribute nothing to React Flow's fitted bounds — a tight fit clips the pills on the
151
+ * outermost transitions. `maxZoom: 1` stops a two-activity graph from being blown up to
152
+ * React Flow's default 2× ceiling, which is what the reader was seeing.
153
+ */
154
+ const FIT_VIEW_OPTIONS = {
155
+ padding: 0.15,
156
+ maxZoom: 1,
157
+ minZoom: PROCESS_MAP_LEGIBLE_ZOOM,
158
+ } as const;
159
+
160
+ /**
161
+ * How far out the reader may zoom.
162
+ *
163
+ * React Flow's own floor is `0.5`, and `fitView` CLAMPS to it — so a process with more
164
+ * ranks than the pane is tall could not be framed at all: the fit stopped at 0.5 and left
165
+ * the last activities below the fold with no way to pull back. Measured on the 11-activity
166
+ * fixture in a 1200×576 pane, where the honest fit is 0.42. A discovered process routinely
167
+ * has three or four times that many activities, so the floor has to be a real overview
168
+ * zoom, not a legibility one — legibility at the far end of the dial is what the minimap
169
+ * and the table twin are for.
170
+ */
171
+ const MIN_ZOOM = 0.1;
172
+
173
+ /** Props for {@link ProcessMap}. `onSelect` shadows the DOM handler, so it is omitted. */
174
+ export interface ProcessMapProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
175
+ /** A discovered graph. Takes precedence over {@link log} when both are given. */
176
+ graph?: ProcessGraph;
177
+ /**
178
+ * An event log to discover from, for a surface with no explorer around it. Runs
179
+ * `/core`'s `discoverGraph` and `detectRework` internally, memoized on the log.
180
+ */
181
+ log?: EventLog;
182
+ /** Fractions of activities / paths to KEEP, as `/core`'s `abstractGraph` reads them. */
183
+ abstraction?: Pick<AbstractionOptions, "activities" | "paths">;
184
+ /** Which readings the nodes and edges paint. */
185
+ metric: ProcessMetricSpec;
186
+ /** Rework tallies for the node badge. Derived from {@link log} when that is given. */
187
+ rework?: ReworkStats;
188
+ /** Controlled selection. Omit for an uncontrolled map that owns its own. */
189
+ selection?: ProcessSelection | null;
190
+ /**
191
+ * Per-element states an active filter contributes (RM-052 round 2, #227) — e.g. from
192
+ * `useProcessExplorer`'s own `selectionStates`. Applying or clearing a filter never adds
193
+ * or removes a node or an edge (Invariant F): it only re-inks the elements a filter
194
+ * dropped as `"excluded"` (dimmed, never `aria-disabled`). Omit for a map with no
195
+ * filtering, which reproduces today's selection-only behaviour exactly.
196
+ */
197
+ selectionStates?: ProcessSelectionStates;
198
+ /** Fires with the new selection, or `null` when the reader deselects. */
199
+ onSelect?: (target: ProcessSelection | null) => void;
200
+ /** Fires with an intent from the filter menu — hand it straight to `filterLog`. */
201
+ onFilterIntent?: (intent: ProcessFilterIntent) => void;
202
+ /** @default "TB" */
203
+ direction?: FlowLayoutDirection;
204
+ /** @default true */
205
+ showMiniMap?: boolean;
206
+ /** @default true */
207
+ showLegend?: boolean;
208
+ /** Render the accessible table twin instead of the canvas. @default false */
209
+ tableView?: boolean;
210
+ /** No graph yet. Renders the loading panel rather than an empty canvas. */
211
+ loading?: boolean;
212
+ /**
213
+ * Accessible name for the canvas region. Defaults to the localized
214
+ * `process.map.label` message.
215
+ */
216
+ label?: string;
217
+ }
218
+
219
+ /** Resolve the graph the map paints, running `/core` only when its own inputs change. */
220
+ function useResolvedGraph(
221
+ graph: ProcessGraph | undefined,
222
+ log: EventLog | undefined,
223
+ abstraction: Pick<AbstractionOptions, "activities" | "paths"> | undefined,
224
+ ): ProcessGraph | undefined {
225
+ const discovered = useMemo(
226
+ () => (graph ? undefined : log ? discoverGraph(log) : undefined),
227
+ [graph, log],
228
+ );
229
+ const base = graph ?? discovered;
230
+ const activities = abstraction?.activities;
231
+ const paths = abstraction?.paths;
232
+ return useMemo(() => {
233
+ if (!base) return undefined;
234
+ if (activities === undefined && paths === undefined) return base;
235
+ return abstractGraph(base, { activities: activities ?? 1, paths: paths ?? 1 });
236
+ }, [base, activities, paths]);
237
+ }
238
+
239
+ /**
240
+ * The process map.
241
+ *
242
+ * @example
243
+ * ```tsx
244
+ * <ProcessMap log={log} metric={{ node: "absolute_case", edge: "absolute" }} />
245
+ * ```
246
+ */
247
+ export function ProcessMap({
248
+ graph,
249
+ log,
250
+ abstraction,
251
+ metric,
252
+ rework,
253
+ selection,
254
+ selectionStates,
255
+ onSelect,
256
+ onFilterIntent,
257
+ direction = "TB",
258
+ showMiniMap = true,
259
+ showLegend = true,
260
+ tableView = false,
261
+ loading = false,
262
+ label,
263
+ className,
264
+ ...props
265
+ }: ProcessMapProps) {
266
+ // Every user-visible string on this surface goes through the locale seam (ADR 0017);
267
+ // the table twin's headers especially, since that table IS the accessible reading of
268
+ // the canvas — an untranslated header leaves numbers with no measure name.
269
+ const { t } = useLocale();
270
+ const mapLabel = label ?? t("process.map.label");
271
+ // The State column's own cell text (#413 review, PRRT_kwDOT6D7ts6gJX2C) — `map-model.ts`
272
+ // has no locale seam of its own, so the render site resolves
273
+ // `PROCESS_SELECTION_STATE_MESSAGE_KEYS` through `t()` instead of printing
274
+ // `selectionStateLabel`'s bare English word. `"associated"` has no key and prints nothing,
275
+ // same as before.
276
+ const selectionStateText = useCallback(
277
+ (state: ProcessSelectionState): string => {
278
+ const key = PROCESS_SELECTION_STATE_MESSAGE_KEYS[state];
279
+ return key ? t(key) : "";
280
+ },
281
+ [t],
282
+ );
283
+ const resolved = useResolvedGraph(graph, log, abstraction);
284
+ const derivedRework = useMemo(
285
+ () => (rework ? undefined : log ? detectRework(log) : undefined),
286
+ [rework, log],
287
+ );
288
+ const activeRework = rework ?? derivedRework;
289
+
290
+ // Controlled/uncontrolled selection, derived once and never flipped between modes.
291
+ const isControlled = selection !== undefined;
292
+ const [ownSelection, setOwnSelection] = useState<ProcessSelection | null>(null);
293
+ const activeSelection = isControlled ? (selection ?? null) : ownSelection;
294
+
295
+ const applySelection = useCallback(
296
+ (next: ProcessSelection | null) => {
297
+ if (!isControlled) setOwnSelection(next);
298
+ onSelect?.(next);
299
+ },
300
+ [isControlled, onSelect],
301
+ );
302
+
303
+ const structureKey = useMemo(
304
+ () => (resolved ? processGraphStructureKey(resolved) : ""),
305
+ [resolved],
306
+ );
307
+
308
+ // PASS 1 — a model with no back-edge knowledge, because nothing has been laid out yet.
309
+ const firstPass = useMemo(
310
+ () =>
311
+ resolved
312
+ ? buildProcessMapModel({
313
+ graph: resolved,
314
+ metric,
315
+ rework: activeRework,
316
+ selection: activeSelection,
317
+ selectionStates,
318
+ })
319
+ : null,
320
+ [resolved, metric, activeRework, activeSelection, selectionStates],
321
+ );
322
+
323
+ // ── Measured node sizes ───────────────────────────────────────────────────
324
+ // dagre needs to know how big an activity card actually is, and React Flow only ever
325
+ // writes its measurements onto its OWN internal record — the node objects this
326
+ // component builds never learn them. Without this, `layoutFlow` fell back to React
327
+ // Flow's generic 172×40 default for a card that really renders ~176×85, so every rank
328
+ // was laid out about half a card too close and the edge label pills landed on the nodes
329
+ // below them. Collecting the dimensions here is what lets the SECOND layout pass use
330
+ // real sizes.
331
+ const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number; height: number }>>({});
332
+ const handleNodesChange = useCallback((changes: NodeChange<ProcessMapNode>[]) => {
333
+ setNodeSizes((current) => {
334
+ let next: Record<string, { width: number; height: number }> | null = null;
335
+ for (const change of changes) {
336
+ if (change.type !== "dimensions" || !change.dimensions) continue;
337
+ const previous = current[change.id];
338
+ if (
339
+ previous?.width === change.dimensions.width &&
340
+ previous.height === change.dimensions.height
341
+ ) {
342
+ continue;
343
+ }
344
+ next ??= { ...current };
345
+ next[change.id] = { width: change.dimensions.width, height: change.dimensions.height };
346
+ }
347
+ return next ?? current;
348
+ });
349
+ }, []);
350
+
351
+ const layoutNodes = useMemo(() => {
352
+ const source = firstPass?.nodes ?? EMPTY_NODES;
353
+ let changed = false;
354
+ const next = source.map((node) => {
355
+ const measured = nodeSizes[node.id];
356
+ if (!measured) return node;
357
+ changed = true;
358
+ return { ...node, measured };
359
+ });
360
+ return changed ? next : source;
361
+ }, [firstPass, nodeSizes]);
362
+
363
+ /**
364
+ * The measured ENVELOPE, quantized — the layout cache key's size half.
365
+ *
366
+ * Deliberately not the per-node sizes: a metric switch rewrites every subtitle, which
367
+ * can nudge a card's width by a pixel, and keying on that would re-run dagre for a
368
+ * metric change — exactly the twitch `useProcessLayout`'s cache exists to prevent (and
369
+ * what `layoutRuns` asserts against). The envelope, rounded to 8px, moves when the card
370
+ * ANATOMY changes (a meter appears, a line wraps) and sits still otherwise.
371
+ */
372
+ const sizeKey = useMemo(() => {
373
+ let width = 0;
374
+ let height = 0;
375
+ for (const size of Object.values(nodeSizes)) {
376
+ if (size.width > width) width = size.width;
377
+ if (size.height > height) height = size.height;
378
+ }
379
+ if (width === 0 && height === 0) return "unmeasured";
380
+ return `${Math.round(width / 8)}x${Math.round(height / 8)}`;
381
+ }, [nodeSizes]);
382
+
383
+ const layoutKey = `${structureKey}::${sizeKey}`;
384
+
385
+ const layout: UseProcessLayoutResult = useProcessLayout({
386
+ nodes: layoutNodes,
387
+ edges: firstPass?.edges ?? EMPTY_EDGES,
388
+ structureKey: layoutKey,
389
+ direction,
390
+ });
391
+
392
+ // PASS 2 — the same model, now told which edges run against the layout direction, so
393
+ // those edges take the dashed back-edge shape. The structure key is unchanged, so this
394
+ // is a cache HIT: `layoutRuns` does not increase.
395
+ const model: ProcessMapModel | null = useMemo(
396
+ () =>
397
+ resolved
398
+ ? buildProcessMapModel({
399
+ graph: resolved,
400
+ metric,
401
+ rework: activeRework,
402
+ selection: activeSelection,
403
+ selectionStates,
404
+ backEdgeIds: layout.backEdgeIds,
405
+ })
406
+ : null,
407
+ [resolved, metric, activeRework, activeSelection, selectionStates, layout.backEdgeIds],
408
+ );
409
+
410
+ const positionedNodes = useMemo(
411
+ () => (model ? applyPositions(model, layout, direction) : EMPTY_NODES),
412
+ [model, layout, direction],
413
+ );
414
+
415
+ // ── Hover ─────────────────────────────────────────────────────────────────
416
+ const [hover, setHover] = useState<ProcessMapHoverState>(EMPTY_PROCESS_MAP_HOVER);
417
+ const edgesRef = useRef<ProcessMapEdge[]>([]);
418
+ edgesRef.current = model?.edges ?? [];
419
+
420
+ const handleNodeEnter = useCallback((_event: ReactMouseEvent, node: { id: string }) => {
421
+ const incident = new Set<string>();
422
+ for (const edge of edgesRef.current) {
423
+ if (edge.source === node.id || edge.target === node.id) incident.add(edge.id);
424
+ }
425
+ setHover({ activityId: node.id, incidentEdgeIds: incident });
426
+ }, []);
427
+ const handleNodeLeave = useCallback(() => setHover(EMPTY_PROCESS_MAP_HOVER), []);
428
+
429
+ // ── Filter-intent menu ────────────────────────────────────────────────────
430
+ const [menuTarget, setMenuTarget] = useState<ProcessSelection | null>(null);
431
+ const menuOpen = menuTarget !== null;
432
+
433
+ /**
434
+ * How to put focus back when the menu closes (F5).
435
+ *
436
+ * Radix returns focus to the TRIGGER by default, which is correct for a menu the user
437
+ * opened from the trigger and wrong for one opened with `f` while standing on a node or
438
+ * an arrow — that drops a keyboard user out of the graph and makes them tab back in.
439
+ * Holds a {@link focusRestorer} rather than an element, because a portalled edge label
440
+ * does not survive the re-render. Set only on the keyboard path; `null` means "let Radix
441
+ * do its usual thing".
442
+ */
443
+ const returnFocusRef = useRef<(() => void) | null>(null);
444
+
445
+ const openMenuFor = useCallback(
446
+ (target: ProcessSelection | null, restoreFocus: (() => void) | null = null) => {
447
+ if (!target) return;
448
+ returnFocusRef.current = restoreFocus;
449
+ setMenuTarget(target);
450
+ },
451
+ [],
452
+ );
453
+
454
+ // The menu filters by ACTIVITY, so a transition offers both of its endpoints.
455
+ const menuActivities = useMemo(() => {
456
+ if (!menuTarget) return [];
457
+ if (menuTarget.kind === "activity") return [menuTarget.id];
458
+ const edge = (model?.edges ?? []).find((e) => e.id === menuTarget.id);
459
+ if (!edge) return [];
460
+ return edge.source === edge.target ? [edge.source] : [edge.source, edge.target];
461
+ }, [menuTarget, model]);
462
+
463
+ /** Which element a key event is "about": the focused node/edge, else the selection. */
464
+ const targetOfEvent = useCallback(
465
+ (element: HTMLElement | null): ProcessSelection | null => {
466
+ // React Flow stamps `data-id` on both node and edge wrappers, so the focused
467
+ // element identifies itself; the selection is only the fallback.
468
+ const focusedId = element?.closest<HTMLElement>("[data-id]")?.dataset.id;
469
+ if (!focusedId) return activeSelection;
470
+ return model?.edges.some((edge) => edge.id === focusedId)
471
+ ? { kind: "transition", id: focusedId }
472
+ : { kind: "activity", id: focusedId };
473
+ },
474
+ [activeSelection, model],
475
+ );
476
+
477
+ /**
478
+ * The two element keys, for a target the caller already resolved.
479
+ *
480
+ * Shared by the root handler (which resolves the target from the focused DOM element)
481
+ * and by `ProcessTransitionEdge`, which resolves it from its own `id` because an edge's
482
+ * label pill is portalled out of the edge's DOM subtree and so has no `[data-id]`
483
+ * ancestor to read. Returns whether it handled the key, so the edge can stop the event
484
+ * rather than let the root re-handle it against the wrong target.
485
+ */
486
+ const handleElementKey = useCallback(
487
+ (event: ReactKeyboardEvent, target: ProcessSelection | null): boolean => {
488
+ if (!target) return false;
489
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
490
+
491
+ // Enter / Space: React Flow's `elementSelectionKeys` handling marks its OWN
492
+ // selection on the focused element — which is a different thing from this map's
493
+ // domain selection. This bridges the two rather than re-implementing either: React
494
+ // Flow still runs (no `preventDefault`), and the domain selection follows the same
495
+ // toggle a click performs.
496
+ if (event.key === "Enter" || event.key === " ") {
497
+ applySelection(
498
+ activeSelection?.kind === target.kind && activeSelection.id === target.id ? null : target,
499
+ );
500
+ return true;
501
+ }
502
+
503
+ if (event.key !== "f" && event.key !== "F") return false;
504
+ event.preventDefault();
505
+ // Remember where the user was standing, so Escape puts them back there (F5).
506
+ const active = (event.target as HTMLElement | null)?.ownerDocument.activeElement;
507
+ openMenuFor(target, active instanceof HTMLElement ? focusRestorer(active) : null);
508
+ return true;
509
+ },
510
+ [activeSelection, applySelection, openMenuFor],
511
+ );
512
+
513
+ const handleKeyDown = useCallback(
514
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
515
+ const element = event.target as HTMLElement | null;
516
+ // Never steal a letter someone is typing.
517
+ if (element?.closest("input, textarea, [contenteditable='true']")) return;
518
+ // Enter/Space only means "select" while standing ON a React Flow element; anywhere
519
+ // else it is the button/menu-item activation the browser already owns.
520
+ const onFlowElement = Boolean(element?.closest<HTMLElement>("[data-id]"));
521
+ if ((event.key === "Enter" || event.key === " ") && !onFlowElement) return;
522
+ handleElementKey(event, targetOfEvent(element));
523
+ },
524
+ [handleElementKey, targetOfEvent],
525
+ );
526
+
527
+ /**
528
+ * The edge half of {@link handleElementKey}, handed to every `ProcessTransitionEdge`.
529
+ *
530
+ * `stopPropagation` matters: without it the same event reaches the root handler, whose
531
+ * `targetOfEvent` finds no `[data-id]` ancestor above a portalled pill and would fall
532
+ * back to the current SELECTION — i.e. `f` on one arrow's pill would open the menu for
533
+ * a different element entirely.
534
+ */
535
+ const handleEdgeKey = useCallback(
536
+ (edgeId: string, event: ReactKeyboardEvent) => {
537
+ if (handleElementKey(event, { kind: "transition", id: edgeId })) event.stopPropagation();
538
+ },
539
+ [handleElementKey],
540
+ );
541
+
542
+ const emitIntent = useCallback(
543
+ (kind: ProcessFilterIntent["kind"], activity: string) => {
544
+ onFilterIntent?.({ kind, activity } as ProcessFilterIntent);
545
+ setMenuTarget(null);
546
+ },
547
+ [onFilterIntent],
548
+ );
549
+
550
+ // A selection that disappears (the graph was re-abstracted) must not keep a menu open
551
+ // against an element that is no longer on the map.
552
+ useEffect(() => {
553
+ if (!menuTarget || !model) return;
554
+ const stillThere =
555
+ menuTarget.kind === "activity"
556
+ ? model.nodes.some((n) => n.id === menuTarget.id)
557
+ : model.edges.some((e) => e.id === menuTarget.id);
558
+ if (!stillThere) setMenuTarget(null);
559
+ }, [menuTarget, model]);
560
+
561
+ if (loading) {
562
+ return (
563
+ <div
564
+ data-slot="process-map"
565
+ data-state="loading"
566
+ className={cn("relative size-full min-h-64", className)}
567
+ {...props}
568
+ >
569
+ <StatePanel kind="loading" title={t("process.map.loading")} />
570
+ </div>
571
+ );
572
+ }
573
+
574
+ if (!model || model.nodes.length === 0) {
575
+ return (
576
+ <div
577
+ data-slot="process-map"
578
+ data-state="empty"
579
+ className={cn("relative size-full min-h-64", className)}
580
+ {...props}
581
+ >
582
+ <StatePanel
583
+ kind="empty"
584
+ title={t("process.map.empty")}
585
+ description={t("process.map.emptyBody")}
586
+ />
587
+ </div>
588
+ );
589
+ }
590
+
591
+ const filterMenu = (
592
+ <DropdownMenu
593
+ // NOT modal: a modal Radix menu calls `hideOthers`, which stamps `aria-hidden` on
594
+ // every sibling of the menu — including the overlay that holds this menu's own
595
+ // trigger, producing a focusable element inside an `aria-hidden` subtree (axe
596
+ // `aria-hidden-focus`). A canvas menu also has no business scroll-locking the page.
597
+ modal={false}
598
+ open={menuOpen}
599
+ onOpenChange={(open) => {
600
+ if (!open) setMenuTarget(null);
601
+ }}
602
+ >
603
+ <DropdownMenuTrigger asChild>
604
+ <Button
605
+ type="button"
606
+ variant="outline"
607
+ size="sm"
608
+ data-slot="process-map-filter-trigger"
609
+ aria-keyshortcuts="f"
610
+ onClick={() => {
611
+ if (menuOpen) return;
612
+ openMenuFor(activeSelection ?? { kind: "activity", id: model.nodes[0]!.id });
613
+ }}
614
+ >
615
+ <Filter aria-hidden="true" className="size-4" />
616
+ {t("process.map.filter")}
617
+ </Button>
618
+ </DropdownMenuTrigger>
619
+ <DropdownMenuContent
620
+ align="end"
621
+ data-slot="process-map-filter-menu"
622
+ // Put a keyboard user back where they were standing (F5). Radix's default is to
623
+ // focus the TRIGGER, which is right for a menu opened FROM the trigger and wrong
624
+ // for one opened with `f` from a node — that drops the user out of the graph and
625
+ // makes them tab all the way back in.
626
+ onCloseAutoFocus={(event) => {
627
+ const restore = returnFocusRef.current;
628
+ returnFocusRef.current = null;
629
+ if (!restore) return;
630
+ event.preventDefault();
631
+ restore();
632
+ }}
633
+ >
634
+ {menuActivities.map((activity, index) => (
635
+ <div key={activity}>
636
+ {index > 0 ? <DropdownMenuSeparator /> : null}
637
+ <DropdownMenuLabel>{activity}</DropdownMenuLabel>
638
+ {PROCESS_FILTER_INTENT_KINDS.map((kind) => (
639
+ <DropdownMenuItem
640
+ key={kind}
641
+ // #346: the visible text alone is not unique on a transition's menu (the
642
+ // same four intents render once per endpoint), so the accessible name
643
+ // states which activity this item filters by, via the locale seam — the
644
+ // visible text stays unsuffixed and compact.
645
+ aria-label={t(PROCESS_FILTER_INTENT_MESSAGE_KEYS[kind], { activity })}
646
+ onSelect={() => emitIntent(kind, activity)}
647
+ >
648
+ {PROCESS_FILTER_INTENT_LABELS[kind]}
649
+ </DropdownMenuItem>
650
+ ))}
651
+ </div>
652
+ ))}
653
+ </DropdownMenuContent>
654
+ </DropdownMenu>
655
+ );
656
+
657
+ // #375: one polite live region names what the active selection/filter just changed —
658
+ // every OTHER element's per-element "excluded" state (activityAriaLabel/
659
+ // transitionAriaLabel) is correct but unannounceable in aggregate; this is the summary
660
+ // nothing else in the map derives. Two independently-pluralized fragments composed at the
661
+ // call site (see the message's own comment) — never an empty string, even when nothing
662
+ // is excluded, so the region is never silently blank on mount.
663
+ //
664
+ // #413 review (PRRT_kwDOT6D7ts6gJX2I): counts ALONE are not enough. Moving between two
665
+ // selections/filters that exclude DIFFERENT elements but land on the SAME activity and
666
+ // transition counts left this string byte-for-byte identical, so the region — which
667
+ // announces content CHANGES, not model changes — stayed silent even though the map
668
+ // re-inked. Fold in which elements are excluded (sorted, so the string is stable across a
669
+ // render that changed nothing else), not only how many — that tracks the actual affected
670
+ // set regardless of whether it moved via `selection` or via `selectionStates`.
671
+ const excludedActivityNames = model.activityRows
672
+ .filter((row) => row.selectionState === "excluded")
673
+ .map((row) => row.title)
674
+ .sort((a, b) => a.localeCompare(b));
675
+ const excludedTransitionNames = model.transitionRows
676
+ .filter((row) => row.selectionState === "excluded")
677
+ .map((row) => `${row.source} → ${row.target}`)
678
+ .sort((a, b) => a.localeCompare(b));
679
+ const selectionSummary = [
680
+ t("process.map.excludedActivities", {
681
+ count: model.excludedCounts.activities,
682
+ total: model.excludedCounts.totalActivities,
683
+ }),
684
+ excludedActivityNames.length > 0
685
+ ? t("process.map.excludedActivityNames", { names: excludedActivityNames.join(", ") })
686
+ : null,
687
+ t("process.map.excludedTransitions", {
688
+ count: model.excludedCounts.transitions,
689
+ total: model.excludedCounts.totalTransitions,
690
+ }),
691
+ excludedTransitionNames.length > 0
692
+ ? t("process.map.excludedTransitionNames", { names: excludedTransitionNames.join(", ") })
693
+ : null,
694
+ ]
695
+ .filter((part): part is string => Boolean(part))
696
+ .join(" · ");
697
+
698
+ if (tableView) {
699
+ return (
700
+ <div
701
+ data-slot="process-map"
702
+ data-view="table"
703
+ className={cn("flex size-full flex-col gap-4", className)}
704
+ {...props}
705
+ >
706
+ <p
707
+ data-slot="process-map-selection-summary"
708
+ role="status"
709
+ aria-live="polite"
710
+ className="sr-only"
711
+ >
712
+ {selectionSummary}
713
+ </p>
714
+ <div className="flex items-center justify-end">{filterMenu}</div>
715
+ <Table data-slot="process-map-activity-table">
716
+ <TableCaption>
717
+ {t("process.map.activityCaption", { metric: model.nodeMetricLabel.toLowerCase() })}
718
+ </TableCaption>
719
+ <TableHeader>
720
+ <TableRow>
721
+ <TableHead scope="col">{t("process.map.columnActivity")}</TableHead>
722
+ <TableHead scope="col">{t("process.map.columnRole")}</TableHead>
723
+ <TableHead scope="col">{model.nodeMetricLabel}</TableHead>
724
+ <TableHead scope="col">{t("process.map.columnRework")}</TableHead>
725
+ <TableHead scope="col">{t("process.map.columnState")}</TableHead>
726
+ </TableRow>
727
+ </TableHeader>
728
+ <TableBody>
729
+ {model.activityRows.map((row) => (
730
+ <TableRow
731
+ key={row.id}
732
+ data-selection={row.selectionState}
733
+ // Complementary, colour-only styling hook (step 4): the real channel is the
734
+ // State cell's text below, which fires for `selected` AND `excluded`; this
735
+ // only lights up `TableRow`'s existing `data-[state=selected]:bg-accent`.
736
+ data-state={row.selectionState === "selected" ? "selected" : undefined}
737
+ >
738
+ <TableCell>{row.title}</TableCell>
739
+ <TableCell>{row.role}</TableCell>
740
+ <TableCell className="tabular-nums">
741
+ {row.secondaryLabel
742
+ ? `${row.primaryLabel} · ${row.secondaryLabel}`
743
+ : row.primaryLabel}
744
+ </TableCell>
745
+ <TableCell className="tabular-nums">{row.reworkCount ?? 0}</TableCell>
746
+ <TableCell>{selectionStateText(row.selectionState)}</TableCell>
747
+ </TableRow>
748
+ ))}
749
+ </TableBody>
750
+ </Table>
751
+ <Table data-slot="process-map-transition-table">
752
+ <TableCaption>
753
+ {t("process.map.transitionCaption", {
754
+ metric: model.edgeMetricLabel.toLowerCase(),
755
+ })}
756
+ </TableCaption>
757
+ <TableHeader>
758
+ <TableRow>
759
+ <TableHead scope="col">{t("process.map.columnFrom")}</TableHead>
760
+ <TableHead scope="col">{t("process.map.columnTo")}</TableHead>
761
+ <TableHead scope="col">{t("process.map.columnShape")}</TableHead>
762
+ <TableHead scope="col">{model.edgeMetricLabel}</TableHead>
763
+ <TableHead scope="col">{t("process.map.columnState")}</TableHead>
764
+ </TableRow>
765
+ </TableHeader>
766
+ <TableBody>
767
+ {model.transitionRows.map((row) => (
768
+ <TableRow
769
+ key={row.id}
770
+ data-selection={row.selectionState}
771
+ data-state={row.selectionState === "selected" ? "selected" : undefined}
772
+ >
773
+ <TableCell>{row.source}</TableCell>
774
+ <TableCell>{row.target}</TableCell>
775
+ <TableCell>{row.shape}</TableCell>
776
+ <TableCell className="tabular-nums">
777
+ {row.secondaryLabel
778
+ ? `${row.primaryLabel} · ${row.secondaryLabel}`
779
+ : row.primaryLabel}
780
+ </TableCell>
781
+ <TableCell>{selectionStateText(row.selectionState)}</TableCell>
782
+ </TableRow>
783
+ ))}
784
+ </TableBody>
785
+ </Table>
786
+ </div>
787
+ );
788
+ }
789
+
790
+ return (
791
+ <div
792
+ data-slot="process-map"
793
+ data-view="canvas"
794
+ data-direction={direction}
795
+ className={cn("relative size-full min-h-64", className)}
796
+ onKeyDown={handleKeyDown}
797
+ {...props}
798
+ >
799
+ <p
800
+ data-slot="process-map-selection-summary"
801
+ role="status"
802
+ aria-live="polite"
803
+ className="sr-only"
804
+ >
805
+ {selectionSummary}
806
+ </p>
807
+ <ProcessMapHoverContext value={hover}>
808
+ <ProcessMapEdgeKeyContext value={handleEdgeKey}>
809
+ <CanvasShell
810
+ nodes={positionedNodes}
811
+ edges={model.edges}
812
+ nodeTypes={NODE_TYPES}
813
+ edgeTypes={EDGE_TYPES}
814
+ // dagre runs in an EFFECT, so the first paint has every node stacked at the
815
+ // origin and React Flow's own one-shot `fitView` fits that degenerate box —
816
+ // it clamps at `maxZoom` (2) and never fires again, which is why this canvas
817
+ // used to open at 2× showing 2 of 11 activities with a perfectly good layout
818
+ // underneath. Re-fit on the key the layout itself is cached on: a structural
819
+ // change (or a direction flip) genuinely moves the picture and has to be
820
+ // re-framed; a metric switch is a cache hit, leaves this key alone, and must
821
+ // NOT yank the viewport out from under the reader.
822
+ fitViewKey={`${layoutKey}::${direction}::${layout.layoutRuns}`}
823
+ fitViewKeyOptions={FIT_VIEW_OPTIONS}
824
+ minZoom={MIN_ZOOM}
825
+ nodesDraggable={false}
826
+ nodesConnectable={false}
827
+ // One tab stop per arrow, not two. Every edge already renders a focusable,
828
+ // named label pill (`EdgeLabelPill`), so leaving React Flow's edge `<g>`
829
+ // focusable as well doubled the stops in front of the activities — 28 of them
830
+ // before the first node in the shipped fixture. Keys pressed on the pill still
831
+ // reach this component through `ProcessMapEdgeKeyContext`.
832
+ edgesFocusable={false}
833
+ className={PROCESS_MAP_NODE_MOTION_CLASS}
834
+ aria-label={mapLabel}
835
+ onNodeClick={(_event, node) =>
836
+ applySelection(
837
+ activeSelection?.kind === "activity" && activeSelection.id === node.id
838
+ ? null
839
+ : { kind: "activity", id: node.id },
840
+ )
841
+ }
842
+ onEdgeClick={(_event, edge) =>
843
+ applySelection(
844
+ activeSelection?.kind === "transition" && activeSelection.id === edge.id
845
+ ? null
846
+ : { kind: "transition", id: edge.id },
847
+ )
848
+ }
849
+ onNodeMouseEnter={handleNodeEnter}
850
+ onNodeMouseLeave={handleNodeLeave}
851
+ onNodeContextMenu={(event, node) => {
852
+ event.preventDefault();
853
+ openMenuFor({ kind: "activity", id: node.id });
854
+ }}
855
+ onEdgeContextMenu={(event, edge) => {
856
+ event.preventDefault();
857
+ openMenuFor({ kind: "transition", id: edge.id });
858
+ }}
859
+ onPaneClick={() => applySelection(null)}
860
+ onNodesChange={handleNodesChange}
861
+ >
862
+ {/* `bottom-right` is the minimap's corner; the controls take the other
863
+ bottom corner so a pannable/zoomable minimap never sits on top of them
864
+ (#350). The top rail (legend + filter trigger) already owns the top. */}
865
+ <ZoomControls position="bottom-left" />
866
+ {showMiniMap ? <FlowMiniMap pannable zoomable /> : null}
867
+ </CanvasShell>
868
+ </ProcessMapEdgeKeyContext>
869
+ </ProcessMapHoverContext>
870
+
871
+ <div
872
+ data-slot="process-map-top-rail"
873
+ className="pointer-events-none absolute inset-x-3 top-3 flex items-start justify-between gap-3"
874
+ >
875
+ {showLegend ? (
876
+ <Legend
877
+ variant="scale"
878
+ kind="width"
879
+ domain={model.edgeDomain}
880
+ format={model.formatEdgeValue}
881
+ title={model.edgeMetricLabel}
882
+ className="pointer-events-auto"
883
+ />
884
+ ) : (
885
+ <span />
886
+ )}
887
+ <div className="pointer-events-auto">{filterMenu}</div>
888
+ </div>
889
+ </div>
890
+ );
891
+ }
892
+
893
+ /**
894
+ * Remember how to FIND the element focus should come back to — not the element itself.
895
+ *
896
+ * An edge's label pill is rendered through React Flow's `EdgeLabelRenderer`, a portal
897
+ * whose children are RE-CREATED on a re-render (measured: opening the filter menu replaces
898
+ * all fifteen pill buttons in the shipped fixture). By the time the menu closes, the
899
+ * button the user was standing on is detached and focusing it does nothing. A node wrapper
900
+ * does survive, so it is found again by its `data-id`; a portalled label is found by its
901
+ * position among its siblings in the label layer, which is stable because React reconciles
902
+ * the portals in edge order.
903
+ */
904
+ function focusRestorer(element: HTMLElement): () => void {
905
+ const flowId = element.closest<HTMLElement>("[data-id]")?.dataset.id;
906
+ const slot = element.dataset.slot;
907
+ const layer = element.closest<HTMLElement>(".react-flow__edgelabel-renderer");
908
+ const peers =
909
+ slot && layer ? [...layer.querySelectorAll<HTMLElement>(`[data-slot="${slot}"]`)] : [];
910
+ const index = peers.indexOf(element);
911
+ const root = element.closest<HTMLElement>('[data-slot="process-map"]');
912
+
913
+ return () => {
914
+ if (element.isConnected) {
915
+ element.focus();
916
+ return;
917
+ }
918
+ if (flowId && root) {
919
+ root.querySelector<HTMLElement>(`[data-id="${CSS.escape(flowId)}"]`)?.focus();
920
+ return;
921
+ }
922
+ if (slot && index >= 0 && layer?.isConnected) {
923
+ layer.querySelectorAll<HTMLElement>(`[data-slot="${slot}"]`)[index]?.focus();
924
+ }
925
+ };
926
+ }
927
+
928
+ /** Stable empty arrays, so a graph-less render does not churn the layout hook's inputs. */
929
+ const EMPTY_NODES: ProcessMapModel["nodes"] = [];
930
+ const EMPTY_EDGES: ProcessMapModel["edges"] = [];
931
+
932
+ /**
933
+ * Re-apply the laid-out positions onto the second-pass model, IN LAID-OUT ORDER.
934
+ *
935
+ * The layout hook positions the FIRST-pass nodes; the second pass rebuilds them with
936
+ * back-edge knowledge, which produces new objects at the origin again. Matching on id is
937
+ * the same trick `applyLayoutSnapshot` uses, one level up.
938
+ *
939
+ * The sort is the other half, and it is load-bearing for the keyboard: React Flow renders
940
+ * node wrappers in array order, and each wrapper is a tab stop, so the array order IS the
941
+ * tab order. Mapping over `model.nodes` made that MODEL order — which is discovery order,
942
+ * so `Tab` could reach a mid-process activity before the start one. Reading down the graph
943
+ * (`TB`: ascending `y`, then `x`; `LR`: the transpose) is what a sighted user's eye does,
944
+ * so it is what `Tab` should do. Ties break on id so the order is total and stable — dagre
945
+ * gives whole ranks the same coordinate.
946
+ *
947
+ * This runs AFTER `useProcessLayout`, on its output; the hook's inputs and its cache key
948
+ * are untouched, so re-ordering here cannot make the layout re-run.
949
+ */
950
+ function applyPositions(
951
+ model: ProcessMapModel,
952
+ layout: UseProcessLayoutResult,
953
+ direction: FlowLayoutDirection,
954
+ ) {
955
+ const byId = new Map(layout.nodes.map((node) => [node.id, node]));
956
+ const positioned = model.nodes.map((node) => {
957
+ const laidOut = byId.get(node.id);
958
+ if (!laidOut) return node;
959
+ return {
960
+ ...node,
961
+ position: laidOut.position,
962
+ sourcePosition: laidOut.sourcePosition,
963
+ targetPosition: laidOut.targetPosition,
964
+ };
965
+ });
966
+
967
+ const alongFlow = direction === "LR" || direction === "RL" ? "x" : "y";
968
+ const acrossFlow = alongFlow === "x" ? "y" : "x";
969
+ // `BT`/`RL` lay rank 0 out at the HIGH end of the flow axis, so reading order down the
970
+ // process is descending there — the sign, not a different axis.
971
+ const sign = direction === "BT" || direction === "RL" ? -1 : 1;
972
+ return positioned.sort((a, b) => {
973
+ const along = sign * (a.position[alongFlow] - b.position[alongFlow]);
974
+ if (along !== 0) return along;
975
+ const across = a.position[acrossFlow] - b.position[acrossFlow];
976
+ if (across !== 0) return across;
977
+ return a.id.localeCompare(b.id);
978
+ });
979
+ }