@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,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 down</ToggleGroupItem>
|
|
215
|
+
<ToggleGroupItem value="LR">Left to 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
|
+
};
|