@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.
- 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__/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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object-centric mode — RM-066. The pure model plus the table twin; the canvas itself is
|
|
3
|
+
* covered in a real browser by `process-map-object-centric.stories.tsx`.
|
|
4
|
+
*/
|
|
5
|
+
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { fromOcel } from "../core/adapters/ocel";
|
|
8
|
+
import {
|
|
9
|
+
discoverObjectCentricGraph,
|
|
10
|
+
objectCentricProcessGraph,
|
|
11
|
+
objectTypeColorScale,
|
|
12
|
+
} from "../core/discover-object-centric-graph";
|
|
13
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
14
|
+
import { OCEL_SAMPLE } from "../core/fixtures/ocel-sample";
|
|
15
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
16
|
+
import {
|
|
17
|
+
buildObjectCentricMapModel,
|
|
18
|
+
buildProcessMapModel,
|
|
19
|
+
objectCentricEdgeId,
|
|
20
|
+
processEdgeId,
|
|
21
|
+
type ProcessTransitionEdgeData,
|
|
22
|
+
} from "./map-model";
|
|
23
|
+
import { ProcessMap } from "./process-map";
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup);
|
|
26
|
+
|
|
27
|
+
const parsed = fromOcel(OCEL_SAMPLE);
|
|
28
|
+
if (!parsed.ok) throw new Error("fixture must parse");
|
|
29
|
+
const objectCentric = discoverObjectCentricGraph(parsed.logs, { objectTypes: parsed.objectTypes });
|
|
30
|
+
const flat = objectCentricProcessGraph(objectCentric);
|
|
31
|
+
const scale = objectTypeColorScale(objectCentric);
|
|
32
|
+
const metric = { node: "absolute_case", edge: "absolute" } as const;
|
|
33
|
+
|
|
34
|
+
function build(selection?: { kind: "activity" | "transition"; id: string }) {
|
|
35
|
+
return buildObjectCentricMapModel({
|
|
36
|
+
graph: flat,
|
|
37
|
+
metric,
|
|
38
|
+
selection,
|
|
39
|
+
objectCentric,
|
|
40
|
+
objectTypeScale: scale,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("buildObjectCentricMapModel", () => {
|
|
45
|
+
it("is exactly buildProcessMapModel without an object-centric graph", () => {
|
|
46
|
+
const graph = discoverGraph(generateSyntheticLog({ cases: 20, seed: 3 }));
|
|
47
|
+
// Serialized: `formatEdgeValue` is a fresh closure per build, equal only in behaviour.
|
|
48
|
+
expect(JSON.stringify(buildObjectCentricMapModel({ graph, metric }))).toBe(
|
|
49
|
+
JSON.stringify(buildProcessMapModel({ graph, metric })),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("gives a shared activity one chip per object type, each named in text", () => {
|
|
54
|
+
const pack = build().nodes.find((node) => node.id === "Pack")!;
|
|
55
|
+
expect(pack.data.objectTypes!.map((entry) => entry.type)).toEqual(["order", "item", "package"]);
|
|
56
|
+
const names = pack.data.objectTypes!.map((entry) => entry.ariaLabel);
|
|
57
|
+
expect(new Set(names).size).toBe(3);
|
|
58
|
+
expect(names[1]).toBe("item: 2 objects, 2 occurrences");
|
|
59
|
+
expect(pack.ariaLabel).toContain("item: 2 objects, 2 occurrences");
|
|
60
|
+
for (const entry of pack.data.objectTypes!) {
|
|
61
|
+
expect(entry.code).toMatch(/^.{2}$/);
|
|
62
|
+
expect(entry.color.token).toMatch(/^--chart-\d+$/);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("draws one edge per object type, side by side, in the type's chart colour", () => {
|
|
67
|
+
const model = build();
|
|
68
|
+
const pair = model.edges.filter(
|
|
69
|
+
(edge) => edge.source === "Place Order" && edge.target === "Pack",
|
|
70
|
+
);
|
|
71
|
+
expect(pair.map((edge) => edge.id)).toEqual([
|
|
72
|
+
objectCentricEdgeId("Place Order", "Pack", "order"),
|
|
73
|
+
objectCentricEdgeId("Place Order", "Pack", "item"),
|
|
74
|
+
]);
|
|
75
|
+
const data = pair.map((edge) => edge.data as ProcessTransitionEdgeData);
|
|
76
|
+
expect(data.map((d) => [d.parallelIndex, d.parallelCount])).toEqual([
|
|
77
|
+
[0, 2],
|
|
78
|
+
[1, 2],
|
|
79
|
+
]);
|
|
80
|
+
expect(pair[0]!.style).toEqual({ stroke: `var(${scale.colorFor("order").token})` });
|
|
81
|
+
expect(data[1]!.label).toBe(`${scale.codeFor("item")} 1`);
|
|
82
|
+
expect(data[1]!.ariaLabel).toMatch(/^item flow: Transition from Place Order to Pack/);
|
|
83
|
+
// Never merged across types: 2 (order) + 3 (item) + 1 (package).
|
|
84
|
+
expect(model.edges).toHaveLength(6);
|
|
85
|
+
expect(model.transitionRows.map((row) => row.objectType)).toHaveLength(6);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("selects only the named per-type edge, keeping its pair's neighbourhood", () => {
|
|
89
|
+
const id = objectCentricEdgeId("Place Order", "Pack", "item");
|
|
90
|
+
const model = build({ kind: "transition", id });
|
|
91
|
+
const states = Object.fromEntries(
|
|
92
|
+
model.edges.map((edge) => [edge.id, (edge.data as ProcessTransitionEdgeData).selectionState]),
|
|
93
|
+
);
|
|
94
|
+
expect(states[id]).toBe("selected");
|
|
95
|
+
expect(states[objectCentricEdgeId("Place Order", "Pack", "order")]).toBe("associated");
|
|
96
|
+
expect(states[objectCentricEdgeId("Pack", "Ship", "package")]).toBe("excluded");
|
|
97
|
+
|
|
98
|
+
const merged = build({ kind: "transition", id: processEdgeId("Place Order", "Pack") });
|
|
99
|
+
const selected = merged.edges.filter(
|
|
100
|
+
(edge) => (edge.data as ProcessTransitionEdgeData).selectionState === "selected",
|
|
101
|
+
);
|
|
102
|
+
expect(selected).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("ProcessMap — object-centric table twin", () => {
|
|
107
|
+
it("adds an object-types column and one transition row per type", () => {
|
|
108
|
+
render(<ProcessMap objectCentric={objectCentric} metric={metric} tableView />);
|
|
109
|
+
const activities = screen.getByRole("table", { name: /Activities/ });
|
|
110
|
+
expect(within(activities).getByRole("columnheader", { name: "Object types" })).toBeVisible();
|
|
111
|
+
const transitions = screen.getByRole("table", { name: /Transitions/ });
|
|
112
|
+
expect(within(transitions).getByRole("columnheader", { name: "Object type" })).toBeVisible();
|
|
113
|
+
// Header row + six per-type transitions.
|
|
114
|
+
expect(within(transitions).getAllByRole("row")).toHaveLength(7);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders the empty panel for an object-centric graph with no activities", () => {
|
|
118
|
+
const empty = discoverObjectCentricGraph({ order: { events: [] } });
|
|
119
|
+
render(<ProcessMap objectCentric={empty} metric={metric} />);
|
|
120
|
+
expect(document.querySelector('[data-slot="process-map"]')).toHaveAttribute(
|
|
121
|
+
"data-state",
|
|
122
|
+
"empty",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("warns once in development when given objectCentric together with graph", () => {
|
|
127
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
128
|
+
render(<ProcessMap objectCentric={objectCentric} graph={flat} metric={metric} tableView />);
|
|
129
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("mutually exclusive"));
|
|
130
|
+
warn.mockRestore();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -62,14 +62,21 @@
|
|
|
62
62
|
* themes). The meter's fill (a redundant, `aria-hidden`, non-text channel) still dims at the
|
|
63
63
|
* shared rung.
|
|
64
64
|
*/
|
|
65
|
-
import { useMemo, type CSSProperties } from "react";
|
|
65
|
+
import { useMemo, type CSSProperties, type ReactNode } from "react";
|
|
66
66
|
import { CircleDot, Flag, Play, RefreshCw } from "lucide-react";
|
|
67
67
|
import { Badge } from "@elabs-ai/components-ui";
|
|
68
68
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
69
69
|
import { FlowNode, type FlowNodeData } from "@elabs-ai/components-flow";
|
|
70
70
|
import type { NodeProps } from "@xyflow/react";
|
|
71
71
|
import { useProcessMapHover } from "./process-map-context";
|
|
72
|
+
import type { ActivityColor } from "../core/activity-color-scale";
|
|
73
|
+
import { activityAccentStyle } from "./activity-accent";
|
|
72
74
|
import { activityRole, GHOST_OPACITY, type ProcessMapNode } from "./map-model";
|
|
75
|
+
import type { ProcessObjectTypeCount } from "./map-model";
|
|
76
|
+
import {
|
|
77
|
+
CONFORMANCE_STATE_ENCODING,
|
|
78
|
+
type ConformanceState,
|
|
79
|
+
} from "../conformance-overlay/conformance-state";
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
82
|
* Local override for the ghost frame: retargets `--border` (what `FlowNode`'s own
|
|
@@ -100,6 +107,123 @@ function meterFill(saturation: number): string {
|
|
|
100
107
|
return `color-mix(in oklab, var(--primary) ${percent}%, var(--surface-muted))`;
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The footer row: the meter alone (today's rendering, byte-identical) or, when the map has
|
|
112
|
+
* a shared `colorScale` (RM-054), the activity's identity swatch in front of the meter.
|
|
113
|
+
*
|
|
114
|
+
* The swatch is a small mark, never the card fill, so identity colour and the metric's
|
|
115
|
+
* saturation ramp never compete for the same pixels. It is `aria-hidden`: the activity's
|
|
116
|
+
* identity already reaches the reader as its printed title and accessible name, so the
|
|
117
|
+
* colour is a redundant cross-view cue (WCAG 1.4.1), not a channel of its own.
|
|
118
|
+
*/
|
|
119
|
+
function accentFooter(accent: ActivityColor | undefined, isDimmed: boolean, meter: ReactNode) {
|
|
120
|
+
if (!accent) return meter;
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex items-center gap-1.5">
|
|
123
|
+
<span
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
data-slot="process-activity-node-accent"
|
|
126
|
+
data-color-token={accent.token}
|
|
127
|
+
data-pattern={accent.pattern}
|
|
128
|
+
className="size-2.5 shrink-0 rounded-sm"
|
|
129
|
+
style={{ ...activityAccentStyle(accent), ...(isDimmed ? { opacity: GHOST_OPACITY } : {}) }}
|
|
130
|
+
/>
|
|
131
|
+
<div className="min-w-0 flex-1">{meter}</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Object-centric — RM-066. One chip per object type above the footer row: a chart-token
|
|
138
|
+
* square, the type's two-character code and its object count. Never colour alone — the
|
|
139
|
+
* code and count are printed, and each chip is a named image (`role="img"` plus the
|
|
140
|
+
* type's own "order: 2 objects, 3 occurrences" name, also shown as a `title` tooltip).
|
|
141
|
+
*/
|
|
142
|
+
function objectTypeFooter(
|
|
143
|
+
objectTypes: ProcessObjectTypeCount[] | undefined,
|
|
144
|
+
isDimmed: boolean,
|
|
145
|
+
footer: ReactNode,
|
|
146
|
+
) {
|
|
147
|
+
if (!objectTypes || objectTypes.length === 0) return footer;
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex flex-col gap-1.5">
|
|
150
|
+
<div data-slot="process-activity-node-object-types" className="flex flex-wrap gap-1">
|
|
151
|
+
{objectTypes.map((entry) => (
|
|
152
|
+
<span
|
|
153
|
+
key={entry.type}
|
|
154
|
+
role="img"
|
|
155
|
+
data-object-type={entry.type}
|
|
156
|
+
data-color-token={entry.color.token}
|
|
157
|
+
aria-label={entry.ariaLabel}
|
|
158
|
+
title={entry.ariaLabel}
|
|
159
|
+
className="inline-flex items-center gap-1 rounded-sm text-meta tabular-nums text-muted-foreground"
|
|
160
|
+
>
|
|
161
|
+
<span
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
className="size-2.5 shrink-0 rounded-sm"
|
|
164
|
+
style={{
|
|
165
|
+
...activityAccentStyle(entry.color),
|
|
166
|
+
...(isDimmed ? { opacity: GHOST_OPACITY } : {}),
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
<span aria-hidden="true" className="font-medium text-foreground">
|
|
170
|
+
{entry.code}
|
|
171
|
+
</span>
|
|
172
|
+
<span aria-hidden="true">{entry.cases}</span>
|
|
173
|
+
</span>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
{footer}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The card frame under a conformance state (RM-062): the border token is retargeted to the
|
|
183
|
+
* state's tone and the line style is set on `FlowNode`'s own card through a descendant
|
|
184
|
+
* variant — composition from outside, same as {@link GHOST_FRAME_STYLE}, never a fork.
|
|
185
|
+
* Merged AFTER the ghost style, so an excluded node keeps its quieter fill (selection) and
|
|
186
|
+
* still shows the conformance border and dash (conformance): the two layers compose.
|
|
187
|
+
*/
|
|
188
|
+
const CONFORMANCE_FRAME_CLASS: Record<ConformanceState, string> = {
|
|
189
|
+
both: "[&_[data-slot=flow-node]]:border-solid",
|
|
190
|
+
logOnly: "[&_[data-slot=flow-node]]:border-dotted",
|
|
191
|
+
modelOnly: "[&_[data-slot=flow-node]]:border-dashed",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function frameStyle(
|
|
195
|
+
isDimmed: boolean,
|
|
196
|
+
conformance: ConformanceState | undefined,
|
|
197
|
+
): CSSProperties | undefined {
|
|
198
|
+
if (!conformance) return isDimmed ? GHOST_FRAME_STYLE : undefined;
|
|
199
|
+
return {
|
|
200
|
+
...(isDimmed ? GHOST_FRAME_STYLE : {}),
|
|
201
|
+
"--border": CONFORMANCE_STATE_ENCODING[conformance].colorVar,
|
|
202
|
+
} as CSSProperties;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The conformance glyph pinned to the card's top-start corner. `aria-hidden`: the state's
|
|
207
|
+
* word is already folded into the node's accessible name by `ProcessMap`, so the glyph is
|
|
208
|
+
* the visible, non-colour channel, not a second announcement.
|
|
209
|
+
*/
|
|
210
|
+
function ConformanceMarker({ state }: { state: ConformanceState }) {
|
|
211
|
+
const encoding = CONFORMANCE_STATE_ENCODING[state];
|
|
212
|
+
const Glyph = encoding.icon;
|
|
213
|
+
return (
|
|
214
|
+
<span
|
|
215
|
+
aria-hidden="true"
|
|
216
|
+
data-slot="process-activity-node-conformance"
|
|
217
|
+
data-conformance={state}
|
|
218
|
+
data-glyph={encoding.glyph}
|
|
219
|
+
data-dash={encoding.dash}
|
|
220
|
+
className="absolute -start-2 -top-2 z-10 flex size-5 items-center justify-center rounded-full bg-flow-node shadow-xs"
|
|
221
|
+
>
|
|
222
|
+
<Glyph className={cn("size-3", encoding.markClass)} />
|
|
223
|
+
</span>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
103
227
|
/** Start/end glyph pairing, in `FlowNode`'s own tone-glyph idiom. */
|
|
104
228
|
function roleIcon(isStart: boolean, isEnd: boolean) {
|
|
105
229
|
if (isStart && isEnd) return CircleDot;
|
|
@@ -139,19 +263,25 @@ export function ProcessActivityNode(props: NodeProps<ProcessMapNode>) {
|
|
|
139
263
|
// same number is already printed in the subtitle above and repeated in the node's
|
|
140
264
|
// accessible name — a third announcement would be noise, not access. The fill (not
|
|
141
265
|
// the text) is the one thing here that still dims at the shared ghost rung.
|
|
142
|
-
footer: (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
data
|
|
147
|
-
|
|
148
|
-
style={isDimmed ? { opacity: GHOST_OPACITY } : undefined}
|
|
149
|
-
>
|
|
266
|
+
footer: objectTypeFooter(
|
|
267
|
+
data.objectTypes,
|
|
268
|
+
isDimmed,
|
|
269
|
+
accentFooter(
|
|
270
|
+
data.accent,
|
|
271
|
+
isDimmed,
|
|
150
272
|
<div
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
273
|
+
aria-hidden="true"
|
|
274
|
+
data-slot="process-activity-node-meter"
|
|
275
|
+
data-percent={percent}
|
|
276
|
+
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-muted transition-opacity duration-fast ease-standard motion-reduce:transition-none"
|
|
277
|
+
style={isDimmed ? { opacity: GHOST_OPACITY } : undefined}
|
|
278
|
+
>
|
|
279
|
+
<div
|
|
280
|
+
className="h-full rounded-full transition-[width] duration-base ease-standard motion-reduce:transition-none"
|
|
281
|
+
style={{ width: `${percent}%`, background: meterFill(data.saturation) }}
|
|
282
|
+
/>
|
|
283
|
+
</div>,
|
|
284
|
+
),
|
|
155
285
|
),
|
|
156
286
|
}),
|
|
157
287
|
[
|
|
@@ -160,6 +290,8 @@ export function ProcessActivityNode(props: NodeProps<ProcessMapNode>) {
|
|
|
160
290
|
data.primaryLabel,
|
|
161
291
|
data.secondaryLabel,
|
|
162
292
|
data.saturation,
|
|
293
|
+
data.accent,
|
|
294
|
+
data.objectTypes,
|
|
163
295
|
RoleIcon,
|
|
164
296
|
percent,
|
|
165
297
|
isDimmed,
|
|
@@ -172,6 +304,7 @@ export function ProcessActivityNode(props: NodeProps<ProcessMapNode>) {
|
|
|
172
304
|
data-selection={data.selectionState}
|
|
173
305
|
data-role={activityRole(data).toLowerCase()}
|
|
174
306
|
data-hover={isHovered ? "true" : undefined}
|
|
307
|
+
data-conformance={data.conformance}
|
|
175
308
|
// `relative` positions the rework badge — and, because the meter now lives INSIDE
|
|
176
309
|
// the card (`FlowNode`'s `footer` slot), this wrapper is exactly as tall as the
|
|
177
310
|
// card. That equality is load-bearing, not cosmetic: React Flow lays every
|
|
@@ -191,8 +324,13 @@ export function ProcessActivityNode(props: NodeProps<ProcessMapNode>) {
|
|
|
191
324
|
<span className="sr-only">{data.reworkCount} repeated executions</span>
|
|
192
325
|
</Badge>
|
|
193
326
|
) : null}
|
|
327
|
+
{data.conformance ? <ConformanceMarker state={data.conformance} /> : null}
|
|
194
328
|
|
|
195
|
-
<div
|
|
329
|
+
<div
|
|
330
|
+
data-slot="process-activity-node-frame"
|
|
331
|
+
className={data.conformance ? CONFORMANCE_FRAME_CLASS[data.conformance] : undefined}
|
|
332
|
+
style={frameStyle(isDimmed, data.conformance)}
|
|
333
|
+
>
|
|
196
334
|
<FlowNode {...props} type="brand" data={flowData} />
|
|
197
335
|
</div>
|
|
198
336
|
</div>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
5
|
+
import { AbstractionControls, type ObjectTypeAbstraction } from "../abstraction-controls";
|
|
6
|
+
import type { AbstractionOptions } from "../core/abstract-graph";
|
|
7
|
+
import { fromOcel, type OcelEvent, type OcelJson, type OcelObject } from "../core/adapters/ocel";
|
|
8
|
+
import {
|
|
9
|
+
abstractObjectCentricGraph,
|
|
10
|
+
discoverObjectCentricGraph,
|
|
11
|
+
objectCentricProcessGraph,
|
|
12
|
+
type ObjectCentricGraph,
|
|
13
|
+
} from "../core/discover-object-centric-graph";
|
|
14
|
+
import { OCEL_SAMPLE } from "../core/fixtures/ocel-sample";
|
|
15
|
+
import { ProcessMap } from "./process-map";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A deterministic order-to-delivery OCEL 2.0 log: orders own items, items are picked (and
|
|
19
|
+
* sometimes checked or re-picked) and packed, packages ship and are delivered. `Place
|
|
20
|
+
* Order` is shared by orders and items, `Pack` by all three types, `Ship` by orders and
|
|
21
|
+
* packages — the shapes an object-centric map exists to show.
|
|
22
|
+
*/
|
|
23
|
+
function orderToDeliveryOcel(orders = 12): OcelJson {
|
|
24
|
+
const objects: OcelObject[] = [];
|
|
25
|
+
const events: OcelEvent[] = [];
|
|
26
|
+
let clock = Date.UTC(2026, 0, 5, 8);
|
|
27
|
+
let eventId = 0;
|
|
28
|
+
const at = (minutes: number) => {
|
|
29
|
+
clock += minutes * 60_000;
|
|
30
|
+
return new Date(clock).toISOString();
|
|
31
|
+
};
|
|
32
|
+
const emit = (type: string, minutes: number, objectIds: string[]) => {
|
|
33
|
+
eventId += 1;
|
|
34
|
+
events.push({
|
|
35
|
+
id: `e${eventId}`,
|
|
36
|
+
type,
|
|
37
|
+
time: at(minutes),
|
|
38
|
+
relationships: objectIds.map((objectId) => ({ objectId })),
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (let o = 1; o <= orders; o += 1) {
|
|
43
|
+
const order = `o${o}`;
|
|
44
|
+
const items = Array.from({ length: 1 + (o % 3) }, (_, i) => `o${o}-i${i + 1}`);
|
|
45
|
+
const pkg = `o${o}-p1`;
|
|
46
|
+
objects.push({ id: order, type: "order" });
|
|
47
|
+
for (const item of items) objects.push({ id: item, type: "item" });
|
|
48
|
+
objects.push({ id: pkg, type: "package" });
|
|
49
|
+
|
|
50
|
+
emit("Place Order", 30, [order, ...items]);
|
|
51
|
+
emit("Confirm Payment", 20, [order]);
|
|
52
|
+
items.forEach((item, index) => {
|
|
53
|
+
emit("Pick Item", 15, [item]);
|
|
54
|
+
if ((o + index) % 3 === 0) emit("Quality Check", 10, [item]);
|
|
55
|
+
if ((o + index) % 5 === 0) {
|
|
56
|
+
emit("Repick", 10, [item]);
|
|
57
|
+
emit("Pick Item", 10, [item]);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
emit("Pack", 25, [order, ...items, pkg]);
|
|
61
|
+
emit("Ship", 60, [order, pkg]);
|
|
62
|
+
emit("Deliver", 240, [pkg]);
|
|
63
|
+
if (o % 2 === 0) emit("Send Invoice", 30, [order]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
objectTypes: [{ name: "order" }, { name: "item" }, { name: "package" }],
|
|
68
|
+
objects,
|
|
69
|
+
events,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function discover(document: OcelJson, objectTypes?: string[]): ObjectCentricGraph {
|
|
74
|
+
const result = fromOcel(document, objectTypes ? { objectTypes } : undefined);
|
|
75
|
+
if (!result.ok) throw new Error(result.errors.map((error) => error.message).join("; "));
|
|
76
|
+
return discoverObjectCentricGraph(result.logs, { objectTypes: result.objectTypes });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const deliveryLog = orderToDeliveryOcel();
|
|
80
|
+
const twoTypes = discover(deliveryLog, ["order", "item"]);
|
|
81
|
+
const threeTypes = discover(deliveryLog);
|
|
82
|
+
const sample = discover(OCEL_SAMPLE);
|
|
83
|
+
|
|
84
|
+
const metric = { node: "absolute_case", edge: "absolute" } as const;
|
|
85
|
+
|
|
86
|
+
const meta = {
|
|
87
|
+
title: "Process/ProcessMap/Object-centric",
|
|
88
|
+
component: ProcessMap,
|
|
89
|
+
parameters: {
|
|
90
|
+
layout: "fullscreen",
|
|
91
|
+
docs: {
|
|
92
|
+
description: {
|
|
93
|
+
component:
|
|
94
|
+
"`ProcessMap`’s object-centric mode (RM-066), fed by `fromOcel` and " +
|
|
95
|
+
"`discoverObjectCentricGraph`. An activity shared by several object types is ONE " +
|
|
96
|
+
"node carrying a chip per type — a chart-token square, the type’s printed " +
|
|
97
|
+
"two-letter code and its object count, named for assistive technology. Edges are " +
|
|
98
|
+
"never merged across types: each type draws its own, side by side, in its own " +
|
|
99
|
+
"colour, and every pill is prefixed with the type’s code, so no type is told " +
|
|
100
|
+
"apart by colour alone.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
decorators: [
|
|
105
|
+
(Story) => (
|
|
106
|
+
<div className="h-[36rem] w-full bg-background">
|
|
107
|
+
<Story />
|
|
108
|
+
</div>
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
} satisfies Meta<typeof ProcessMap>;
|
|
112
|
+
export default meta;
|
|
113
|
+
type Story = StoryObj<typeof meta>;
|
|
114
|
+
|
|
115
|
+
/** Orders and their items: `Place Order` and `Pack` are shared, picking is item-only. */
|
|
116
|
+
export const TwoObjectTypes: Story = {
|
|
117
|
+
args: { objectCentric: twoTypes, metric },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Orders, items and packages — `Pack` joins all three, `Ship` joins orders and packages. */
|
|
121
|
+
export const ThreeObjectTypes: Story = {
|
|
122
|
+
args: { objectCentric: threeTypes, metric },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The hand-verifiable fixture (`core/fixtures/ocel-sample.ts`): `Pack` is referenced by an
|
|
127
|
+
* order, two items and a package, so its node shows three chips.
|
|
128
|
+
*/
|
|
129
|
+
export const SharedActivityMerge: Story = {
|
|
130
|
+
args: { objectCentric: sample, metric },
|
|
131
|
+
play: async ({ canvasElement }) => {
|
|
132
|
+
const chips = await waitFor(() => {
|
|
133
|
+
const node = canvasElement.querySelector<HTMLElement>(
|
|
134
|
+
'.react-flow__node[data-id="Pack"] [data-slot="process-activity-node-object-types"]',
|
|
135
|
+
);
|
|
136
|
+
expect(node).not.toBeNull();
|
|
137
|
+
return [...node!.querySelectorAll<HTMLElement>("[data-object-type]")];
|
|
138
|
+
});
|
|
139
|
+
await expect(chips).toHaveLength(3);
|
|
140
|
+
const names = chips.map((chip) => chip.getAttribute("aria-label"));
|
|
141
|
+
await expect(new Set(names).size).toBe(3);
|
|
142
|
+
// Never colour-only: every chip prints its type's code.
|
|
143
|
+
for (const chip of chips) await expect(chip.textContent?.trim()).toMatch(/^\S{2}\s*\d+$/);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function PerTypeAbstractionDemo({ graph }: { graph: ObjectCentricGraph }) {
|
|
148
|
+
const [abstraction, setAbstraction] = useState<AbstractionOptions>({ activities: 1, paths: 1 });
|
|
149
|
+
const [perType, setPerType] = useState<Record<string, ObjectTypeAbstraction>>(() =>
|
|
150
|
+
Object.fromEntries(graph.objectTypes.map((type) => [type, { activities: 1, paths: 1 }])),
|
|
151
|
+
);
|
|
152
|
+
const abstracted = useMemo(() => abstractObjectCentricGraph(graph, perType), [graph, perType]);
|
|
153
|
+
const flat = useMemo(() => objectCentricProcessGraph(abstracted), [abstracted]);
|
|
154
|
+
const hidden = Object.values(abstracted.hiddenByType).reduce(
|
|
155
|
+
(sum, entry) => ({
|
|
156
|
+
activities: sum.activities + entry.activities,
|
|
157
|
+
paths: sum.paths + entry.paths,
|
|
158
|
+
}),
|
|
159
|
+
{ activities: 0, paths: 0 },
|
|
160
|
+
);
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex size-full gap-4 p-4">
|
|
163
|
+
<AbstractionControls
|
|
164
|
+
className="w-72 shrink-0"
|
|
165
|
+
abstraction={abstraction}
|
|
166
|
+
onAbstractionChange={(next) => setAbstraction((current) => ({ ...current, ...next }))}
|
|
167
|
+
graph={flat}
|
|
168
|
+
hiddenCounts={hidden}
|
|
169
|
+
perType={perType}
|
|
170
|
+
onPerTypeChange={setPerType}
|
|
171
|
+
/>
|
|
172
|
+
<div className="min-w-0 flex-1">
|
|
173
|
+
<ProcessMap objectCentric={abstracted} metric={metric} />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A global slider pair plus one collapsible, linkable pair per object type. Unlinking
|
|
181
|
+
* `item` and abstracting it on its own thins the item-coloured edges while the order
|
|
182
|
+
* edges stay exactly as they were.
|
|
183
|
+
*/
|
|
184
|
+
export const PerTypeAbstraction: Story = {
|
|
185
|
+
args: { objectCentric: twoTypes, metric },
|
|
186
|
+
render: () => <PerTypeAbstractionDemo graph={twoTypes} />,
|
|
187
|
+
play: async ({ canvasElement }) => {
|
|
188
|
+
const canvas = within(canvasElement);
|
|
189
|
+
const count = (type: string) =>
|
|
190
|
+
canvasElement.querySelectorAll(
|
|
191
|
+
`[data-slot="process-transition-edge"][data-object-type="${type}"]`,
|
|
192
|
+
).length;
|
|
193
|
+
await waitFor(() => expect(count("item")).toBeGreaterThan(0));
|
|
194
|
+
const orderBefore = count("order");
|
|
195
|
+
const itemBefore = count("item");
|
|
196
|
+
|
|
197
|
+
await userEvent.click(canvas.getByRole("button", { name: "Link item to the global sliders" }));
|
|
198
|
+
await userEvent.click(canvas.getByRole("button", { name: /^item/, expanded: false }));
|
|
199
|
+
const activities = canvas.getByRole("slider", { name: "item activities" });
|
|
200
|
+
activities.focus();
|
|
201
|
+
await userEvent.keyboard("{Home}");
|
|
202
|
+
const paths = canvas.getByRole("slider", { name: "item paths" });
|
|
203
|
+
paths.focus();
|
|
204
|
+
await userEvent.keyboard("{Home}");
|
|
205
|
+
|
|
206
|
+
await waitFor(() => expect(count("item")).toBeLessThan(itemBefore));
|
|
207
|
+
await expect(count("order")).toBe(orderBefore);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** No graph yet — the same whole-region loading panel as the single-case map. */
|
|
212
|
+
export const Loading: Story = {
|
|
213
|
+
args: { objectCentric: threeTypes, metric, loading: true },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** A log whose projections carry no events. */
|
|
217
|
+
export const Empty: Story = {
|
|
218
|
+
args: { objectCentric: discoverObjectCentricGraph({ order: { events: [] } }), metric },
|
|
219
|
+
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
waitForSettledCanvas,
|
|
12
12
|
} from "@elabs-ai/components-flow/test";
|
|
13
13
|
import { abstractGraph } from "../core/abstract-graph";
|
|
14
|
+
import { activityColorScale } from "../core/activity-color-scale";
|
|
14
15
|
import { detectRework } from "../core/detect-rework";
|
|
15
16
|
import { discoverGraph } from "../core/discover-graph";
|
|
16
17
|
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
@@ -239,6 +240,23 @@ export const Frequency: Story = {
|
|
|
239
240
|
await expectWellFramedCanvas(canvasElement);
|
|
240
241
|
expectClickableZoomControls(canvasElement);
|
|
241
242
|
|
|
243
|
+
// #354 — a focused label pill must announce the real transition (both endpoints +
|
|
244
|
+
// the measure), never the bare printed count: "Approve Order → Reserve Stock" and
|
|
245
|
+
// "Check Credit → Approve Order" both print "213" (same edge metric, different
|
|
246
|
+
// endpoints), so the bare number is not even locally unique, let alone informative.
|
|
247
|
+
// `toHaveAccessibleName`/`getByRole` with an exact string, never a regex — a regex
|
|
248
|
+
// still matches a truncated or polluted name and the bug survives.
|
|
249
|
+
await expect(
|
|
250
|
+
canvas.getByRole("button", {
|
|
251
|
+
name: "Transition from Check Credit to Approve Order, Transitions 213",
|
|
252
|
+
}),
|
|
253
|
+
).toBeInTheDocument();
|
|
254
|
+
const pillNames = [
|
|
255
|
+
...canvasElement.querySelectorAll<HTMLElement>('[data-slot="edge-label-pill"]'),
|
|
256
|
+
].map((pill) => pill.getAttribute("aria-label"));
|
|
257
|
+
expect(pillNames.every((name) => Boolean(name))).toBe(true);
|
|
258
|
+
expect(new Set(pillNames).size).toBe(pillNames.length);
|
|
259
|
+
|
|
242
260
|
// Click-to-select: a node picks itself, and the rest of the graph reads as excluded.
|
|
243
261
|
const nodes = canvasElement.querySelectorAll<HTMLElement>(
|
|
244
262
|
'[data-slot="process-activity-node"]',
|
|
@@ -280,6 +298,27 @@ export const Rework: Story = {
|
|
|
280
298
|
},
|
|
281
299
|
};
|
|
282
300
|
|
|
301
|
+
/**
|
|
302
|
+
* With a shared `colorScale` (RM-054): each activity shows a small identity swatch beside
|
|
303
|
+
* its meter — the same colour `VariantExplorer` paints that activity's chips with when it
|
|
304
|
+
* gets the same scale. The swatch is a mark, never the card fill, so it does not compete
|
|
305
|
+
* with the metric reading.
|
|
306
|
+
*/
|
|
307
|
+
export const WithActivityColors: Story = {
|
|
308
|
+
args: {
|
|
309
|
+
graph,
|
|
310
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
311
|
+
colorScale: activityColorScale(graph),
|
|
312
|
+
},
|
|
313
|
+
play: async ({ canvasElement }) => {
|
|
314
|
+
await waitFor(() =>
|
|
315
|
+
expect(
|
|
316
|
+
canvasElement.querySelectorAll('[data-slot="process-activity-node-accent"]').length,
|
|
317
|
+
).toBe(graph.activities.length),
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
283
322
|
/** Half the activities and a third of the paths kept — `/core`'s `abstractGraph`. */
|
|
284
323
|
export const Abstracted: Story = {
|
|
285
324
|
args: {
|
|
@@ -555,6 +594,31 @@ export const LeftToRight: Story = {
|
|
|
555
594
|
},
|
|
556
595
|
};
|
|
557
596
|
|
|
597
|
+
// elkjs adapter — RM-067
|
|
598
|
+
/**
|
|
599
|
+
* The same graph laid out by ELK (`layoutEngine="elk"`) instead of dagre. elkjs is loaded
|
|
600
|
+
* lazily on first layout; the map, its framing and its keyboard order behave exactly as
|
|
601
|
+
* the dagre default does.
|
|
602
|
+
*/
|
|
603
|
+
export const ElkLayout: Story = {
|
|
604
|
+
args: {
|
|
605
|
+
graph,
|
|
606
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
607
|
+
direction: "LR",
|
|
608
|
+
layoutEngine: "elk",
|
|
609
|
+
},
|
|
610
|
+
play: async ({ canvasElement }) => {
|
|
611
|
+
await waitFor(
|
|
612
|
+
() =>
|
|
613
|
+
expect(
|
|
614
|
+
canvasElement.querySelectorAll('[data-slot="process-activity-node"]').length,
|
|
615
|
+
).toBeGreaterThan(0),
|
|
616
|
+
{ timeout: 10_000 },
|
|
617
|
+
);
|
|
618
|
+
await expectWellFramedCanvas(canvasElement);
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
|
|
558
622
|
/**
|
|
559
623
|
* The accessible twin required for a graph that cannot be read as a picture. It renders
|
|
560
624
|
* from the same model as the canvas, so the numbers are identical by construction.
|