@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,276 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MetricLayerSwitch — the "what number is this graph drawing" control (RM-052, issue
5
+ * #227). A `ProcessMap` always shows SOME number per activity/transition; this component
6
+ * is how a reader picks it.
7
+ *
8
+ * ## Three layers, not just two selects
9
+ *
10
+ * The Frequency/Performance/Rework `ToggleGroup` picks a FAMILY of readings before the
11
+ * two `Select`s narrow to one member of it:
12
+ * - **Frequency** — counts and shares (`ActivityFrequencyMode` / `TransitionFrequencyMode`).
13
+ * - **Performance** — duration aggregates (`PerformanceAgg`) — the same 7-value domain on
14
+ * both node and edge, so locking is always safe here.
15
+ * - **Rework** — there is no frequency/performance READING for "how much rework happened
16
+ * at this activity"; that overlay is `ProcessMap`'s own `rework` prop, driven by
17
+ * `detectRework`, not a `ProcessMetric`. Selecting this layer disables both `Select`s
18
+ * (and the lock, which has nothing to lock) rather than inventing a metric value that
19
+ * does not exist.
20
+ *
21
+ * ## The lock keeps node and edge metrics in sync — and keeps the domain honest
22
+ *
23
+ * `ActivityFrequencyMode` only ever has 4 members (a node has no "antecedent"/
24
+ * "consequent" side); `TransitionFrequencyMode` has 6. So the LOCKED edge `Select` is
25
+ * restricted to the 4-member intersection — every option it offers is guaranteed valid
26
+ * for the node too — and widens back to all 6 the moment the lock comes off. In the
27
+ * Performance layer both sides already share one domain, so locking never restricts
28
+ * anything there.
29
+ *
30
+ * ## Metric-value labels are REUSED from `ProcessMap`, not re-localized
31
+ *
32
+ * `nodeMetricLabel`/`edgeMetricLabel` (`process-map/map-model.ts`) already resolve every
33
+ * `ProcessMetric` to its correct, audience-specific text — "Occurrences" for a node's
34
+ * `absolute` vs "Transitions" for an edge's, "Share of events" vs "Share of transitions"
35
+ * for `relative`, and so on. Minting a SECOND, shared `process.metricLayerSwitch.metric.*`
36
+ * locale key per value would either duplicate that resolution or (worse) collapse the
37
+ * node/edge distinction those two functions exist to preserve — so this component calls
38
+ * them directly instead, per the reuse-first rule in `.claude/rules/quality-gates.md`.
39
+ */
40
+ import { forwardRef, useCallback, useId, useState, type HTMLAttributes } from "react";
41
+ import { Lock, LockOpen } from "lucide-react";
42
+ import {
43
+ Select,
44
+ SelectContent,
45
+ SelectItem,
46
+ SelectTrigger,
47
+ SelectValue,
48
+ Toggle,
49
+ ToggleGroup,
50
+ ToggleGroupItem,
51
+ useLocale,
52
+ } from "@elabs-ai/components-ui";
53
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
54
+ import {
55
+ edgeMetricLabel,
56
+ isPerformanceMetric,
57
+ nodeMetricLabel,
58
+ type ProcessMetric,
59
+ } from "../process-map/map-model";
60
+
61
+ export type MetricLayer = "frequency" | "performance" | "rework";
62
+
63
+ export interface MetricLayerSwitchMetric {
64
+ node: ProcessMetric;
65
+ edge: ProcessMetric;
66
+ }
67
+
68
+ export interface MetricLayerSwitchProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
69
+ layer: MetricLayer;
70
+ onLayerChange(layer: MetricLayer): void;
71
+ metric: MetricLayerSwitchMetric;
72
+ onMetricChange(next: Partial<MetricLayerSwitchMetric>): void;
73
+ /** Controlled lock state. Omit to let the component manage it (`defaultLocked`). */
74
+ locked?: boolean;
75
+ /** Initial lock state when uncontrolled. Default `true`. */
76
+ defaultLocked?: boolean;
77
+ onLockedChange?(locked: boolean): void;
78
+ label?: string;
79
+ }
80
+
81
+ const FREQUENCY_NODE_OPTIONS: readonly ProcessMetric[] = [
82
+ "absolute",
83
+ "absolute_case",
84
+ "relative",
85
+ "relative_case",
86
+ ];
87
+ const FREQUENCY_EDGE_OPTIONS_LOCKED: readonly ProcessMetric[] = FREQUENCY_NODE_OPTIONS;
88
+ const FREQUENCY_EDGE_OPTIONS_UNLOCKED: readonly ProcessMetric[] = [
89
+ "absolute",
90
+ "absolute_case",
91
+ "relative",
92
+ "relative_case",
93
+ "relative_antecedent",
94
+ "relative_consequent",
95
+ ];
96
+ const PERFORMANCE_OPTIONS: readonly ProcessMetric[] = [
97
+ "median",
98
+ "mean",
99
+ "min",
100
+ "max",
101
+ "sum",
102
+ "p90",
103
+ "trimmed_mean",
104
+ ];
105
+
106
+ export const MetricLayerSwitch = forwardRef<HTMLDivElement, MetricLayerSwitchProps>(
107
+ function MetricLayerSwitch(
108
+ {
109
+ layer,
110
+ onLayerChange,
111
+ metric,
112
+ onMetricChange,
113
+ locked: lockedProp,
114
+ defaultLocked = true,
115
+ onLockedChange,
116
+ label,
117
+ className,
118
+ ...props
119
+ },
120
+ ref,
121
+ ) {
122
+ const { t } = useLocale();
123
+ const nodeId = useId();
124
+ const edgeId = useId();
125
+
126
+ const [uncontrolledLocked, setUncontrolledLocked] = useState(defaultLocked);
127
+ const locked = lockedProp ?? uncontrolledLocked;
128
+ const setLocked = useCallback(
129
+ (next: boolean) => {
130
+ if (lockedProp === undefined) setUncontrolledLocked(next);
131
+ onLockedChange?.(next);
132
+ },
133
+ [lockedProp, onLockedChange],
134
+ );
135
+
136
+ const isRework = layer === "rework";
137
+ const isPerformance = layer === "performance";
138
+
139
+ const nodeOptions = isPerformance ? PERFORMANCE_OPTIONS : FREQUENCY_NODE_OPTIONS;
140
+ const edgeOptions = isPerformance
141
+ ? PERFORMANCE_OPTIONS
142
+ : locked
143
+ ? FREQUENCY_EDGE_OPTIONS_LOCKED
144
+ : FREQUENCY_EDGE_OPTIONS_UNLOCKED;
145
+
146
+ const handleLayerChange = useCallback(
147
+ (next: string) => {
148
+ if (!next) return; // Radix ToggleGroup type="single" emits "" when re-clicking the active item
149
+ const nextLayer = next as MetricLayer;
150
+ onLayerChange(nextLayer);
151
+ if (nextLayer === "performance" && !isPerformanceMetric(metric.node)) {
152
+ onMetricChange({ node: "median", edge: "median" });
153
+ } else if (nextLayer === "frequency" && isPerformanceMetric(metric.node)) {
154
+ onMetricChange({ node: "absolute", edge: "absolute" });
155
+ }
156
+ },
157
+ [onLayerChange, onMetricChange, metric.node],
158
+ );
159
+
160
+ const handleNodeChange = useCallback(
161
+ (value: string) => {
162
+ const next = value as ProcessMetric;
163
+ onMetricChange(locked ? { node: next, edge: next } : { node: next });
164
+ },
165
+ [onMetricChange, locked],
166
+ );
167
+ const handleEdgeChange = useCallback(
168
+ (value: string) => {
169
+ const next = value as ProcessMetric;
170
+ onMetricChange(locked ? { node: next, edge: next } : { edge: next });
171
+ },
172
+ [onMetricChange, locked],
173
+ );
174
+
175
+ const handleLockToggle = useCallback(
176
+ (pressed: boolean) => {
177
+ setLocked(pressed);
178
+ // Turning the lock ON syncs the edge metric to the node's — the node domain is
179
+ // always the intersection-safe one, so this is always a valid edge value.
180
+ if (pressed && metric.edge !== metric.node) {
181
+ onMetricChange({ edge: metric.node });
182
+ }
183
+ },
184
+ [setLocked, metric.edge, metric.node, onMetricChange],
185
+ );
186
+
187
+ return (
188
+ <div
189
+ ref={ref}
190
+ data-slot="metric-layer-switch"
191
+ role="group"
192
+ aria-label={label ?? t("process.metricLayerSwitch.label")}
193
+ className={cn("flex flex-col gap-3", className)}
194
+ {...props}
195
+ >
196
+ <ToggleGroup
197
+ type="single"
198
+ variant="segmented"
199
+ value={layer}
200
+ onValueChange={handleLayerChange}
201
+ aria-label={t("process.metricLayerSwitch.layer")}
202
+ >
203
+ <ToggleGroupItem value="frequency">
204
+ {t("process.metricLayerSwitch.frequency")}
205
+ </ToggleGroupItem>
206
+ <ToggleGroupItem value="performance">
207
+ {t("process.metricLayerSwitch.performance")}
208
+ </ToggleGroupItem>
209
+ <ToggleGroupItem value="rework">{t("process.metricLayerSwitch.rework")}</ToggleGroupItem>
210
+ </ToggleGroup>
211
+
212
+ {/* `min-w-0` on both columns: without it a flex child refuses to shrink below its
213
+ content width, so in a narrow rail (a `SplitPanel` side, an inspector column)
214
+ this row overflows and the edge `Select` is clipped by the pane instead of the
215
+ two selects sharing what space there is. */}
216
+ <div className="flex items-end gap-2">
217
+ <div
218
+ data-slot="metric-layer-switch-node"
219
+ className="flex min-w-0 flex-1 flex-col gap-1.5"
220
+ >
221
+ <label htmlFor={nodeId} className="text-meta text-muted-foreground">
222
+ {t("process.metricLayerSwitch.node")}
223
+ </label>
224
+ <Select value={metric.node} onValueChange={handleNodeChange} disabled={isRework}>
225
+ <SelectTrigger id={nodeId} aria-label={t("process.metricLayerSwitch.node")}>
226
+ <SelectValue />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ {nodeOptions.map((option) => (
230
+ <SelectItem key={option} value={option}>
231
+ {nodeMetricLabel(option)}
232
+ </SelectItem>
233
+ ))}
234
+ </SelectContent>
235
+ </Select>
236
+ </div>
237
+
238
+ <Toggle
239
+ pressed={locked}
240
+ onPressedChange={handleLockToggle}
241
+ disabled={isRework}
242
+ aria-label={
243
+ locked
244
+ ? t("process.metricLayerSwitch.lockOn")
245
+ : t("process.metricLayerSwitch.lockOff")
246
+ }
247
+ data-slot="metric-layer-switch-lock"
248
+ >
249
+ {locked ? <Lock aria-hidden="true" /> : <LockOpen aria-hidden="true" />}
250
+ </Toggle>
251
+
252
+ <div
253
+ data-slot="metric-layer-switch-edge"
254
+ className="flex min-w-0 flex-1 flex-col gap-1.5"
255
+ >
256
+ <label htmlFor={edgeId} className="text-meta text-muted-foreground">
257
+ {t("process.metricLayerSwitch.edge")}
258
+ </label>
259
+ <Select value={metric.edge} onValueChange={handleEdgeChange} disabled={isRework}>
260
+ <SelectTrigger id={edgeId} aria-label={t("process.metricLayerSwitch.edge")}>
261
+ <SelectValue />
262
+ </SelectTrigger>
263
+ <SelectContent>
264
+ {edgeOptions.map((option) => (
265
+ <SelectItem key={option} value={option}>
266
+ {edgeMetricLabel(option)}
267
+ </SelectItem>
268
+ ))}
269
+ </SelectContent>
270
+ </Select>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ );
275
+ },
276
+ );
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Process Explorer — the flagship screen of `@elabs-ai/components-process` (RM-057).
3
+ *
4
+ * ## Intent
5
+ *
6
+ * An analyst opens this to answer one question: *where does this process actually go
7
+ * wrong?* They should leave knowing the dominant path, which steps repeat or stall, and
8
+ * be one click from interrogating any of it — without ever leaving the screen.
9
+ *
10
+ * ## Why it is a story and not a component
11
+ *
12
+ * Every part of it already ships: `ProcessKpiStrip`, `MetricLayerSwitch`,
13
+ * `AbstractionControls` and `ProcessMap` are the package's exported views, and
14
+ * `useProcessExplorer` is the state machine that keeps them agreeing with each other. The
15
+ * screen is the ARRANGEMENT of those parts, and an arrangement is exactly what
16
+ * `docs/DECISIONS.md` §D4 says to copy-own rather than import: a team building a
17
+ * process-mining app wants this file's 200 lines in their repo, editable, not a
18
+ * `<ProcessExplorer>` whose layout they cannot reach. So this is the reference screen —
19
+ * read it, copy it, change it.
20
+ *
21
+ * ## Anatomy
22
+ *
23
+ * Three bands, top to bottom, and one rail:
24
+ *
25
+ * 1. **Header + KPI strip** — the reading a stakeholder needs before any interaction.
26
+ * 2. **`ViewToolbar`** — the repo's one-row grammar for "what am I looking at / what can I
27
+ * do about it" (`Docs/View Toolbar Contract`). Active filters are `FilterChip`s here,
28
+ * not a bespoke pill row, and the case count is a `ResultCount` so "142 of 240" reads
29
+ * the same as it does on every table in the system.
30
+ * 3. **Rail + canvas** — a `SplitPanel` with the two dials and the inspector on the
31
+ * recessed side, the map on the plain one. The dials sit BESIDE the canvas rather than
32
+ * floating over it because they are read as often as they are used.
33
+ *
34
+ * Selecting an activity or a transition fills the inspector; nothing is ever removed from
35
+ * the canvas by a filter, only dimmed, so the reader can always click an excluded element
36
+ * to bring it back (Invariant F — see `useProcessExplorer`).
37
+ */
38
+ import type { Meta, StoryObj } from "@storybook/react-vite";
39
+ import "@xyflow/react/dist/style.css";
40
+ import { useMemo, useState } from "react";
41
+ import { LayoutGrid, Table2 } from "lucide-react";
42
+ import { expect, userEvent, waitFor, within } from "storybook/test";
43
+ import {
44
+ Button,
45
+ Descriptions,
46
+ DescriptionsItem,
47
+ FilterChip,
48
+ ResultCount,
49
+ SectionHeader,
50
+ Separator,
51
+ SplitPanel,
52
+ StatePanel,
53
+ ToggleGroup,
54
+ ToggleGroupItem,
55
+ ViewToolbar,
56
+ ViewToolbarFilters,
57
+ } from "@elabs-ai/components-ui";
58
+ import { InspectorPanel } from "@elabs-ai/components-flow";
59
+ import type { FlowLayoutDirection } from "@elabs-ai/components-flow";
60
+ import { generateBpi2012Subset } from "./core/fixtures/generate-bpi-2012-subset";
61
+ import type { EventLog } from "./core/types";
62
+ import { AbstractionControls } from "./abstraction-controls";
63
+ import { MetricLayerSwitch } from "./metric-layer-switch";
64
+ import { ProcessKpiStrip } from "./process-kpi-strip";
65
+ import { ProcessMap, formatDurationMs, processEdgeId } from "./process-map";
66
+ import type { FilterIntent } from "./use-process-explorer";
67
+ import { useProcessExplorer } from "./use-process-explorer";
68
+
69
+ const log = generateBpi2012Subset({ cases: 240, seed: 1 });
70
+
71
+ /** A log with no events — the shape a too-narrow filter or an empty extract leaves behind. */
72
+ const emptyLog: EventLog = { events: [] };
73
+
74
+ /**
75
+ * A filter intent as a sentence fragment.
76
+ *
77
+ * `FilterChip`'s contract is label-in-value text (`"Status: Failed"`, never a bare
78
+ * `"Failed"`), because a chip has to read on its own in a screen reader's list of
79
+ * controls. An intent is a discriminated union, so this is a `switch`, not a lookup.
80
+ */
81
+ function intentLabel(intent: FilterIntent): string {
82
+ switch (intent.kind) {
83
+ case "with":
84
+ return `Includes: ${intent.activity}`;
85
+ case "without":
86
+ return `Excludes: ${intent.activity}`;
87
+ case "startsWith":
88
+ return `Starts with: ${intent.activity}`;
89
+ case "endsWith":
90
+ return `Ends with: ${intent.activity}`;
91
+ case "follower":
92
+ return `${intent.direct ? "Directly follows" : "Eventually follows"}: ${intent.a} → ${intent.b}`;
93
+ case "variant":
94
+ return `Variants: ${intent.ids.length}`;
95
+ default:
96
+ return "Filter";
97
+ }
98
+ }
99
+
100
+ /**
101
+ * What the inspector shows for the current selection.
102
+ *
103
+ * Read off the SAME graph the canvas draws, so the panel and the picture can never
104
+ * disagree — the numbers are one derivation, presented twice.
105
+ */
106
+ function SelectionDetail({
107
+ graph,
108
+ selection,
109
+ }: {
110
+ graph: ReturnType<typeof useProcessExplorer>["graph"];
111
+ selection: { kind: "activity" | "transition"; id: string } | null;
112
+ }) {
113
+ if (!selection) return null;
114
+
115
+ if (selection.kind === "activity") {
116
+ const activity = graph.activities.find((candidate) => candidate.id === selection.id);
117
+ if (!activity) return null;
118
+ return (
119
+ <Descriptions>
120
+ <DescriptionsItem label="Cases" numeric>
121
+ {activity.cases.toLocaleString()}
122
+ </DescriptionsItem>
123
+ <DescriptionsItem label="Executions" numeric>
124
+ {activity.instances.toLocaleString()}
125
+ </DescriptionsItem>
126
+ <DescriptionsItem label="Median duration" numeric>
127
+ {formatDurationMs(activity.duration.median)}
128
+ </DescriptionsItem>
129
+ <DescriptionsItem label="Role">
130
+ {activity.isStart ? "Start" : activity.isEnd ? "End" : "Intermediate"}
131
+ </DescriptionsItem>
132
+ </Descriptions>
133
+ );
134
+ }
135
+
136
+ const transition = graph.transitions.find(
137
+ (candidate) => processEdgeId(candidate.source, candidate.target) === selection.id,
138
+ );
139
+ if (!transition) return null;
140
+ return (
141
+ <Descriptions>
142
+ <DescriptionsItem label="From">{transition.source}</DescriptionsItem>
143
+ <DescriptionsItem label="To">{transition.target}</DescriptionsItem>
144
+ <DescriptionsItem label="Transitions" numeric>
145
+ {transition.count.toLocaleString()}
146
+ </DescriptionsItem>
147
+ <DescriptionsItem label="Cases" numeric>
148
+ {transition.caseCount.toLocaleString()}
149
+ </DescriptionsItem>
150
+ <DescriptionsItem label="Median wait" numeric>
151
+ {formatDurationMs(transition.duration.median)}
152
+ </DescriptionsItem>
153
+ </Descriptions>
154
+ );
155
+ }
156
+
157
+ /** The screen. Everything below is composition — no new primitive is authored here. */
158
+ function ProcessExplorer({ eventLog = log }: { eventLog?: EventLog }) {
159
+ // Opening BELOW 100% is the product-correct default, not a legibility workaround — the
160
+ // map handles legibility itself now, by refusing to open below a readable zoom. A
161
+ // directly-follows graph discovered at FULL detail is the "spaghetti model" every
162
+ // process-mining tool warns about, and every one of them (Disco, ProM, Celonis) opens
163
+ // its sliders short of 100% for exactly that reason: the screen should open on the
164
+ // readable core of the process, not on all 24 activities plus every rare path. The
165
+ // analyst raises the dials to see the long tail, and the "N activities hidden" line
166
+ // under them keeps what is missing stated rather than silent.
167
+ const explorer = useProcessExplorer(eventLog, {
168
+ abstraction: { activities: 0.55, paths: 0.4 },
169
+ });
170
+ const [direction, setDirection] = useState<FlowLayoutDirection>("TB");
171
+ const [view, setView] = useState<"canvas" | "table">("canvas");
172
+
173
+ // The UNFILTERED case count, so `ResultCount` can honestly read "142 of 240". The
174
+ // explorer's own `kpis.cases` is the filtered figure — the other half of that sentence.
175
+ const totalCases = useMemo(
176
+ () => new Set(eventLog.events.map((event) => event.caseId)).size,
177
+ [eventLog],
178
+ );
179
+ const selectionTitle = explorer.selection
180
+ ? explorer.selection.kind === "activity"
181
+ ? explorer.selection.id
182
+ : explorer.selection.id.replace(" → ", " → ")
183
+ : undefined;
184
+
185
+ const hasProcess = explorer.graph.activities.length > 0;
186
+
187
+ const trends = useMemo(() => ({}), []);
188
+
189
+ return (
190
+ // `min-h-0` on every link of the chain, and `overflow-hidden` at the root: without
191
+ // them a flex child defaults to `min-height: auto` and refuses to shrink below its
192
+ // content, so the canvas — the one region that should absorb the leftover height —
193
+ // is instead the region that pushes the screen taller than its frame.
194
+ <div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden bg-background p-6">
195
+ <SectionHeader
196
+ eyebrow="Order to cash"
197
+ title="Process Explorer"
198
+ description={`Discovered from ${totalCases.toLocaleString()} cases of the BPI-2012 loan-application log. Every number on this screen comes from one derivation, so the canvas and the tables cannot disagree.`}
199
+ />
200
+
201
+ <ProcessKpiStrip kpis={explorer.kpis} trends={trends} loading={explorer.loading} />
202
+
203
+ <ViewToolbar
204
+ info="Activities are sized by how many cases reach them; arrows are weighted by how often the handover happens. Click an activity to inspect it, or filter from its menu."
205
+ actions={
206
+ <div className="flex items-center gap-2">
207
+ <ToggleGroup
208
+ type="single"
209
+ value={direction}
210
+ onValueChange={(next) => next && setDirection(next as FlowLayoutDirection)}
211
+ aria-label="Layout direction"
212
+ size="sm"
213
+ >
214
+ <ToggleGroupItem value="TB">Top&nbsp;down</ToggleGroupItem>
215
+ <ToggleGroupItem value="LR">Left&nbsp;to&nbsp;right</ToggleGroupItem>
216
+ </ToggleGroup>
217
+ <ToggleGroup
218
+ type="single"
219
+ value={view}
220
+ onValueChange={(next) => next && setView(next as "canvas" | "table")}
221
+ aria-label="View"
222
+ size="sm"
223
+ >
224
+ <ToggleGroupItem value="canvas" aria-label="Canvas">
225
+ <LayoutGrid aria-hidden="true" />
226
+ </ToggleGroupItem>
227
+ <ToggleGroupItem value="table" aria-label="Table">
228
+ <Table2 aria-hidden="true" />
229
+ </ToggleGroupItem>
230
+ </ToggleGroup>
231
+ </div>
232
+ }
233
+ >
234
+ {explorer.intents.length > 0 ? (
235
+ <ViewToolbarFilters
236
+ onClearAll={() => {
237
+ for (let index = explorer.intents.length - 1; index >= 0; index -= 1) {
238
+ explorer.clearIntent(index);
239
+ }
240
+ }}
241
+ >
242
+ {explorer.intents.map((intent, index) => (
243
+ <FilterChip
244
+ key={`${intent.kind}-${index}`}
245
+ label={intentLabel(intent)}
246
+ onRemove={() => explorer.clearIntent(index)}
247
+ />
248
+ ))}
249
+ </ViewToolbarFilters>
250
+ ) : null}
251
+ <ResultCount count={explorer.kpis.cases} total={totalCases} />
252
+ </ViewToolbar>
253
+
254
+ {hasProcess ? (
255
+ <SplitPanel
256
+ // The canvas is the screen's subject, so it takes every pixel the bands above
257
+ // do not need — and never less than this floor, below which a process map stops
258
+ // being readable at all and the reader is better served by the table twin.
259
+ className="min-h-[26rem] flex-1"
260
+ // 24rem, not less: the metric row puts two `Select`s and the lock side by side,
261
+ // and below this width the default values truncate to a stem ("Occurren…") that
262
+ // names nothing. The longest labels ("90th percentile duration") still truncate
263
+ // — that is what the trigger's own `title` tooltip is for — but the values a
264
+ // reader meets on arrival are whole. The rail is read as much as it is used.
265
+ startSize="24rem"
266
+ startTone="recessed"
267
+ start={
268
+ <div className="flex h-full min-h-0 flex-col gap-4 overflow-auto p-4">
269
+ <MetricLayerSwitch
270
+ layer={explorer.layer}
271
+ onLayerChange={explorer.setLayer}
272
+ metric={explorer.metric}
273
+ onMetricChange={explorer.setMetric}
274
+ />
275
+ <Separator />
276
+ <AbstractionControls
277
+ abstraction={explorer.abstraction}
278
+ onAbstractionChange={explorer.setAbstraction}
279
+ graph={explorer.graph}
280
+ hiddenCounts={explorer.hiddenCounts}
281
+ />
282
+ <Separator />
283
+ <InspectorPanel
284
+ title={selectionTitle ?? "Details"}
285
+ hasSelection={explorer.selection !== null}
286
+ selectionKey={explorer.selection?.id}
287
+ onClose={() => explorer.onSelect(null)}
288
+ emptyMessage="Select an activity or a transition to see its numbers."
289
+ >
290
+ <SelectionDetail graph={explorer.graph} selection={explorer.selection} />
291
+ </InspectorPanel>
292
+ </div>
293
+ }
294
+ end={
295
+ <ProcessMap
296
+ className="h-full"
297
+ graph={explorer.graph}
298
+ metric={explorer.metric}
299
+ rework={explorer.rework}
300
+ direction={direction}
301
+ selection={explorer.selection}
302
+ onSelect={explorer.onSelect}
303
+ selectionStates={explorer.selectionStates}
304
+ onFilterIntent={explorer.applyIntent}
305
+ tableView={view === "table"}
306
+ loading={explorer.loading}
307
+ />
308
+ }
309
+ />
310
+ ) : (
311
+ <StatePanel
312
+ kind="empty"
313
+ title="No process to show"
314
+ description="This log has no completed cases, so there is no directly-follows relation to discover. Widen the filter or load a different log."
315
+ actions={<Button variant="outline">Load a sample log</Button>}
316
+ />
317
+ )}
318
+ </div>
319
+ );
320
+ }
321
+
322
+ const meta = {
323
+ title: "Process/ProcessExplorer",
324
+ component: ProcessExplorer,
325
+ parameters: {
326
+ layout: "fullscreen",
327
+ docs: {
328
+ description: {
329
+ component:
330
+ "The flagship screen of `@elabs-ai/components-process`, the repo's one layer-3 " +
331
+ "package (ADR 0034): KPI strip, filter row, metric and abstraction dials, " +
332
+ "inspector and process map, all driven by a single `useProcessExplorer` state " +
333
+ "machine so no two panels can disagree about what is being shown. Every part is " +
334
+ "an exported view of this package or a primitive from " +
335
+ "`@elabs-ai/components-ui`/`-flow`/`-charts`/`-data` — the screen authors no " +
336
+ "edge, mark, table or control of its own, which is the binding rule for this " +
337
+ "package. Copy this file as the starting point for a process-mining app rather " +
338
+ "than importing it: the value here is the arrangement, and an arrangement you " +
339
+ "cannot edit is the wrong abstraction (`docs/DECISIONS.md` §D4).",
340
+ },
341
+ },
342
+ },
343
+ decorators: [
344
+ (Story) => (
345
+ <div className="h-[52rem] w-full">
346
+ <Story />
347
+ </div>
348
+ ),
349
+ ],
350
+ } satisfies Meta<typeof ProcessExplorer>;
351
+
352
+ export default meta;
353
+ type Story = StoryObj<typeof meta>;
354
+
355
+ /** The screen as an analyst first sees it: nothing filtered, frequency on both channels. */
356
+ export const Default: Story = {
357
+ play: async ({ canvasElement }) => {
358
+ const canvas = within(canvasElement);
359
+ // The reading a stakeholder needs is present before any interaction.
360
+ await waitFor(() => expect(canvas.getByText("Process Explorer")).toBeInTheDocument());
361
+ await waitFor(() =>
362
+ expect(canvasElement.querySelectorAll('[data-slot="process-activity-node"]').length),
363
+ );
364
+
365
+ // Selecting an activity fills the inspector — the panel reads the same graph the
366
+ // canvas draws, so this also proves the two are wired to one model.
367
+ const nodes = canvasElement.querySelectorAll<HTMLElement>(
368
+ '[data-slot="process-activity-node"]',
369
+ );
370
+ expect(nodes.length).toBeGreaterThan(1);
371
+ await userEvent.click(nodes[0]!);
372
+ await waitFor(() => expect(canvas.getByText("Median duration")).toBeInTheDocument());
373
+
374
+ // The table twin renders the identical numbers, so the screen is readable without
375
+ // reading a picture.
376
+ await userEvent.click(canvas.getByRole("radio", { name: "Table" }));
377
+ await waitFor(() => expect(canvas.getByRole("table", { name: /Activities/ })).toBeVisible());
378
+ },
379
+ };
380
+
381
+ /**
382
+ * The empty state, designed with the happy path rather than retrofitted: a log whose
383
+ * cases were all filtered away has nothing to discover, and says so with a way out.
384
+ */
385
+ export const NoProcess: Story = {
386
+ args: { eventLog: emptyLog },
387
+ play: async ({ canvasElement }) => {
388
+ const canvas = within(canvasElement);
389
+ await waitFor(() => expect(canvas.getByText("No process to show")).toBeInTheDocument());
390
+ expect(canvas.getByRole("button", { name: "Load a sample log" })).toBeInTheDocument();
391
+ },
392
+ };
@@ -0,0 +1,6 @@
1
+ export { ProcessKpiStrip } from "./process-kpi-strip";
2
+ export type {
3
+ ProcessKpiStripKpis,
4
+ ProcessKpiStripProps,
5
+ ProcessKpiStripTrendKey,
6
+ } from "./process-kpi-strip";