@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.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- package/src/violation-list/violation-list.tsx +259 -0
|
@@ -0,0 +1,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
|
+
);
|