@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.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- 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" (`
|
|
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,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
|
+
};
|