@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,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
|
+
}
|