@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,870 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PerformanceSpectrum — segments × time, one line per case (RM-060, issue #209, §4 R15).
5
+ *
6
+ * ProM's performance spectrum: a fixed, chosen sequence of segments (`from → to` pairs)
7
+ * stacked as rows, all sharing one absolute-time x-axis. In `mode="lines"` every
8
+ * occurrence is a line from `(start, row top)` to `(end, row bottom)`, so a steep line is
9
+ * fast, a slanted one slow, parallel lines are FIFO, crossing lines overtake, and a
10
+ * bundle converging on one instant is a batch. `mode="aggregated"` trades the lines for
11
+ * one bar per `binSize` bucket (height = cases entering, fill = median quartile).
12
+ *
13
+ * ## Composition, not re-authoring
14
+ *
15
+ * Marks paint on `@elabs-ai/components-charts`'s `CanvasLayer` (RM-046) — never SVG — one
16
+ * layer per row, which is also what makes the keyboard contract fall out: each row's
17
+ * layer owns ONE tab stop, so `Tab` moves row by row, the arrow keys walk that row's
18
+ * occurrences in time order, and `Enter` activates the focused occurrence's case. Hover
19
+ * hit-testing uses the charts `createSpatialGrid`; the tooltip is `ChartTooltipContent`;
20
+ * the colour key is `Legend`; the ramp is `resolvePalette("sequential")`, the one
21
+ * `HeatmapChart` uses.
22
+ *
23
+ * ## Colour is never the only channel
24
+ *
25
+ * A line's quartile colour is REDUNDANT with its geometry: the horizontal run of a line
26
+ * is its duration. Every occurrence and bar also speaks its quartile in words through the
27
+ * layer's cursor, each row carries a parallel summary (count, cases, median, p90), and
28
+ * `tableView` renders the same numbers as a table. The sequential ramp is
29
+ * lightness-monotonic, so the quartiles also separate in greyscale.
30
+ *
31
+ * ## It emits; it never filters
32
+ *
33
+ * Dragging across the time axis (or Shift+Arrow then Enter on it) calls
34
+ * `onFilterIntent({ kind: "cases", ids })` once, with every case that has an occurrence
35
+ * overlapping the range. The spectrum itself never narrows its data (§5.3).
36
+ */
37
+ import {
38
+ forwardRef,
39
+ useCallback,
40
+ useId,
41
+ useMemo,
42
+ useRef,
43
+ useState,
44
+ type HTMLAttributes,
45
+ type KeyboardEvent,
46
+ type PointerEvent,
47
+ } from "react";
48
+ import {
49
+ CanvasLayer,
50
+ canvasTokenColor,
51
+ CHART_HAIRLINE_WIDTH,
52
+ ChartTooltipContent,
53
+ createSpatialGrid,
54
+ Legend,
55
+ LegendItemComponent,
56
+ LegendLabel,
57
+ LegendMarker,
58
+ resolvePalette,
59
+ type CanvasLayerRect,
60
+ type SpatialGrid,
61
+ } from "@elabs-ai/components-charts";
62
+ import {
63
+ StatePanel,
64
+ Table,
65
+ TableBody,
66
+ TableCaption,
67
+ TableCell,
68
+ TableHead,
69
+ TableHeader,
70
+ TableRow,
71
+ useLocale,
72
+ } from "@elabs-ai/components-ui";
73
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
74
+ import { discoverGraph } from "../core/discover-graph";
75
+ import { asNormalizedLog } from "../core/event-log";
76
+ import { extractVariants } from "../core/extract-variants";
77
+ import type { FilterSpec } from "../core/filter-log";
78
+ import {
79
+ segmentOrderByFrequency,
80
+ segmentOrderForVariant,
81
+ segmentsFor,
82
+ type SegmentDefinition,
83
+ } from "../core/segments";
84
+ import type { EventLog } from "../core/types";
85
+ import { formatDurationMs, type ProcessSelection } from "../process-map/map-model";
86
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
87
+ import {
88
+ aggregateSegmentBins,
89
+ buildSpectrumRows,
90
+ casesInRange,
91
+ spectrumDomain,
92
+ spectrumTicks,
93
+ type SpectrumBin,
94
+ type SpectrumLine,
95
+ type SpectrumRow,
96
+ } from "./aggregate-segments";
97
+ import {
98
+ PERFORMANCE_SPECTRUM_DEFAULT_LABELS,
99
+ PerformanceSpectrumProvider,
100
+ usePerformanceSpectrum,
101
+ type PerformanceSpectrumContextValue,
102
+ type PerformanceSpectrumLabels,
103
+ } from "./performance-spectrum-context";
104
+
105
+ /** One day in ms — the aggregated mode's default bucket. */
106
+ export const PERFORMANCE_SPECTRUM_DEFAULT_BIN_SIZE = 86_400_000;
107
+ /** Default row height in CSS px when `height` is not given. */
108
+ export const PERFORMANCE_SPECTRUM_ROW_HEIGHT = 56;
109
+ /** Default number of segment rows. */
110
+ export const PERFORMANCE_SPECTRUM_SEGMENT_LIMIT = 12;
111
+
112
+ /** Which segments a spectrum shows, in row order. */
113
+ export type PerformanceSpectrumOrder = SegmentDefinition[] | "frequency" | { variantId: string };
114
+
115
+ /** The one intent the spectrum emits — a `/core` `FilterSpec`, straight into `filterLog`. */
116
+ export type PerformanceSpectrumFilterIntent = Extract<FilterSpec, { kind: "cases" }>;
117
+
118
+ /** Props for {@link PerformanceSpectrum}. */
119
+ export interface PerformanceSpectrumProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
120
+ log: EventLog;
121
+ /** Row order: explicit segments, the busiest transitions, or one variant's path. @default "frequency" */
122
+ order?: PerformanceSpectrumOrder;
123
+ /** At most this many rows, whichever `order` is used. @default 12 */
124
+ segmentLimit?: number;
125
+ /** One line per occurrence, or one bar per time bucket. @default "lines" */
126
+ mode?: "lines" | "aggregated";
127
+ /** Bucket width in ms, aggregated mode only. @default 86_400_000 (1 day) */
128
+ binSize?: number;
129
+ /** Rows whose segment is (or touches) the selected transition/activity are marked. */
130
+ selection?: ProcessSelection | null;
131
+ /** When given, the time axis becomes a brush that emits `{ kind: "cases", ids }`. */
132
+ onFilterIntent?: (intent: PerformanceSpectrumFilterIntent) => void;
133
+ /** Click or `Enter` on an occurrence (lines) or on a bar's first case (aggregated). */
134
+ onCaseSelect?: (caseId: string, occurrence: SpectrumLine) => void;
135
+ /** Total plot height in CSS px, split evenly across rows (min 24 px a row). */
136
+ height?: number;
137
+ /** Render the accessible table twin instead of the chart. @default false */
138
+ tableView?: boolean;
139
+ /** Data not ready yet — renders the loading panel. */
140
+ loading?: boolean;
141
+ /** Override any user-visible string. */
142
+ labels?: Partial<PerformanceSpectrumLabels>;
143
+ }
144
+
145
+ /** `var(--chart-seq-3)` → `--chart-seq-3`, the name `canvasTokenColor` resolves. */
146
+ function tokenName(reference: string): string {
147
+ return /var\((--[\w-]+)\)/.exec(reference)?.[1] ?? reference;
148
+ }
149
+
150
+ /** A canvas-safe ink when a token cannot be resolved — a CSS system colour, never a literal. */
151
+ const INK_FALLBACK = "CanvasText";
152
+
153
+ /** Hover sample spacing along a line, in CSS px; kept under the grid's hit radius. */
154
+ const LINE_SAMPLE_PX = 6;
155
+ const LINE_SAMPLE_MAX = 64;
156
+ const HIT_RADIUS = 8;
157
+
158
+ function isRowSelected(row: SpectrumRow, selection: ProcessSelection | null | undefined): boolean {
159
+ if (!selection) return false;
160
+ if (selection.kind === "transition") return selection.id === row.key;
161
+ return row.definition.from === selection.id || row.definition.to === selection.id;
162
+ }
163
+
164
+ interface SpectrumRowProps {
165
+ row: SpectrumRow;
166
+ bins: SpectrumBin[] | null;
167
+ maxBinCount: number;
168
+ selected: boolean;
169
+ }
170
+
171
+ /** One segment row — a `CanvasLayer` painting either lines or bars. */
172
+ function PerformanceSpectrumRow({ row, bins, maxBinCount, selected }: SpectrumRowProps) {
173
+ const ctx = usePerformanceSpectrum();
174
+ const { labels, domain, ticks, rowHeight, quartileColors, formatInstant, formatDuration } = ctx;
175
+ const [d0, d1] = domain;
176
+ const span = d1 - d0;
177
+ const widthRef = useRef(0);
178
+ const gridRef = useRef<{ width: number; grid: SpatialGrid<SpectrumLine> } | null>(null);
179
+
180
+ const xOf = useCallback((t: number, width: number) => ((t - d0) / span) * width, [d0, span]);
181
+
182
+ const binIndex = useMemo(() => {
183
+ const map = new Map<number, SpectrumBin>();
184
+ if (!bins) return map;
185
+ for (const bin of bins) map.set(Math.round((bin.start - d0) / ctx.binSize), bin);
186
+ return map;
187
+ }, [bins, d0, ctx.binSize]);
188
+
189
+ const barGeometry = useCallback(
190
+ (bin: SpectrumBin, width: number): CanvasLayerRect => {
191
+ const x = xOf(bin.start, width);
192
+ const w = Math.max(1, xOf(bin.end, width) - x - 1);
193
+ const h = Math.max(1, (bin.count / Math.max(1, maxBinCount)) * (rowHeight - 4));
194
+ return { x, y: rowHeight - h, width: w, height: h };
195
+ },
196
+ [maxBinCount, rowHeight, xOf],
197
+ );
198
+
199
+ const draw = (c: CanvasRenderingContext2D, scales: { width: number; height: number }) => {
200
+ const { width, height } = scales;
201
+ widthRef.current = width;
202
+ const el = c.canvas;
203
+
204
+ c.strokeStyle = canvasTokenColor("--chart-grid", el, INK_FALLBACK);
205
+ c.lineWidth = CHART_HAIRLINE_WIDTH;
206
+ c.beginPath();
207
+ for (const tick of ticks) {
208
+ const x = Math.round(xOf(tick, width)) + 0.5;
209
+ c.moveTo(x, 0);
210
+ c.lineTo(x, height);
211
+ }
212
+ c.stroke();
213
+
214
+ const inks = quartileColors.map((ref) => canvasTokenColor(tokenName(ref), el, INK_FALLBACK));
215
+
216
+ if (bins) {
217
+ for (const bin of bins) {
218
+ const rect = barGeometry(bin, width);
219
+ c.fillStyle = inks[bin.quartile - 1] as string;
220
+ c.fillRect(rect.x, rect.y, rect.width, rect.height);
221
+ }
222
+ return;
223
+ }
224
+
225
+ c.lineWidth = 1;
226
+ // Fastest first, slowest last: the slow lines — the ones worth finding — sit on top.
227
+ for (let q = 1; q <= 4; q += 1) {
228
+ c.strokeStyle = inks[q - 1] as string;
229
+ c.beginPath();
230
+ for (const line of row.lines) {
231
+ if (line.quartile !== q) continue;
232
+ c.moveTo(xOf(line.start, width), 1);
233
+ c.lineTo(xOf(line.end, width), height - 1);
234
+ }
235
+ c.stroke();
236
+ }
237
+ };
238
+
239
+ const lineGrid = (width: number): SpatialGrid<SpectrumLine> => {
240
+ const cached = gridRef.current;
241
+ if (cached && cached.width === width) return cached.grid;
242
+ const grid = createSpatialGrid<SpectrumLine>(HIT_RADIUS);
243
+ for (const line of row.lines) {
244
+ const x0 = xOf(line.start, width);
245
+ const x1 = xOf(line.end, width);
246
+ const steps = Math.min(
247
+ LINE_SAMPLE_MAX,
248
+ Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), rowHeight) / LINE_SAMPLE_PX)),
249
+ );
250
+ for (let k = 0; k <= steps; k += 1) {
251
+ const f = k / steps;
252
+ grid.insert(x0 + (x1 - x0) * f, rowHeight * f, line);
253
+ }
254
+ }
255
+ gridRef.current = { width, grid };
256
+ return grid;
257
+ };
258
+
259
+ const hitTest = (x: number, y: number): SpectrumLine | SpectrumBin | null => {
260
+ const width = widthRef.current;
261
+ if (width <= 0) return null;
262
+ if (bins) {
263
+ const t = d0 + (x / width) * span;
264
+ return binIndex.get(Math.floor((t - d0) / ctx.binSize)) ?? null;
265
+ }
266
+ return lineGrid(width).query(x, y, HIT_RADIUS);
267
+ };
268
+
269
+ const isBin = (datum: SpectrumLine | SpectrumBin): datum is SpectrumBin => "count" in datum;
270
+
271
+ const focusRect = (datum: SpectrumLine | SpectrumBin): CanvasLayerRect => {
272
+ const width = widthRef.current;
273
+ if (isBin(datum)) {
274
+ const rect = barGeometry(datum, width);
275
+ return {
276
+ x: rect.x - 2,
277
+ y: Math.max(1, rect.y - 2),
278
+ width: rect.width + 4,
279
+ height: rect.height + 1,
280
+ };
281
+ }
282
+ const x0 = xOf(datum.start, width);
283
+ const x1 = xOf(datum.end, width);
284
+ return { x: Math.min(x0, x1) - 3, y: 1, width: Math.abs(x1 - x0) + 6, height: rowHeight - 2 };
285
+ };
286
+
287
+ const labelFor = (datum: SpectrumLine | SpectrumBin): string =>
288
+ isBin(datum)
289
+ ? fillLabel(labels.bin, {
290
+ start: formatInstant(datum.start),
291
+ end: formatInstant(datum.end),
292
+ count: datum.count,
293
+ median: formatDuration(datum.medianDuration),
294
+ quartile: ctx.quartileName(datum.quartile),
295
+ })
296
+ : fillLabel(labels.occurrence, {
297
+ caseId: datum.caseId,
298
+ start: formatInstant(datum.start),
299
+ end: formatInstant(datum.end),
300
+ duration: formatDuration(datum.duration),
301
+ quartile: ctx.quartileName(datum.quartile),
302
+ });
303
+
304
+ const segmentLabel =
305
+ row.definition.label ??
306
+ fillLabel(labels.segment, { from: row.definition.from, to: row.definition.to });
307
+
308
+ const renderTooltip = (datum: SpectrumLine | SpectrumBin) => {
309
+ const color = quartileColors[datum.quartile - 1] as string;
310
+ const quartile = ctx.quartileName(datum.quartile);
311
+ return isBin(datum) ? (
312
+ <ChartTooltipContent
313
+ title={segmentLabel}
314
+ rows={[
315
+ { color, label: labels.tooltipStart, value: formatInstant(datum.start) },
316
+ { color, label: labels.tooltipEnd, value: formatInstant(datum.end) },
317
+ { color, label: labels.tooltipCount, value: String(datum.count) },
318
+ { color, label: labels.tooltipMedian, value: formatDuration(datum.medianDuration) },
319
+ { color, label: labels.tooltipQuartile, value: quartile },
320
+ ]}
321
+ />
322
+ ) : (
323
+ <ChartTooltipContent
324
+ title={datum.caseId}
325
+ rows={[
326
+ { color, label: labels.tooltipSegment, value: segmentLabel },
327
+ { color, label: labels.tooltipStart, value: formatInstant(datum.start) },
328
+ { color, label: labels.tooltipEnd, value: formatInstant(datum.end) },
329
+ { color, label: labels.tooltipDuration, value: formatDuration(datum.duration) },
330
+ { color, label: labels.tooltipQuartile, value: quartile },
331
+ ]}
332
+ />
333
+ );
334
+ };
335
+
336
+ const activate = (datum: SpectrumLine | SpectrumBin) => {
337
+ if (!ctx.onCaseSelect) return;
338
+ if (isBin(datum)) {
339
+ const first = row.lines.find((l) => l.start >= datum.start && l.start < datum.end);
340
+ if (first) ctx.onCaseSelect(first.caseId, first);
341
+ return;
342
+ }
343
+ ctx.onCaseSelect(datum.caseId, datum);
344
+ };
345
+
346
+ const points: Array<SpectrumLine | SpectrumBin> = bins ?? row.lines;
347
+ const first = row.lines[0];
348
+ const last = row.lines[row.lines.length - 1];
349
+ const drawSignature = [
350
+ row.key,
351
+ row.lines.length,
352
+ first?.start,
353
+ last?.end,
354
+ bins ? `bins:${bins.length}:${ctx.binSize}:${maxBinCount}` : "lines",
355
+ d0,
356
+ d1,
357
+ ticks.length,
358
+ rowHeight,
359
+ ].join("|");
360
+
361
+ return (
362
+ <div
363
+ data-slot="performance-spectrum-row"
364
+ data-selected={selected ? "" : undefined}
365
+ className="border-t border-border-strong"
366
+ >
367
+ <CanvasLayer<SpectrumLine | SpectrumBin>
368
+ accessibleLabel={selected ? `${segmentLabel}, ${labels.selected}` : segmentLabel}
369
+ accessibleDescription={fillLabel(labels.rowSummary, {
370
+ count: row.lines.length,
371
+ cases: row.caseCount,
372
+ median: formatDuration(row.medianDuration),
373
+ p90: formatDuration(row.p90Duration),
374
+ })}
375
+ draw={draw}
376
+ drawSignature={drawSignature}
377
+ focusRect={focusRect}
378
+ height={rowHeight}
379
+ hitTest={hitTest}
380
+ labelFor={labelFor}
381
+ onDatapointActivate={activate}
382
+ points={points}
383
+ renderTooltip={renderTooltip}
384
+ style={{ height: rowHeight }}
385
+ />
386
+ </div>
387
+ );
388
+ }
389
+
390
+ /**
391
+ * The performance spectrum.
392
+ *
393
+ * @example
394
+ * ```tsx
395
+ * <PerformanceSpectrum
396
+ * log={explorer.filteredLog}
397
+ * order="frequency"
398
+ * onFilterIntent={(intent) => setCaseFilter(intent)}
399
+ * />
400
+ * ```
401
+ */
402
+ export const PerformanceSpectrum = forwardRef<HTMLDivElement, PerformanceSpectrumProps>(
403
+ function PerformanceSpectrum(
404
+ {
405
+ log,
406
+ order = "frequency",
407
+ segmentLimit = PERFORMANCE_SPECTRUM_SEGMENT_LIMIT,
408
+ mode = "lines",
409
+ binSize = PERFORMANCE_SPECTRUM_DEFAULT_BIN_SIZE,
410
+ selection,
411
+ onFilterIntent,
412
+ onCaseSelect,
413
+ height,
414
+ tableView = false,
415
+ loading = false,
416
+ labels: labelOverrides,
417
+ className,
418
+ ...props
419
+ },
420
+ ref,
421
+ ) {
422
+ const { formatDate } = useLocale();
423
+ const labels = useMemo<PerformanceSpectrumLabels>(
424
+ () => ({ ...PERFORMANCE_SPECTRUM_DEFAULT_LABELS, ...labelOverrides }),
425
+ [labelOverrides],
426
+ );
427
+
428
+ const normalized = useMemo(() => asNormalizedLog(log), [log]);
429
+
430
+ // `order` is often an inline literal (`{ variantId }`), so key the derivation on its
431
+ // CONTENT rather than its identity — re-extracting variants on every render is the
432
+ // most expensive thing this component could do by accident.
433
+ const orderKey = JSON.stringify(order);
434
+ const resolvedOrder = useMemo<SegmentDefinition[]>(() => {
435
+ const limit = Math.max(0, Math.floor(segmentLimit));
436
+ const parsed = JSON.parse(orderKey) as PerformanceSpectrumOrder;
437
+ if (parsed === "frequency") return segmentOrderByFrequency(discoverGraph(normalized), limit);
438
+ if (Array.isArray(parsed)) return parsed.slice(0, limit);
439
+ const variant = extractVariants(normalized).find((v) => v.id === parsed.variantId);
440
+ return variant ? segmentOrderForVariant(variant).slice(0, limit) : [];
441
+ }, [normalized, orderKey, segmentLimit]);
442
+
443
+ const rows = useMemo(
444
+ () => buildSpectrumRows(resolvedOrder, segmentsFor(normalized, resolvedOrder)),
445
+ [normalized, resolvedOrder],
446
+ );
447
+ const domain = useMemo(() => spectrumDomain(rows), [rows]);
448
+ const ticks = useMemo(() => spectrumTicks(domain), [domain]);
449
+ const occurrenceCount = rows.reduce((n, row) => n + row.lines.length, 0);
450
+
451
+ const binsByRow = useMemo(
452
+ () =>
453
+ mode === "aggregated"
454
+ ? rows.map((row) => aggregateSegmentBins(row, binSize, domain[0]))
455
+ : null,
456
+ [binSize, domain, mode, rows],
457
+ );
458
+ const maxBinCount = useMemo(
459
+ () =>
460
+ binsByRow
461
+ ? binsByRow.reduce((m, bins) => bins.reduce((mm, b) => Math.max(mm, b.count), m), 0)
462
+ : 0,
463
+ [binsByRow],
464
+ );
465
+
466
+ const rowHeight =
467
+ height !== undefined && rows.length > 0
468
+ ? Math.max(24, Math.floor(height / rows.length))
469
+ : PERFORMANCE_SPECTRUM_ROW_HEIGHT;
470
+
471
+ const spanMs = domain[1] - domain[0];
472
+ const formatInstant = useCallback(
473
+ (ms: number) => formatDate(ms, { dateStyle: "medium", timeStyle: "short" }),
474
+ [formatDate],
475
+ );
476
+ const formatTick = useCallback(
477
+ (ms: number) =>
478
+ spanMs > 2 * 86_400_000
479
+ ? formatDate(ms, { month: "short", day: "numeric" })
480
+ : formatDate(ms, { hour: "2-digit", minute: "2-digit" }),
481
+ [formatDate, spanMs],
482
+ );
483
+ const quartileName = useCallback(
484
+ (q: number) => {
485
+ const base = fillLabel(labels.quartile, { n: q });
486
+ if (q === 1) return `${base} (${labels.quartileFastest})`;
487
+ if (q === 4) return `${base} (${labels.quartileSlowest})`;
488
+ return base;
489
+ },
490
+ [labels],
491
+ );
492
+
493
+ const quartileColors = useMemo(() => resolvePalette("sequential", 4), []);
494
+
495
+ const contextValue = useMemo<PerformanceSpectrumContextValue>(
496
+ () => ({
497
+ labels,
498
+ mode,
499
+ binSize: Number.isFinite(binSize) && binSize > 0 ? binSize : 1,
500
+ domain,
501
+ ticks,
502
+ rowHeight,
503
+ quartileColors,
504
+ formatInstant,
505
+ formatDuration: formatDurationMs,
506
+ quartileName,
507
+ onCaseSelect,
508
+ }),
509
+ [
510
+ binSize,
511
+ domain,
512
+ formatInstant,
513
+ labels,
514
+ mode,
515
+ onCaseSelect,
516
+ quartileColors,
517
+ quartileName,
518
+ rowHeight,
519
+ ticks,
520
+ ],
521
+ );
522
+
523
+ // ── Brush ──────────────────────────────────────────────────────────────────
524
+ const trackRef = useRef<HTMLDivElement | null>(null);
525
+ const hintId = useId();
526
+ const [brush, setBrush] = useState<{ from: number; to: number } | null>(null);
527
+ const [caret, setCaret] = useState<number | null>(null);
528
+ const [trackFocused, setTrackFocused] = useState(false);
529
+ const anchorRef = useRef<number | null>(null);
530
+ const dragRef = useRef<{ pointerId: number; startX: number } | null>(null);
531
+
532
+ const timeAt = (clientX: number): number | null => {
533
+ const rect = trackRef.current?.getBoundingClientRect();
534
+ if (!rect || rect.width <= 0 || !Number.isFinite(clientX)) return null;
535
+ const f = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
536
+ return domain[0] + f * spanMs;
537
+ };
538
+
539
+ const commit = (range: { from: number; to: number } | null) => {
540
+ if (!onFilterIntent || !range) return;
541
+ const lo = Math.min(range.from, range.to);
542
+ const hi = Math.max(range.from, range.to);
543
+ if (hi <= lo) return;
544
+ onFilterIntent({ kind: "cases", ids: casesInRange(rows, lo, hi) });
545
+ };
546
+
547
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
548
+ const t = timeAt(event.clientX);
549
+ if (t === null) return;
550
+ event.currentTarget.setPointerCapture?.(event.pointerId);
551
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX };
552
+ anchorRef.current = t;
553
+ setBrush({ from: t, to: t });
554
+ };
555
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
556
+ if (dragRef.current?.pointerId !== event.pointerId || anchorRef.current === null) return;
557
+ const t = timeAt(event.clientX);
558
+ if (t !== null) setBrush({ from: anchorRef.current, to: t });
559
+ };
560
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
561
+ const drag = dragRef.current;
562
+ if (drag?.pointerId !== event.pointerId || anchorRef.current === null) return;
563
+ dragRef.current = null;
564
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
565
+ const t = timeAt(event.clientX);
566
+ // A click (under 3 px of travel) clears rather than filtering to an instant.
567
+ if (t === null || Math.abs(event.clientX - drag.startX) < 3) {
568
+ setBrush(null);
569
+ anchorRef.current = null;
570
+ return;
571
+ }
572
+ const range = { from: anchorRef.current, to: t };
573
+ setBrush(range);
574
+ anchorRef.current = null;
575
+ commit(range);
576
+ };
577
+
578
+ const onTrackKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
579
+ const step = spanMs / 48;
580
+ const current = caret ?? domain[0];
581
+ let next: number | null = null;
582
+ switch (event.key) {
583
+ case "ArrowRight":
584
+ next = current + step;
585
+ break;
586
+ case "ArrowLeft":
587
+ next = current - step;
588
+ break;
589
+ case "PageUp":
590
+ next = current + step * 6;
591
+ break;
592
+ case "PageDown":
593
+ next = current - step * 6;
594
+ break;
595
+ case "Home":
596
+ next = domain[0];
597
+ break;
598
+ case "End":
599
+ next = domain[1];
600
+ break;
601
+ case "Enter":
602
+ event.preventDefault();
603
+ commit(brush);
604
+ return;
605
+ case "Escape":
606
+ event.preventDefault();
607
+ setBrush(null);
608
+ anchorRef.current = null;
609
+ return;
610
+ default:
611
+ return;
612
+ }
613
+ event.preventDefault();
614
+ const clamped = Math.min(domain[1], Math.max(domain[0], next));
615
+ if (event.shiftKey) {
616
+ if (anchorRef.current === null) anchorRef.current = current;
617
+ setBrush({ from: anchorRef.current, to: clamped });
618
+ } else {
619
+ anchorRef.current = null;
620
+ }
621
+ setCaret(clamped);
622
+ };
623
+
624
+ const pct = (t: number) => `${((t - domain[0]) / spanMs) * 100}%`;
625
+ const brushText =
626
+ brush && brush.from !== brush.to
627
+ ? fillLabel(labels.brushRange, {
628
+ start: formatInstant(Math.min(brush.from, brush.to)),
629
+ end: formatInstant(Math.max(brush.from, brush.to)),
630
+ count: casesInRange(rows, brush.from, brush.to).length,
631
+ })
632
+ : "";
633
+
634
+ // ── States ─────────────────────────────────────────────────────────────────
635
+ if (loading) {
636
+ return (
637
+ <div
638
+ ref={ref}
639
+ data-slot="performance-spectrum"
640
+ data-state="loading"
641
+ className={cn("relative flex h-72 flex-col", className)}
642
+ {...props}
643
+ >
644
+ <StatePanel kind="loading" title={labels.loading} />
645
+ </div>
646
+ );
647
+ }
648
+
649
+ if (occurrenceCount === 0) {
650
+ return (
651
+ <div
652
+ ref={ref}
653
+ data-slot="performance-spectrum"
654
+ data-state="empty"
655
+ className={cn("relative flex h-72 flex-col", className)}
656
+ {...props}
657
+ >
658
+ <StatePanel kind="empty" title={labels.empty} description={labels.emptyBody} />
659
+ </div>
660
+ );
661
+ }
662
+
663
+ const segmentLabel = (row: SpectrumRow) =>
664
+ row.definition.label ??
665
+ fillLabel(labels.segment, { from: row.definition.from, to: row.definition.to });
666
+
667
+ if (tableView) {
668
+ return (
669
+ <div
670
+ ref={ref}
671
+ data-slot="performance-spectrum"
672
+ data-view="table"
673
+ className={cn("flex flex-col gap-3", className)}
674
+ {...props}
675
+ >
676
+ <Table data-slot="performance-spectrum-table">
677
+ <TableCaption>{labels.tableCaption}</TableCaption>
678
+ <TableHeader>
679
+ <TableRow>
680
+ <TableHead scope="col">{labels.columnSegment}</TableHead>
681
+ <TableHead scope="col">{labels.columnCases}</TableHead>
682
+ <TableHead scope="col">{labels.columnMedian}</TableHead>
683
+ <TableHead scope="col">{labels.columnP90}</TableHead>
684
+ </TableRow>
685
+ </TableHeader>
686
+ <TableBody>
687
+ {rows.map((row) => {
688
+ const selected = isRowSelected(row, selection);
689
+ return (
690
+ <TableRow key={row.key} data-state={selected ? "selected" : undefined}>
691
+ <TableCell>
692
+ {segmentLabel(row)}
693
+ {selected ? <span className="sr-only">, {labels.selected}</span> : null}
694
+ </TableCell>
695
+ <TableCell className="tabular-nums">{row.caseCount}</TableCell>
696
+ <TableCell className="tabular-nums">
697
+ {formatDurationMs(row.medianDuration)}
698
+ </TableCell>
699
+ <TableCell className="tabular-nums">
700
+ {formatDurationMs(row.p90Duration)}
701
+ </TableCell>
702
+ </TableRow>
703
+ );
704
+ })}
705
+ </TableBody>
706
+ </Table>
707
+ </div>
708
+ );
709
+ }
710
+
711
+ const legendItems = quartileColors.map((color, i) => ({
712
+ label: quartileName(i + 1),
713
+ value: 0,
714
+ color,
715
+ }));
716
+
717
+ return (
718
+ <PerformanceSpectrumProvider value={contextValue}>
719
+ <div
720
+ ref={ref}
721
+ role="group"
722
+ aria-label={labels.label}
723
+ data-slot="performance-spectrum"
724
+ data-view="chart"
725
+ data-mode={mode}
726
+ className={cn("flex min-w-0 flex-col gap-3", className)}
727
+ {...props}
728
+ >
729
+ <div
730
+ data-slot="performance-spectrum-legend"
731
+ className="flex flex-wrap items-center gap-2"
732
+ >
733
+ <span className="text-meta text-muted-foreground">{labels.legend}</span>
734
+ <Legend items={legendItems} className="flex-row flex-wrap gap-1">
735
+ <LegendItemComponent className="flex items-center gap-1.5 px-1.5 py-0.5">
736
+ <LegendMarker />
737
+ <LegendLabel className="text-meta" />
738
+ </LegendItemComponent>
739
+ </Legend>
740
+ </div>
741
+
742
+ <div data-slot="performance-spectrum-body" dir="ltr" className="flex min-w-0">
743
+ <div
744
+ aria-hidden="true"
745
+ data-slot="performance-spectrum-gutter"
746
+ className="flex w-40 shrink-0 flex-col"
747
+ >
748
+ {rows.map((row) => {
749
+ const selected = isRowSelected(row, selection);
750
+ return (
751
+ <div
752
+ key={row.key}
753
+ data-selected={selected ? "" : undefined}
754
+ className={cn(
755
+ "flex min-w-0 flex-col justify-between border-t border-s-2 border-t-border-strong py-0.5 ps-2 pe-2",
756
+ selected ? "border-s-primary" : "border-s-transparent",
757
+ )}
758
+ style={{ height: rowHeight + 1 }}
759
+ >
760
+ <span className={cn("truncate text-meta", selected && "font-medium")}>
761
+ {row.definition.label ?? row.definition.from}
762
+ </span>
763
+ <span className="truncate text-meta text-muted-foreground">
764
+ {row.definition.label ? "" : row.definition.to}
765
+ </span>
766
+ </div>
767
+ );
768
+ })}
769
+ </div>
770
+
771
+ <div data-slot="performance-spectrum-plot" className="flex min-w-0 flex-1 flex-col">
772
+ <div className="relative">
773
+ {rows.map((row, index) => (
774
+ <PerformanceSpectrumRow
775
+ key={row.key}
776
+ row={row}
777
+ bins={binsByRow ? (binsByRow[index] as SpectrumBin[]) : null}
778
+ maxBinCount={maxBinCount}
779
+ selected={isRowSelected(row, selection)}
780
+ />
781
+ ))}
782
+ {brush && brush.from !== brush.to ? (
783
+ <div
784
+ aria-hidden="true"
785
+ data-slot="performance-spectrum-brush"
786
+ className="pointer-events-none absolute inset-y-0 border-x border-primary bg-primary/10"
787
+ style={{
788
+ insetInlineStart: pct(Math.min(brush.from, brush.to)),
789
+ width: `${(Math.abs(brush.to - brush.from) / spanMs) * 100}%`,
790
+ }}
791
+ />
792
+ ) : null}
793
+ {trackFocused && caret !== null ? (
794
+ <div
795
+ aria-hidden="true"
796
+ data-slot="performance-spectrum-caret"
797
+ className="pointer-events-none absolute inset-y-0 w-0.5 bg-ring"
798
+ style={{ insetInlineStart: pct(caret) }}
799
+ />
800
+ ) : null}
801
+ </div>
802
+
803
+ <div
804
+ ref={trackRef}
805
+ data-slot="performance-spectrum-axis"
806
+ className={cn(
807
+ "relative h-8 touch-none select-none border-t border-border-strong",
808
+ onFilterIntent && "focus-ring-inset cursor-col-resize",
809
+ )}
810
+ {...(onFilterIntent
811
+ ? {
812
+ role: "group",
813
+ tabIndex: 0,
814
+ "aria-label": labels.brush,
815
+ "aria-describedby": hintId,
816
+ onPointerDown,
817
+ onPointerMove,
818
+ onPointerUp,
819
+ onPointerCancel: () => {
820
+ dragRef.current = null;
821
+ anchorRef.current = null;
822
+ },
823
+ onKeyDown: onTrackKeyDown,
824
+ onFocus: () => {
825
+ setTrackFocused(true);
826
+ setCaret((c) => c ?? domain[0]);
827
+ },
828
+ onBlur: () => setTrackFocused(false),
829
+ }
830
+ : { "aria-hidden": true })}
831
+ >
832
+ {ticks.map((tick, i) => (
833
+ <span
834
+ key={tick}
835
+ className={cn(
836
+ "pointer-events-none absolute top-1 whitespace-nowrap text-meta text-muted-foreground tabular-nums",
837
+ i === 0
838
+ ? ""
839
+ : i === ticks.length - 1
840
+ ? "-translate-x-full"
841
+ : "-translate-x-1/2",
842
+ )}
843
+ style={{ insetInlineStart: pct(tick) }}
844
+ >
845
+ {formatTick(tick)}
846
+ </span>
847
+ ))}
848
+ </div>
849
+ {onFilterIntent ? (
850
+ <>
851
+ <span id={hintId} className="sr-only">
852
+ {labels.brushHint}
853
+ </span>
854
+ <span
855
+ role="status"
856
+ aria-live="polite"
857
+ data-slot="performance-spectrum-brush-status"
858
+ className="sr-only"
859
+ >
860
+ {brushText}
861
+ </span>
862
+ </>
863
+ ) : null}
864
+ </div>
865
+ </div>
866
+ </div>
867
+ </PerformanceSpectrumProvider>
868
+ );
869
+ },
870
+ );