@elabs-ai/components-process 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Pure model behind `DottedChart` (RM-059) — no React, no canvas, so the row order, the
3
+ * time transform and the brush enumeration are fixture-testable on their own.
4
+ *
5
+ * A dotted chart puts one ROW per case and one DOT per event. `computeDots` groups a log by
6
+ * case (through `/core`'s `normalizeLog`, so lifecycle pairs and the three timestamp
7
+ * encodings are resolved exactly once), orders the rows by `sort`, and places every event
8
+ * on the x axis according to `x`:
9
+ *
10
+ * - `absolute` — the event's own start time (epoch ms).
11
+ * - `relative` — milliseconds since the case's first event.
12
+ * - `relative_day` — time of day, `0 … 24 h` (UTC), so daily rhythms line up.
13
+ * - `relative_week` — time of week, `0 … 7 d`, weeks starting Monday 00:00 (UTC).
14
+ *
15
+ * The two cyclic modes use UTC on purpose: a chart whose dots move when the reviewer's
16
+ * machine changes time zone is not a deterministic fixture.
17
+ */
18
+ import {
19
+ ACTIVITY_COLOR_SLOTS,
20
+ ACTIVITY_OTHER_TOKEN,
21
+ type ActivityColor,
22
+ } from "../core/activity-color-scale";
23
+ import { asNormalizedLog, type AnyLog, type NormalizedEvent } from "../core/event-log";
24
+
25
+ /** How the x axis places an event. */
26
+ export type DottedChartX = "absolute" | "relative" | "relative_day" | "relative_week";
27
+
28
+ /** How case rows are ordered, top to bottom. Every order is ascending and stable. */
29
+ export type DottedChartSort = "start" | "end" | "duration" | "start_day";
30
+
31
+ const DAY_MS = 86_400_000;
32
+ const WEEK_MS = 7 * DAY_MS;
33
+ /** 1970-01-01 was a Thursday; shifting by 3 days makes weeks start on Monday. */
34
+ const MONDAY_OFFSET_MS = 3 * DAY_MS;
35
+
36
+ /** One event, placed on the chart. */
37
+ export interface DottedChartDot {
38
+ kind: "dot";
39
+ caseId: string;
40
+ /** Index of this dot's row in {@link DottedChartModel.rows}. */
41
+ rowIndex: number;
42
+ /** The normalized event (activity, start, end, resource, attributes). */
43
+ event: NormalizedEvent;
44
+ /** The event's position on the x axis, in the unit `x` implies (always milliseconds). */
45
+ x: number;
46
+ }
47
+
48
+ /** One case, placed on the chart. */
49
+ export interface DottedChartRow {
50
+ kind: "row";
51
+ caseId: string;
52
+ /** Position in `rows` — top row is `0`. */
53
+ index: number;
54
+ start: number;
55
+ end: number;
56
+ duration: number;
57
+ eventCount: number;
58
+ firstActivity: string;
59
+ lastActivity: string;
60
+ /** This row's dots are `dots.slice(dotStart, dotEnd)`, in time order. */
61
+ dotStart: number;
62
+ dotEnd: number;
63
+ }
64
+
65
+ /** What {@link computeDots} returns. */
66
+ export interface DottedChartModel {
67
+ x: DottedChartX;
68
+ sort: DottedChartSort;
69
+ rows: DottedChartRow[];
70
+ /** Every dot, grouped by row in `rows` order, each row's dots in time order. */
71
+ dots: DottedChartDot[];
72
+ /** The x extent to scale against. Never zero-width. */
73
+ domain: [number, number];
74
+ }
75
+
76
+ export interface ComputeDotsOptions {
77
+ x?: DottedChartX;
78
+ sort?: DottedChartSort;
79
+ }
80
+
81
+ function timeOfDay(ms: number): number {
82
+ return ((ms % DAY_MS) + DAY_MS) % DAY_MS;
83
+ }
84
+
85
+ function timeOfWeek(ms: number): number {
86
+ const shifted = ms + MONDAY_OFFSET_MS;
87
+ return ((shifted % WEEK_MS) + WEEK_MS) % WEEK_MS;
88
+ }
89
+
90
+ function placeX(x: DottedChartX, at: number, caseStart: number): number {
91
+ switch (x) {
92
+ case "relative":
93
+ return at - caseStart;
94
+ case "relative_day":
95
+ return timeOfDay(at);
96
+ case "relative_week":
97
+ return timeOfWeek(at);
98
+ default:
99
+ return at;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build the dotted-chart model for a log.
105
+ *
106
+ * Deterministic: the same log and options always yield the same rows, dots and domain.
107
+ * Events whose start could not be parsed are left out (they have no place on a time axis);
108
+ * a case left with no placeable event is left out too.
109
+ */
110
+ export function computeDots(log: AnyLog, options: ComputeDotsOptions = {}): DottedChartModel {
111
+ const x = options.x ?? "absolute";
112
+ const sort = options.sort ?? "start";
113
+ const normalized = asNormalizedLog(log);
114
+
115
+ const cases = normalized.cases
116
+ .map((kase) => ({
117
+ kase,
118
+ events: kase.events.filter((event) => Number.isFinite(event.start)),
119
+ }))
120
+ .filter((entry) => entry.events.length > 0);
121
+
122
+ const keyOf = (entry: (typeof cases)[number]): number => {
123
+ const first = entry.events[0] as NormalizedEvent;
124
+ const last = entry.events[entry.events.length - 1] as NormalizedEvent;
125
+ const start = first.start;
126
+ const end = Math.max(last.end, entry.kase.end);
127
+ switch (sort) {
128
+ case "end":
129
+ return end;
130
+ case "duration":
131
+ return end - start;
132
+ case "start_day":
133
+ return timeOfDay(start);
134
+ default:
135
+ return start;
136
+ }
137
+ };
138
+
139
+ // Decorate-sort-undecorate keeps the sort stable and each key computed once.
140
+ const ordered = cases
141
+ .map((entry, position) => ({ entry, position, key: keyOf(entry) }))
142
+ .sort((a, b) => a.key - b.key || a.position - b.position);
143
+
144
+ const rows: DottedChartRow[] = [];
145
+ const dots: DottedChartDot[] = [];
146
+ let min = Number.POSITIVE_INFINITY;
147
+ let max = Number.NEGATIVE_INFINITY;
148
+
149
+ for (const { entry } of ordered) {
150
+ const { kase, events } = entry;
151
+ const rowIndex = rows.length;
152
+ const first = events[0] as NormalizedEvent;
153
+ const last = events[events.length - 1] as NormalizedEvent;
154
+ const start = first.start;
155
+ const end = Number.isFinite(kase.end) ? Math.max(kase.end, start) : start;
156
+ const dotStart = dots.length;
157
+ for (const event of events) {
158
+ const value = placeX(x, event.start, start);
159
+ if (value < min) min = value;
160
+ if (value > max) max = value;
161
+ dots.push({ kind: "dot", caseId: kase.caseId, rowIndex, event, x: value });
162
+ }
163
+ rows.push({
164
+ kind: "row",
165
+ caseId: kase.caseId,
166
+ index: rowIndex,
167
+ start,
168
+ end,
169
+ duration: end - start,
170
+ eventCount: events.length,
171
+ firstActivity: first.activity,
172
+ lastActivity: last.activity,
173
+ dotStart,
174
+ dotEnd: dots.length,
175
+ });
176
+ }
177
+
178
+ let domain: [number, number];
179
+ if (x === "relative_day") domain = [0, DAY_MS];
180
+ else if (x === "relative_week") domain = [0, WEEK_MS];
181
+ else if (!Number.isFinite(min)) domain = [0, 1];
182
+ else if (min === max) domain = [min - 1, max + 1];
183
+ else domain = [min, max];
184
+
185
+ return { x, sort, rows, dots, domain };
186
+ }
187
+
188
+ /**
189
+ * Case ids of the rows a brush covers, in `rows` order.
190
+ *
191
+ * A row is covered when its index is in `[firstRow, lastRow]` (inclusive, either order) AND
192
+ * at least one of its dots lies in the x range `[x0, x1]` (inclusive, either order). The
193
+ * x test is what makes a brush over an empty stretch of time select nothing.
194
+ */
195
+ export function casesInBrush(
196
+ model: DottedChartModel,
197
+ rowRange: readonly [number, number],
198
+ xRange: readonly [number, number],
199
+ ): string[] {
200
+ const firstRow = Math.max(0, Math.min(rowRange[0], rowRange[1]));
201
+ const lastRow = Math.min(model.rows.length - 1, Math.max(rowRange[0], rowRange[1]));
202
+ const lo = Math.min(xRange[0], xRange[1]);
203
+ const hi = Math.max(xRange[0], xRange[1]);
204
+ const ids: string[] = [];
205
+ for (let r = firstRow; r <= lastRow; r += 1) {
206
+ const row = model.rows[r] as DottedChartRow;
207
+ for (let d = row.dotStart; d < row.dotEnd; d += 1) {
208
+ const value = (model.dots[d] as DottedChartDot).x;
209
+ if (value >= lo && value <= hi) {
210
+ ids.push(row.caseId);
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ return ids;
216
+ }
217
+
218
+ /** One entry of a {@link rankCategoryColors} legend. */
219
+ export interface DottedChartCategory extends ActivityColor {
220
+ key: string;
221
+ /** How many dots carry this category. */
222
+ count: number;
223
+ }
224
+
225
+ /**
226
+ * Colour arbitrary category keys (resources, a caller's own grouping) with the SAME budget
227
+ * `activityColorScale` uses: the {@link ACTIVITY_COLOR_SLOTS} most frequent keys take
228
+ * `--chart-1 …`, every other key shares {@link ACTIVITY_OTHER_TOKEN} and is flagged
229
+ * `pattern: "other"`. Ranked by count descending, ties by key. Returns token names only.
230
+ */
231
+ export function rankCategoryColors(keys: readonly string[]): DottedChartCategory[] {
232
+ const counts = new Map<string, number>();
233
+ for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
234
+ return [...counts.entries()]
235
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
236
+ .map(([key, count], index) =>
237
+ index < ACTIVITY_COLOR_SLOTS
238
+ ? { key, count, token: `--chart-${index + 1}` }
239
+ : { key, count, token: ACTIVITY_OTHER_TOKEN, pattern: "other" as const },
240
+ );
241
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Every user-visible string `DottedChart` (RM-059) renders. `{name}` placeholders are
3
+ * filled at render; override any subset through the component's `labels` prop to localize.
4
+ */
5
+
6
+ export interface DottedChartLabels {
7
+ /** Accessible name of the plot and its keyboard cursor. */
8
+ chart: string;
9
+ /** `{cases}`, `{events}`, `{from}`, `{to}`, `{sort}`, `{colorBy}` — the parallel summary. */
10
+ summary: string;
11
+ sortStart: string;
12
+ sortEnd: string;
13
+ sortDuration: string;
14
+ sortStartDay: string;
15
+ colorByActivity: string;
16
+ colorByResource: string;
17
+ colorByCustom: string;
18
+ /** `{caseId}`, `{events}`, `{duration}`, `{activities}` — spoken for a focused case row. */
19
+ row: string;
20
+ /** `{activity}`, `{time}`, `{caseId}` — spoken/shown for one event. */
21
+ dot: string;
22
+ /** `{resource}` — appended to a dot tooltip when the event has a resource. */
23
+ dotResource: string;
24
+ eventsOne: string;
25
+ eventsOther: string;
26
+ casesOne: string;
27
+ casesOther: string;
28
+ /** `{count}` more activities past the spoken prefix of a long trace. */
29
+ moreActivities: string;
30
+ /** `{cases}` — visible count of the current selection. */
31
+ selectionCount: string;
32
+ axisAbsolute: string;
33
+ axisRelative: string;
34
+ axisDay: string;
35
+ axisWeek: string;
36
+ /** The legend entry every category past the colour budget shares. */
37
+ other: string;
38
+ /** Category key for an event that carries no resource. */
39
+ noResource: string;
40
+ tableCaption: string;
41
+ columnCase: string;
42
+ columnFirstActivity: string;
43
+ columnLastActivity: string;
44
+ columnStart: string;
45
+ columnEnd: string;
46
+ columnDuration: string;
47
+ columnEvents: string;
48
+ columnState: string;
49
+ selected: string;
50
+ empty: string;
51
+ emptyBody: string;
52
+ }
53
+
54
+ /** The shipped English labels. */
55
+ export const DOTTED_CHART_DEFAULT_LABELS: Readonly<DottedChartLabels> = Object.freeze({
56
+ chart: "Dotted chart — one row per case, one dot per event",
57
+ summary:
58
+ "{cases} and {events}, from {from} to {to}. One row per case, sorted by {sort}; one dot per event, coloured by {colorBy}.",
59
+ sortStart: "case start",
60
+ sortEnd: "case end",
61
+ sortDuration: "case duration",
62
+ sortStartDay: "time of day the case started",
63
+ colorByActivity: "activity",
64
+ colorByResource: "resource",
65
+ colorByCustom: "category",
66
+ row: "Case {caseId}, {events}, {duration}: {activities}",
67
+ dot: "{activity} at {time}, case {caseId}",
68
+ dotResource: "by {resource}",
69
+ eventsOne: "{count} event",
70
+ eventsOther: "{count} events",
71
+ casesOne: "{count} case",
72
+ casesOther: "{count} cases",
73
+ moreActivities: "and {count} more",
74
+ selectionCount: "{cases} selected",
75
+ axisAbsolute: "Time (UTC)",
76
+ axisRelative: "Time since case start",
77
+ axisDay: "Time of day (UTC)",
78
+ axisWeek: "Time of week (UTC)",
79
+ other: "Other",
80
+ noResource: "No resource",
81
+ tableCaption: "Cases — first and last activity, start, end, duration and event count",
82
+ columnCase: "Case",
83
+ columnFirstActivity: "First activity",
84
+ columnLastActivity: "Last activity",
85
+ columnStart: "Start",
86
+ columnEnd: "End",
87
+ columnDuration: "Duration",
88
+ columnEvents: "Events",
89
+ columnState: "State",
90
+ selected: "selected",
91
+ empty: "No events",
92
+ emptyBody: "No cases match the current filters.",
93
+ });
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useCallback, useState } from "react";
3
+ import { expect, fn, userEvent, waitFor, within } from "storybook/test";
4
+ import { activityColorScale } from "../core/activity-color-scale";
5
+ import { discoverGraph } from "../core/discover-graph";
6
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
+ import type { EventLog } from "../core/types";
8
+ import { computeDots } from "./compute-dots";
9
+ import { DottedChart, type DottedChartProps } from "./dotted-chart";
10
+
11
+ const log = generateSyntheticLog({ cases: 300, seed: 42 });
12
+ const colorScale = activityColorScale(discoverGraph(log));
13
+ const rows = computeDots(log).rows;
14
+
15
+ const HOUR = 3_600_000;
16
+ const T0 = Date.UTC(2026, 0, 5, 9);
17
+ /** Three short cases — small enough that a play function can aim at a known dot. */
18
+ const tinyLog: EventLog = {
19
+ events: [
20
+ { caseId: "order-1", activity: "Create Order", timestamp: T0 },
21
+ { caseId: "order-1", activity: "Ship Order", timestamp: T0 + 6 * HOUR },
22
+ { caseId: "order-2", activity: "Create Order", timestamp: T0 + 2 * HOUR },
23
+ { caseId: "order-2", activity: "Ship Order", timestamp: T0 + 9 * HOUR },
24
+ { caseId: "order-3", activity: "Create Order", timestamp: T0 + 4 * HOUR },
25
+ { caseId: "order-3", activity: "Cancel Order", timestamp: T0 + 12 * HOUR },
26
+ ],
27
+ };
28
+
29
+ /** Owns the selection a playbook would own, so the stories are interactive. */
30
+ function Stateful({
31
+ initialSelected = [],
32
+ onSelect,
33
+ ...props
34
+ }: DottedChartProps & { initialSelected?: string[] }) {
35
+ const [selected, setSelected] = useState<string[]>(initialSelected);
36
+ const handleSelect = useCallback(
37
+ (ids: string[]) => {
38
+ setSelected(ids);
39
+ onSelect?.(ids);
40
+ },
41
+ [onSelect],
42
+ );
43
+ return <DottedChart {...props} selectedCaseIds={selected} onSelect={handleSelect} />;
44
+ }
45
+
46
+ const meta = {
47
+ title: "Process/DottedChart",
48
+ component: DottedChart,
49
+ tags: ["autodocs"],
50
+ parameters: {
51
+ layout: "padded",
52
+ // `color` here picks a COLOURING STRATEGY ("activity" | "resource" | a
53
+ // function), not a CSS colour. The controls colour matcher is keyed on the
54
+ // prop NAME, and it runs (and warns) before any argTypes enhancer can veto
55
+ // it, so the opt-out has to be the matcher itself: a pattern no prop name
56
+ // matches. Without this the props table offered a hex picker whose value the
57
+ // component cannot use (2026-09-17 review §A5).
58
+ controls: { matchers: { color: /^$/ } },
59
+ docs: {
60
+ description: {
61
+ component:
62
+ "One row per case, one dot per event, coloured by activity with the same scale " +
63
+ "`ProcessMap` and `VariantExplorer` read. Built on the charts canvas mark layer inside " +
64
+ "`ChartFrame`, so 100k dots stay one canvas with one keyboard cursor and a spoken " +
65
+ "summary. Click a dot or press Enter on a row to select a case; drag a rectangle to " +
66
+ 'emit a `{ kind: "cases" }` filter intent. The chart never filters itself.',
67
+ },
68
+ },
69
+ },
70
+ args: {
71
+ log,
72
+ colorScale,
73
+ title: "Order-to-cash cases over time",
74
+ description: "Each row is a case, each dot an event, coloured by activity.",
75
+ onSelect: fn(),
76
+ onFilterIntent: fn(),
77
+ },
78
+ render: (args) => <Stateful {...args} />,
79
+ } satisfies Meta<typeof DottedChart>;
80
+ export default meta;
81
+ type Story = StoryObj<typeof meta>;
82
+
83
+ /** Absolute time on the x axis, rows sorted by case start (the defaults). */
84
+ export const Absolute: Story = {
85
+ play: async ({ canvasElement }) => {
86
+ const canvas = within(canvasElement);
87
+ await expect(
88
+ await canvas.findByText(new RegExp(`^${rows.length} cases and`)),
89
+ ).toBeInTheDocument();
90
+ await expect(canvasElement.querySelector('[data-slot="canvas-layer-surface"]')).not.toBeNull();
91
+ },
92
+ };
93
+
94
+ /** Every case starts at zero — compare case shapes, not calendar dates. */
95
+ export const RelativeToStart: Story = {
96
+ args: { x: "relative" },
97
+ };
98
+
99
+ /** Shortest cases on top; the long tail collects at the bottom. */
100
+ export const SortByDuration: Story = {
101
+ args: { sort: "duration" },
102
+ play: async ({ canvasElement, args }) => {
103
+ const canvas = within(canvasElement);
104
+ const sorted = computeDots(log, { sort: "duration" }).rows;
105
+ const cursor = await canvas.findByRole("button", { name: /dotted chart/i });
106
+ cursor.focus();
107
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}");
108
+ await waitFor(() =>
109
+ expect(
110
+ canvasElement.querySelector('[data-slot="canvas-layer-cursor-status"]'),
111
+ ).toHaveTextContent(`Case ${sorted[2]!.caseId},`),
112
+ );
113
+ await userEvent.keyboard("{Enter}");
114
+ await waitFor(() => expect(args.onSelect).toHaveBeenCalledWith([sorted[2]!.caseId]));
115
+ await expect(await canvas.findByText("1 case selected")).toBeInTheDocument();
116
+ },
117
+ };
118
+
119
+ /** Colour by who executed each event; the same colour budget as activities. */
120
+ export const ColorByResource: Story = {
121
+ args: { color: "resource", title: "Work by resource over time" },
122
+ };
123
+
124
+ /** Ten cases selected — everything else dims, and a text count states the selection. */
125
+ export const WithSelection: Story = {
126
+ render: (args) => (
127
+ <Stateful {...args} initialSelected={rows.slice(0, 10).map((row) => row.caseId)} />
128
+ ),
129
+ play: async ({ canvasElement }) => {
130
+ const canvas = within(canvasElement);
131
+ await expect(await canvas.findByText("10 cases selected")).toBeInTheDocument();
132
+ },
133
+ };
134
+
135
+ /** Click a dot to select its case. */
136
+ export const ClickToSelect: Story = {
137
+ args: { log: tinyLog, colorScale: undefined, title: "Three orders", height: 240 },
138
+ play: async ({ canvasElement, args }) => {
139
+ const canvas = within(canvasElement);
140
+ await canvas.findByRole("button", { name: /dotted chart/i });
141
+ const surface = canvasElement.querySelector(
142
+ '[data-slot="canvas-layer-surface"]',
143
+ ) as HTMLCanvasElement;
144
+ const rect = surface.getBoundingClientRect();
145
+ // order-1's first event is the earliest in the log: the left inset, top row centre.
146
+ const inset = 6;
147
+ const rowHeight = (rect.height - 2 * inset) / 3;
148
+ await userEvent.pointer([
149
+ {
150
+ keys: "[MouseLeft]",
151
+ target: surface,
152
+ coords: { clientX: rect.left + inset, clientY: rect.top + inset + rowHeight / 2 },
153
+ },
154
+ ]);
155
+ await waitFor(() => expect(args.onSelect).toHaveBeenCalledWith(["order-1"]));
156
+ await expect(await canvas.findByText("1 case selected")).toBeInTheDocument();
157
+ },
158
+ };
159
+
160
+ /** The accessible twin: every case as a table row. */
161
+ export const TableView: Story = {
162
+ args: { log: tinyLog, colorScale: undefined, tableView: true, title: "Three orders" },
163
+ play: async ({ canvasElement }) => {
164
+ const canvas = within(canvasElement);
165
+ const table = await canvas.findByRole("table");
166
+ await expect(within(table).getAllByRole("row")).toHaveLength(4);
167
+ await expect(within(table).getByRole("cell", { name: "order-3" })).toBeInTheDocument();
168
+ },
169
+ };
170
+
171
+ /** No log yet — the frame’s skeleton. */
172
+ export const Loading: Story = {
173
+ args: { loading: true, log: { events: [] } },
174
+ };
175
+
176
+ /** The filters left no cases. */
177
+ export const Empty: Story = {
178
+ args: { log: { events: [] } },
179
+ play: async ({ canvasElement }) => {
180
+ await expect(await within(canvasElement).findByText("No events")).toBeInTheDocument();
181
+ },
182
+ };
@@ -0,0 +1,135 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
4
+ import type { EventLog } from "../core/types";
5
+ import { computeDots } from "./compute-dots";
6
+ import { DottedChart } from "./dotted-chart";
7
+
8
+ const PLOT = { width: 600, height: 300 };
9
+
10
+ // jsdom has no layout: give every element a fixed box so the plot measures and mounts
11
+ // its canvas layer. The canvas itself has no 2D context under jsdom, which is fine — these
12
+ // tests exercise the keyboard, brush and text channels, not pixels.
13
+ const originalRect = HTMLElement.prototype.getBoundingClientRect;
14
+ beforeAll(() => {
15
+ HTMLElement.prototype.getBoundingClientRect = () =>
16
+ ({
17
+ x: 0,
18
+ y: 0,
19
+ left: 0,
20
+ top: 0,
21
+ right: PLOT.width,
22
+ bottom: PLOT.height,
23
+ width: PLOT.width,
24
+ height: PLOT.height,
25
+ toJSON: () => ({}),
26
+ }) as DOMRect;
27
+ HTMLCanvasElement.prototype.getContext = (() => null) as never;
28
+ // jsdom ships no PointerEvent; without one `fireEvent.pointer*` drops clientX/pointerId.
29
+ if (typeof window.PointerEvent !== "function") {
30
+ class PointerEventShim extends MouseEvent {
31
+ pointerId: number;
32
+ constructor(type: string, init: PointerEventInit = {}) {
33
+ super(type, init);
34
+ this.pointerId = init.pointerId ?? 0;
35
+ }
36
+ }
37
+ window.PointerEvent = PointerEventShim as unknown as typeof PointerEvent;
38
+ }
39
+ });
40
+ afterAll(() => {
41
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
42
+ });
43
+ afterEach(cleanup);
44
+
45
+ const log: EventLog = generateSyntheticLog({ cases: 12, seed: 11 });
46
+ const rows = computeDots(log).rows;
47
+
48
+ function cursor() {
49
+ return screen.getByRole("button", { name: /dotted chart/i });
50
+ }
51
+
52
+ describe("DottedChart — rendering", () => {
53
+ it("states the case and event counts in the parallel summary", () => {
54
+ render(<DottedChart log={log} />);
55
+ expect(screen.getByText(new RegExp(`^${rows.length} cases and \\d+ events`))).toBeTruthy();
56
+ });
57
+
58
+ it("renders a legend entry for each activity present", () => {
59
+ const { container } = render(<DottedChart log={log} />);
60
+ const legend = container.querySelector('[data-slot="dotted-chart-legend"]');
61
+ expect(legend?.textContent).toContain(log.events[0]!.activity);
62
+ });
63
+
64
+ it("renders the empty panel when the log has no events", () => {
65
+ render(<DottedChart log={{ events: [] }} />);
66
+ expect(screen.getByText("No events")).toBeTruthy();
67
+ });
68
+
69
+ it("renders the table twin with one row per case", () => {
70
+ render(<DottedChart log={log} tableView />);
71
+ const table = screen.getByRole("table");
72
+ // header row + one row per case
73
+ expect(table.querySelectorAll("tr")).toHaveLength(rows.length + 1);
74
+ expect(screen.getByRole("columnheader", { name: "Duration" })).toBeTruthy();
75
+ });
76
+
77
+ it("marks selected cases in the table twin with text, not colour alone", () => {
78
+ render(<DottedChart log={log} tableView selectedCaseIds={[rows[0]!.caseId]} />);
79
+ expect(screen.getByRole("columnheader", { name: "State" })).toBeTruthy();
80
+ expect(screen.getByText("selected")).toBeTruthy();
81
+ });
82
+ });
83
+
84
+ describe("DottedChart — keyboard", () => {
85
+ it("moves by case row in sort order and selects with Enter", () => {
86
+ const onSelect = vi.fn();
87
+ render(<DottedChart log={log} sort="duration" onSelect={onSelect} />);
88
+ const sorted = computeDots(log, { sort: "duration" }).rows;
89
+ const button = cursor();
90
+ fireEvent.focus(button);
91
+ fireEvent.keyDown(button, { key: "ArrowDown" });
92
+ expect(screen.getByRole("status").textContent).toContain(`Case ${sorted[1]!.caseId}`);
93
+ fireEvent.click(button);
94
+ expect(onSelect).toHaveBeenCalledWith([sorted[1]!.caseId]);
95
+ });
96
+
97
+ it("extends a range with Shift+Arrow and commits it as one filter intent", () => {
98
+ const onSelect = vi.fn();
99
+ const onFilterIntent = vi.fn();
100
+ render(<DottedChart log={log} onSelect={onSelect} onFilterIntent={onFilterIntent} />);
101
+ const button = cursor();
102
+ fireEvent.focus(button);
103
+ fireEvent.keyDown(button, { key: "ArrowDown", shiftKey: true });
104
+ fireEvent.keyDown(button, { key: "ArrowDown", shiftKey: true });
105
+ const expected = rows.slice(0, 3).map((row) => row.caseId);
106
+ expect(onSelect).toHaveBeenLastCalledWith(expected);
107
+ fireEvent.click(button);
108
+ expect(onFilterIntent).toHaveBeenCalledTimes(1);
109
+ expect(onFilterIntent).toHaveBeenCalledWith({ kind: "cases", ids: expected });
110
+ });
111
+ });
112
+
113
+ describe("DottedChart — brush", () => {
114
+ it("emits one filter intent with every brushed case, in rows order", () => {
115
+ const big = generateSyntheticLog({ cases: 200, seed: 9 });
116
+ const bigRows = computeDots(big).rows;
117
+ const onSelect = vi.fn();
118
+ const onFilterIntent = vi.fn();
119
+ const { container } = render(
120
+ <DottedChart log={big} onSelect={onSelect} onFilterIntent={onFilterIntent} />,
121
+ );
122
+ const plot = container.querySelector('[data-slot="dotted-chart-plot"]') as HTMLElement;
123
+ fireEvent.pointerDown(plot, { button: 0, pointerId: 1, clientX: 0, clientY: 0 });
124
+ fireEvent.pointerMove(plot, { pointerId: 1, clientX: 300, clientY: 150 });
125
+ fireEvent.pointerMove(plot, { pointerId: 1, clientX: PLOT.width, clientY: PLOT.height });
126
+ expect(container.querySelector('[data-slot="dotted-chart-brush"]')).not.toBeNull();
127
+ fireEvent.pointerUp(plot, { pointerId: 1, clientX: PLOT.width, clientY: PLOT.height });
128
+
129
+ const expected = bigRows.map((row) => row.caseId);
130
+ expect(onFilterIntent).toHaveBeenCalledTimes(1);
131
+ expect(onFilterIntent).toHaveBeenCalledWith({ kind: "cases", ids: expected });
132
+ expect(onSelect).toHaveBeenCalledWith(expected);
133
+ expect(container.querySelector('[data-slot="dotted-chart-brush"]')).toBeNull();
134
+ });
135
+ });