@elabs-ai/components-process 4.1.0 → 5.0.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 (145) 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__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Unit coverage for `ProcessCompare` (RM-064). Every side mounts a real `ProcessMap` canvas
3
+ * on first render regardless of whether a test later toggles into `tableView` — the twin
4
+ * bypasses React Flow/dagre entirely and is the cheapest way to assert on diff-state TEXT
5
+ * (see `process-compare.tsx`'s own module docblock for why text, not a glyph, is the second
6
+ * channel here), but the INITIAL mount still needs React Flow's transform math, hence the
7
+ * same `DOMMatrixReadOnly` polyfill `process-map.test.tsx` carries.
8
+ */
9
+ import { cleanup, render, screen, within } from "@testing-library/react";
10
+ import userEvent from "@testing-library/user-event";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { discoverGraph } from "../core/discover-graph";
13
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
14
+ import type { AbstractionOptions } from "../core/abstract-graph";
15
+ import { ProcessCompare } from "./process-compare";
16
+
17
+ // jsdom ships no `DOMMatrixReadOnly`/`DOMMatrix` at all, which `@xyflow/react`'s internal
18
+ // transform math calls unconditionally — see `process-map.test.tsx`'s identical polyfill.
19
+ if (typeof globalThis.DOMMatrixReadOnly === "undefined") {
20
+ class DOMMatrixReadOnlyPolyfill {
21
+ m22 = 1;
22
+ constructor(_init?: unknown) {}
23
+ }
24
+ globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyPolyfill as unknown as typeof DOMMatrixReadOnly;
25
+ }
26
+ if (typeof globalThis.DOMMatrix === "undefined") {
27
+ globalThis.DOMMatrix = globalThis.DOMMatrixReadOnly as unknown as typeof DOMMatrix;
28
+ }
29
+
30
+ afterEach(cleanup);
31
+
32
+ const METRIC = { node: "absolute_case", edge: "absolute" } as const;
33
+ const IDENTITY_ABSTRACTION: AbstractionOptions = {
34
+ activities: 1,
35
+ paths: 1,
36
+ invert: false,
37
+ keepConnected: true,
38
+ };
39
+
40
+ const logA = generateSyntheticLog({ cases: 30, seed: 1 });
41
+ const logB = generateSyntheticLog({ cases: 45, seed: 2 });
42
+ const graphA = discoverGraph(logA);
43
+ const graphB = discoverGraph(logB);
44
+
45
+ function noop() {}
46
+
47
+ describe("ProcessCompare — side-by-side", () => {
48
+ it("renders both sides' own headers and table twin, in A-then-B order", () => {
49
+ render(
50
+ <ProcessCompare
51
+ a={{ label: "Before", graph: graphA, log: logA }}
52
+ b={{ label: "After", graph: graphB, log: logB }}
53
+ metric={METRIC}
54
+ abstraction={IDENTITY_ABSTRACTION}
55
+ onAbstractionChange={noop}
56
+ />,
57
+ );
58
+
59
+ const headings = screen.getAllByRole("heading", { level: 3 });
60
+ expect(headings.map((h) => h.textContent)).toEqual(["Before", "After"]);
61
+
62
+ // Both panels render a real canvas region — two, not one, and A before B in the DOM.
63
+ const regions = screen.getAllByLabelText(/Process map — /);
64
+ expect(regions).toHaveLength(2);
65
+ expect(regions[0]).toHaveAccessibleName("Process map — Before");
66
+ expect(regions[1]).toHaveAccessibleName("Process map — After");
67
+ });
68
+
69
+ it("shares one AbstractionControls that patches the same handler for both sides", async () => {
70
+ const onAbstractionChange = vi.fn();
71
+ render(
72
+ <ProcessCompare
73
+ a={{ label: "Before", graph: graphA }}
74
+ b={{ label: "After", graph: graphB }}
75
+ metric={METRIC}
76
+ abstraction={IDENTITY_ABSTRACTION}
77
+ onAbstractionChange={onAbstractionChange}
78
+ />,
79
+ );
80
+
81
+ // Only ONE abstraction control group — not one per side.
82
+ expect(screen.getAllByRole("group", { name: "Abstraction" })).toHaveLength(1);
83
+
84
+ const auto = screen.getByRole("button", { name: /Auto/i });
85
+ await userEvent.click(auto);
86
+ expect(onAbstractionChange).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it("toggles both sides into the accessible table twin together", async () => {
90
+ render(
91
+ <ProcessCompare
92
+ a={{ label: "Before", graph: graphA }}
93
+ b={{ label: "After", graph: graphB }}
94
+ metric={METRIC}
95
+ abstraction={IDENTITY_ABSTRACTION}
96
+ onAbstractionChange={noop}
97
+ />,
98
+ );
99
+
100
+ expect(screen.queryAllByRole("table")).toHaveLength(0);
101
+ await userEvent.click(screen.getByRole("switch", { name: "Table view" }));
102
+ // Two activity tables + two transition tables, one pair per side.
103
+ expect(screen.getAllByRole("table")).toHaveLength(4);
104
+ });
105
+
106
+ it("shows the KPI strip's cases pair with a delta on B's tile", () => {
107
+ render(
108
+ <ProcessCompare
109
+ a={{ label: "Before", graph: graphA, log: logA }}
110
+ b={{ label: "After", graph: graphB, log: logB }}
111
+ metric={METRIC}
112
+ abstraction={IDENTITY_ABSTRACTION}
113
+ onAbstractionChange={noop}
114
+ />,
115
+ );
116
+
117
+ // MetricCard's own value rung (`text-2xl`) disambiguates from the abstraction
118
+ // controls' unrelated "30%"/"45%"-shaped tick text elsewhere in the same tree.
119
+ expect(screen.getByText("30", { selector: ".text-2xl" })).toBeInTheDocument();
120
+ expect(screen.getByText("45", { selector: ".text-2xl" })).toBeInTheDocument();
121
+ // (45-30)/30 = +50%.
122
+ expect(screen.getByText("+50%")).toBeInTheDocument();
123
+ });
124
+
125
+ it("renders every map's own loading panel while neither side has a graph yet", () => {
126
+ render(
127
+ <ProcessCompare
128
+ a={{ label: "Before", graph: undefined }}
129
+ b={{ label: "After", graph: undefined }}
130
+ metric={METRIC}
131
+ abstraction={IDENTITY_ABSTRACTION}
132
+ onAbstractionChange={noop}
133
+ loading
134
+ />,
135
+ );
136
+
137
+ expect(screen.getAllByRole("status")).not.toHaveLength(0);
138
+ });
139
+ });
140
+
141
+ describe("ProcessCompare — superimposed", () => {
142
+ it("renders exactly one map with a three-entry, text-first diff legend", () => {
143
+ render(
144
+ <ProcessCompare
145
+ a={{ label: "Before", graph: graphA }}
146
+ b={{ label: "After", graph: graphB }}
147
+ mode="superimposed"
148
+ metric={METRIC}
149
+ abstraction={IDENTITY_ABSTRACTION}
150
+ onAbstractionChange={noop}
151
+ />,
152
+ );
153
+
154
+ expect(screen.getAllByLabelText(/Process map — Before vs After/)).toHaveLength(1);
155
+ expect(screen.getByText("common")).toBeInTheDocument();
156
+ expect(screen.getByText("Before only")).toBeInTheDocument();
157
+ expect(screen.getByText("After only")).toBeInTheDocument();
158
+ });
159
+
160
+ it("suffixes every activity's title with its diff-state word — never colour alone", async () => {
161
+ render(
162
+ <ProcessCompare
163
+ a={{ label: "Before", graph: graphA }}
164
+ b={{ label: "After", graph: graphB }}
165
+ mode="superimposed"
166
+ metric={METRIC}
167
+ abstraction={IDENTITY_ABSTRACTION}
168
+ onAbstractionChange={noop}
169
+ />,
170
+ );
171
+
172
+ await userEvent.click(screen.getByRole("switch", { name: "Table view" }));
173
+ const activityTable = screen.getAllByRole("table")[0]!;
174
+ const rowText = within(activityTable)
175
+ .getAllByRole("row")
176
+ .map((row) => row.textContent ?? "");
177
+ // Every activity row carries one of the three diff words as real text.
178
+ expect(rowText.some((text) => /common|Before only|After only/.test(text))).toBe(true);
179
+ });
180
+
181
+ it("marks activities unique to one side when the two graphs share no activity at all", async () => {
182
+ // `generateSyntheticLog` draws from one fixed activity vocabulary, so two seeds are not
183
+ // a reliable way to get a GUARANTEED disjoint pair — hand-built graphs are.
184
+ const disjointGraph = (id: string): ReturnType<typeof discoverGraph> => ({
185
+ activities: [
186
+ {
187
+ id,
188
+ label: id,
189
+ instances: 5,
190
+ cases: 5,
191
+ isStart: true,
192
+ isEnd: true,
193
+ duration: { min: 1, max: 1, mean: 1, median: 1, p90: 1, sum: 5, trimmedMean: 1 },
194
+ },
195
+ ],
196
+ transitions: [],
197
+ startActivities: { [id]: 5 },
198
+ endActivities: { [id]: 5 },
199
+ totals: { cases: 5, events: 5, variants: 1 },
200
+ });
201
+
202
+ render(
203
+ <ProcessCompare
204
+ a={{ label: "Before", graph: disjointGraph("OnlyBefore") }}
205
+ b={{ label: "After", graph: disjointGraph("OnlyAfter") }}
206
+ mode="superimposed"
207
+ metric={METRIC}
208
+ abstraction={IDENTITY_ABSTRACTION}
209
+ onAbstractionChange={noop}
210
+ />,
211
+ );
212
+ // The legend always names all three possible states (it documents the encoding, not
213
+ // which ones are present) — the table twin is what proves NEITHER activity row here
214
+ // reads "common".
215
+ await userEvent.click(screen.getByRole("switch", { name: "Table view" }));
216
+ const activityTable = screen.getAllByRole("table")[0]!;
217
+ const rowText = within(activityTable)
218
+ .getAllByRole("row")
219
+ .map((row) => row.textContent ?? "");
220
+ expect(rowText.some((text) => text.includes("Before only"))).toBe(true);
221
+ expect(rowText.some((text) => text.includes("After only"))).toBe(true);
222
+ expect(rowText.some((text) => text.includes("common"))).toBe(false);
223
+ });
224
+ });
@@ -0,0 +1,251 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessCompare — side-by-side or superimposed diff of two process maps (RM-064).
5
+ *
6
+ * ## What it is
7
+ *
8
+ * A composition, like every other view in this package: `side-by-side` renders two
9
+ * `CompareSide` panels — each a plain `ProcessMap` — with ONE `AbstractionControls` above
10
+ * both (fanned out to both maps, the same "lift state, drive two children" shape
11
+ * `useProcessExplorer` already uses for a single map); `superimposed` renders a SINGLE
12
+ * `ProcessMap` over the union of both graphs. Either way, `CompareKpiStrip` reads underneath
13
+ * — cases and median throughput, A vs B, with a delta on B's tile.
14
+ *
15
+ * ## The diff channel is never colour alone
16
+ *
17
+ * Superimposed mode paints each activity's accent swatch with a diff-state colour token
18
+ * (`--success` common, `--chart-1` A-only, `--destructive` B-only — {@link diffColorScale})
19
+ * AND appends the diff state as real text to the activity's own title
20
+ * ({@link withDiffLabels}) — the same text a screen reader announces as part of the node's
21
+ * accessible name, not a decorative glyph a colour-blind or non-visual reader could miss
22
+ * (WCAG 1.4.1). The `Legend` alongside states the same three words. Transitions carry no
23
+ * diff encoding of their own: `ProcessMap` has no per-edge colour hook to repurpose the way
24
+ * `colorScale` lets a node's accent be repurposed, and adding one is a `ProcessMap` change
25
+ * outside this item's scope (see this component's own module test for the deviation this
26
+ * records).
27
+ *
28
+ * ## No new `ProcessMap` prop
29
+ *
30
+ * Both modes compose the SHIPPED `ProcessMap` twice or once — no fork, no new prop on it.
31
+ * One consequence: `ProcessMap` exposes no viewport ref or controlled-viewport prop, so
32
+ * `side-by-side`'s two `CanvasShell`s cannot be zoom/pan-synced from outside it. The
33
+ * roadmap's `onZoomSync` is therefore NOT implemented here — a prop that could only ever be
34
+ * a no-op is a worse API than no prop at all (see `.claude/rules/conventions.md`'s "no
35
+ * speculative props"). Two panes with independent pan/zoom, laid out side by side so a
36
+ * reader can still eyeball the same region on both, is what ships.
37
+ */
38
+ import { forwardRef, useId, useMemo, useState, type HTMLAttributes } from "react";
39
+ import { Table2 } from "lucide-react";
40
+ import { Label, Switch, useLocale } from "@elabs-ai/components-ui";
41
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
42
+ import { Legend, type FlowLayoutDirection } from "@elabs-ai/components-flow";
43
+ import { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
44
+ import { diffGraphs } from "../core/diff-graphs";
45
+ import type { EventLog, ProcessGraph } from "../core/types";
46
+ import { AbstractionControls } from "../abstraction-controls/abstraction-controls";
47
+ import { ProcessMap, type ProcessMapProps } from "../process-map/process-map";
48
+ import { CompareKpiStrip } from "./compare-kpi-strip";
49
+ import { CompareSide } from "./compare-side";
50
+ import { resolveCompareKpis, withDiffLabels } from "./compare-model";
51
+ import { diffColorScale, diffToProcessGraph, EMPTY_PROCESS_GRAPH } from "./diff-to-graph";
52
+
53
+ /** One side of the comparison. */
54
+ export interface ProcessCompareSide {
55
+ /** How this side is named in headers, the KPI strip and the diff legend/labels. */
56
+ label: string;
57
+ /** A discovered graph. `undefined` while this side is still loading. */
58
+ graph: ProcessGraph | undefined;
59
+ /** The raw log, for `CompareKpiStrip`'s median-throughput reading. Optional. */
60
+ log?: EventLog;
61
+ }
62
+
63
+ export type ProcessCompareMode = "side-by-side" | "superimposed";
64
+
65
+ export interface ProcessCompareProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
66
+ a: ProcessCompareSide;
67
+ b: ProcessCompareSide;
68
+ /** @default "side-by-side" */
69
+ mode?: ProcessCompareMode;
70
+ metric: ProcessMapProps["metric"];
71
+ /** Shared abstraction, synced across both sides (or the one superimposed map). */
72
+ abstraction: AbstractionOptions;
73
+ onAbstractionChange(next: Partial<AbstractionOptions>): void;
74
+ /** @default "TB" */
75
+ direction?: FlowLayoutDirection;
76
+ /** Neither side has a graph yet. Renders every map's own loading panel. @default false */
77
+ loading?: boolean;
78
+ /** Accessible name for the region. Defaults to the localized `process.compare.label`. */
79
+ label?: string;
80
+ }
81
+
82
+ /** ProcessMap's own narrower abstraction shape — `AbstractionControls` needs the full options. */
83
+ function toMapAbstraction(
84
+ abstraction: AbstractionOptions,
85
+ ): Pick<AbstractionOptions, "activities" | "paths"> {
86
+ return { activities: abstraction.activities, paths: abstraction.paths };
87
+ }
88
+
89
+ /**
90
+ * Side-by-side or superimposed diff of two process maps.
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * <ProcessCompare
95
+ * a={{ label: "Before", graph: beforeGraph }}
96
+ * b={{ label: "After", graph: afterGraph }}
97
+ * metric={{ node: "absolute_case", edge: "absolute" }}
98
+ * abstraction={abstraction}
99
+ * onAbstractionChange={setAbstraction}
100
+ * />
101
+ * ```
102
+ */
103
+ export const ProcessCompare = forwardRef<HTMLDivElement, ProcessCompareProps>(
104
+ function ProcessCompare(
105
+ {
106
+ a,
107
+ b,
108
+ mode = "side-by-side",
109
+ metric,
110
+ abstraction,
111
+ onAbstractionChange,
112
+ direction = "TB",
113
+ loading = false,
114
+ label,
115
+ className,
116
+ ...props
117
+ },
118
+ ref,
119
+ ) {
120
+ const { t } = useLocale();
121
+ const tableViewId = useId();
122
+ const [tableView, setTableView] = useState(false);
123
+
124
+ const mapAbstraction = useMemo(() => toMapAbstraction(abstraction), [abstraction]);
125
+
126
+ // The union of both sides — read for the ONE shared `AbstractionControls` (both modes)
127
+ // and, in superimposed mode, for the single map's own `graph` prop below. Computed
128
+ // unconditionally: it is a cheap linear scan, and building it once keeps the "auto"
129
+ // heuristic's reference graph and the superimposed render byte-identical rather than two
130
+ // independently-maintained unions.
131
+ const diff = useMemo(
132
+ () => diffGraphs(a.graph ?? EMPTY_PROCESS_GRAPH, b.graph ?? EMPTY_PROCESS_GRAPH),
133
+ [a.graph, b.graph],
134
+ );
135
+ const unionGraph = useMemo(() => diffToProcessGraph(diff), [diff]);
136
+ const abstracted = useMemo(
137
+ () => abstractGraph(unionGraph, abstraction),
138
+ [unionGraph, abstraction],
139
+ );
140
+
141
+ const aKpis = useMemo(() => resolveCompareKpis(a), [a]);
142
+ const bKpis = useMemo(() => resolveCompareKpis(b), [b]);
143
+
144
+ const superimposedGraph = useMemo(
145
+ () => withDiffLabels(unionGraph, diff, a.label, b.label, t),
146
+ [unionGraph, diff, a.label, b.label, t],
147
+ );
148
+ const superimposedColorScale = useMemo(() => diffColorScale(diff), [diff]);
149
+
150
+ return (
151
+ <div
152
+ ref={ref}
153
+ data-slot="process-compare"
154
+ role="group"
155
+ aria-label={label ?? t("process.compare.label")}
156
+ className={cn("flex flex-col gap-4", className)}
157
+ {...props}
158
+ >
159
+ <div
160
+ data-slot="process-compare-toolbar"
161
+ className="flex flex-wrap items-start justify-between gap-3"
162
+ >
163
+ <AbstractionControls
164
+ abstraction={abstraction}
165
+ onAbstractionChange={onAbstractionChange}
166
+ graph={abstracted}
167
+ hiddenCounts={abstracted.hidden}
168
+ />
169
+ <div className="flex items-center gap-2">
170
+ <Switch id={tableViewId} checked={tableView} onCheckedChange={setTableView} />
171
+ <Label htmlFor={tableViewId} className="inline-flex items-center gap-1.5 text-body">
172
+ <Table2 aria-hidden="true" className="size-4" />
173
+ {t("process.compare.tableView")}
174
+ </Label>
175
+ </div>
176
+ </div>
177
+
178
+ {mode === "superimposed" ? (
179
+ <div data-slot="process-compare-superimposed" className="flex flex-col gap-3">
180
+ <Legend
181
+ variant="categorical"
182
+ title={t("process.compare.legendTitle")}
183
+ items={[
184
+ { label: t("process.compare.diffSuffixCommon"), color: "var(--success)" },
185
+ {
186
+ label: t("process.compare.diffSuffixOnly", { label: a.label }),
187
+ color: "var(--chart-1)",
188
+ },
189
+ {
190
+ label: t("process.compare.diffSuffixOnly", { label: b.label }),
191
+ color: "var(--destructive)",
192
+ },
193
+ ]}
194
+ />
195
+ <div className="min-h-96 flex-1">
196
+ <ProcessMap
197
+ graph={superimposedGraph}
198
+ metric={metric}
199
+ abstraction={mapAbstraction}
200
+ direction={direction}
201
+ colorScale={superimposedColorScale}
202
+ tableView={tableView}
203
+ loading={loading}
204
+ label={t("process.compare.superimposedMapLabel", {
205
+ aLabel: a.label,
206
+ bLabel: b.label,
207
+ })}
208
+ className="size-full"
209
+ />
210
+ </div>
211
+ </div>
212
+ ) : (
213
+ <div data-slot="process-compare-sides" className="grid gap-4 md:grid-cols-2">
214
+ <CompareSide
215
+ label={a.label}
216
+ map={{
217
+ graph: a.graph,
218
+ log: a.log,
219
+ metric,
220
+ abstraction: mapAbstraction,
221
+ direction,
222
+ tableView,
223
+ loading,
224
+ label: t("process.compare.mapLabel", { label: a.label }),
225
+ }}
226
+ />
227
+ <CompareSide
228
+ label={b.label}
229
+ map={{
230
+ graph: b.graph,
231
+ log: b.log,
232
+ metric,
233
+ abstraction: mapAbstraction,
234
+ direction,
235
+ tableView,
236
+ loading,
237
+ label: t("process.compare.mapLabel", { label: b.label }),
238
+ }}
239
+ />
240
+ </div>
241
+ )}
242
+
243
+ <CompareKpiStrip
244
+ a={{ label: a.label, kpis: aKpis }}
245
+ b={{ label: b.label, kpis: bKpis }}
246
+ loading={loading}
247
+ />
248
+ </div>
249
+ );
250
+ },
251
+ );
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * 1. **Header + KPI strip** — the reading a stakeholder needs before any interaction.
26
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,
27
+ * do about it" (`Layout/ViewToolbar Contract`). Active filters are `FilterChip`s here,
28
28
  * not a bespoke pill row, and the case count is a `ResultCount` so "142 of 240" reads
29
29
  * the same as it does on every table in the system.
30
30
  * 3. **Rail + canvas** — a `SplitPanel` with the two dials and the inspector on the
@@ -0,0 +1,2 @@
1
+ export { ProcessFilterBar } from "./process-filter-bar";
2
+ export type { ProcessFilterBarProps } from "./process-filter-bar";
@@ -0,0 +1,156 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
5
+ import type { AbstractionOptions } from "../core/abstract-graph";
6
+ import type { FilterIntent } from "../use-process-explorer";
7
+ import { useProcessExplorer } from "../use-process-explorer";
8
+ import { ProcessFilterBar } from "./process-filter-bar";
9
+
10
+ const log = generateSyntheticLog({ cases: 300, seed: 7 });
11
+
12
+ const SINGLE_INTENT: FilterIntent[] = [{ kind: "with", activity: "Reject Order" }];
13
+
14
+ const CHAIN_INTENTS: FilterIntent[] = [
15
+ { kind: "without", activity: "Cancel Order" },
16
+ { kind: "with", activity: "Amend Order" },
17
+ { kind: "endsWith", activity: "Receive Payment" },
18
+ ];
19
+
20
+ /**
21
+ * Real `useProcessExplorer` state, seeded once with `initialIntents` — this is the same
22
+ * hook `ProcessFilterBar` is meant to sit beside (RM-056, #205), so every number the bar
23
+ * renders (per-chip excluded counts, the filtered/total split, hidden-by-abstraction) is
24
+ * the hook's own live output, not a hand-typed stand-in.
25
+ */
26
+ function ProcessFilterBarDemo({
27
+ initialIntents = [],
28
+ abstraction,
29
+ }: {
30
+ initialIntents?: FilterIntent[];
31
+ abstraction?: Partial<AbstractionOptions>;
32
+ }) {
33
+ const explorer = useProcessExplorer(log, { abstraction });
34
+ const seeded = useRef(false);
35
+ useEffect(() => {
36
+ if (seeded.current) return;
37
+ seeded.current = true;
38
+ for (const intent of initialIntents) explorer.applyIntent(intent);
39
+ }, [explorer, initialIntents]);
40
+
41
+ const totalCases = useMemo(() => new Set(log.events.map((event) => event.caseId)).size, []);
42
+
43
+ return (
44
+ <div className="max-w-2xl">
45
+ <ProcessFilterBar
46
+ intents={explorer.intents}
47
+ excludedByIntent={explorer.excludedByIntent}
48
+ totalCases={totalCases}
49
+ filteredCases={explorer.kpis.cases}
50
+ hiddenCounts={explorer.hiddenCounts}
51
+ onRemove={explorer.clearIntent}
52
+ onClearAll={() => {
53
+ for (let index = explorer.intents.length - 1; index >= 0; index -= 1) {
54
+ explorer.clearIntent(index);
55
+ }
56
+ }}
57
+ />
58
+ </div>
59
+ );
60
+ }
61
+
62
+ const meta = {
63
+ title: "Process/ProcessFilterBar",
64
+ component: ProcessFilterBar,
65
+ tags: ["autodocs"],
66
+ parameters: {
67
+ docs: {
68
+ description: {
69
+ component:
70
+ "Filter chain breadcrumbs (§4 R13): one removable chip per active " +
71
+ "`useProcessExplorer` intent, each carrying how many cases that intent ALONE " +
72
+ "excludes (`excludedByIntent`, the hook's own additive field), plus a summary " +
73
+ "line stating what filtering and abstraction currently hide. Composes " +
74
+ "`@elabs-ai/components-data`'s `FilterBar`/`FilterChip` — no chip markup of its " +
75
+ "own (`pnpm check --rule process-reuse`).",
76
+ },
77
+ },
78
+ },
79
+ } satisfies Meta<typeof ProcessFilterBar>;
80
+ export default meta;
81
+ type Story = StoryObj<typeof meta>;
82
+
83
+ /** One active intent — the simplest non-empty chain. */
84
+ export const SingleFilter: Story = {
85
+ render: () => <ProcessFilterBarDemo initialIntents={SINGLE_INTENT} />,
86
+ play: async ({ canvasElement }) => {
87
+ const canvas = within(canvasElement);
88
+ await waitFor(() =>
89
+ expect(canvas.getByRole("button", { name: /Contains Reject Order/ })).toBeInTheDocument(),
90
+ );
91
+ },
92
+ };
93
+
94
+ /**
95
+ * A three-intent chain — the breadcrumb reading this component exists for. Each chip's
96
+ * "excluded N" is the MARGINAL count that one intent removes on top of the ones before it,
97
+ * never the whole chain's total.
98
+ */
99
+ export const FilterChain: Story = {
100
+ render: () => <ProcessFilterBarDemo initialIntents={CHAIN_INTENTS} />,
101
+ play: async ({ canvasElement }) => {
102
+ const canvas = within(canvasElement);
103
+ await waitFor(() => expect(canvas.getAllByRole("button").length).toBeGreaterThan(1));
104
+
105
+ // Removing the middle chip by CLICK drops it, and only it.
106
+ const beforeCount = canvas.getAllByRole("button", { name: /^Remove filter:/ }).length;
107
+ await userEvent.click(canvas.getByRole("button", { name: /Contains Amend Order/ }));
108
+ await waitFor(() =>
109
+ expect(canvas.getAllByRole("button", { name: /^Remove filter:/ }).length).toBe(
110
+ beforeCount - 1,
111
+ ),
112
+ );
113
+ expect(canvas.queryByRole("button", { name: /Contains Amend Order/ })).not.toBeInTheDocument();
114
+
115
+ // Keyboard: Tab lands on a chip (a real <button>), Enter removes it — no new
116
+ // interaction pattern, the same contract `FilterChip` already ships.
117
+ const remaining = canvas.getByRole("button", { name: /Excludes Cancel Order/ });
118
+ remaining.focus();
119
+ await userEvent.keyboard("{Enter}");
120
+ await waitFor(() =>
121
+ expect(canvas.getAllByRole("button", { name: /^Remove filter:/ }).length).toBe(
122
+ beforeCount - 2,
123
+ ),
124
+ );
125
+ },
126
+ };
127
+
128
+ /** The chain plus a busy abstraction — the summary line states both clauses at once. */
129
+ export const WithAbstractionHidden: Story = {
130
+ render: () => (
131
+ <ProcessFilterBarDemo
132
+ initialIntents={CHAIN_INTENTS}
133
+ abstraction={{ activities: 0.4, paths: 0.4 }}
134
+ />
135
+ ),
136
+ play: async ({ canvasElement }) => {
137
+ const canvas = within(canvasElement);
138
+ await waitFor(() =>
139
+ expect(canvas.getByText(/activit(?:y|ies) hidden by abstraction/)).toBeInTheDocument(),
140
+ );
141
+ },
142
+ };
143
+
144
+ /**
145
+ * No active intents: the chip row and its "Clear all" action render nothing at all — the
146
+ * summary line is the only thing left, and it reads as "showing everything".
147
+ */
148
+ export const Empty: Story = {
149
+ render: () => <ProcessFilterBarDemo />,
150
+ play: async ({ canvasElement }) => {
151
+ const canvas = within(canvasElement);
152
+ expect(canvas.queryByRole("group")).not.toBeInTheDocument();
153
+ expect(canvas.queryByRole("button")).not.toBeInTheDocument();
154
+ await waitFor(() => expect(canvas.getByText(/^Showing all /)).toBeInTheDocument());
155
+ },
156
+ };