@elabs-ai/components-process 4.2.0 → 5.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 (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,1304 @@
1
+ /**
2
+ * Process explorer template — the canonical full-screen `process-explorer` archetype: a
3
+ * map-first process-mining workspace over one order-to-cash event log.
4
+ *
5
+ * - a slim header with the scope, and the six KPIs as ONE ribbon (`layout="inline"`), so
6
+ * the height of the screen belongs to the process map;
7
+ * - a toolbar that owns every control — metric layer, deviations, detail sliders, the
8
+ * active filters, layout direction, table twin — so nothing floats over the map;
9
+ * - the map on the left, top-down so the whole process fits a pane of any width, and
10
+ * beside it a panel of statistical insights (throughput distribution against the SLA,
11
+ * the slowest hand-overs, the weekly trend, box and violin plots by region and
12
+ * channel), the variants as colour strips, the selected element, and deviations;
13
+ * - a dock for the wide, time-based views — dotted chart, performance spectrum, workload
14
+ * heatmap, case table — that stays a tab strip until one of them is asked for.
15
+ *
16
+ * This story is the single source of truth: `pnpm gen` derives the consumer template source
17
+ * (`docs/playbooks/templates/process-explorer.tsx`) from it, and the copy-own registry block
18
+ * (`registry/blocks/process-explorer-page/`) mirrors it. One `useProcessExplorer` instance
19
+ * drives every panel, so the map, the chips, the KPIs, the variants and every chart always
20
+ * describe the same set of cases (Invariant F: filtering re-inks, it never removes).
21
+ * Remember `import "@xyflow/react/dist/style.css"` is wired in Storybook preview.
22
+ * Verify across every theme with globals=theme:<slug>.
23
+ */
24
+ import type { Meta, StoryObj } from "@storybook/react-vite";
25
+ import "@xyflow/react/dist/style.css";
26
+ import { expect, userEvent, waitFor, within } from "storybook/test";
27
+ import { useMemo, useState, type ReactNode } from "react";
28
+ import {
29
+ ArrowDown,
30
+ ArrowLeft,
31
+ ArrowRight,
32
+ ChevronsUp,
33
+ PanelBottom,
34
+ PanelRight,
35
+ Play,
36
+ ShieldAlert,
37
+ SlidersHorizontal,
38
+ Table2,
39
+ Waypoints,
40
+ } from "lucide-react";
41
+ import {
42
+ Badge,
43
+ Button,
44
+ Descriptions,
45
+ DescriptionsItem,
46
+ Dialog,
47
+ DialogContent,
48
+ DialogDescription,
49
+ DialogHeader,
50
+ DialogTitle,
51
+ Popover,
52
+ PopoverContent,
53
+ PopoverTrigger,
54
+ Select,
55
+ SelectContent,
56
+ SelectItem,
57
+ SelectTrigger,
58
+ SelectValue,
59
+ Separator,
60
+ Sheet,
61
+ SheetContent,
62
+ SheetDescription,
63
+ SheetHeader,
64
+ SheetTitle,
65
+ StatePanel,
66
+ Tabs,
67
+ TabsContent,
68
+ TabsList,
69
+ TabsTrigger,
70
+ Toggle,
71
+ ToggleGroup,
72
+ ToggleGroupItem,
73
+ Tooltip,
74
+ TooltipContent,
75
+ TooltipProvider,
76
+ TooltipTrigger,
77
+ useLocale,
78
+ } from "@elabs-ai/components-ui";
79
+ import {
80
+ Bar,
81
+ BarChart,
82
+ BarYAxis,
83
+ ChartConfigProvider,
84
+ ChartTooltip,
85
+ DistributionChart,
86
+ Grid,
87
+ HeatmapChart,
88
+ Line,
89
+ LineChart,
90
+ XAxis,
91
+ YAxis,
92
+ } from "@elabs-ai/components-charts";
93
+ import {
94
+ AbstractionControls,
95
+ activityColorScale,
96
+ CaseTable,
97
+ CaseTimeline,
98
+ casesFromLog,
99
+ createCaseTableColumns,
100
+ DottedChart,
101
+ formatDurationMs,
102
+ MetricLayerSwitch,
103
+ PerformanceSpectrum,
104
+ processEdgeId,
105
+ ProcessFilterBar,
106
+ ProcessKpiStrip,
107
+ ProcessMap,
108
+ ProcessReplay,
109
+ useProcessExplorer,
110
+ VariantExplorer,
111
+ ViolationList,
112
+ type CaseRow,
113
+ type MetricLayer,
114
+ type ProcessMapProps,
115
+ } from "./index";
116
+ import {
117
+ conformanceRateSeries,
118
+ liftHappyPath,
119
+ tokenReplay,
120
+ type EventLog,
121
+ type EventRow,
122
+ } from "./core";
123
+
124
+ /** Read off the components that own them, so this screen imports no package for a type alone. */
125
+ type CaseColumn = ReturnType<typeof createCaseTableColumns>[number];
126
+ type MapDirection = NonNullable<ProcessMapProps["direction"]>;
127
+
128
+ // ── The event log ────────────────────────────────────────────────────────────
129
+
130
+ const HOUR = 3_600_000;
131
+ const DAY = 24 * HOUR;
132
+ /** Monday 6 July 2026, 07:00 UTC — the first order of the quarter. */
133
+ const LOG_START = Date.UTC(2026, 6, 6, 7);
134
+ /** The log was exported ten weeks and two days later; anything after this has not happened. */
135
+ const LOG_END = LOG_START + 72 * DAY;
136
+ const CASE_COUNT = 420;
137
+ /** Order to cash is promised in 21 days. */
138
+ const SLA_DAYS = 21;
139
+
140
+ const REGIONS = ["North", "South", "East", "West"] as const;
141
+ const SEGMENTS = ["Enterprise", "Mid-market", "SMB"] as const;
142
+ const CHANNELS = ["Web shop", "EDI", "Sales rep"] as const;
143
+
144
+ /** A deterministic 32-bit PRNG (mulberry32): the same log on the server and in the browser. */
145
+ function seeded(seed: number): () => number {
146
+ let a = seed >>> 0;
147
+ return () => {
148
+ a = (a + 0x6d2b79f5) >>> 0;
149
+ let t = a;
150
+ t = Math.imul(t ^ (t >>> 15), t | 1);
151
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
152
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
153
+ };
154
+ }
155
+
156
+ /** People work 07:00–19:00 on weekdays; anything that lands outside waits for the morning. */
157
+ function toWorkingHours(ms: number, random: () => number): number {
158
+ const date = new Date(ms);
159
+ const hour = date.getUTCHours();
160
+ if (hour >= 19) date.setUTCDate(date.getUTCDate() + 1);
161
+ if (hour >= 19 || hour < 7) date.setUTCHours(7, Math.floor(random() * 170), 0, 0);
162
+ const weekday = date.getUTCDay();
163
+ if (weekday === 6) date.setUTCDate(date.getUTCDate() + 2);
164
+ if (weekday === 0) date.setUTCDate(date.getUTCDate() + 1);
165
+ return date.getTime();
166
+ }
167
+
168
+ /**
169
+ * A quarter of order-to-cash: a happy path, a credit re-check loop that hits enterprise
170
+ * orders hardest, a backorder detour that is mostly a West problem, a three-week credit
171
+ * backlog in the South, invoices sent before the goods, payment reminders, rejections,
172
+ * cancellations — and the orders that were still open when the log was exported.
173
+ */
174
+ function buildOrderToCashLog(): EventLog {
175
+ const random = seeded(20260706);
176
+ const between = (min: number, max: number) => min + random() * (max - min);
177
+ const pick = <T,>(items: readonly T[], weights: readonly number[]): T => {
178
+ let roll = random() * weights.reduce((sum, weight) => sum + weight, 0);
179
+ for (let index = 0; index < items.length; index += 1) {
180
+ roll -= weights[index]!;
181
+ if (roll <= 0) return items[index]!;
182
+ }
183
+ return items[items.length - 1]!;
184
+ };
185
+
186
+ const events: EventRow[] = [];
187
+ const caseAttributes: Record<string, Record<string, unknown>> = {};
188
+
189
+ for (let index = 0; index < CASE_COUNT; index += 1) {
190
+ const caseId = `SO-${String(48200 + index * 3)}`;
191
+ const region = pick(REGIONS, [3, 2.4, 2.2, 1.8]);
192
+ const segment = pick(SEGMENTS, [1.6, 2.6, 3]);
193
+ const channel = pick(CHANNELS, segment === "Enterprise" ? [1, 3, 3] : [4, 1.5, 1.5]);
194
+ const week = Math.floor((index / CASE_COUNT) * 10);
195
+ const weekdayOffset = Math.floor(random() * 5);
196
+ caseAttributes[caseId] = {
197
+ region,
198
+ segment,
199
+ channel,
200
+ orderValue: Math.round(between(1, segment === "Enterprise" ? 90 : 24)) * 500,
201
+ };
202
+
203
+ let clock = LOG_START + week * 7 * DAY + weekdayOffset * DAY + between(0, 10) * HOUR;
204
+ const push = (activity: string, waitHours: number, resource: string, office = true) => {
205
+ clock += waitHours * HOUR;
206
+ if (office) clock = toWorkingHours(clock, random);
207
+ events.push({ caseId, activity, timestamp: clock, resource });
208
+ };
209
+ const clerk = pick(["A. Novak", "B. Ferreira", "C. Okafor"], [3, 2, 2]);
210
+ const analyst = pick(["D. Lindqvist", "E. Haddad"], [1, 1]);
211
+
212
+ push("Create Order", 0, channel === "EDI" ? "EDI gateway" : clerk);
213
+
214
+ // The South's credit desk ran three weeks behind in weeks 4–6.
215
+ const backlog = region === "South" && week >= 3 && week <= 5;
216
+ push(
217
+ "Check Credit",
218
+ backlog ? between(30, 76) : between(0.5, 5),
219
+ segment === "SMB" && !backlog ? "Credit service" : analyst,
220
+ );
221
+
222
+ let loops = 0;
223
+ const amendOdds = segment === "Enterprise" ? 0.3 : segment === "Mid-market" ? 0.14 : 0.06;
224
+ while (loops < 2 && random() < (loops === 0 ? amendOdds : 0.2)) {
225
+ push("Amend Order", between(3, 22), clerk);
226
+ push("Check Credit", between(2, 9), analyst);
227
+ loops += 1;
228
+ }
229
+
230
+ if (random() < 0.05) {
231
+ push("Reject Order", between(1, 6), analyst);
232
+ continue;
233
+ }
234
+ push("Approve Order", segment === "Enterprise" ? between(6, 30) : between(1, 8), analyst);
235
+ push("Reserve Stock", between(0.5, 5), "Warehouse system");
236
+
237
+ if (random() < 0.03) {
238
+ push("Cancel Order", between(4, 40), clerk);
239
+ continue;
240
+ }
241
+ if (random() < (region === "West" ? 0.3 : 0.08)) {
242
+ push("Backorder", between(2, 6), "Warehouse system");
243
+ push("Reserve Stock", between(48, 140), "Warehouse system");
244
+ }
245
+ push(
246
+ "Pick Items",
247
+ between(4, 26),
248
+ pick(["F. Marchetti", "G. Sato", "Picking robot"], [2, 2, 3]),
249
+ );
250
+
251
+ if (random() < 0.08) {
252
+ push("Send Invoice", between(2, 10), "Billing run");
253
+ push("Ship Order", between(8, 30), "Carrier hand-over");
254
+ } else {
255
+ push("Ship Order", between(6, 30), "Carrier hand-over");
256
+ push("Send Invoice", between(1, 12), "Billing run");
257
+ }
258
+
259
+ if (random() < 0.16) {
260
+ push("Payment Reminder", between(14, 21) * 24, "Dunning run");
261
+ push("Receive Payment", between(2, 9) * 24, "Bank feed", false);
262
+ } else {
263
+ push(
264
+ "Receive Payment",
265
+ (channel === "EDI" ? between(3, 10) : between(5, 18)) * 24,
266
+ "Bank feed",
267
+ false,
268
+ );
269
+ }
270
+ }
271
+
272
+ // Whatever is dated after the export has not happened yet: those orders are still open.
273
+ const happened = events.filter((event) => (event.timestamp as number) <= LOG_END);
274
+ return { events: happened, caseAttributes };
275
+ }
276
+
277
+ const ORDER_TO_CASH = buildOrderToCashLog();
278
+
279
+ /** The process as designed — what conformance is measured against. */
280
+ const REFERENCE_MODEL = liftHappyPath({
281
+ id: "order-to-cash",
282
+ label: "Order to cash",
283
+ steps: [
284
+ { activity: "Create Order" },
285
+ { activity: "Check Credit" },
286
+ { activity: "Approve Order" },
287
+ { activity: "Reserve Stock" },
288
+ { activity: "Pick Items" },
289
+ { activity: "Ship Order" },
290
+ { activity: "Send Invoice" },
291
+ { activity: "Receive Payment" },
292
+ ],
293
+ });
294
+
295
+ /** The log narrowed to one region; case attributes travel with the cases that survive. */
296
+ function scopeLog(log: EventLog, region: string): EventLog {
297
+ if (region === "all") return log;
298
+ const keep = new Set(
299
+ Object.entries(log.caseAttributes ?? {})
300
+ .filter(([, attributes]) => attributes.region === region)
301
+ .map(([caseId]) => caseId),
302
+ );
303
+ const caseAttributes: Record<string, Record<string, unknown>> = {};
304
+ for (const caseId of keep) caseAttributes[caseId] = log.caseAttributes![caseId]!;
305
+ return { events: log.events.filter((event) => keep.has(event.caseId)), caseAttributes };
306
+ }
307
+
308
+ // ── Statistics read off the filtered log ─────────────────────────────────────
309
+
310
+ type TraceEvent = { activity: string; ms: number; resource?: string };
311
+ type Trace = { caseId: string; events: TraceEvent[]; attributes: Record<string, unknown> };
312
+ type ThroughputRow = { days: number; region: string; channel: string };
313
+ type WeekRow = { date: Date; median: number; p90: number };
314
+ type HandoverRow = { name: string; id: string; median: number; p90: number; count: number };
315
+ type LoadRow = { day: string; hour: string; events: number };
316
+ type ResourceRow = { name: string; events: number };
317
+ type WaitRow = { hours: number };
318
+
319
+ const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
320
+ const OFFICE_HOURS = Array.from({ length: 12 }, (_, index) => String(index + 7).padStart(2, "0"));
321
+
322
+ function quantileOf(sorted: readonly number[], q: number): number {
323
+ if (sorted.length === 0) return 0;
324
+ const position = (sorted.length - 1) * q;
325
+ const low = Math.floor(position);
326
+ const high = Math.ceil(position);
327
+ return sorted[low]! + (sorted[high]! - sorted[low]!) * (position - low);
328
+ }
329
+
330
+ function tracesOf(log: EventLog): Trace[] {
331
+ const byCase = new Map<string, Trace>();
332
+ for (const event of log.events) {
333
+ let trace = byCase.get(event.caseId);
334
+ if (!trace) {
335
+ trace = {
336
+ caseId: event.caseId,
337
+ events: [],
338
+ attributes: log.caseAttributes?.[event.caseId] ?? {},
339
+ };
340
+ byCase.set(event.caseId, trace);
341
+ }
342
+ trace.events.push({
343
+ activity: event.activity,
344
+ ms: new Date(event.timestamp).getTime(),
345
+ resource: event.resource,
346
+ });
347
+ }
348
+ const traces = [...byCase.values()];
349
+ for (const trace of traces) trace.events.sort((a, b) => a.ms - b.ms);
350
+ return traces;
351
+ }
352
+
353
+ /** Everything the analysis dock plots, derived once per filtered log. */
354
+ function analyse(traces: readonly Trace[]) {
355
+ const throughput: ThroughputRow[] = [];
356
+ const closedByWeek = new Map<number, number[]>();
357
+ const load = new Map<string, number>();
358
+ const resources = new Map<string, number>();
359
+ let open = 0;
360
+
361
+ for (const trace of traces) {
362
+ const first = trace.events[0]!;
363
+ const last = trace.events[trace.events.length - 1]!;
364
+ if (last.activity === "Receive Payment") {
365
+ const days = (last.ms - first.ms) / DAY;
366
+ throughput.push({
367
+ days,
368
+ region: String(trace.attributes.region ?? "Unknown"),
369
+ channel: String(trace.attributes.channel ?? "Unknown"),
370
+ });
371
+ const week = Math.floor((last.ms - LOG_START) / (7 * DAY));
372
+ closedByWeek.set(week, [...(closedByWeek.get(week) ?? []), days]);
373
+ } else if (last.activity !== "Reject Order" && last.activity !== "Cancel Order") {
374
+ open += 1;
375
+ }
376
+ for (const event of trace.events) {
377
+ const date = new Date(event.ms);
378
+ const day = WEEKDAYS[date.getUTCDay() - 1];
379
+ const hour = String(date.getUTCHours()).padStart(2, "0");
380
+ if (day && OFFICE_HOURS.includes(hour)) {
381
+ load.set(`${day}|${hour}`, (load.get(`${day}|${hour}`) ?? 0) + 1);
382
+ }
383
+ if (event.resource) resources.set(event.resource, (resources.get(event.resource) ?? 0) + 1);
384
+ }
385
+ }
386
+
387
+ const weeks: WeekRow[] = [...closedByWeek.entries()]
388
+ .filter(([, days]) => days.length >= 3)
389
+ .sort(([a], [b]) => a - b)
390
+ .map(([week, days]) => {
391
+ const sorted = [...days].sort((a, b) => a - b);
392
+ return {
393
+ date: new Date(LOG_START + week * 7 * DAY),
394
+ median: Number(quantileOf(sorted, 0.5).toFixed(1)),
395
+ p90: Number(quantileOf(sorted, 0.9).toFixed(1)),
396
+ };
397
+ });
398
+
399
+ const workload: LoadRow[] = WEEKDAYS.flatMap((day) =>
400
+ OFFICE_HOURS.map((hour) => ({ day, hour, events: load.get(`${day}|${hour}`) ?? 0 })),
401
+ );
402
+ const resourceLoad: ResourceRow[] = [...resources.entries()]
403
+ .map(([name, events]) => ({ name, events }))
404
+ .sort((a, b) => b.events - a.events)
405
+ .slice(0, 6);
406
+
407
+ const sortedDays = throughput.map((row) => row.days).sort((a, b) => a - b);
408
+ const late = sortedDays.filter((days) => days > SLA_DAYS).length;
409
+ return {
410
+ throughput,
411
+ weeks,
412
+ workload,
413
+ resourceLoad,
414
+ open,
415
+ closed: throughput.length,
416
+ medianDays: quantileOf(sortedDays, 0.5),
417
+ p90Days: quantileOf(sortedDays, 0.9),
418
+ lateShare: throughput.length > 0 ? late / throughput.length : 0,
419
+ };
420
+ }
421
+
422
+ /** Hours between two activities wherever one directly follows the other; or into one activity. */
423
+ function waitsFor(
424
+ traces: readonly Trace[],
425
+ selection: { kind: "activity" | "transition"; id: string },
426
+ edge: { source: string; target: string } | undefined,
427
+ ): WaitRow[] {
428
+ const rows: WaitRow[] = [];
429
+ for (const trace of traces) {
430
+ for (let index = 1; index < trace.events.length; index += 1) {
431
+ const from = trace.events[index - 1]!;
432
+ const to = trace.events[index]!;
433
+ const match =
434
+ selection.kind === "activity"
435
+ ? to.activity === selection.id
436
+ : edge !== undefined && from.activity === edge.source && to.activity === edge.target;
437
+ if (match) rows.push({ hours: (to.ms - from.ms) / HOUR });
438
+ }
439
+ }
440
+ return rows;
441
+ }
442
+
443
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
444
+ const count = new Intl.NumberFormat("en-US");
445
+ const dayLabel = new Intl.DateTimeFormat("en-US", {
446
+ month: "short",
447
+ day: "numeric",
448
+ timeZone: "UTC",
449
+ });
450
+
451
+ // ── Small pieces ─────────────────────────────────────────────────────────────
452
+
453
+ /** One statistical view in the dock: the finding as its title, how to read it underneath. */
454
+ function Figure({
455
+ title,
456
+ note,
457
+ className,
458
+ children,
459
+ }: {
460
+ title: string;
461
+ note: string;
462
+ className?: string;
463
+ children: ReactNode;
464
+ }) {
465
+ return (
466
+ <figure className={`flex min-w-0 flex-col gap-2 ${className ?? ""}`}>
467
+ <figcaption className="flex min-w-0 flex-col">
468
+ <span className="truncate text-body font-medium">{title}</span>
469
+ <span className="truncate text-meta text-muted-foreground">{note}</span>
470
+ </figcaption>
471
+ {children}
472
+ </figure>
473
+ );
474
+ }
475
+
476
+ /** An icon button with its name on hover — the toolbar has no room for labels. */
477
+ function ToolbarToggle({
478
+ label,
479
+ pressed,
480
+ onPressedChange,
481
+ children,
482
+ }: {
483
+ label: string;
484
+ pressed: boolean;
485
+ onPressedChange: (pressed: boolean) => void;
486
+ children: ReactNode;
487
+ }) {
488
+ return (
489
+ // Self-provided, like `IconButton` — a Radix `Tooltip` throws without a
490
+ // provider above it, and this toolbar is dropped into any shell.
491
+ <TooltipProvider>
492
+ <Tooltip>
493
+ <TooltipTrigger asChild>
494
+ <Toggle aria-label={label} pressed={pressed} onPressedChange={onPressedChange} size="sm">
495
+ {children}
496
+ </Toggle>
497
+ </TooltipTrigger>
498
+ <TooltipContent>{label}</TooltipContent>
499
+ </Tooltip>
500
+ </TooltipProvider>
501
+ );
502
+ }
503
+
504
+ /**
505
+ * The rail's reading of the current selection — numbers from the SAME graph the canvas
506
+ * draws, and the distribution of the waits behind its median.
507
+ */
508
+ function SelectionDetail({
509
+ graph,
510
+ selection,
511
+ traces,
512
+ }: {
513
+ graph: ReturnType<typeof useProcessExplorer>["graph"];
514
+ selection: { kind: "activity" | "transition"; id: string } | null;
515
+ traces: readonly Trace[];
516
+ }) {
517
+ const transition =
518
+ selection?.kind === "transition"
519
+ ? graph.transitions.find(
520
+ (candidate) => processEdgeId(candidate.source, candidate.target) === selection.id,
521
+ )
522
+ : undefined;
523
+ const waits = useMemo(
524
+ () => (selection ? waitsFor(traces, selection, transition) : []),
525
+ [traces, selection, transition],
526
+ );
527
+
528
+ if (!selection) {
529
+ return (
530
+ <StatePanel
531
+ kind="empty"
532
+ size="sm"
533
+ title="Nothing selected"
534
+ description="Select an activity or a transition on the map to read its numbers and the distribution behind them."
535
+ />
536
+ );
537
+ }
538
+
539
+ const activity =
540
+ selection.kind === "activity"
541
+ ? graph.activities.find((candidate) => candidate.id === selection.id)
542
+ : undefined;
543
+ if (!activity && !transition) return null;
544
+
545
+ return (
546
+ <div className="flex flex-col gap-4">
547
+ <div className="flex flex-col gap-1">
548
+ <span className="text-meta text-muted-foreground">
549
+ {activity ? "Activity" : "Transition"}
550
+ </span>
551
+ <span className="text-subtitle font-semibold text-balance">
552
+ {activity ? activity.id : `${transition!.source} → ${transition!.target}`}
553
+ </span>
554
+ </div>
555
+ {activity ? (
556
+ <Descriptions>
557
+ <DescriptionsItem label="Cases" numeric>
558
+ {count.format(activity.cases)}
559
+ </DescriptionsItem>
560
+ <DescriptionsItem label="Executions" numeric>
561
+ {count.format(activity.instances)}
562
+ </DescriptionsItem>
563
+ <DescriptionsItem label="Role">
564
+ {activity.isStart ? "Start" : activity.isEnd ? "End" : "Intermediate"}
565
+ </DescriptionsItem>
566
+ </Descriptions>
567
+ ) : (
568
+ <Descriptions>
569
+ <DescriptionsItem label="Hand-overs" numeric>
570
+ {count.format(transition!.count)}
571
+ </DescriptionsItem>
572
+ <DescriptionsItem label="Median wait" numeric>
573
+ {formatDurationMs(transition!.duration.median)}
574
+ </DescriptionsItem>
575
+ <DescriptionsItem label="90th percentile" numeric>
576
+ {formatDurationMs(transition!.duration.p90)}
577
+ </DescriptionsItem>
578
+ <DescriptionsItem label="Total waiting" numeric>
579
+ {formatDurationMs(transition!.duration.sum)}
580
+ </DescriptionsItem>
581
+ </Descriptions>
582
+ )}
583
+ {waits.length >= 8 ? (
584
+ <Figure
585
+ title={activity ? "Wait before this activity" : "Wait on this hand-over"}
586
+ note={`Hours, ${count.format(waits.length)} occurrences; the flag is the median.`}
587
+ >
588
+ <div className="h-40">
589
+ <DistributionChart
590
+ accessibleLabel="Distribution of waiting time in hours"
591
+ bins={16}
592
+ data={waits}
593
+ kind="histogram"
594
+ valueKey="hours"
595
+ />
596
+ </div>
597
+ </Figure>
598
+ ) : null}
599
+ </div>
600
+ );
601
+ }
602
+
603
+ // ── The screen ───────────────────────────────────────────────────────────────
604
+
605
+ type DockTab = "timeline" | "spectrum" | "workload" | "cases";
606
+ type RailTab = "insights" | "variants" | "selection" | "deviations";
607
+
608
+ /** The screen. Everything below is composition — no new primitive is authored here. */
609
+ export function ProcessExplorerTemplate() {
610
+ const [region, setRegion] = useState("all");
611
+ const log = useMemo(() => scopeLog(ORDER_TO_CASH, region), [region]);
612
+ const explorer = useProcessExplorer(log, { abstraction: { activities: 1, paths: 0.8 } });
613
+
614
+ const [direction, setDirection] = useState<MapDirection>("TB");
615
+ const [tableView, setTableView] = useState(false);
616
+ const [showDeviations, setShowDeviations] = useState(false);
617
+ const [railOpen, setRailOpen] = useState(true);
618
+ const [railTab, setRailTab] = useState<RailTab>("insights");
619
+ const [dockOpen, setDockOpen] = useState(false);
620
+ const [dockTab, setDockTab] = useState<DockTab>("timeline");
621
+ const [timelineAxis, setTimelineAxis] = useState<"absolute" | "relative">("relative");
622
+ const [dockTall, setDockTall] = useState(false);
623
+ const [replayOpen, setReplayOpen] = useState(false);
624
+ const [openCaseId, setOpenCaseId] = useState<string | null>(null);
625
+
626
+ const totalCases = useMemo(() => new Set(log.events.map((event) => event.caseId)).size, [log]);
627
+ // Built ONCE per graph and shared by the map, the variant rail and the dotted chart, so
628
+ // "Check Credit" is the same swatch in all three.
629
+ const colorScale = useMemo(() => activityColorScale(explorer.graph), [explorer.graph]);
630
+ const traces = useMemo(() => tracesOf(explorer.filteredLog), [explorer.filteredLog]);
631
+ const stats = useMemo(() => analyse(traces), [traces]);
632
+ const conformance = useMemo(
633
+ () => tokenReplay(explorer.filteredLog, REFERENCE_MODEL),
634
+ [explorer.filteredLog],
635
+ );
636
+ // The case table reads the same replay the map and the KPI ribbon do: a case conforms when
637
+ // it replays on the reference model without a single deviation.
638
+ const cases = useMemo(() => {
639
+ const fits = new Map(conformance.traces.map((trace) => [trace.caseId, trace.fitness >= 1]));
640
+ return casesFromLog(explorer.filteredLog).map(
641
+ (row): CaseRow => ({
642
+ ...row,
643
+ conformance: fits.get(row.caseId) ? "conforming" : "nonConforming",
644
+ }),
645
+ );
646
+ }, [explorer.filteredLog, conformance]);
647
+ const { t, formatDate } = useLocale();
648
+ const caseColumns = useMemo(() => {
649
+ const base = createCaseTableColumns({ t, formatDate });
650
+ const column = (key: string) =>
651
+ base.find((candidate) => "accessorKey" in candidate && candidate.accessorKey === key)!;
652
+ const rank = new Map(explorer.variants.map((variant, index) => [variant.id, index + 1]));
653
+ const attribute = (key: string, header: string): CaseColumn => ({
654
+ id: key,
655
+ header,
656
+ accessorFn: (row) => String(row.attributes?.[key] ?? "—"),
657
+ });
658
+ return [
659
+ { ...column("caseId"), header: "Order" },
660
+ attribute("region", "Region"),
661
+ attribute("channel", "Channel"),
662
+ column("start"),
663
+ column("end"),
664
+ { ...column("durationMs"), header: "Throughput" },
665
+ column("eventCount"),
666
+ {
667
+ id: "variant",
668
+ header: "Variant",
669
+ meta: { numeric: true },
670
+ accessorFn: (row) => rank.get(row.variantId) ?? 0,
671
+ cell: ({ getValue }) => `#${getValue<number>()}`,
672
+ },
673
+ column("conformance"),
674
+ ] satisfies CaseColumn[];
675
+ }, [t, formatDate, explorer.variants]);
676
+ const conformanceSeries = useMemo(
677
+ () => conformanceRateSeries(explorer.filteredLog, REFERENCE_MODEL, "week"),
678
+ [explorer.filteredLog],
679
+ );
680
+ const trends = useMemo(() => {
681
+ const weeks = Array.from({ length: 10 }, () => ({ cases: 0, events: 0, rework: 0 }));
682
+ for (const trace of traces) {
683
+ const bucket = weeks[Math.floor((trace.events[0]!.ms - LOG_START) / (7 * DAY))];
684
+ if (!bucket) continue;
685
+ bucket.cases += 1;
686
+ bucket.events += trace.events.length;
687
+ const seen = new Set(trace.events.map((event) => event.activity));
688
+ if (seen.size < trace.events.length) bucket.rework += 1;
689
+ }
690
+ return {
691
+ cases: weeks.map((week) => week.cases),
692
+ events: weeks.map((week) => week.events),
693
+ reworkRate: weeks.map((week) => (week.cases > 0 ? week.rework / week.cases : 0)),
694
+ medianThroughput: stats.weeks.map((week) => week.median * DAY),
695
+ };
696
+ }, [traces, stats.weeks]);
697
+
698
+ const handovers: HandoverRow[] = useMemo(
699
+ () =>
700
+ explorer.graph.transitions
701
+ .filter((transition) => transition.count >= 5)
702
+ .map((transition) => ({
703
+ name: `${transition.source} → ${transition.target}`,
704
+ id: processEdgeId(transition.source, transition.target),
705
+ median: Number((transition.duration.median / HOUR).toFixed(1)),
706
+ p90: Number((transition.duration.p90 / HOUR).toFixed(1)),
707
+ count: transition.count,
708
+ }))
709
+ .sort((a, b) => b.median - a.median)
710
+ .slice(0, 6),
711
+ [explorer.graph],
712
+ );
713
+
714
+ const openCaseEvents = useMemo(
715
+ () =>
716
+ openCaseId ? explorer.filteredLog.events.filter((event) => event.caseId === openCaseId) : [],
717
+ [explorer.filteredLog, openCaseId],
718
+ );
719
+
720
+ const hasProcess = explorer.graph.activities.length > 0;
721
+ const slowest = handovers[0];
722
+
723
+ function select(next: Parameters<typeof explorer.onSelect>[0]) {
724
+ explorer.onSelect(next);
725
+ if (next) {
726
+ setRailOpen(true);
727
+ setRailTab("selection");
728
+ }
729
+ }
730
+
731
+ // The toolbar's layer switch and the full `MetricLayerSwitch` in the popover write the same
732
+ // state; the metric coercion mirrors the component's own (a performance layer cannot paint
733
+ // a frequency metric, and the reverse).
734
+ function changeLayer(next: string) {
735
+ if (!next) return;
736
+ explorer.setLayer(next as MetricLayer);
737
+ if (next === "performance") explorer.setMetric({ node: "median", edge: "median" });
738
+ if (next === "frequency") explorer.setMetric({ node: "absolute", edge: "absolute" });
739
+ }
740
+
741
+ // The variant rail emits ids and how to apply them — it never filters itself. Keeping at
742
+ // most ONE "variant" intent in the chain, updated in place, keeps "last interaction wins".
743
+ function applyVariantSelection(ids: string[], mode: "replace" | "toggle") {
744
+ const activeIndex = explorer.intents.findIndex((intent) => intent.kind === "variant");
745
+ const active = activeIndex >= 0 ? explorer.intents[activeIndex] : undefined;
746
+ const previousIds = active && active.kind === "variant" ? active.ids : [];
747
+ const toggled = ids[0]!;
748
+ const nextIds =
749
+ mode === "replace"
750
+ ? ids
751
+ : previousIds.includes(toggled)
752
+ ? previousIds.filter((id) => id !== toggled)
753
+ : [...previousIds, toggled];
754
+ if (activeIndex >= 0) explorer.clearIntent(activeIndex);
755
+ if (nextIds.length > 0) explorer.applyIntent({ kind: "variant", ids: nextIds });
756
+ }
757
+
758
+ function clearFilters() {
759
+ for (let index = explorer.intents.length - 1; index >= 0; index -= 1) {
760
+ explorer.clearIntent(index);
761
+ }
762
+ }
763
+
764
+ return (
765
+ // `min-h-0` on every link of the chain and `overflow-hidden` at the root: the map is the
766
+ // one region that absorbs the leftover height, so nothing above or below it may refuse
767
+ // to shrink. `@container`: the rail and the header details follow THIS box, not the window.
768
+ // Every chart here sits in a narrow column by design; keep their axes and labels instead of
769
+ // letting the narrow breakpoint thin them to a phone's furniture.
770
+ <ChartConfigProvider value={{ density: { base: "md", narrow: "md" } }}>
771
+ <div className="@container flex h-full min-h-176 flex-col overflow-hidden bg-background text-foreground">
772
+ <header className="flex h-header shrink-0 items-center gap-3 border-b border-border px-4">
773
+ <Waypoints aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
774
+ <h1 className="truncate text-body font-semibold">Order to cash</h1>
775
+ <Badge variant="outline">Q3 2026</Badge>
776
+ <span className="hidden truncate text-meta text-muted-foreground @4xl:inline">
777
+ {`${dayLabel.format(LOG_START)} – ${dayLabel.format(LOG_END)} · ${count.format(stats.closed)} closed · ${count.format(stats.open)} still open · SLA ${SLA_DAYS} days`}
778
+ </span>
779
+ <div className="ms-auto flex shrink-0 items-center gap-2">
780
+ <Select value={region} onValueChange={setRegion}>
781
+ <SelectTrigger aria-label="Region" className="min-w-36" size="sm">
782
+ <SelectValue />
783
+ </SelectTrigger>
784
+ <SelectContent>
785
+ <SelectItem value="all">All regions</SelectItem>
786
+ {REGIONS.map((name) => (
787
+ <SelectItem key={name} value={name}>
788
+ {name}
789
+ </SelectItem>
790
+ ))}
791
+ </SelectContent>
792
+ </Select>
793
+ <Button variant="outline" size="sm" onClick={() => setReplayOpen(true)}>
794
+ <Play aria-hidden="true" />
795
+ Replay
796
+ </Button>
797
+ </div>
798
+ </header>
799
+
800
+ <ProcessKpiStrip
801
+ className="shrink-0 border-b border-border"
802
+ layout="inline"
803
+ kpis={explorer.kpis}
804
+ conformance={conformance}
805
+ conformanceSeries={conformanceSeries}
806
+ trends={trends}
807
+ loading={explorer.loading}
808
+ />
809
+
810
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
811
+ <ToggleGroup
812
+ type="single"
813
+ variant="segmented"
814
+ size="sm"
815
+ value={explorer.layer}
816
+ onValueChange={changeLayer}
817
+ aria-label="Metric layer"
818
+ >
819
+ <ToggleGroupItem value="frequency">Frequency</ToggleGroupItem>
820
+ <ToggleGroupItem value="performance">Performance</ToggleGroupItem>
821
+ <ToggleGroupItem value="rework">Rework</ToggleGroupItem>
822
+ </ToggleGroup>
823
+ <ToolbarToggle
824
+ label="Show deviations from the reference model"
825
+ pressed={showDeviations}
826
+ onPressedChange={setShowDeviations}
827
+ >
828
+ <ShieldAlert aria-hidden="true" />
829
+ </ToolbarToggle>
830
+ <Popover>
831
+ <PopoverTrigger asChild>
832
+ <Button variant="ghost" size="sm">
833
+ <SlidersHorizontal aria-hidden="true" />
834
+ <span className="hidden @3xl:inline">Detail</span>
835
+ </Button>
836
+ </PopoverTrigger>
837
+ <PopoverContent align="start" className="flex w-80 flex-col gap-4">
838
+ <MetricLayerSwitch
839
+ layer={explorer.layer}
840
+ onLayerChange={explorer.setLayer}
841
+ metric={explorer.metric}
842
+ onMetricChange={explorer.setMetric}
843
+ />
844
+ <Separator />
845
+ <AbstractionControls
846
+ abstraction={explorer.abstraction}
847
+ onAbstractionChange={explorer.setAbstraction}
848
+ graph={explorer.graph}
849
+ hiddenCounts={explorer.hiddenCounts}
850
+ />
851
+ </PopoverContent>
852
+ </Popover>
853
+ <Separator orientation="vertical" className="h-5" />
854
+ {/* One line, summary first: the chips join the row instead of pushing the map down. */}
855
+ <ProcessFilterBar
856
+ className="min-w-0 flex-1 flex-row-reverse items-center justify-end gap-3 [&>p]:shrink-0 [&>p]:whitespace-nowrap"
857
+ intents={explorer.intents}
858
+ excludedByIntent={explorer.excludedByIntent}
859
+ totalCases={totalCases}
860
+ filteredCases={explorer.kpis.cases}
861
+ hiddenCounts={explorer.hiddenCounts}
862
+ onRemove={explorer.clearIntent}
863
+ onClearAll={clearFilters}
864
+ />
865
+ <ToggleGroup
866
+ type="single"
867
+ size="sm"
868
+ value={direction}
869
+ onValueChange={(next) => next && setDirection(next as MapDirection)}
870
+ aria-label="Layout direction"
871
+ >
872
+ <ToggleGroupItem value="LR" aria-label="Left to right">
873
+ <ArrowRight aria-hidden="true" />
874
+ </ToggleGroupItem>
875
+ <ToggleGroupItem value="TB" aria-label="Top down">
876
+ <ArrowDown aria-hidden="true" />
877
+ </ToggleGroupItem>
878
+ </ToggleGroup>
879
+ <ToolbarToggle
880
+ label="Read the map as a table"
881
+ pressed={tableView}
882
+ onPressedChange={setTableView}
883
+ >
884
+ <Table2 aria-hidden="true" />
885
+ </ToolbarToggle>
886
+ <ToolbarToggle
887
+ label="Insights, variants and details"
888
+ pressed={railOpen}
889
+ onPressedChange={setRailOpen}
890
+ >
891
+ <PanelRight aria-hidden="true" />
892
+ </ToolbarToggle>
893
+ <ToolbarToggle
894
+ label="Timelines, workload and cases"
895
+ pressed={dockOpen}
896
+ onPressedChange={setDockOpen}
897
+ >
898
+ <PanelBottom aria-hidden="true" />
899
+ </ToolbarToggle>
900
+ </div>
901
+
902
+ <div className="flex min-h-0 flex-1">
903
+ <div className="min-h-0 min-w-0 flex-1">
904
+ {hasProcess ? (
905
+ <ProcessMap
906
+ className="h-full"
907
+ graph={explorer.graph}
908
+ metric={explorer.metric}
909
+ rework={explorer.rework}
910
+ direction={direction}
911
+ selection={explorer.selection}
912
+ onSelect={select}
913
+ selectionStates={explorer.selectionStates}
914
+ onFilterIntent={explorer.applyIntent}
915
+ colorScale={colorScale}
916
+ conformance={showDeviations ? conformance : undefined}
917
+ showMiniMap={false}
918
+ showLegend={false}
919
+ fitMinZoom={0.35}
920
+ fitPadding={0.06}
921
+ refitKey={`${railOpen}:${dockOpen}:${dockTall}`}
922
+ tableView={tableView}
923
+ loading={explorer.loading}
924
+ />
925
+ ) : (
926
+ <StatePanel
927
+ kind="empty"
928
+ title="No process to show"
929
+ description="No case matches this scope, so there is no directly-follows relation to discover. Widen the filter or pick another region."
930
+ />
931
+ )}
932
+ </div>
933
+ {railOpen ? (
934
+ <aside className="@container hidden w-2/5 max-w-xl min-w-80 shrink-0 flex-col border-s border-border bg-surface-muted @3xl:flex">
935
+ <Tabs
936
+ value={railTab}
937
+ onValueChange={(next) => setRailTab(next as RailTab)}
938
+ className="flex min-h-0 flex-1 flex-col"
939
+ >
940
+ <TabsList variant="underline" className="shrink-0 px-3">
941
+ <TabsTrigger value="insights">Insights</TabsTrigger>
942
+ <TabsTrigger value="variants">{`Variants ${count.format(explorer.variants.length)}`}</TabsTrigger>
943
+ <TabsTrigger value="selection">Selection</TabsTrigger>
944
+ <TabsTrigger value="deviations">Deviations</TabsTrigger>
945
+ </TabsList>
946
+
947
+ <TabsContent value="insights" className="min-h-0 flex-1 overflow-auto p-4">
948
+ <div className="flex flex-col gap-6">
949
+ <Figure
950
+ title={`${percent.format(stats.lateShare)} of closed orders missed the ${SLA_DAYS}-day SLA`}
951
+ note={`Days from order to payment · median ${stats.medianDays.toFixed(1)} d · P90 ${stats.p90Days.toFixed(1)} d`}
952
+ >
953
+ <div className="h-36">
954
+ <DistributionChart
955
+ accessibleLabel="Distribution of order-to-payment time in days"
956
+ bins={24}
957
+ data={stats.throughput}
958
+ kind="histogram"
959
+ referenceLines={[{ value: SLA_DAYS, label: `SLA ${SLA_DAYS} d` }]}
960
+ valueKey="days"
961
+ />
962
+ </div>
963
+ </Figure>
964
+ <Figure
965
+ title={
966
+ slowest
967
+ ? `Slowest hand-over: ${slowest.name}`
968
+ : "No hand-over has enough cases to rank"
969
+ }
970
+ note="Median wait in hours, tick at the 90th percentile · select a bar to find it on the map"
971
+ >
972
+ <BarChart
973
+ accessibleLabel="The slowest hand-overs by median waiting time"
974
+ data={handovers}
975
+ onDatapointClick={(point) =>
976
+ select({
977
+ kind: "transition",
978
+ id: String((point.datum as HandoverRow).id),
979
+ })
980
+ }
981
+ orientation="horizontal"
982
+ overlays={[
983
+ { kind: "value", key: "p90", label: "90th percentile", marker: "tick" },
984
+ ]}
985
+ plotHeight={204}
986
+ xDataKey="name"
987
+ >
988
+ <Grid vertical />
989
+ <Bar dataKey="median" fill="var(--chart-1)" lineCap="round" />
990
+ <BarYAxis maxWidth={216} />
991
+ <ChartTooltip />
992
+ </BarChart>
993
+ </Figure>
994
+ <Figure
995
+ title="Throughput by closing week"
996
+ note="Days · strong line: median · light line: 90th percentile"
997
+ >
998
+ <LineChart
999
+ accessibleLabel="Median and 90th-percentile throughput time per closing week"
1000
+ data={stats.weeks}
1001
+ plotHeight={132}
1002
+ >
1003
+ <Grid horizontal />
1004
+ <Line
1005
+ curve="monotone"
1006
+ dataKey="p90"
1007
+ stroke="var(--chart-3)"
1008
+ strokeWidth={1.5}
1009
+ />
1010
+ <Line
1011
+ curve="monotone"
1012
+ dataKey="median"
1013
+ stroke="var(--chart-1)"
1014
+ strokeWidth={2.5}
1015
+ />
1016
+ <XAxis numTicks={4} tickFormat={(date) => dayLabel.format(date)} />
1017
+ <YAxis />
1018
+ <ChartTooltip />
1019
+ </LineChart>
1020
+ </Figure>
1021
+ <div className="grid grid-cols-1 gap-6 @lg:grid-cols-2">
1022
+ <Figure title="By region" note={`Days; dashed line: ${SLA_DAYS}-day SLA`}>
1023
+ <div className="h-44">
1024
+ <DistributionChart
1025
+ accessibleLabel="Order-to-payment time in days by region"
1026
+ data={stats.throughput}
1027
+ groupKey="region"
1028
+ kind="box"
1029
+ referenceLines={[{ value: SLA_DAYS, label: "SLA" }]}
1030
+ valueKey="days"
1031
+ />
1032
+ </div>
1033
+ </Figure>
1034
+ <Figure title="By channel" note="Days; EDI customers pay on shorter terms">
1035
+ <div className="h-44">
1036
+ <DistributionChart
1037
+ accessibleLabel="Order-to-payment time in days by sales channel"
1038
+ data={stats.throughput}
1039
+ groupKey="channel"
1040
+ kind="violin"
1041
+ referenceLines={[{ value: SLA_DAYS, label: "SLA" }]}
1042
+ valueKey="days"
1043
+ />
1044
+ </div>
1045
+ </Figure>
1046
+ </div>
1047
+ </div>
1048
+ </TabsContent>
1049
+
1050
+ <TabsContent value="variants" className="min-h-0 flex-1 p-3">
1051
+ <VariantExplorer
1052
+ className="h-full"
1053
+ variants={explorer.variants}
1054
+ colorScale={colorScale}
1055
+ selectionStates={explorer.selectionStates}
1056
+ onSelect={applyVariantSelection}
1057
+ columns={["cases", "coverage"]}
1058
+ sequenceDisplay="swatch"
1059
+ loading={explorer.loading}
1060
+ />
1061
+ </TabsContent>
1062
+ <TabsContent value="selection" className="min-h-0 flex-1 overflow-auto p-4">
1063
+ <SelectionDetail
1064
+ graph={explorer.graph}
1065
+ selection={explorer.selection}
1066
+ traces={traces}
1067
+ />
1068
+ </TabsContent>
1069
+ <TabsContent value="deviations" className="min-h-0 flex-1 overflow-auto p-3">
1070
+ <ViolationList conformance={conformance} onFilterIntent={explorer.applyIntent} />
1071
+ </TabsContent>
1072
+ </Tabs>
1073
+ </aside>
1074
+ ) : null}
1075
+ </div>
1076
+
1077
+ {/* The wide, time-based views live in a dock that stays out of the way until asked for:
1078
+ its tab strip is always there, its body only when open. */}
1079
+ <section
1080
+ aria-label="Timelines, workload and cases"
1081
+ className={`flex shrink-0 flex-col border-t border-border ${dockOpen ? (dockTall ? "h-3/5" : "h-88") : ""}`}
1082
+ >
1083
+ <Tabs
1084
+ value={dockOpen ? dockTab : ""}
1085
+ onValueChange={(next) => {
1086
+ setDockTab(next as DockTab);
1087
+ setDockOpen(true);
1088
+ }}
1089
+ className="flex min-h-0 flex-1 flex-col"
1090
+ >
1091
+ <div className="flex shrink-0 items-center gap-2 pe-2">
1092
+ <TabsList variant="underline" className="min-w-0 flex-1 border-b-0 px-3">
1093
+ <TabsTrigger value="timeline">Dotted chart</TabsTrigger>
1094
+ <TabsTrigger value="spectrum">Performance spectrum</TabsTrigger>
1095
+ <TabsTrigger value="workload">Workload</TabsTrigger>
1096
+ <TabsTrigger value="cases">{`Cases ${count.format(cases.length)}`}</TabsTrigger>
1097
+ </TabsList>
1098
+ {dockOpen && dockTab === "timeline" ? (
1099
+ <ToggleGroup
1100
+ type="single"
1101
+ size="sm"
1102
+ value={timelineAxis}
1103
+ onValueChange={(next) => next && setTimelineAxis(next as "absolute" | "relative")}
1104
+ aria-label="Time axis"
1105
+ >
1106
+ <ToggleGroupItem value="relative">Since order</ToggleGroupItem>
1107
+ <ToggleGroupItem value="absolute">Calendar</ToggleGroupItem>
1108
+ </ToggleGroup>
1109
+ ) : null}
1110
+ {dockOpen ? (
1111
+ <ToolbarToggle label="Taller" pressed={dockTall} onPressedChange={setDockTall}>
1112
+ <ChevronsUp aria-hidden="true" />
1113
+ </ToolbarToggle>
1114
+ ) : null}
1115
+ </div>
1116
+
1117
+ {dockOpen ? (
1118
+ <>
1119
+ <TabsContent
1120
+ value="timeline"
1121
+ className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
1122
+ >
1123
+ <DottedChart
1124
+ log={explorer.filteredLog}
1125
+ x={timelineAxis}
1126
+ sort={timelineAxis === "relative" ? "duration" : "start"}
1127
+ colorScale={colorScale}
1128
+ height={dockTall ? 400 : 204}
1129
+ onFilterIntent={explorer.applyIntent}
1130
+ loading={explorer.loading}
1131
+ />
1132
+ </TabsContent>
1133
+ <TabsContent
1134
+ value="spectrum"
1135
+ className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
1136
+ >
1137
+ <PerformanceSpectrum
1138
+ log={explorer.filteredLog}
1139
+ order="frequency"
1140
+ segmentLimit={dockTall ? 9 : 6}
1141
+ selection={explorer.selection}
1142
+ onFilterIntent={explorer.applyIntent}
1143
+ loading={explorer.loading}
1144
+ />
1145
+ </TabsContent>
1146
+ <TabsContent
1147
+ value="workload"
1148
+ className="min-h-0 flex-1 overflow-auto border-t border-border p-4"
1149
+ >
1150
+ <div className="grid grid-cols-1 gap-6 @4xl:grid-cols-3">
1151
+ <Figure
1152
+ className="@4xl:col-span-2"
1153
+ title="When the work happens"
1154
+ note="Events per weekday and hour (UTC) across the quarter; the stronger the cell, the busier the hour"
1155
+ >
1156
+ <HeatmapChart
1157
+ data={stats.workload}
1158
+ valueFormat="compact"
1159
+ plotHeight={dockTall ? 340 : 196}
1160
+ valueKey="events"
1161
+ x="hour"
1162
+ xOrder={OFFICE_HOURS}
1163
+ y="day"
1164
+ yOrder={WEEKDAYS}
1165
+ />
1166
+ </Figure>
1167
+ <Figure
1168
+ title="Who does the work"
1169
+ note="Events per resource, people and systems alike"
1170
+ >
1171
+ <BarChart
1172
+ accessibleLabel="Events per resource"
1173
+ data={stats.resourceLoad}
1174
+ orientation="horizontal"
1175
+ plotHeight={dockTall ? 340 : 228}
1176
+ xDataKey="name"
1177
+ >
1178
+ <Grid vertical />
1179
+ <Bar dataKey="events" fill="var(--chart-2)" lineCap="round" />
1180
+ <BarYAxis maxWidth={140} />
1181
+ <ChartTooltip />
1182
+ </BarChart>
1183
+ </Figure>
1184
+ </div>
1185
+ </TabsContent>
1186
+ <TabsContent
1187
+ value="cases"
1188
+ className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
1189
+ >
1190
+ <CaseTable
1191
+ cases={cases}
1192
+ columns={caseColumns}
1193
+ onCaseOpen={setOpenCaseId}
1194
+ exportFileName="order-to-cash-cases"
1195
+ />
1196
+ </TabsContent>
1197
+ </>
1198
+ ) : null}
1199
+ </Tabs>
1200
+ </section>
1201
+
1202
+ {/* Drill path: variant or brush → case table → one case's timeline. */}
1203
+ <Sheet open={openCaseId !== null} onOpenChange={(open) => !open && setOpenCaseId(null)}>
1204
+ <SheetContent side="right" className="flex w-full flex-col gap-4 sm:max-w-3xl">
1205
+ <SheetHeader>
1206
+ <Button
1207
+ variant="ghost"
1208
+ size="sm"
1209
+ className="w-fit"
1210
+ onClick={() => {
1211
+ setOpenCaseId(null);
1212
+ setDockOpen(true);
1213
+ setDockTab("cases");
1214
+ }}
1215
+ >
1216
+ <ArrowLeft aria-hidden="true" />
1217
+ Back to cases
1218
+ </Button>
1219
+ <SheetTitle>{`Order ${openCaseId ?? ""}`}</SheetTitle>
1220
+ <SheetDescription>
1221
+ Activity durations and waiting time for this order.
1222
+ </SheetDescription>
1223
+ </SheetHeader>
1224
+ <div className="min-h-0 flex-1 overflow-auto">
1225
+ {openCaseId ? <CaseTimeline caseId={openCaseId} events={openCaseEvents} /> : null}
1226
+ </div>
1227
+ </SheetContent>
1228
+ </Sheet>
1229
+
1230
+ <Dialog open={replayOpen} onOpenChange={setReplayOpen}>
1231
+ <DialogContent className="flex h-4/5 max-w-6xl flex-col">
1232
+ <DialogHeader>
1233
+ <DialogTitle>Replay the quarter</DialogTitle>
1234
+ <DialogDescription>
1235
+ Every order in scope as a token on the map, all started together so the queues are
1236
+ comparable. The list beside it ranks where tokens pile up.
1237
+ </DialogDescription>
1238
+ </DialogHeader>
1239
+ <div className="min-h-0 flex-1">
1240
+ {replayOpen ? (
1241
+ <ProcessReplay
1242
+ graph={explorer.graph}
1243
+ log={explorer.filteredLog}
1244
+ direction="LR"
1245
+ synchronizedStart
1246
+ defaultSpeed={4}
1247
+ />
1248
+ ) : null}
1249
+ </div>
1250
+ </DialogContent>
1251
+ </Dialog>
1252
+ </div>
1253
+ </ChartConfigProvider>
1254
+ );
1255
+ }
1256
+
1257
+ const meta = {
1258
+ title: "Patterns/Templates/Operations/Process Explorer",
1259
+ parameters: {
1260
+ layout: "fullscreen",
1261
+ docs: { subtitle: "For process-mining and operations-excellence tools" },
1262
+ },
1263
+ tags: ["autodocs"],
1264
+ } satisfies Meta;
1265
+ export default meta;
1266
+ type Story = StoryObj<typeof meta>;
1267
+
1268
+ export const Default: Story = {
1269
+ render: () => (
1270
+ <div className="h-svh">
1271
+ <ProcessExplorerTemplate />
1272
+ </div>
1273
+ ),
1274
+ play: async ({ canvasElement }) => {
1275
+ const canvas = within(canvasElement);
1276
+ await waitFor(() => expect(canvas.getByText("Order to cash")).toBeInTheDocument());
1277
+
1278
+ const filterSummary = () =>
1279
+ canvasElement.querySelector<HTMLElement>('[data-slot="process-filter-bar-summary"]');
1280
+ const kpiStrip = () =>
1281
+ canvasElement.querySelector<HTMLElement>('[data-slot="process-kpi-strip"]');
1282
+
1283
+ // The ribbon, not the card grid: the screen's height belongs to the map.
1284
+ await waitFor(() => expect(kpiStrip()).toHaveAttribute("data-layout", "inline"));
1285
+ await waitFor(() => expect(filterSummary()?.textContent ?? "").toMatch(/showing all/i));
1286
+ const initialFilterText = filterSummary()!.textContent;
1287
+ const initialKpiText = kpiStrip()!.textContent;
1288
+
1289
+ // Select the first (most frequent) variant from the panel — the filter chain and the KPI
1290
+ // ribbon both read the same `useProcessExplorer` state, so both must move.
1291
+ await userEvent.click(canvas.getByRole("tab", { name: /^Variants/ }));
1292
+ await waitFor(() =>
1293
+ expect(
1294
+ canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]').length,
1295
+ ).toBeGreaterThan(0),
1296
+ );
1297
+ const rows = canvasElement.querySelectorAll<HTMLElement>('[data-slot="variant-explorer-row"]');
1298
+ await userEvent.click(within(rows[0]!).getByRole("img"));
1299
+
1300
+ await waitFor(() => expect(filterSummary()!.textContent).not.toBe(initialFilterText));
1301
+ expect(filterSummary()!.textContent ?? "").not.toMatch(/showing all/i);
1302
+ await waitFor(() => expect(kpiStrip()!.textContent).not.toBe(initialKpiText));
1303
+ },
1304
+ };