@workflow/web-shared 5.0.0-beta.26 → 5.0.0-beta.28
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/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
- package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +3 -5
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
- package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/minimap.js +337 -0
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
- package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
- package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +5 -7
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +10 -3
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
- package/dist/components/new-trace-viewer/context.d.ts +2 -0
- package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/context.js +3 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +53 -181
- package/dist/components/new-trace-viewer/utils.d.ts +21 -0
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +44 -3
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +38 -35
- package/dist/components/sidebar/attributes-block.d.ts +18 -7
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +76 -18
- package/dist/components/sidebar/events-list.d.ts +1 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +9 -10
- package/dist/components/trace-viewer/components/map.d.ts +2 -2
- package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/node.d.ts +7 -7
- package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
- package/dist/components/ui/collapsible.d.ts +46 -0
- package/dist/components/ui/collapsible.d.ts.map +1 -0
- package/dist/components/ui/collapsible.js +66 -0
- package/dist/components/ui/data-inspector.js +2 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/hydration.d.ts +12 -2
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +41 -18
- package/dist/lib/utils.d.ts +8 -5
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +13 -16
- package/package.json +6 -6
- package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
- package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
- package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
- package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
- package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
- package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
- package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
- package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
- package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
- package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +21 -1
- package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
- package/src/components/new-trace-viewer/context.tsx +4 -1
- package/src/components/new-trace-viewer/trace-viewer.tsx +93 -312
- package/src/components/new-trace-viewer/utils.test.ts +65 -0
- package/src/components/new-trace-viewer/utils.ts +76 -2
- package/src/components/sidebar/attribute-panel.tsx +106 -105
- package/src/components/sidebar/attributes-block.tsx +170 -50
- package/src/components/sidebar/events-list.tsx +107 -92
- package/src/components/ui/collapsible.tsx +219 -0
- package/src/index.ts +8 -7
- package/src/lib/hydration.ts +48 -25
- package/src/lib/utils.test.ts +33 -0
- package/src/lib/utils.ts +12 -16
- package/dist/components/sidebar/detail-card.d.ts +0 -13
- package/dist/components/sidebar/detail-card.d.ts.map +0 -1
- package/dist/components/sidebar/detail-card.js +0 -36
- package/src/components/sidebar/detail-card.tsx +0 -143
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Circle } from 'lucide-react';
|
|
2
2
|
import { useRef } from 'react';
|
|
3
3
|
import { cn } from '../../../lib/cn';
|
|
4
|
-
import type { Span } from '../types';
|
|
5
4
|
import { formatDurationPrecise } from '../../trace-viewer/util/timing';
|
|
6
5
|
import {
|
|
7
6
|
SleepIcon,
|
|
@@ -10,7 +9,8 @@ import {
|
|
|
10
9
|
WorkflowIcon,
|
|
11
10
|
} from '../icons';
|
|
12
11
|
import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
|
|
13
|
-
import {
|
|
12
|
+
import type { Span } from '../types';
|
|
13
|
+
import { getSpanDurationMs, isSpanErrored } from '../utils';
|
|
14
14
|
import { MiddleTruncate } from './middle-truncate/middle-truncate';
|
|
15
15
|
import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
|
|
16
16
|
|
|
@@ -53,9 +53,7 @@ const EventRow = ({
|
|
|
53
53
|
onSelectSpan: (spanId: string) => void;
|
|
54
54
|
}) => {
|
|
55
55
|
const durationMs = getSpanDurationMs(span);
|
|
56
|
-
const
|
|
57
|
-
?.status as string | undefined;
|
|
58
|
-
const isErrored = span.status.code === 2 || workflowStatus === 'failed';
|
|
56
|
+
const isErrored = isSpanErrored(span);
|
|
59
57
|
const { icon: Icon, className: tagClassName } = getEventStyle(
|
|
60
58
|
span.resource,
|
|
61
59
|
isErrored
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
memo,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { cn } from '../../../lib/cn';
|
|
13
|
+
import {
|
|
14
|
+
formatDurationPrecise,
|
|
15
|
+
getHighResInMs,
|
|
16
|
+
} from '../../trace-viewer/util/timing';
|
|
17
|
+
import type { Span } from '../types';
|
|
18
|
+
import {
|
|
19
|
+
clampViewportToRoot,
|
|
20
|
+
isSpanErrored,
|
|
21
|
+
type RootBounds,
|
|
22
|
+
type ViewportRange,
|
|
23
|
+
wheelDeltaToPixels,
|
|
24
|
+
wheelZoomScaleFactor,
|
|
25
|
+
} from '../utils';
|
|
26
|
+
import { TIMELINE_PADDING_PX } from './timeline';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Constants
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const CANVAS_PAD_Y_PX = 6;
|
|
33
|
+
const MIN_LINE_HEIGHT_PX = 1.5;
|
|
34
|
+
const MAX_LINE_HEIGHT_PX = 3;
|
|
35
|
+
const MIN_LINE_WIDTH_PX = 2;
|
|
36
|
+
/** Pointer travel below this is treated as a click rather than a drag. */
|
|
37
|
+
const CLICK_DRAG_THRESHOLD_PX = 3;
|
|
38
|
+
const MIN_BRUSH_WIDTH_PX = 6;
|
|
39
|
+
/** Fraction of the current window that arrow keys pan by. */
|
|
40
|
+
const KEYBOARD_PAN_FRACTION = 0.1;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Line colors resolved from the design-system tokens on the container, so the
|
|
44
|
+
* canvas follows the active theme. Runs and steps carry the accent hues;
|
|
45
|
+
* passive spans (hooks, sleeps) stay gray like their event-list icons.
|
|
46
|
+
*/
|
|
47
|
+
const RESOURCE_LINE_TOKENS: Record<string, string> = {
|
|
48
|
+
run: '--ds-blue-400',
|
|
49
|
+
step: '--ds-green-400',
|
|
50
|
+
hook: '--ds-gray-400',
|
|
51
|
+
sleep: '--ds-gray-400',
|
|
52
|
+
};
|
|
53
|
+
const DEFAULT_LINE_TOKEN = '--ds-gray-400';
|
|
54
|
+
const ERROR_LINE_TOKEN = '--ds-red-400';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Density layer geometry
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
interface DensityLayout {
|
|
61
|
+
lineHeight: number;
|
|
62
|
+
/** [x, y, width] rects grouped by color token, one fill pass per color. */
|
|
63
|
+
rectsByToken: Map<string, [number, number, number][]>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function computeDensityLayout(
|
|
67
|
+
spans: Span[],
|
|
68
|
+
rootStartMs: number,
|
|
69
|
+
rootDurationMs: number,
|
|
70
|
+
contentWidth: number,
|
|
71
|
+
contentHeight: number
|
|
72
|
+
): DensityLayout {
|
|
73
|
+
const count = spans.length;
|
|
74
|
+
const lineHeight = Math.max(
|
|
75
|
+
MIN_LINE_HEIGHT_PX,
|
|
76
|
+
Math.min(MAX_LINE_HEIGHT_PX, contentHeight / count)
|
|
77
|
+
);
|
|
78
|
+
// Distribute rows across the strip; never spread a handful of spans further
|
|
79
|
+
// apart than one line + 1px gap.
|
|
80
|
+
const rowStep =
|
|
81
|
+
count > 1
|
|
82
|
+
? Math.min((contentHeight - lineHeight) / (count - 1), lineHeight + 1)
|
|
83
|
+
: 0;
|
|
84
|
+
|
|
85
|
+
const rectsByToken = new Map<string, [number, number, number][]>();
|
|
86
|
+
spans.forEach((span, index) => {
|
|
87
|
+
const token = isSpanErrored(span)
|
|
88
|
+
? ERROR_LINE_TOKEN
|
|
89
|
+
: (RESOURCE_LINE_TOKENS[span.resource] ?? DEFAULT_LINE_TOKEN);
|
|
90
|
+
const startMs = getHighResInMs(span.startTime);
|
|
91
|
+
const endMs = getHighResInMs(span.endTime);
|
|
92
|
+
const x =
|
|
93
|
+
TIMELINE_PADDING_PX +
|
|
94
|
+
((startMs - rootStartMs) / rootDurationMs) * contentWidth;
|
|
95
|
+
const w = Math.max(
|
|
96
|
+
MIN_LINE_WIDTH_PX,
|
|
97
|
+
((endMs - startMs) / rootDurationMs) * contentWidth
|
|
98
|
+
);
|
|
99
|
+
const y = CANVAS_PAD_Y_PX + index * rowStep;
|
|
100
|
+
const rects = rectsByToken.get(token) ?? [];
|
|
101
|
+
rects.push([x, y, w]);
|
|
102
|
+
rectsByToken.set(token, rects);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { lineHeight, rectsByToken };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Hooks
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** Track the display's device pixel ratio (changes when moving monitors). */
|
|
113
|
+
function useDpr(): number {
|
|
114
|
+
const [dpr, setDpr] = useState(1);
|
|
115
|
+
useLayoutEffect(() => {
|
|
116
|
+
const onChange = (): void => setDpr(window.devicePixelRatio || 1);
|
|
117
|
+
const media = matchMedia(`(resolution: ${dpr}dppx)`);
|
|
118
|
+
media.addEventListener('change', onChange);
|
|
119
|
+
onChange();
|
|
120
|
+
return () => media.removeEventListener('change', onChange);
|
|
121
|
+
}, [dpr]);
|
|
122
|
+
return dpr;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Bump a counter when the theme (html class/data-theme) changes. */
|
|
126
|
+
function useThemeVersion(): number {
|
|
127
|
+
const [version, setVersion] = useState(0);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const observer = new MutationObserver(() => setVersion((v) => v + 1));
|
|
130
|
+
observer.observe(document.documentElement, {
|
|
131
|
+
attributes: true,
|
|
132
|
+
attributeFilter: ['class', 'data-theme', 'style'],
|
|
133
|
+
});
|
|
134
|
+
return () => observer.disconnect();
|
|
135
|
+
}, []);
|
|
136
|
+
return version;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Minimap
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
type DragState =
|
|
144
|
+
| { mode: 'pan'; grabOffsetMs: number }
|
|
145
|
+
| { mode: 'resize-left' }
|
|
146
|
+
| { mode: 'resize-right' }
|
|
147
|
+
| { mode: 'select'; originX: number; originMs: number };
|
|
148
|
+
|
|
149
|
+
export const Minimap = memo(function Minimap({
|
|
150
|
+
spans,
|
|
151
|
+
root,
|
|
152
|
+
viewport,
|
|
153
|
+
minViewportMs,
|
|
154
|
+
onViewportChange,
|
|
155
|
+
onAnimateTo,
|
|
156
|
+
}: {
|
|
157
|
+
spans: Span[];
|
|
158
|
+
root: RootBounds;
|
|
159
|
+
viewport: ViewportRange;
|
|
160
|
+
minViewportMs: number;
|
|
161
|
+
/** Immediate viewport update (used while dragging). */
|
|
162
|
+
onViewportChange: (viewport: ViewportRange) => void;
|
|
163
|
+
/** Eased viewport transition (used for click-to-jump and selections). */
|
|
164
|
+
onAnimateTo: (viewport: ViewportRange) => void;
|
|
165
|
+
}): ReactNode {
|
|
166
|
+
const {
|
|
167
|
+
startTime: rootStartMs,
|
|
168
|
+
endTime: rootEndMs,
|
|
169
|
+
duration: rootDurationMs,
|
|
170
|
+
} = root;
|
|
171
|
+
|
|
172
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
173
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
174
|
+
const dpr = useDpr();
|
|
175
|
+
const themeVersion = useThemeVersion();
|
|
176
|
+
|
|
177
|
+
// Latest viewport for pointer handlers, without re-binding them per frame.
|
|
178
|
+
const viewportRef = useRef<ViewportRange>(viewport);
|
|
179
|
+
viewportRef.current = viewport;
|
|
180
|
+
|
|
181
|
+
const [drag, setDrag] = useState<DragState | null>(null);
|
|
182
|
+
const [selection, setSelection] = useState<{ x0: number; x1: number } | null>(
|
|
183
|
+
null
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const xToTime = useCallback(
|
|
187
|
+
(x: number, contentWidth: number): number => {
|
|
188
|
+
if (contentWidth <= 0) return rootStartMs;
|
|
189
|
+
const fraction = (x - TIMELINE_PADDING_PX) / contentWidth;
|
|
190
|
+
return Math.min(
|
|
191
|
+
Math.max(rootStartMs + fraction * rootDurationMs, rootStartMs),
|
|
192
|
+
rootEndMs
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
[rootStartMs, rootDurationMs, rootEndMs]
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const clamp = useCallback(
|
|
199
|
+
(next: ViewportRange): ViewportRange =>
|
|
200
|
+
clampViewportToRoot(next, rootStartMs, rootEndMs, minViewportMs),
|
|
201
|
+
[rootStartMs, rootEndMs, minViewportMs]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Density canvas: one thin line per span across the full run. Sizing is
|
|
205
|
+
// read live and redraws run straight from the ResizeObserver (which fires
|
|
206
|
+
// before paint), so layout changes — e.g. the detail panel opening — never
|
|
207
|
+
// paint a frame with a stale, stretched bitmap.
|
|
208
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger — the canvas re-resolves its token colors when the theme flips
|
|
209
|
+
useLayoutEffect(() => {
|
|
210
|
+
const canvas = canvasRef.current;
|
|
211
|
+
const container = containerRef.current;
|
|
212
|
+
const ctx = canvas?.getContext('2d');
|
|
213
|
+
if (!canvas || !container || !ctx) return;
|
|
214
|
+
|
|
215
|
+
const draw = (): void => {
|
|
216
|
+
const width = canvas.clientWidth;
|
|
217
|
+
const height = canvas.clientHeight;
|
|
218
|
+
const contentWidth = width - TIMELINE_PADDING_PX * 2;
|
|
219
|
+
if (width <= 0 || height <= 0 || contentWidth <= 0) return;
|
|
220
|
+
|
|
221
|
+
canvas.width = Math.round(width * dpr);
|
|
222
|
+
canvas.height = Math.round(height * dpr);
|
|
223
|
+
ctx.scale(dpr, dpr);
|
|
224
|
+
ctx.clearRect(0, 0, width, height);
|
|
225
|
+
if (spans.length === 0 || rootDurationMs <= 0) return;
|
|
226
|
+
|
|
227
|
+
const { lineHeight, rectsByToken } = computeDensityLayout(
|
|
228
|
+
spans,
|
|
229
|
+
rootStartMs,
|
|
230
|
+
rootDurationMs,
|
|
231
|
+
contentWidth,
|
|
232
|
+
height - CANVAS_PAD_Y_PX * 2
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const style = getComputedStyle(container);
|
|
236
|
+
const drawToken = (token: string): void => {
|
|
237
|
+
const rects = rectsByToken.get(token);
|
|
238
|
+
if (!rects) return;
|
|
239
|
+
ctx.fillStyle =
|
|
240
|
+
style.getPropertyValue(token).trim() || 'rgb(160,160,160)';
|
|
241
|
+
ctx.beginPath();
|
|
242
|
+
for (const [x, y, w] of rects) {
|
|
243
|
+
ctx.roundRect(x, y, w, lineHeight, lineHeight / 2);
|
|
244
|
+
}
|
|
245
|
+
ctx.fill();
|
|
246
|
+
};
|
|
247
|
+
// Errored spans are drawn last so red never disappears under neighbors.
|
|
248
|
+
for (const token of rectsByToken.keys()) {
|
|
249
|
+
if (token !== ERROR_LINE_TOKEN) drawToken(token);
|
|
250
|
+
}
|
|
251
|
+
drawToken(ERROR_LINE_TOKEN);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
draw();
|
|
255
|
+
const observer = new ResizeObserver(draw);
|
|
256
|
+
observer.observe(container);
|
|
257
|
+
return () => observer.disconnect();
|
|
258
|
+
}, [spans, rootStartMs, rootDurationMs, dpr, themeVersion]);
|
|
259
|
+
|
|
260
|
+
// Content width is read from the live rect at event time, so pointer math
|
|
261
|
+
// stays exact while the strip is being resized mid-gesture.
|
|
262
|
+
const pointerFrame = useCallback(
|
|
263
|
+
(e: React.PointerEvent<HTMLDivElement>): { x: number; cw: number } => {
|
|
264
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
265
|
+
return {
|
|
266
|
+
x: e.clientX - rect.left,
|
|
267
|
+
cw: rect.width - TIMELINE_PADDING_PX * 2,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
[]
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const resolveDragState = useCallback(
|
|
274
|
+
(target: HTMLElement, x: number, cw: number): DragState => {
|
|
275
|
+
if (target.closest('[data-minimap-handle="left"]')) {
|
|
276
|
+
return { mode: 'resize-left' };
|
|
277
|
+
}
|
|
278
|
+
if (target.closest('[data-minimap-handle="right"]')) {
|
|
279
|
+
return { mode: 'resize-right' };
|
|
280
|
+
}
|
|
281
|
+
// Panning a full-extent window is a no-op, so let a drag on the brush
|
|
282
|
+
// start a selection instead — the natural first gesture on the map.
|
|
283
|
+
const { start, end } = viewportRef.current;
|
|
284
|
+
const isFullExtent =
|
|
285
|
+
start - rootStartMs < rootDurationMs * 0.001 &&
|
|
286
|
+
rootEndMs - end < rootDurationMs * 0.001;
|
|
287
|
+
if (target.closest('[data-minimap-brush]') && !isFullExtent) {
|
|
288
|
+
return { mode: 'pan', grabOffsetMs: xToTime(x, cw) - start };
|
|
289
|
+
}
|
|
290
|
+
return { mode: 'select', originX: x, originMs: xToTime(x, cw) };
|
|
291
|
+
},
|
|
292
|
+
[xToTime, rootStartMs, rootDurationMs, rootEndMs]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const handlePointerDown = useCallback(
|
|
296
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
297
|
+
if (e.button !== 0) return;
|
|
298
|
+
const { x, cw } = pointerFrame(e);
|
|
299
|
+
if (cw <= 0) return;
|
|
300
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
301
|
+
setDrag(resolveDragState(e.target as HTMLElement, x, cw));
|
|
302
|
+
},
|
|
303
|
+
[pointerFrame, resolveDragState]
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const handlePointerMove = useCallback(
|
|
307
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
308
|
+
if (!drag) return;
|
|
309
|
+
const { x, cw } = pointerFrame(e);
|
|
310
|
+
const t = xToTime(x, cw);
|
|
311
|
+
const { start, end } = viewportRef.current;
|
|
312
|
+
|
|
313
|
+
if (drag.mode === 'pan') {
|
|
314
|
+
const duration = end - start;
|
|
315
|
+
onViewportChange(
|
|
316
|
+
clamp({
|
|
317
|
+
start: t - drag.grabOffsetMs,
|
|
318
|
+
end: t - drag.grabOffsetMs + duration,
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
} else if (drag.mode === 'resize-left') {
|
|
322
|
+
onViewportChange({ start: Math.min(t, end - minViewportMs), end });
|
|
323
|
+
} else if (drag.mode === 'resize-right') {
|
|
324
|
+
onViewportChange({ start, end: Math.max(t, start + minViewportMs) });
|
|
325
|
+
} else if (
|
|
326
|
+
selection ||
|
|
327
|
+
Math.abs(x - drag.originX) > CLICK_DRAG_THRESHOLD_PX
|
|
328
|
+
) {
|
|
329
|
+
setSelection({
|
|
330
|
+
x0: Math.min(drag.originX, x),
|
|
331
|
+
x1: Math.max(drag.originX, x),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
[
|
|
336
|
+
drag,
|
|
337
|
+
selection,
|
|
338
|
+
pointerFrame,
|
|
339
|
+
xToTime,
|
|
340
|
+
onViewportChange,
|
|
341
|
+
clamp,
|
|
342
|
+
minViewportMs,
|
|
343
|
+
]
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const endDrag = useCallback(
|
|
347
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
348
|
+
if (!drag) return;
|
|
349
|
+
if (drag.mode === 'select') {
|
|
350
|
+
const { x, cw } = pointerFrame(e);
|
|
351
|
+
const t = xToTime(x, cw);
|
|
352
|
+
if (selection) {
|
|
353
|
+
onAnimateTo(
|
|
354
|
+
clamp({
|
|
355
|
+
start: Math.min(drag.originMs, t),
|
|
356
|
+
end: Math.max(drag.originMs, t),
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
// Plain click: keep the current zoom, center the window there.
|
|
361
|
+
const { start, end } = viewportRef.current;
|
|
362
|
+
const duration = end - start;
|
|
363
|
+
onAnimateTo(
|
|
364
|
+
clamp({ start: t - duration / 2, end: t + duration / 2 })
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
setSelection(null);
|
|
369
|
+
setDrag(null);
|
|
370
|
+
},
|
|
371
|
+
[drag, selection, pointerFrame, xToTime, onAnimateTo, clamp]
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const handleLostPointerCapture = useCallback(() => {
|
|
375
|
+
setSelection(null);
|
|
376
|
+
setDrag(null);
|
|
377
|
+
}, []);
|
|
378
|
+
|
|
379
|
+
const handleDoubleClick = useCallback(() => {
|
|
380
|
+
onAnimateTo({ start: rootStartMs, end: rootEndMs });
|
|
381
|
+
}, [onAnimateTo, rootStartMs, rootEndMs]);
|
|
382
|
+
|
|
383
|
+
// Wheel: same gestures as the timeline, mapped through the full extent.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const el = containerRef.current;
|
|
386
|
+
if (!el || rootDurationMs <= 0) return;
|
|
387
|
+
|
|
388
|
+
// The minimap's x axis is the full run, so zoom around the absolute time
|
|
389
|
+
// under the cursor rather than a viewport fraction.
|
|
390
|
+
const zoomAtCursor = (e: WheelEvent, cw: number, left: number): void => {
|
|
391
|
+
const scaleFactor = wheelZoomScaleFactor(e);
|
|
392
|
+
const { start, end } = viewportRef.current;
|
|
393
|
+
const duration = end - start;
|
|
394
|
+
const cursorFraction = Math.min(
|
|
395
|
+
Math.max((e.clientX - left - TIMELINE_PADDING_PX) / cw, 0),
|
|
396
|
+
1
|
|
397
|
+
);
|
|
398
|
+
const cursorMs = rootStartMs + cursorFraction * rootDurationMs;
|
|
399
|
+
const anchor = Math.min(
|
|
400
|
+
Math.max(duration > 0 ? (cursorMs - start) / duration : 0.5, 0),
|
|
401
|
+
1
|
|
402
|
+
);
|
|
403
|
+
const newDuration = duration * scaleFactor;
|
|
404
|
+
onViewportChange(
|
|
405
|
+
clamp({
|
|
406
|
+
start: cursorMs - anchor * newDuration,
|
|
407
|
+
end: cursorMs + (1 - anchor) * newDuration,
|
|
408
|
+
})
|
|
409
|
+
);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// Pan in map coordinates: one map pixel is one full-extent fraction.
|
|
413
|
+
const panByMapPixels = (e: WheelEvent, cw: number): void => {
|
|
414
|
+
const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
|
|
415
|
+
const panMs = (dx / cw) * rootDurationMs;
|
|
416
|
+
const { start, end } = viewportRef.current;
|
|
417
|
+
onViewportChange(clamp({ start: start + panMs, end: end + panMs }));
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const onWheel = (e: WheelEvent): void => {
|
|
421
|
+
const isZoomGesture = e.ctrlKey || e.metaKey;
|
|
422
|
+
const hasDeltaX = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
423
|
+
if (!isZoomGesture && !hasDeltaX) return;
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
|
|
426
|
+
const rect = el.getBoundingClientRect();
|
|
427
|
+
const cw = rect.width - TIMELINE_PADDING_PX * 2;
|
|
428
|
+
if (cw <= 0) return;
|
|
429
|
+
if (isZoomGesture) zoomAtCursor(e, cw, rect.left);
|
|
430
|
+
else panByMapPixels(e, cw);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
434
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
435
|
+
}, [rootStartMs, rootDurationMs, onViewportChange, clamp]);
|
|
436
|
+
|
|
437
|
+
// Arrow keys pan the focused brush; Home/End jump to the ends.
|
|
438
|
+
const handleKeyDown = useCallback(
|
|
439
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
440
|
+
const { start, end } = viewportRef.current;
|
|
441
|
+
const duration = end - start;
|
|
442
|
+
const pan = duration * KEYBOARD_PAN_FRACTION;
|
|
443
|
+
if (e.key === 'ArrowLeft') {
|
|
444
|
+
onViewportChange(clamp({ start: start - pan, end: end - pan }));
|
|
445
|
+
} else if (e.key === 'ArrowRight') {
|
|
446
|
+
onViewportChange(clamp({ start: start + pan, end: end + pan }));
|
|
447
|
+
} else if (e.key === 'Home') {
|
|
448
|
+
onViewportChange(
|
|
449
|
+
clamp({ start: rootStartMs, end: rootStartMs + duration })
|
|
450
|
+
);
|
|
451
|
+
} else if (e.key === 'End') {
|
|
452
|
+
onViewportChange(
|
|
453
|
+
clamp({ start: rootEndMs - duration, end: rootEndMs })
|
|
454
|
+
);
|
|
455
|
+
} else {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
e.preventDefault();
|
|
459
|
+
},
|
|
460
|
+
[onViewportChange, clamp, rootStartMs, rootEndMs]
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// Brush geometry in CSS calc() from time fractions, so the thumb reflows in
|
|
464
|
+
// the same frame as any container resize instead of lagging a state update.
|
|
465
|
+
const startFraction = (viewport.start - rootStartMs) / rootDurationMs;
|
|
466
|
+
const endFraction = (viewport.end - rootStartMs) / rootDurationMs;
|
|
467
|
+
const trackWidth = `(100% - ${TIMELINE_PADDING_PX * 2}px)`;
|
|
468
|
+
const brushStyle = {
|
|
469
|
+
left: `calc(${TIMELINE_PADDING_PX}px + ${startFraction} * ${trackWidth})`,
|
|
470
|
+
width: `max(${MIN_BRUSH_WIDTH_PX}px, calc(${
|
|
471
|
+
endFraction - startFraction
|
|
472
|
+
} * ${trackWidth}))`,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const viewDuration = viewport.end - viewport.start;
|
|
476
|
+
const scrollableMs = Math.max(rootDurationMs - viewDuration, 0);
|
|
477
|
+
const scrollPercent =
|
|
478
|
+
scrollableMs > 0
|
|
479
|
+
? Math.round(((viewport.start - rootStartMs) / scrollableMs) * 100)
|
|
480
|
+
: 0;
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: pointer gestures are a mouse-only enhancement; the brush inside is keyboard-operable
|
|
484
|
+
<div
|
|
485
|
+
ref={containerRef}
|
|
486
|
+
className={cn(
|
|
487
|
+
'relative h-10 min-h-10 w-full border-b border-gray-alpha-400 bg-background-100 select-none overflow-hidden touch-none',
|
|
488
|
+
drag?.mode === 'select' && 'cursor-crosshair'
|
|
489
|
+
)}
|
|
490
|
+
onPointerDown={handlePointerDown}
|
|
491
|
+
onPointerMove={handlePointerMove}
|
|
492
|
+
onPointerUp={endDrag}
|
|
493
|
+
onPointerCancel={endDrag}
|
|
494
|
+
onLostPointerCapture={handleLostPointerCapture}
|
|
495
|
+
onDoubleClick={handleDoubleClick}
|
|
496
|
+
>
|
|
497
|
+
{/* Multiply on light themes / screen on dark, so overlapping span lines
|
|
498
|
+
deepen instead of occluding and the glass thumb reads through them. */}
|
|
499
|
+
<canvas
|
|
500
|
+
ref={canvasRef}
|
|
501
|
+
aria-hidden
|
|
502
|
+
className="pointer-events-none absolute inset-0 h-full w-full mix-blend-multiply [.dark-theme_&]:mix-blend-screen [.dark_&]:mix-blend-screen [[data-theme=dark]_&]:mix-blend-screen"
|
|
503
|
+
/>
|
|
504
|
+
{selection ? (
|
|
505
|
+
<div
|
|
506
|
+
aria-hidden
|
|
507
|
+
className="pointer-events-none absolute inset-y-0 border border-blue-500 bg-blue-300 opacity-40"
|
|
508
|
+
style={{ left: selection.x0, width: selection.x1 - selection.x0 }}
|
|
509
|
+
/>
|
|
510
|
+
) : null}
|
|
511
|
+
<div
|
|
512
|
+
data-minimap-brush
|
|
513
|
+
role="scrollbar"
|
|
514
|
+
aria-controls="trace-timeline"
|
|
515
|
+
aria-orientation="horizontal"
|
|
516
|
+
aria-valuemin={0}
|
|
517
|
+
aria-valuemax={100}
|
|
518
|
+
aria-valuenow={scrollPercent}
|
|
519
|
+
aria-valuetext={`Viewing ${formatDurationPrecise(
|
|
520
|
+
viewport.start - rootStartMs
|
|
521
|
+
)} to ${formatDurationPrecise(
|
|
522
|
+
viewport.end - rootStartMs
|
|
523
|
+
)} of ${formatDurationPrecise(rootDurationMs)}`}
|
|
524
|
+
aria-label="Timeline overview"
|
|
525
|
+
tabIndex={0}
|
|
526
|
+
onKeyDown={handleKeyDown}
|
|
527
|
+
className={cn(
|
|
528
|
+
// Glass thumb, matching the legacy trace viewer's map window.
|
|
529
|
+
'group/minimap-brush absolute top-0.5 bottom-0.5 rounded-[3px] border border-gray-alpha-400 bg-gray-alpha-200 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ds-focus-color)]',
|
|
530
|
+
drag?.mode === 'pan' ? 'cursor-grabbing' : 'cursor-grab'
|
|
531
|
+
)}
|
|
532
|
+
style={brushStyle}
|
|
533
|
+
>
|
|
534
|
+
<div
|
|
535
|
+
data-minimap-handle="left"
|
|
536
|
+
className="absolute inset-y-0 -left-1 flex w-2.5 cursor-ew-resize items-center justify-center"
|
|
537
|
+
>
|
|
538
|
+
<span
|
|
539
|
+
className={cn(
|
|
540
|
+
'h-3.5 w-[3px] rounded-full bg-gray-600 opacity-0 transition-opacity group-hover/minimap-brush:opacity-100 group-focus-visible/minimap-brush:opacity-100',
|
|
541
|
+
drag && 'opacity-100'
|
|
542
|
+
)}
|
|
543
|
+
/>
|
|
544
|
+
</div>
|
|
545
|
+
<div
|
|
546
|
+
data-minimap-handle="right"
|
|
547
|
+
className="absolute inset-y-0 -right-1 flex w-2.5 cursor-ew-resize items-center justify-center"
|
|
548
|
+
>
|
|
549
|
+
<span
|
|
550
|
+
className={cn(
|
|
551
|
+
'h-3.5 w-[3px] rounded-full bg-gray-600 opacity-0 transition-opacity group-hover/minimap-brush:opacity-100 group-focus-visible/minimap-brush:opacity-100',
|
|
552
|
+
drag && 'opacity-100'
|
|
553
|
+
)}
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout constants shared by SplitPane and TraceViewerSkeleton. Kept in a
|
|
3
|
+
* module without 'use client' so the skeleton's import chain stays
|
|
4
|
+
* server-component-safe for external consumers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Width (px) of the draggable gutter column between the list and timeline. */
|
|
8
|
+
export const GUTTER_PX = 1;
|
|
9
|
+
|
|
10
|
+
/** Minimum width (px) of either SplitPane pane. */
|
|
11
|
+
export const MIN_PX = 50;
|
|
12
|
+
|
|
13
|
+
/** Default width (px) of the start (event list) pane. */
|
|
14
|
+
export const DEFAULT_START_PX = 340;
|
|
15
|
+
|
|
16
|
+
/** Grid column template for the [list | gutter | timeline] split. */
|
|
17
|
+
export function paneColTemplate(startPx: number): string {
|
|
18
|
+
return `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
|
|
19
|
+
}
|