@elabs-ai/components-process 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,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
- <div
144
- aria-hidden="true"
145
- data-slot="process-activity-node-meter"
146
- data-percent={percent}
147
- className="h-1.5 w-full overflow-hidden rounded-full bg-surface-muted transition-opacity duration-fast ease-standard motion-reduce:transition-none"
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
- className="h-full rounded-full transition-[width] duration-base ease-standard motion-reduce:transition-none"
152
- style={{ width: `${percent}%`, background: meterFill(data.saturation) }}
153
- />
154
- </div>
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 data-slot="process-activity-node-frame" style={isDimmed ? GHOST_FRAME_STYLE : undefined}>
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.