@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
@@ -62,7 +62,7 @@ import { discoverGraph } from "../core/discover-graph";
62
62
  import { durationStats } from "../core/duration-stats";
63
63
  import { extractVariants, variantKey } from "../core/extract-variants";
64
64
  import type { FilterSpec } from "../core/filter-log";
65
- import { filterLog } from "../core/filter-log";
65
+ import { filterLog, filterNormalizedLog } from "../core/filter-log";
66
66
  import { reconcileGraph } from "../core";
67
67
  import type { EventLog, FrequencyMode, PerformanceAgg, ProcessGraph, Variant } from "../core/types";
68
68
  import {
@@ -95,8 +95,15 @@ export interface ProcessExplorerMetricSpec {
95
95
  * variant selection — a case a click on the process map can never produce, but a click on a
96
96
  * variant row can. The widening is local to this hook's own type alias; it does not touch
97
97
  * `ProcessFilterIntent` or `ProcessMap`'s menu, which still only ever emits the original four.
98
+ *
99
+ * `{ kind: "cases" }` joins it for the same reason: `DottedChart` (RM-059) and
100
+ * `PerformanceSpectrum` (RM-060) brush a set of case ids, so their `onFilterIntent` can be
101
+ * wired straight to `applyIntent`.
98
102
  */
99
- export type FilterIntent = ProcessFilterIntent | Extract<FilterSpec, { kind: "variant" }>;
103
+ export type FilterIntent =
104
+ | ProcessFilterIntent
105
+ | Extract<FilterSpec, { kind: "variant" }>
106
+ | Extract<FilterSpec, { kind: "cases" }>;
100
107
 
101
108
  /** Options for {@link useProcessExplorer}. */
102
109
  export interface ProcessExplorerOptions {
@@ -138,6 +145,17 @@ export interface UseProcessExplorerResult {
138
145
  applyIntent(intent: FilterIntent): void;
139
146
  clearIntent(index: number): void;
140
147
  intents: FilterIntent[];
148
+ /**
149
+ * How many cases EACH active intent alone excludes — parallel to {@link intents} (same
150
+ * index), for `ProcessFilterBar` (RM-056, #205)'s per-chip "excluded N" count. For intent
151
+ * `i`, this is the case count filtered by every intent BEFORE `i` minus the case count
152
+ * filtered by every intent up to and including `i` — so it isolates what `i` itself
153
+ * removes from the chain, not what the whole chain removes. `[]` when no intents are
154
+ * active. Computed with one `filterNormalizedLog` call per prefix (`n + 1` calls for `n`
155
+ * intents, reusing each prefix's count for both the term it ends and the term it starts),
156
+ * never `n²` — additive field, `intents`/`applyIntent`/`clearIntent` are unchanged.
157
+ */
158
+ excludedByIntent: number[];
141
159
  filteredLog: EventLog;
142
160
  /**
143
161
  * Per-element states the active filter contributes — pass straight into `ProcessMap`'s
@@ -419,6 +437,19 @@ export function useProcessExplorer(
419
437
  [log, intents],
420
438
  );
421
439
 
440
+ // One `filterNormalizedLog` call per prefix (`0..intents.length`, so `n + 1` for `n`
441
+ // intents) — `prefixCaseCounts[i]` is the case count after applying only the first `i`
442
+ // intents. Each intent's own excluded count is then just the drop between two adjacent
443
+ // prefixes, so nothing here is O(intents²) (RM-056, #205).
444
+ const excludedByIntent = useMemo(() => {
445
+ if (intents.length === 0) return [];
446
+ const prefixCaseCounts: number[] = [];
447
+ for (let i = 0; i <= intents.length; i += 1) {
448
+ prefixCaseCounts.push(filterNormalizedLog(log, intents.slice(0, i)).totals.cases);
449
+ }
450
+ return intents.map((_, i) => prefixCaseCounts[i]! - prefixCaseCounts[i + 1]!);
451
+ }, [log, intents]);
452
+
422
453
  // Two independent discoveries — see the module docblock. `variants`, `kpis` and `rework`
423
454
  // read the FILTERED one; `graph` reads BOTH, full first through abstraction, then
424
455
  // reconciled against filtered (Invariant F: filtering re-inks, never removes).
@@ -529,6 +560,7 @@ export function useProcessExplorer(
529
560
  applyIntent,
530
561
  clearIntent,
531
562
  intents,
563
+ excludedByIntent,
532
564
  filteredLog,
533
565
  selectionStates,
534
566
  hiddenCounts: graph.hidden,
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ /**
4
+ * A variant's share of cases as a bar plus its printed percentage (RM-054).
5
+ *
6
+ * Composes `@elabs-ai/components-ui`'s `Progress` rather than drawing a bar. The bar is
7
+ * `aria-hidden`: the same number is printed beside it and is part of the row's accessible
8
+ * name, so a progressbar per row would only add 2 000 redundant announcements.
9
+ */
10
+ import { forwardRef, type HTMLAttributes } from "react";
11
+ import { Progress } from "@elabs-ai/components-ui";
12
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
13
+
14
+ export interface VariantCoverageBarProps extends HTMLAttributes<HTMLDivElement> {
15
+ /** `0..1`. */
16
+ share: number;
17
+ /** The share, already formatted for display. */
18
+ valueLabel: string;
19
+ }
20
+
21
+ export const VariantCoverageBar = forwardRef<HTMLDivElement, VariantCoverageBarProps>(
22
+ function VariantCoverageBar({ share, valueLabel, className, ...props }, ref) {
23
+ const percent = Math.min(100, Math.max(0, share * 100));
24
+ return (
25
+ <div
26
+ ref={ref}
27
+ data-slot="variant-explorer-coverage"
28
+ className={cn("flex min-w-0 items-center gap-2", className)}
29
+ {...props}
30
+ >
31
+ <Progress aria-hidden="true" value={percent} className="h-1.5 min-w-8 flex-1" />
32
+ <span className="w-12 shrink-0 text-end text-meta tabular-nums">{valueLabel}</span>
33
+ </div>
34
+ );
35
+ },
36
+ );
@@ -0,0 +1,16 @@
1
+ /**
2
+ * VariantExplorer (RM-054) — public surface: the explorer, its labels, and the pure
3
+ * coverage-selection helper a host can reuse to preselect "the top N % of paths".
4
+ */
5
+ export {
6
+ VARIANT_EXPLORER_DEFAULT_LABELS,
7
+ VARIANT_EXPLORER_ROW_HEIGHT,
8
+ VariantExplorer,
9
+ } from "./variant-explorer";
10
+ export type {
11
+ VariantExplorerLabels,
12
+ VariantExplorerProps,
13
+ VariantSelectMode,
14
+ } from "./variant-explorer";
15
+ export { selectVariantsByCoverage, VARIANT_EXPLORER_COLUMNS } from "./variant-explorer-model";
16
+ export type { VariantExplorerColumn } from "./variant-explorer-model";
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ /**
4
+ * One variant's activity sequence as a row of chips (RM-054).
5
+ *
6
+ * Each chip is an activity: an identity swatch painted from the shared
7
+ * `ActivityColorScale` (the same `activityAccentStyle` `ProcessActivityNode` uses, so the
8
+ * two views cannot disagree) plus the activity's label — or, in the abbreviated
9
+ * "DNA strip", its two-character code. Colour is never the only channel: the text always
10
+ * names the activity, and the whole strip is one `role="img"` whose accessible name lists
11
+ * the full sequence, so an abbreviated code never reaches a screen reader in place of a
12
+ * name. HTML spans, not SVG — nothing here needs a coordinate system.
13
+ */
14
+ import { forwardRef, type HTMLAttributes } from "react";
15
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
16
+ import type { ActivityColorScale } from "../core/activity-color-scale";
17
+ import { activityAccentStyle } from "../process-map/activity-accent";
18
+
19
+ export interface VariantSequenceChipsProps extends HTMLAttributes<HTMLDivElement> {
20
+ sequence: readonly string[];
21
+ colorScale: ActivityColorScale;
22
+ /** Two-character codes instead of full labels. */
23
+ abbreviate?: boolean;
24
+ /**
25
+ * `"swatch"` drops the text and draws each activity as one identity block — the densest
26
+ * strip, for a narrow rail where even two-character codes leave room for three steps.
27
+ * The strip's own accessible name (and `title`) still lists the full sequence, and each
28
+ * block carries its activity as `title`, so a name is always one hover away. Wins over
29
+ * `abbreviate`. @default "text"
30
+ */
31
+ display?: "text" | "swatch";
32
+ /** Accessible name for the strip — the full sequence in words. */
33
+ label: string;
34
+ }
35
+
36
+ export const VariantSequenceChips = forwardRef<HTMLDivElement, VariantSequenceChipsProps>(
37
+ function VariantSequenceChips(
38
+ { sequence, colorScale, abbreviate = false, display = "text", label, className, ...props },
39
+ ref,
40
+ ) {
41
+ return (
42
+ <div
43
+ ref={ref}
44
+ role="img"
45
+ aria-label={label}
46
+ title={label}
47
+ data-slot="variant-explorer-sequence"
48
+ data-abbreviated={abbreviate ? "true" : undefined}
49
+ data-display={display === "swatch" ? "swatch" : undefined}
50
+ className={cn(
51
+ "flex min-w-0 flex-nowrap items-center overflow-hidden",
52
+ display === "swatch" || abbreviate ? "gap-0.5" : "gap-1",
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ {sequence.map((activityId, position) => {
58
+ const color = colorScale.colorFor(activityId);
59
+ if (display === "swatch") {
60
+ return (
61
+ <span
62
+ key={`${position}:${activityId}`}
63
+ data-slot="variant-explorer-chip"
64
+ data-activity={activityId}
65
+ data-color-token={color.token}
66
+ data-pattern={color.pattern}
67
+ title={colorScale.labelFor(activityId)}
68
+ className="h-4 w-2 shrink-0 rounded-sm"
69
+ style={activityAccentStyle(color)}
70
+ />
71
+ );
72
+ }
73
+ return (
74
+ <span
75
+ // A sequence may repeat an activity (rework), so the id alone is not unique;
76
+ // the position within an immutable sequence is the stable identity here.
77
+ key={`${position}:${activityId}`}
78
+ data-slot="variant-explorer-chip"
79
+ data-activity={activityId}
80
+ data-color-token={color.token}
81
+ data-pattern={color.pattern}
82
+ className={cn(
83
+ "inline-flex shrink-0 items-center gap-1 rounded-sm border border-border bg-card text-meta text-foreground",
84
+ abbreviate ? "px-1 font-mono tabular-nums" : "max-w-40 px-1.5",
85
+ )}
86
+ >
87
+ <span
88
+ data-slot="variant-explorer-chip-swatch"
89
+ data-color-token={color.token}
90
+ data-pattern={color.pattern}
91
+ className="size-2 shrink-0 rounded-sm"
92
+ style={activityAccentStyle(color)}
93
+ />
94
+ <span className="truncate">
95
+ {abbreviate ? colorScale.codeFor(activityId) : colorScale.labelFor(activityId)}
96
+ </span>
97
+ </span>
98
+ );
99
+ })}
100
+ </div>
101
+ );
102
+ },
103
+ );
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure helpers behind `VariantExplorer` (RM-054) — no React, so the selection semantics
3
+ * are unit-testable on their own and identical on every render.
4
+ */
5
+ import type { Variant } from "../core/types";
6
+
7
+ /** The optional numeric columns a variant row can show, in display order. */
8
+ export type VariantExplorerColumn = "cases" | "coverage" | "medianDuration";
9
+
10
+ /** Every column, in the order the explorer renders them. */
11
+ export const VARIANT_EXPLORER_COLUMNS: readonly VariantExplorerColumn[] = Object.freeze([
12
+ "cases",
13
+ "coverage",
14
+ "medianDuration",
15
+ ]);
16
+
17
+ /**
18
+ * The smallest prefix of `variants` whose `cumulativeShare` reaches `target`.
19
+ *
20
+ * `variants` is expected in `extractVariants` order (count descending, fully tie-broken),
21
+ * so the prefix is the set of most frequent paths that together cover `target` of the
22
+ * cases. Deterministic: the same variants and target always yield the same id list. A
23
+ * target of `0` or less selects nothing; a target above the last variant's share selects
24
+ * every variant. A tiny epsilon absorbs float noise in `cumulativeShare` so a `0.5`
25
+ * target is met by a variant whose share prints as exactly 50 %.
26
+ */
27
+ export function selectVariantsByCoverage(variants: readonly Variant[], target: number): string[] {
28
+ if (!(target > 0)) return [];
29
+ const ids: string[] = [];
30
+ for (const variant of variants) {
31
+ ids.push(variant.id);
32
+ if (variant.cumulativeShare >= target - 1e-9) break;
33
+ }
34
+ return ids;
35
+ }
36
+
37
+ /** Fill `{name}` placeholders in a labels string. Unknown names are left as written. */
38
+ export function fillLabel(template: string, vars: Record<string, string | number>): string {
39
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
40
+ key in vars ? String(vars[key]) : match,
41
+ );
42
+ }
@@ -0,0 +1,226 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import { activityColorScale } from "../core/activity-color-scale";
5
+ import { discoverGraph } from "../core/discover-graph";
6
+ import { extractVariants } from "../core/extract-variants";
7
+ import { generateBpi2012Subset } from "../core/fixtures/generate-bpi-2012-subset";
8
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
9
+ import type { Variant } from "../core/types";
10
+ import {
11
+ VariantExplorer,
12
+ type VariantExplorerProps,
13
+ type VariantSelectMode,
14
+ } from "./variant-explorer";
15
+
16
+ const log = generateSyntheticLog({ cases: 400, seed: 42 });
17
+ const graph = discoverGraph(log);
18
+ const variants = extractVariants(log);
19
+ const colorScale = activityColorScale(graph);
20
+
21
+ const loanLog = generateBpi2012Subset({ cases: 300, seed: 3 });
22
+ const loanVariants = extractVariants(loanLog);
23
+ const loanColorScale = activityColorScale(discoverGraph(loanLog));
24
+
25
+ /** 2 000 synthetic variants — the virtualization acceptance size. */
26
+ const manyVariants: Variant[] = (() => {
27
+ const total = 2000;
28
+ const cases = (total * (total + 1)) / 2;
29
+ let cumulative = 0;
30
+ return Array.from({ length: total }, (_, i) => {
31
+ const base = variants[i % variants.length]!;
32
+ const count = total - i;
33
+ cumulative += count;
34
+ return {
35
+ ...base,
36
+ id: `${base.id}-${i}`,
37
+ count,
38
+ share: count / cases,
39
+ cumulativeShare: cumulative / cases,
40
+ };
41
+ });
42
+ })();
43
+
44
+ /** Owns the selection a playbook would own, so the stories are interactive. */
45
+ function Stateful(
46
+ props: Omit<VariantExplorerProps, "onSelect" | "selectionStates"> & {
47
+ initialSelected?: string[];
48
+ },
49
+ ) {
50
+ const { initialSelected = [], ...rest } = props;
51
+ const [selected, setSelected] = useState<string[]>(initialSelected);
52
+ const onSelect = useCallback((ids: string[], mode: VariantSelectMode) => {
53
+ setSelected((current) => {
54
+ if (mode === "replace") return ids;
55
+ const next = new Set(current);
56
+ for (const id of ids) {
57
+ if (next.has(id)) next.delete(id);
58
+ else next.add(id);
59
+ }
60
+ return [...next];
61
+ });
62
+ }, []);
63
+ const selectionStates = useMemo(
64
+ () => ({ variants: Object.fromEntries(selected.map((id) => [id, "selected" as const])) }),
65
+ [selected],
66
+ );
67
+ return <VariantExplorer {...rest} selectionStates={selectionStates} onSelect={onSelect} />;
68
+ }
69
+
70
+ const meta = {
71
+ title: "Process/VariantExplorer",
72
+ component: VariantExplorer,
73
+ tags: ["autodocs"],
74
+ parameters: {
75
+ layout: "padded",
76
+ docs: {
77
+ description: {
78
+ component:
79
+ "The ranked list of activity sequences in an event log. Each row shows the sequence as " +
80
+ "colour-keyed chips (the same colours `ProcessMap` paints when it gets the same " +
81
+ "`colorScale`), the number of cases, the share of cases covered and the median " +
82
+ "duration. It only emits `onSelect` — a playbook turns that into a variant filter. " +
83
+ "Rows are virtualized; the checkboxes form one roving tab stop (arrows, Page Up/Down, " +
84
+ "Home/End move; Space and Enter toggle).",
85
+ },
86
+ },
87
+ },
88
+ args: {
89
+ variants,
90
+ colorScale,
91
+ onSelect: () => {},
92
+ },
93
+ render: (args) => (
94
+ <div className="w-full max-w-4xl">
95
+ <Stateful {...args} />
96
+ </div>
97
+ ),
98
+ } satisfies Meta<typeof VariantExplorer>;
99
+ export default meta;
100
+ type Story = StoryObj<typeof meta>;
101
+
102
+ /** The ten most frequent paths with every column. */
103
+ export const TopTen: Story = {
104
+ args: { variants: variants.slice(0, 10) },
105
+ play: async ({ canvasElement }) => {
106
+ const canvas = within(canvasElement);
107
+ const rows = canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]');
108
+ await expect(rows.length).toBe(10);
109
+ // Row click toggles the variant.
110
+ const firstSequence = within(rows[0] as HTMLElement).getByRole("img");
111
+ await userEvent.click(firstSequence);
112
+ await waitFor(() => expect(canvas.getAllByRole("checkbox")[0]).toBeChecked());
113
+ },
114
+ };
115
+
116
+ /**
117
+ * "Variant DNA": two-letter chips so long sequences fit; full names stay in the accessible
118
+ * name. A loan-application log with more than eleven activities, so the rarer activities
119
+ * share the hatched "other" swatch.
120
+ */
121
+ export const DnaStrip: Story = {
122
+ args: { variants: loanVariants, colorScale: loanColorScale, abbreviate: true },
123
+ play: async ({ canvasElement }) => {
124
+ await expect(canvasElement.querySelector('[data-pattern="other"]')).not.toBeNull();
125
+ },
126
+ };
127
+
128
+ /**
129
+ * `sequenceDisplay="swatch"`: every step is one identity block with no text — the densest
130
+ * strip, for a rail too narrow for codes. Hover a block for its activity; the strip's
131
+ * accessible name still lists the full sequence.
132
+ */
133
+ export const SwatchStrip: Story = {
134
+ args: {
135
+ variants: loanVariants,
136
+ colorScale: loanColorScale,
137
+ sequenceDisplay: "swatch",
138
+ columns: ["cases"],
139
+ },
140
+ decorators: [
141
+ (StoryFn) => (
142
+ <div className="w-80">
143
+ <StoryFn />
144
+ </div>
145
+ ),
146
+ ],
147
+ };
148
+
149
+ /** The coverage slider selects the fewest paths that cover the target share of cases. */
150
+ export const CoverageSlider: Story = {
151
+ args: { coverageTarget: 0.8, onFilterIntent: () => {} },
152
+ };
153
+
154
+ /** Two variants already selected; keyboard selection with arrows and Space. */
155
+ export const WithSelection: Story = {
156
+ render: (args) => (
157
+ <div className="w-full max-w-4xl">
158
+ <Stateful {...args} initialSelected={[variants[0]!.id, variants[2]!.id]} />
159
+ </div>
160
+ ),
161
+ play: async ({ canvasElement }) => {
162
+ const canvas = within(canvasElement);
163
+ const boxes = canvas.getAllByRole("checkbox");
164
+ await expect(boxes[0]).toBeChecked();
165
+ await expect(boxes[2]).toBeChecked();
166
+
167
+ boxes[0]!.focus();
168
+ await userEvent.keyboard("{ArrowDown}");
169
+ const second = canvasElement.querySelector<HTMLElement>(
170
+ '[data-index="1"] [data-slot="checkbox"]',
171
+ );
172
+ await waitFor(() => expect(second).toHaveFocus());
173
+ await userEvent.keyboard(" ");
174
+ await waitFor(() => expect(second).toBeChecked());
175
+ },
176
+ };
177
+
178
+ /** The accessible table twin — the same numbers as the list, as a real table. */
179
+ export const TableView: Story = {
180
+ args: { variants: variants.slice(0, 12), tableView: true },
181
+ };
182
+
183
+ /** Waiting for variants. */
184
+ export const Loading: Story = {
185
+ args: { variants: [], loading: true },
186
+ };
187
+
188
+ /** A filter left no cases. */
189
+ export const Empty: Story = {
190
+ args: { variants: [] },
191
+ };
192
+
193
+ /**
194
+ * 2 000 variants. The play test scrolls the list and asserts the number of mounted rows
195
+ * stays bounded — the virtualization that keeps scrolling smooth. It does not time frames,
196
+ * which is too noisy to gate CI on.
197
+ */
198
+ export const TwoThousandVariants: Story = {
199
+ args: { variants: manyVariants, abbreviate: true },
200
+ play: async ({ canvasElement }) => {
201
+ const rows = () => canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]').length;
202
+ await waitFor(() => expect(rows()).toBeGreaterThan(0));
203
+ const initial = rows();
204
+ await expect(initial).toBeLessThan(60);
205
+
206
+ const viewport = canvasElement.querySelector<HTMLElement>(
207
+ '[data-slot="variant-explorer-viewport"]',
208
+ )!;
209
+ for (const top of [8_000, 40_000, 79_000]) {
210
+ viewport.scrollTop = top;
211
+ await waitFor(() =>
212
+ expect(
213
+ canvasElement.querySelector(`[data-index="${Math.floor(top / 40) + 1}"]`),
214
+ ).not.toBeNull(),
215
+ );
216
+ await expect(rows()).toBeLessThan(60);
217
+ }
218
+ },
219
+ };
220
+
221
+ /** Decoration dial at 10 — chips and bars are marks inside a control-like row, so they stay put. */
222
+ export const HighDecoration: Story = {
223
+ tags: ["!dev"],
224
+ globals: { decoration: "10" },
225
+ args: { variants: variants.slice(0, 10), coverageTarget: 0.5 },
226
+ };