@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,841 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DottedChart — case rows × time, one dot per event, coloured by activity (RM-059, issue
5
+ * #208, analysis §4 R14; bupaR's `dotted_chart` is the reference).
6
+ *
7
+ * ## It composes; it never draws a primitive of its own
8
+ *
9
+ * The card, toolbar and CSV download are `@elabs-ai/components-charts`' `ChartFrame`; the
10
+ * marks are RM-046's `CanvasLayer` (one canvas for up to ~100k dots, a single keyboard
11
+ * cursor, a spatial-grid hit test, a parallel accessible summary); the legend is the charts
12
+ * `ChartLegend`; the accessible twin is `@elabs-ai/components-ui`'s `Table`. The only thing
13
+ * authored here is the `draw` callback that paints dots into the canvas the layer owns.
14
+ *
15
+ * ## Colour is never the only channel
16
+ *
17
+ * Activities take their hue from the SAME `ActivityColorScale` `ProcessMap` and
18
+ * `VariantExplorer` read, so an activity is one colour across the explorer. Every hue is
19
+ * also named: the legend prints each label, the tooltip and the keyboard cursor speak the
20
+ * activity, and `tableView` lists every case as text. The shared "other" bucket is drawn as
21
+ * a hollow ring rather than a filled dot, so it stays distinguishable in greyscale.
22
+ *
23
+ * ## It emits; it never filters
24
+ *
25
+ * Click a dot (or press Enter on a focused row) → `onSelect([caseId])`. Drag a rectangle →
26
+ * `onSelect(ids)` and `onFilterIntent({ kind: "cases", ids })` once, on release, with the
27
+ * covered case ids in row order. Shift+Arrow extends a keyboard range the same way; Enter
28
+ * then commits it as the filter intent. The chart itself never calls `filterLog`.
29
+ */
30
+ import {
31
+ forwardRef,
32
+ useCallback,
33
+ useMemo,
34
+ useRef,
35
+ useState,
36
+ type HTMLAttributes,
37
+ type KeyboardEvent as ReactKeyboardEvent,
38
+ type PointerEvent as ReactPointerEvent,
39
+ type ReactNode,
40
+ } from "react";
41
+ import {
42
+ CANVAS_LAYER_HIT_RADIUS,
43
+ CanvasLayer,
44
+ CHART_HAIRLINE_WIDTH,
45
+ ChartFrame,
46
+ ChartLegend,
47
+ canvasTokenColor,
48
+ createSpatialGrid,
49
+ type CanvasLayerRect,
50
+ type ChartFrameColumn,
51
+ } from "@elabs-ai/components-charts";
52
+ import {
53
+ StatePanel,
54
+ Table,
55
+ TableBody,
56
+ TableCaption,
57
+ TableCell,
58
+ TableHead,
59
+ TableHeader,
60
+ TableRow,
61
+ useLocale,
62
+ } from "@elabs-ai/components-ui";
63
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
64
+ import { activityColorScale, type ActivityColorScale } from "../core/activity-color-scale";
65
+ import { discoverGraph } from "../core/discover-graph";
66
+ import type { FilterSpec } from "../core/filter-log";
67
+ import type { EventLog, EventRow } from "../core/types";
68
+ import { formatDurationMs } from "../process-map/map-model";
69
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
70
+ import {
71
+ casesInBrush,
72
+ computeDots,
73
+ rankCategoryColors,
74
+ type DottedChartDot,
75
+ type DottedChartModel,
76
+ type DottedChartRow,
77
+ type DottedChartSort,
78
+ type DottedChartX,
79
+ } from "./compute-dots";
80
+ import { DOTTED_CHART_DEFAULT_LABELS, type DottedChartLabels } from "./dotted-chart-labels";
81
+ import { useElementSize } from "./use-element-size";
82
+
83
+ /** Dot radius in CSS pixels — a categorical marker, not an area encoding. */
84
+ export const DOTTED_CHART_DOT_RADIUS = 2;
85
+
86
+ /** Plot inset in CSS pixels, so a dot on the domain edge is not clipped. */
87
+ const PLOT_INSET = 6;
88
+ /** Opacity of dots outside the current selection. */
89
+ const DIMMED_ALPHA = 0.2;
90
+ /** A pointer drag shorter than this (CSS px) is a click, not a brush. */
91
+ const BRUSH_THRESHOLD = 4;
92
+ /** How many activities the keyboard cursor speaks before summarizing the rest. */
93
+ const SPOKEN_ACTIVITY_LIMIT = 12;
94
+
95
+ const DAY_MS = 86_400_000;
96
+ /** 1970-01-05 00:00 UTC — the first Monday after the epoch. */
97
+ const FIRST_MONDAY_MS = 4 * DAY_MS;
98
+
99
+ /** What a brush (or a committed keyboard range) emits. A subset of `/core`'s `FilterSpec`. */
100
+ export type DottedChartFilterIntent = Extract<FilterSpec, { kind: "cases" }>;
101
+
102
+ /** How dots are coloured. A function returns a CATEGORY KEY, never a colour. */
103
+ export type DottedChartColor = "activity" | "resource" | ((row: EventRow) => string);
104
+
105
+ /** A datum under the cursor: a case row (keyboard) or one event dot (pointer). */
106
+ export type DottedChartDatum = DottedChartRow | DottedChartDot;
107
+
108
+ export interface DottedChartProps extends Omit<
109
+ HTMLAttributes<HTMLDivElement>,
110
+ "onSelect" | "title" | "color"
111
+ > {
112
+ log: EventLog;
113
+ /** How the x axis places an event. @default "absolute" */
114
+ x?: DottedChartX;
115
+ /** How case rows are ordered, top to bottom. @default "start" */
116
+ sort?: DottedChartSort;
117
+ /**
118
+ * What colours a dot. `"activity"` reads `colorScale`; `"resource"` and a function rank
119
+ * their category keys onto the same `--chart-1 …` budget. @default "activity"
120
+ */
121
+ color?: DottedChartColor;
122
+ /** The shared activity colour scale — hand `ProcessMap` the same instance. Built from `log` when omitted. */
123
+ colorScale?: ActivityColorScale;
124
+ /** Case ids to highlight; every other dot dims. */
125
+ selectedCaseIds?: readonly string[];
126
+ /** Fires with the case ids a click, brush or keyboard range selects. */
127
+ onSelect?: (caseIds: string[]) => void;
128
+ /** Fires once when a brush is released (or a keyboard range committed), with ids in row order. */
129
+ onFilterIntent?: (intent: DottedChartFilterIntent) => void;
130
+ /** Card title — write the conclusion, not "Dotted chart". */
131
+ title?: ReactNode;
132
+ /** Prose under the title. */
133
+ description?: ReactNode;
134
+ /** Body height in CSS pixels (legend + plot + axis). @default 360 */
135
+ height?: number;
136
+ /** Render the accessible table twin instead of the plot. @default false */
137
+ tableView?: boolean;
138
+ /** No log yet. Renders the frame's skeleton. */
139
+ loading?: boolean;
140
+ /** Override any user-visible string. */
141
+ labels?: Partial<DottedChartLabels>;
142
+ }
143
+
144
+ interface CategoryEntry {
145
+ label: string;
146
+ token: string;
147
+ pattern?: "other";
148
+ }
149
+
150
+ interface ColorBuckets {
151
+ /** Legend entries in rank order, with every "other" category collapsed into one. */
152
+ legend: CategoryEntry[];
153
+ /** Per-dot category label (activity / resource / key). */
154
+ dotLabel: string[];
155
+ /** Dot indices grouped by paint token. */
156
+ buckets: { token: string; pattern?: "other"; indices: Int32Array }[];
157
+ }
158
+
159
+ function asEventRow(dot: DottedChartDot): EventRow {
160
+ const { event } = dot;
161
+ const row: EventRow = {
162
+ caseId: dot.caseId,
163
+ activity: event.activity,
164
+ timestamp: event.end,
165
+ startTimestamp: event.start,
166
+ };
167
+ if (event.resource !== undefined) row.resource = event.resource;
168
+ if (event.attributes !== undefined) row.attributes = event.attributes;
169
+ return row;
170
+ }
171
+
172
+ function buildColorBuckets(
173
+ model: DottedChartModel,
174
+ color: DottedChartColor,
175
+ scale: ActivityColorScale,
176
+ labels: DottedChartLabels,
177
+ ): ColorBuckets {
178
+ const dotLabel = new Array<string>(model.dots.length);
179
+ const tokenOf = new Map<string, { token: string; pattern?: "other" }>();
180
+ const legend: CategoryEntry[] = [];
181
+ let hasOther = false;
182
+
183
+ if (color === "activity") {
184
+ for (let i = 0; i < model.dots.length; i += 1) {
185
+ dotLabel[i] = (model.dots[i] as DottedChartDot).event.activity;
186
+ }
187
+ const present = new Set(dotLabel);
188
+ for (const entry of scale.legend) {
189
+ if (!present.has(entry.activityId)) continue;
190
+ tokenOf.set(entry.activityId, scale.colorFor(entry.activityId));
191
+ if (entry.pattern === "other") hasOther = true;
192
+ else legend.push({ label: entry.label, token: entry.token });
193
+ }
194
+ for (const id of present) {
195
+ if (!tokenOf.has(id)) {
196
+ tokenOf.set(id, scale.colorFor(id));
197
+ if (scale.colorFor(id).pattern === "other") hasOther = true;
198
+ }
199
+ }
200
+ for (let i = 0; i < dotLabel.length; i += 1) {
201
+ dotLabel[i] = scale.labelFor(dotLabel[i] as string);
202
+ }
203
+ } else {
204
+ const keyOf =
205
+ color === "resource"
206
+ ? (dot: DottedChartDot) => dot.event.resource ?? labels.noResource
207
+ : (dot: DottedChartDot) => color(asEventRow(dot));
208
+ for (let i = 0; i < model.dots.length; i += 1) {
209
+ dotLabel[i] = keyOf(model.dots[i] as DottedChartDot);
210
+ }
211
+ for (const category of rankCategoryColors(dotLabel)) {
212
+ tokenOf.set(category.key, { token: category.token, pattern: category.pattern });
213
+ if (category.pattern === "other") hasOther = true;
214
+ else legend.push({ label: category.key, token: category.token });
215
+ }
216
+ }
217
+
218
+ const grouped = new Map<string, { token: string; pattern?: "other"; indices: number[] }>();
219
+ // Activity mode keys `tokenOf` by activity id, which `dotLabel` no longer holds.
220
+ for (let i = 0; i < model.dots.length; i += 1) {
221
+ const key =
222
+ color === "activity" ? (model.dots[i] as DottedChartDot).event.activity : dotLabel[i];
223
+ const paint = tokenOf.get(key as string) ?? { token: "--chart-12", pattern: "other" as const };
224
+ const bucketKey = `${paint.token}|${paint.pattern ?? ""}`;
225
+ let bucket = grouped.get(bucketKey);
226
+ if (!bucket) {
227
+ bucket = { token: paint.token, pattern: paint.pattern, indices: [] };
228
+ grouped.set(bucketKey, bucket);
229
+ }
230
+ bucket.indices.push(i);
231
+ }
232
+
233
+ if (hasOther) legend.push({ label: labels.other, token: "--chart-12", pattern: "other" });
234
+
235
+ return {
236
+ legend,
237
+ dotLabel,
238
+ buckets: [...grouped.values()].map((b) => ({
239
+ token: b.token,
240
+ pattern: b.pattern,
241
+ indices: Int32Array.from(b.indices),
242
+ })),
243
+ };
244
+ }
245
+
246
+ /** Evenly spaced axis ticks for the model's domain, as `[value, label]` pairs. */
247
+ function buildTicks(
248
+ model: DottedChartModel,
249
+ innerWidth: number,
250
+ formatDate: (d: Date | number, opts?: Intl.DateTimeFormatOptions) => string,
251
+ ): { value: number; label: string }[] {
252
+ const [d0, d1] = model.domain;
253
+ if (model.x === "relative_day") {
254
+ return [0, 6, 12, 18].map((h) => ({
255
+ value: h * 3_600_000,
256
+ label: formatDate(h * 3_600_000, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }),
257
+ }));
258
+ }
259
+ if (model.x === "relative_week") {
260
+ return Array.from({ length: 7 }, (_, day) => ({
261
+ value: day * DAY_MS,
262
+ label: formatDate(FIRST_MONDAY_MS + day * DAY_MS, { weekday: "short", timeZone: "UTC" }),
263
+ }));
264
+ }
265
+ const count = Math.max(2, Math.min(8, Math.floor(innerWidth / 120)));
266
+ const span = d1 - d0;
267
+ const dateOpts: Intl.DateTimeFormatOptions =
268
+ span > 2 * DAY_MS
269
+ ? { month: "short", day: "numeric", timeZone: "UTC" }
270
+ : { hour: "2-digit", minute: "2-digit", timeZone: "UTC" };
271
+ return Array.from({ length: count }, (_, i) => {
272
+ const value = d0 + (span * i) / (count - 1);
273
+ return {
274
+ value,
275
+ label: model.x === "relative" ? formatDurationMs(value) : formatDate(value, dateOpts),
276
+ };
277
+ });
278
+ }
279
+
280
+ /** Stable per-object numbers, so a `drawSignature` can name "this data" cheaply. */
281
+ const objectIds = new WeakMap<object, number>();
282
+ let nextObjectId = 1;
283
+ function objectId(value: object): number {
284
+ let id = objectIds.get(value);
285
+ if (id === undefined) {
286
+ id = nextObjectId;
287
+ nextObjectId += 1;
288
+ objectIds.set(value, id);
289
+ }
290
+ return id;
291
+ }
292
+
293
+ const EMPTY_IDS: readonly string[] = Object.freeze([]);
294
+
295
+ /**
296
+ * The dotted chart.
297
+ *
298
+ * @example
299
+ * ```tsx
300
+ * const scale = useMemo(() => activityColorScale(fullGraph), [fullGraph]);
301
+ * <DottedChart
302
+ * log={explorer.filteredLog}
303
+ * colorScale={scale}
304
+ * sort="duration"
305
+ * onFilterIntent={explorer.applyIntent}
306
+ * />
307
+ * ```
308
+ */
309
+ export const DottedChart = forwardRef<HTMLDivElement, DottedChartProps>(function DottedChart(
310
+ {
311
+ log,
312
+ x = "absolute",
313
+ sort = "start",
314
+ color = "activity",
315
+ colorScale,
316
+ selectedCaseIds = EMPTY_IDS,
317
+ onSelect,
318
+ onFilterIntent,
319
+ title,
320
+ description,
321
+ height = 360,
322
+ tableView = false,
323
+ loading = false,
324
+ labels: labelOverrides,
325
+ className,
326
+ ...props
327
+ },
328
+ ref,
329
+ ) {
330
+ const { locale, formatNumber, formatDate } = useLocale();
331
+ const labels = useMemo<DottedChartLabels>(
332
+ () => ({ ...DOTTED_CHART_DEFAULT_LABELS, ...labelOverrides }),
333
+ [labelOverrides],
334
+ );
335
+ const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
336
+ const plural = useCallback(
337
+ (count: number, one: string, other: string) =>
338
+ fillLabel(pluralRules.select(count) === "one" ? one : other, {
339
+ count: formatNumber(count),
340
+ }),
341
+ [pluralRules, formatNumber],
342
+ );
343
+ const formatTime = useCallback(
344
+ (ms: number) => formatDate(ms, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }),
345
+ [formatDate],
346
+ );
347
+
348
+ // ── Model ───────────────────────────────────────────────────────────────
349
+ const model = useMemo(() => computeDots(log, { x, sort }), [log, x, sort]);
350
+ const scale = useMemo(
351
+ () => colorScale ?? activityColorScale(discoverGraph(log)),
352
+ [colorScale, log],
353
+ );
354
+ const colors = useMemo(
355
+ () => buildColorBuckets(model, color, scale, labels),
356
+ [model, color, scale, labels],
357
+ );
358
+ const selected = useMemo(() => new Set(selectedCaseIds), [selectedCaseIds]);
359
+ const eventCount = model.dots.length;
360
+
361
+ // ── Geometry ────────────────────────────────────────────────────────────
362
+ const [plotRef, plotSize] = useElementSize<HTMLDivElement>();
363
+ const { width, height: plotHeight } = plotSize;
364
+ const geometry = useMemo(() => {
365
+ const left = PLOT_INSET;
366
+ const right = Math.max(left + 1, width - PLOT_INSET);
367
+ const top = PLOT_INSET;
368
+ const bottom = Math.max(top + 1, plotHeight - PLOT_INSET);
369
+ const [d0, d1] = model.domain;
370
+ const rowHeight = (bottom - top) / Math.max(1, model.rows.length);
371
+ const xToPx = (value: number) => left + ((value - d0) / (d1 - d0)) * (right - left);
372
+ const pxToX = (px: number) => d0 + ((px - left) / (right - left)) * (d1 - d0);
373
+ const pxToRow = (py: number) =>
374
+ Math.min(model.rows.length - 1, Math.max(0, Math.floor((py - top) / rowHeight)));
375
+ const px = new Float32Array(model.dots.length);
376
+ const py = new Float32Array(model.dots.length);
377
+ const grid = createSpatialGrid<DottedChartDot>(CANVAS_LAYER_HIT_RADIUS);
378
+ for (let i = 0; i < model.dots.length; i += 1) {
379
+ const dot = model.dots[i] as DottedChartDot;
380
+ const dx = xToPx(dot.x);
381
+ const dy = top + (dot.rowIndex + 0.5) * rowHeight;
382
+ px[i] = dx;
383
+ py[i] = dy;
384
+ grid.insert(dx, dy, dot);
385
+ }
386
+ return { left, right, top, bottom, rowHeight, xToPx, pxToX, pxToRow, px, py, grid };
387
+ }, [model, width, plotHeight]);
388
+
389
+ const ticks = useMemo(
390
+ () => buildTicks(model, geometry.right - geometry.left, formatDate),
391
+ [model, geometry, formatDate],
392
+ );
393
+
394
+ // ── Paint ───────────────────────────────────────────────────────────────
395
+ const draw = useCallback(
396
+ (ctx: CanvasRenderingContext2D) => {
397
+ const el = ctx.canvas;
398
+ const { px, py, top, bottom } = geometry;
399
+
400
+ ctx.globalAlpha = 1;
401
+ ctx.lineWidth = CHART_HAIRLINE_WIDTH;
402
+ ctx.strokeStyle = canvasTokenColor("--chart-grid", el, "GrayText");
403
+ ctx.beginPath();
404
+ for (const tick of ticks) {
405
+ const tx = Math.round(geometry.xToPx(tick.value)) + 0.5;
406
+ ctx.moveTo(tx, top);
407
+ ctx.lineTo(tx, bottom);
408
+ }
409
+ ctx.stroke();
410
+
411
+ const dim = selected.size > 0;
412
+ const passes: (boolean | null)[] = dim ? [false, true] : [null];
413
+ for (const pass of passes) {
414
+ ctx.globalAlpha = pass === false ? DIMMED_ALPHA : 1;
415
+ for (const bucket of colors.buckets) {
416
+ const ink = canvasTokenColor(bucket.token, el, "CanvasText");
417
+ ctx.beginPath();
418
+ for (const i of bucket.indices) {
419
+ if (pass !== null) {
420
+ const isSelected = selected.has((model.dots[i] as DottedChartDot).caseId);
421
+ if (isSelected !== pass) continue;
422
+ }
423
+ const cx = px[i] as number;
424
+ const cy = py[i] as number;
425
+ ctx.moveTo(cx + DOTTED_CHART_DOT_RADIUS, cy);
426
+ ctx.arc(cx, cy, DOTTED_CHART_DOT_RADIUS, 0, Math.PI * 2);
427
+ }
428
+ if (bucket.pattern === "other") {
429
+ ctx.lineWidth = 1;
430
+ ctx.strokeStyle = ink;
431
+ ctx.stroke();
432
+ } else {
433
+ ctx.fillStyle = ink;
434
+ ctx.fill();
435
+ }
436
+ }
437
+ }
438
+ ctx.globalAlpha = 1;
439
+ },
440
+ [geometry, ticks, colors, selected, model],
441
+ );
442
+ const drawSignature = `${objectId(geometry)}:${objectId(colors)}:${objectId(selected)}:${objectId(ticks)}`;
443
+
444
+ // ── Text channels ───────────────────────────────────────────────────────
445
+ const rowLabel = useCallback(
446
+ (row: DottedChartRow) => {
447
+ const activities: string[] = [];
448
+ const limit = Math.min(row.dotEnd, row.dotStart + SPOKEN_ACTIVITY_LIMIT);
449
+ for (let d = row.dotStart; d < limit; d += 1) {
450
+ activities.push(colors.dotLabel[d] as string);
451
+ }
452
+ let spoken = activities.join(", ");
453
+ const rest = row.eventCount - activities.length;
454
+ if (rest > 0) spoken = `${spoken}, ${fillLabel(labels.moreActivities, { count: rest })}`;
455
+ let text = fillLabel(labels.row, {
456
+ caseId: row.caseId,
457
+ events: plural(row.eventCount, labels.eventsOne, labels.eventsOther),
458
+ duration: formatDurationMs(row.duration),
459
+ activities: spoken,
460
+ });
461
+ if (selected.has(row.caseId)) text = `${text}, ${labels.selected}`;
462
+ return text;
463
+ },
464
+ [colors, labels, plural, selected],
465
+ );
466
+
467
+ const dotText = useCallback(
468
+ (dot: DottedChartDot) => {
469
+ const base = fillLabel(labels.dot, {
470
+ activity: dot.event.activity,
471
+ time: formatTime(dot.event.start),
472
+ caseId: dot.caseId,
473
+ });
474
+ return dot.event.resource
475
+ ? `${base}, ${fillLabel(labels.dotResource, { resource: dot.event.resource })}`
476
+ : base;
477
+ },
478
+ [labels, formatTime],
479
+ );
480
+
481
+ const labelFor = useCallback(
482
+ (datum: DottedChartDatum) => (datum.kind === "row" ? rowLabel(datum) : dotText(datum)),
483
+ [rowLabel, dotText],
484
+ );
485
+
486
+ const summary = useMemo(() => {
487
+ const first = model.rows.reduce((m, r) => Math.min(m, r.start), Number.POSITIVE_INFINITY);
488
+ const last = model.rows.reduce((m, r) => Math.max(m, r.end), Number.NEGATIVE_INFINITY);
489
+ const sortLabel = {
490
+ start: labels.sortStart,
491
+ end: labels.sortEnd,
492
+ duration: labels.sortDuration,
493
+ start_day: labels.sortStartDay,
494
+ }[sort];
495
+ const colorBy =
496
+ color === "activity"
497
+ ? labels.colorByActivity
498
+ : color === "resource"
499
+ ? labels.colorByResource
500
+ : labels.colorByCustom;
501
+ return fillLabel(labels.summary, {
502
+ cases: plural(model.rows.length, labels.casesOne, labels.casesOther),
503
+ events: plural(eventCount, labels.eventsOne, labels.eventsOther),
504
+ from: Number.isFinite(first) ? formatTime(first) : "—",
505
+ to: Number.isFinite(last) ? formatTime(last) : "—",
506
+ sort: sortLabel,
507
+ colorBy,
508
+ });
509
+ }, [model, labels, sort, color, plural, eventCount, formatTime]);
510
+
511
+ // ── Selection: click, brush, keyboard range ─────────────────────────────
512
+ const interactive = Boolean(onSelect || onFilterIntent);
513
+ const suppressClickRef = useRef(false);
514
+ const focusedRowRef = useRef<DottedChartRow | null>(null);
515
+ const anchorRef = useRef<number | null>(null);
516
+ const extendRef = useRef(false);
517
+ const rangeRef = useRef<string[] | null>(null);
518
+
519
+ const handleActivate = useCallback(
520
+ (datum: DottedChartDatum) => {
521
+ if (suppressClickRef.current) {
522
+ suppressClickRef.current = false;
523
+ return;
524
+ }
525
+ const range = rangeRef.current;
526
+ if (datum.kind === "row" && range && range.length > 1) {
527
+ onFilterIntent?.({ kind: "cases", ids: range });
528
+ return;
529
+ }
530
+ onSelect?.([datum.caseId]);
531
+ },
532
+ [onSelect, onFilterIntent],
533
+ );
534
+
535
+ const handleFocus = useCallback(
536
+ (datum: DottedChartDatum | null) => {
537
+ if (datum === null || datum.kind !== "row") {
538
+ focusedRowRef.current = null;
539
+ anchorRef.current = null;
540
+ extendRef.current = false;
541
+ rangeRef.current = null;
542
+ return;
543
+ }
544
+ focusedRowRef.current = datum;
545
+ if (extendRef.current && anchorRef.current !== null) {
546
+ extendRef.current = false;
547
+ const lo = Math.min(anchorRef.current, datum.index);
548
+ const hi = Math.max(anchorRef.current, datum.index);
549
+ const ids = model.rows.slice(lo, hi + 1).map((row) => row.caseId);
550
+ rangeRef.current = ids;
551
+ onSelect?.(ids);
552
+ }
553
+ },
554
+ [model, onSelect],
555
+ );
556
+
557
+ const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
558
+ if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
559
+ return;
560
+ }
561
+ if (event.shiftKey) {
562
+ if (anchorRef.current === null) anchorRef.current = focusedRowRef.current?.index ?? 0;
563
+ extendRef.current = true;
564
+ } else {
565
+ anchorRef.current = null;
566
+ extendRef.current = false;
567
+ rangeRef.current = null;
568
+ }
569
+ }, []);
570
+
571
+ const brushStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null);
572
+ const [brush, setBrush] = useState<CanvasLayerRect | null>(null);
573
+
574
+ const localPoint = (event: ReactPointerEvent<HTMLDivElement>) => {
575
+ const rect = event.currentTarget.getBoundingClientRect();
576
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
577
+ };
578
+
579
+ const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
580
+ suppressClickRef.current = false;
581
+ rangeRef.current = null;
582
+ if (!interactive || event.button !== 0 || model.rows.length === 0) return;
583
+ brushStartRef.current = { ...localPoint(event), pointerId: event.pointerId };
584
+ };
585
+
586
+ const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
587
+ const start = brushStartRef.current;
588
+ if (!start || start.pointerId !== event.pointerId) return;
589
+ const point = localPoint(event);
590
+ if (
591
+ !brush &&
592
+ Math.abs(point.x - start.x) < BRUSH_THRESHOLD &&
593
+ Math.abs(point.y - start.y) < BRUSH_THRESHOLD
594
+ ) {
595
+ return;
596
+ }
597
+ if (!brush) event.currentTarget.setPointerCapture?.(event.pointerId);
598
+ setBrush({
599
+ x: Math.min(start.x, point.x),
600
+ y: Math.min(start.y, point.y),
601
+ width: Math.abs(point.x - start.x),
602
+ height: Math.abs(point.y - start.y),
603
+ });
604
+ };
605
+
606
+ const endBrush = (event: ReactPointerEvent<HTMLDivElement>, commit: boolean) => {
607
+ const start = brushStartRef.current;
608
+ brushStartRef.current = null;
609
+ if (!start || !brush) return;
610
+ setBrush(null);
611
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
612
+ event.currentTarget.releasePointerCapture(event.pointerId);
613
+ }
614
+ if (!commit) return;
615
+ suppressClickRef.current = true;
616
+ const ids = casesInBrush(
617
+ model,
618
+ [geometry.pxToRow(brush.y), geometry.pxToRow(brush.y + brush.height)],
619
+ [geometry.pxToX(brush.x), geometry.pxToX(brush.x + brush.width)],
620
+ );
621
+ if (ids.length === 0) return;
622
+ onSelect?.(ids);
623
+ onFilterIntent?.({ kind: "cases", ids });
624
+ };
625
+
626
+ // ── Table twin + CSV rows ───────────────────────────────────────────────
627
+ const csvColumns = useMemo<ChartFrameColumn[]>(
628
+ () => [
629
+ { key: "caseId", header: labels.columnCase },
630
+ { key: "firstActivity", header: labels.columnFirstActivity },
631
+ { key: "lastActivity", header: labels.columnLastActivity },
632
+ { key: "start", header: labels.columnStart },
633
+ { key: "end", header: labels.columnEnd },
634
+ { key: "durationMs", header: labels.columnDuration },
635
+ { key: "events", header: labels.columnEvents },
636
+ ],
637
+ [labels],
638
+ );
639
+ const csvRows = useMemo(
640
+ () =>
641
+ model.rows.map((row) => ({
642
+ caseId: row.caseId,
643
+ firstActivity: row.firstActivity,
644
+ lastActivity: row.lastActivity,
645
+ start: new Date(row.start).toISOString(),
646
+ end: new Date(row.end).toISOString(),
647
+ durationMs: row.duration,
648
+ events: row.eventCount,
649
+ })),
650
+ [model],
651
+ );
652
+
653
+ const axisLabel = {
654
+ absolute: labels.axisAbsolute,
655
+ relative: labels.axisRelative,
656
+ relative_day: labels.axisDay,
657
+ relative_week: labels.axisWeek,
658
+ }[x];
659
+
660
+ let body: ReactNode;
661
+ if (model.rows.length === 0) {
662
+ body = (
663
+ <StatePanel
664
+ data-slot="dotted-chart-empty"
665
+ kind="empty"
666
+ title={labels.empty}
667
+ titleAs="div"
668
+ description={labels.emptyBody}
669
+ />
670
+ );
671
+ } else if (tableView) {
672
+ const hasSelection = selected.size > 0;
673
+ body = (
674
+ <div data-slot="dotted-chart-table" className="size-full overflow-auto">
675
+ <Table>
676
+ <TableCaption>{labels.tableCaption}</TableCaption>
677
+ <TableHeader>
678
+ <TableRow>
679
+ <TableHead scope="col">{labels.columnCase}</TableHead>
680
+ <TableHead scope="col">{labels.columnFirstActivity}</TableHead>
681
+ <TableHead scope="col">{labels.columnLastActivity}</TableHead>
682
+ <TableHead scope="col">{labels.columnStart}</TableHead>
683
+ <TableHead scope="col">{labels.columnEnd}</TableHead>
684
+ <TableHead scope="col">{labels.columnDuration}</TableHead>
685
+ <TableHead scope="col">{labels.columnEvents}</TableHead>
686
+ {hasSelection ? <TableHead scope="col">{labels.columnState}</TableHead> : null}
687
+ </TableRow>
688
+ </TableHeader>
689
+ <TableBody>
690
+ {model.rows.map((row) => {
691
+ const isSelected = selected.has(row.caseId);
692
+ return (
693
+ <TableRow key={row.caseId} data-state={isSelected ? "selected" : undefined}>
694
+ <TableCell>{row.caseId}</TableCell>
695
+ <TableCell>{scale.labelFor(row.firstActivity)}</TableCell>
696
+ <TableCell>{scale.labelFor(row.lastActivity)}</TableCell>
697
+ <TableCell className="tabular-nums">{formatTime(row.start)}</TableCell>
698
+ <TableCell className="tabular-nums">{formatTime(row.end)}</TableCell>
699
+ <TableCell className="tabular-nums">{formatDurationMs(row.duration)}</TableCell>
700
+ <TableCell className="tabular-nums">{formatNumber(row.eventCount)}</TableCell>
701
+ {hasSelection ? <TableCell>{isSelected ? labels.selected : ""}</TableCell> : null}
702
+ </TableRow>
703
+ );
704
+ })}
705
+ </TableBody>
706
+ </Table>
707
+ </div>
708
+ );
709
+ } else {
710
+ body = (
711
+ <div data-slot="dotted-chart-body" className="flex size-full min-h-0 flex-col gap-2">
712
+ <div data-slot="dotted-chart-legend" className="flex shrink-0 items-start gap-3">
713
+ <ChartLegend
714
+ className="max-h-16 min-w-0 flex-1 flex-row flex-wrap gap-x-1 gap-y-0 overflow-y-auto"
715
+ itemClassName="px-1.5 py-0.5"
716
+ labelClassName="text-meta"
717
+ showValue={false}
718
+ items={colors.legend.map((entry) => ({
719
+ label: entry.label,
720
+ value: 0,
721
+ color: `var(${entry.token})`,
722
+ }))}
723
+ />
724
+ {selected.size > 0 ? (
725
+ <span
726
+ data-slot="dotted-chart-selection"
727
+ className="shrink-0 text-meta text-muted-foreground tabular-nums"
728
+ >
729
+ {fillLabel(labels.selectionCount, {
730
+ cases: plural(selected.size, labels.casesOne, labels.casesOther),
731
+ })}
732
+ </span>
733
+ ) : null}
734
+ </div>
735
+ <div
736
+ ref={plotRef}
737
+ dir="ltr"
738
+ data-slot="dotted-chart-plot"
739
+ className={cn("relative min-h-0 flex-1", interactive && "touch-none select-none")}
740
+ onKeyDown={handleKeyDown}
741
+ onPointerDown={handlePointerDown}
742
+ onPointerMove={handlePointerMove}
743
+ onPointerUp={(event) => endBrush(event, true)}
744
+ onPointerCancel={(event) => endBrush(event, false)}
745
+ >
746
+ {width > 0 && plotHeight > 0 ? (
747
+ <CanvasLayer<DottedChartDatum>
748
+ points={model.rows}
749
+ draw={draw}
750
+ drawSignature={drawSignature}
751
+ hitTest={(hx, hy) => geometry.grid.query(hx, hy, CANVAS_LAYER_HIT_RADIUS)}
752
+ width={width}
753
+ height={plotHeight}
754
+ labelFor={labelFor}
755
+ focusRect={(datum) =>
756
+ datum.kind === "row"
757
+ ? {
758
+ x: geometry.left - 2,
759
+ y: geometry.top + datum.index * geometry.rowHeight - 1,
760
+ width: geometry.right - geometry.left + 4,
761
+ height: Math.max(4, geometry.rowHeight + 2),
762
+ }
763
+ : {
764
+ x: geometry.xToPx(datum.x) - 4,
765
+ y: geometry.top + (datum.rowIndex + 0.5) * geometry.rowHeight - 4,
766
+ width: 8,
767
+ height: 8,
768
+ }
769
+ }
770
+ renderTooltip={(datum) => (
771
+ <span className="block max-w-64 text-meta">{labelFor(datum)}</span>
772
+ )}
773
+ onDatapointFocus={handleFocus}
774
+ onDatapointActivate={handleActivate}
775
+ accessibleLabel={labels.chart}
776
+ accessibleDescription={summary}
777
+ />
778
+ ) : null}
779
+ {brush ? (
780
+ <div
781
+ aria-hidden="true"
782
+ data-slot="dotted-chart-brush"
783
+ className="pointer-events-none absolute rounded-sm border border-primary bg-primary/10"
784
+ style={{ left: brush.x, top: brush.y, width: brush.width, height: brush.height }}
785
+ />
786
+ ) : null}
787
+ </div>
788
+ <div
789
+ aria-hidden="true"
790
+ dir="ltr"
791
+ data-slot="dotted-chart-axis"
792
+ className="relative h-4 shrink-0 text-meta text-muted-foreground tabular-nums"
793
+ >
794
+ {ticks.map((tick, i) => (
795
+ <span
796
+ key={tick.value}
797
+ className="absolute top-0 whitespace-nowrap"
798
+ style={{
799
+ left: geometry.xToPx(tick.value),
800
+ transform:
801
+ i === 0
802
+ ? undefined
803
+ : i === ticks.length - 1 && model.x !== "relative_week"
804
+ ? "translateX(-100%)"
805
+ : "translateX(-50%)",
806
+ }}
807
+ >
808
+ {tick.label}
809
+ </span>
810
+ ))}
811
+ </div>
812
+ <div
813
+ aria-hidden="true"
814
+ data-slot="dotted-chart-axis-label"
815
+ className="shrink-0 text-center text-meta text-muted-foreground"
816
+ >
817
+ {axisLabel}
818
+ </div>
819
+ </div>
820
+ );
821
+ }
822
+
823
+ return (
824
+ <ChartFrame
825
+ ref={ref}
826
+ data-slot="dotted-chart"
827
+ data-view={tableView ? "table" : "plot"}
828
+ title={title}
829
+ description={description}
830
+ data={model.rows.length > 0 ? csvRows : undefined}
831
+ columns={csvColumns}
832
+ features={["expand", "download"]}
833
+ height={height}
834
+ loading={loading}
835
+ className={cn(className)}
836
+ {...props}
837
+ >
838
+ {body}
839
+ </ChartFrame>
840
+ );
841
+ });