@workflow/web-shared 5.0.0-beta.27 → 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/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/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/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 +6 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +46 -94
- 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/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 +5 -5
- package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
- package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
- package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +17 -0
- package/src/components/new-trace-viewer/trace-viewer.tsx +77 -115
- package/src/components/new-trace-viewer/utils.test.ts +65 -0
- package/src/components/new-trace-viewer/utils.ts +76 -2
- package/src/lib/utils.test.ts +33 -0
- package/src/lib/utils.ts +12 -16
|
@@ -12,13 +12,17 @@ import {
|
|
|
12
12
|
} from 'react';
|
|
13
13
|
import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
|
|
14
14
|
import { useReducedMotion } from '../../hooks/use-reduced-motion';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
formatDurationPrecise,
|
|
17
|
+
getHighResInMs,
|
|
18
|
+
} from '../trace-viewer/util/timing';
|
|
16
19
|
import { IconButton } from '../ui/icon-button';
|
|
17
20
|
import { Kbd } from '../ui/kbd';
|
|
18
21
|
import { Spinner } from '../ui/spinner';
|
|
19
22
|
import { TooltipProvider } from '../ui/tooltip';
|
|
20
23
|
import { TraceDetailPanel } from './components/detail-panel';
|
|
21
24
|
import EventList from './components/event-list';
|
|
25
|
+
import { Minimap } from './components/minimap';
|
|
22
26
|
import { SplitPane } from './components/split-pane';
|
|
23
27
|
import {
|
|
24
28
|
TIMELINE_PADDING_PX,
|
|
@@ -30,7 +34,14 @@ import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
|
|
|
30
34
|
import { ActiveSpanProvider, useActiveSpan } from './context';
|
|
31
35
|
import { searchSpans } from './search';
|
|
32
36
|
import type { TraceWithMeta } from './types';
|
|
33
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
clampViewportToRoot,
|
|
39
|
+
computeRootBounds,
|
|
40
|
+
computeTimeMarkers,
|
|
41
|
+
type ViewportRange,
|
|
42
|
+
wheelDeltaToPixels,
|
|
43
|
+
wheelZoomScaleFactor,
|
|
44
|
+
} from './utils';
|
|
34
45
|
|
|
35
46
|
interface NewTraceViewerProps {
|
|
36
47
|
trace: TraceWithMeta;
|
|
@@ -43,17 +54,12 @@ const MIN_VIEWPORT_MS = 0.001;
|
|
|
43
54
|
|
|
44
55
|
const ZOOM_DEBOUNCE_MS = 150;
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
end: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function useAnimatedViewport(initial: Viewport) {
|
|
52
|
-
const [viewport, setViewportState] = useState<Viewport>(initial);
|
|
57
|
+
function useAnimatedViewport(initial: ViewportRange) {
|
|
58
|
+
const [viewport, setViewportState] = useState<ViewportRange>(initial);
|
|
53
59
|
const animRef = useRef<{
|
|
54
60
|
raf: number;
|
|
55
|
-
from:
|
|
56
|
-
to:
|
|
61
|
+
from: ViewportRange;
|
|
62
|
+
to: ViewportRange;
|
|
57
63
|
start: number;
|
|
58
64
|
} | null>(null);
|
|
59
65
|
const currentRef = useRef(initial);
|
|
@@ -68,7 +74,7 @@ function useAnimatedViewport(initial: Viewport) {
|
|
|
68
74
|
}, []);
|
|
69
75
|
|
|
70
76
|
const animateTo = useCallback(
|
|
71
|
-
(target:
|
|
77
|
+
(target: ViewportRange) => {
|
|
72
78
|
cancel();
|
|
73
79
|
|
|
74
80
|
if (reducedMotion) {
|
|
@@ -98,7 +104,7 @@ function useAnimatedViewport(initial: Viewport) {
|
|
|
98
104
|
);
|
|
99
105
|
|
|
100
106
|
const setViewport = useCallback(
|
|
101
|
-
(update:
|
|
107
|
+
(update: ViewportRange | ((prev: ViewportRange) => ViewportRange)) => {
|
|
102
108
|
cancel();
|
|
103
109
|
if (typeof update === 'function') {
|
|
104
110
|
setViewportState((prev) => {
|
|
@@ -180,7 +186,7 @@ function NewTraceViewerContent({
|
|
|
180
186
|
end: root.startTime + root.duration,
|
|
181
187
|
});
|
|
182
188
|
|
|
183
|
-
const prevRootRef = useRef<
|
|
189
|
+
const prevRootRef = useRef<ViewportRange>({
|
|
184
190
|
start: root.startTime,
|
|
185
191
|
end: root.startTime + root.duration,
|
|
186
192
|
});
|
|
@@ -221,60 +227,45 @@ function NewTraceViewerContent({
|
|
|
221
227
|
animateTo({ start: root.startTime, end: root.startTime + root.duration });
|
|
222
228
|
}, [animateTo, root.startTime, root.duration]);
|
|
223
229
|
|
|
230
|
+
const clampToRoot = useCallback(
|
|
231
|
+
(next: ViewportRange): ViewportRange =>
|
|
232
|
+
clampViewportToRoot(next, root.startTime, root.endTime, MIN_VIEWPORT_MS),
|
|
233
|
+
[root.startTime, root.endTime]
|
|
234
|
+
);
|
|
235
|
+
|
|
224
236
|
// Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
|
|
225
237
|
// off-screen marker indicators to scroll their marker into view.
|
|
226
238
|
const handleRevealTime = useCallback(
|
|
227
239
|
(timeMs: number) => {
|
|
228
|
-
const rootS = root.startTime;
|
|
229
|
-
const rootE = root.startTime + root.duration;
|
|
230
240
|
const { start, end } = viewportRef.current;
|
|
231
241
|
const duration = end - start;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (newEnd > rootE) {
|
|
239
|
-
newEnd = rootE;
|
|
240
|
-
newStart = Math.max(rootS, rootE - duration);
|
|
241
|
-
}
|
|
242
|
-
animateTo({ start: newStart, end: newEnd });
|
|
242
|
+
animateTo(
|
|
243
|
+
clampToRoot({
|
|
244
|
+
start: timeMs - duration / 2,
|
|
245
|
+
end: timeMs + duration / 2,
|
|
246
|
+
})
|
|
247
|
+
);
|
|
243
248
|
},
|
|
244
|
-
[animateTo,
|
|
249
|
+
[animateTo, clampToRoot]
|
|
245
250
|
);
|
|
246
251
|
|
|
247
252
|
const ZOOM_FACTOR = 0.5;
|
|
248
253
|
|
|
249
254
|
const zoomBy = useCallback(
|
|
250
255
|
(factor: number) => {
|
|
251
|
-
const rootS = root.startTime;
|
|
252
|
-
const rootE = root.startTime + root.duration;
|
|
253
|
-
const rootD = root.duration;
|
|
254
|
-
|
|
255
256
|
setViewport((prev) => {
|
|
256
|
-
const prevDuration = prev.end - prev.start;
|
|
257
257
|
const center = (prev.start + prev.end) / 2;
|
|
258
258
|
const newDuration = Math.max(
|
|
259
259
|
MIN_VIEWPORT_MS,
|
|
260
|
-
|
|
260
|
+
(prev.end - prev.start) * factor
|
|
261
261
|
);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
newStart = rootS;
|
|
267
|
-
newEnd = rootS + newDuration;
|
|
268
|
-
}
|
|
269
|
-
if (newEnd > rootE) {
|
|
270
|
-
newEnd = rootE;
|
|
271
|
-
newStart = Math.max(rootS, rootE - newDuration);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return { start: newStart, end: newEnd };
|
|
262
|
+
return clampToRoot({
|
|
263
|
+
start: center - newDuration / 2,
|
|
264
|
+
end: center + newDuration / 2,
|
|
265
|
+
});
|
|
275
266
|
});
|
|
276
267
|
},
|
|
277
|
-
[setViewport,
|
|
268
|
+
[setViewport, clampToRoot]
|
|
278
269
|
);
|
|
279
270
|
|
|
280
271
|
const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
|
|
@@ -291,12 +282,8 @@ function NewTraceViewerContent({
|
|
|
291
282
|
const spanEnd = getHighResInMs(span.endTime);
|
|
292
283
|
const spanDuration = spanEnd - spanStart;
|
|
293
284
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const rootD = root.duration;
|
|
297
|
-
|
|
298
|
-
if (spanDuration > rootD * 0.8) {
|
|
299
|
-
animateTo({ start: rootS, end: rootE });
|
|
285
|
+
if (spanDuration > root.duration * 0.8) {
|
|
286
|
+
animateTo({ start: root.startTime, end: root.endTime });
|
|
300
287
|
return;
|
|
301
288
|
}
|
|
302
289
|
|
|
@@ -310,20 +297,16 @@ function NewTraceViewerContent({
|
|
|
310
297
|
newEnd = center + MIN_VIEWPORT_MS / 2;
|
|
311
298
|
}
|
|
312
299
|
|
|
313
|
-
|
|
314
|
-
const duration = newEnd - newStart;
|
|
315
|
-
newStart = rootS;
|
|
316
|
-
newEnd = Math.min(rootE, rootS + duration);
|
|
317
|
-
}
|
|
318
|
-
if (newEnd > rootE) {
|
|
319
|
-
const duration = newEnd - newStart;
|
|
320
|
-
newEnd = rootE;
|
|
321
|
-
newStart = Math.max(rootS, rootE - duration);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
animateTo({ start: newStart, end: newEnd });
|
|
300
|
+
animateTo(clampToRoot({ start: newStart, end: newEnd }));
|
|
325
301
|
},
|
|
326
|
-
[
|
|
302
|
+
[
|
|
303
|
+
animateTo,
|
|
304
|
+
trace.spans,
|
|
305
|
+
root.startTime,
|
|
306
|
+
root.endTime,
|
|
307
|
+
root.duration,
|
|
308
|
+
clampToRoot,
|
|
309
|
+
]
|
|
327
310
|
);
|
|
328
311
|
|
|
329
312
|
// Bring a row into view when keyboard/button navigation lands on a span that
|
|
@@ -423,7 +406,7 @@ function NewTraceViewerContent({
|
|
|
423
406
|
if (hoverFraction == null) return null;
|
|
424
407
|
const absTime = viewport.start + hoverFraction * viewDuration;
|
|
425
408
|
const offset = absTime - root.startTime;
|
|
426
|
-
return { fraction: hoverFraction, label:
|
|
409
|
+
return { fraction: hoverFraction, label: formatDurationPrecise(offset) };
|
|
427
410
|
}, [hoverFraction, viewport.start, viewDuration, root.startTime]);
|
|
428
411
|
|
|
429
412
|
const handleTimelineMouseMove = useCallback(
|
|
@@ -451,12 +434,7 @@ function NewTraceViewerContent({
|
|
|
451
434
|
|
|
452
435
|
useEffect(() => {
|
|
453
436
|
const el = timelineRef.current;
|
|
454
|
-
if (!el) return;
|
|
455
|
-
|
|
456
|
-
const rootS = root.startTime;
|
|
457
|
-
const rootE = root.startTime + root.duration;
|
|
458
|
-
const rootD = root.duration;
|
|
459
|
-
if (rootD <= 0) return;
|
|
437
|
+
if (!el || root.duration <= 0) return;
|
|
460
438
|
|
|
461
439
|
const onWheel = (e: WheelEvent): void => {
|
|
462
440
|
const isZoomGesture = e.ctrlKey || e.metaKey;
|
|
@@ -470,9 +448,6 @@ function NewTraceViewerContent({
|
|
|
470
448
|
if (contentWidth <= 0) return;
|
|
471
449
|
|
|
472
450
|
if (isZoomGesture) {
|
|
473
|
-
let dy = e.deltaY;
|
|
474
|
-
if (e.deltaMode === 1) dy *= 16;
|
|
475
|
-
|
|
476
451
|
const cursorFraction = Math.max(
|
|
477
452
|
0,
|
|
478
453
|
Math.min(
|
|
@@ -480,59 +455,37 @@ function NewTraceViewerContent({
|
|
|
480
455
|
(e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
|
|
481
456
|
)
|
|
482
457
|
);
|
|
483
|
-
const
|
|
484
|
-
const scaleFactor = 2 ** (dy / (isMouseWheel ? 200 : 60));
|
|
458
|
+
const scaleFactor = wheelZoomScaleFactor(e);
|
|
485
459
|
|
|
486
460
|
setViewport((prev) => {
|
|
487
461
|
const prevDuration = prev.end - prev.start;
|
|
488
462
|
const cursorTime = prev.start + cursorFraction * prevDuration;
|
|
463
|
+
// Clamp the duration before anchoring so the cursor keeps its
|
|
464
|
+
// fraction even when the zoom hits the min/max bounds.
|
|
489
465
|
const newDuration = Math.max(
|
|
490
466
|
MIN_VIEWPORT_MS,
|
|
491
|
-
Math.min(
|
|
467
|
+
Math.min(root.duration, prevDuration * scaleFactor)
|
|
492
468
|
);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (newStart < rootS) {
|
|
498
|
-
newStart = rootS;
|
|
499
|
-
newEnd = rootS + newDuration;
|
|
500
|
-
}
|
|
501
|
-
if (newEnd > rootE) {
|
|
502
|
-
newEnd = rootE;
|
|
503
|
-
newStart = Math.max(rootS, rootE - newDuration);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return { start: newStart, end: newEnd };
|
|
469
|
+
return clampToRoot({
|
|
470
|
+
start: cursorTime - cursorFraction * newDuration,
|
|
471
|
+
end: cursorTime + (1 - cursorFraction) * newDuration,
|
|
472
|
+
});
|
|
507
473
|
});
|
|
508
474
|
} else {
|
|
509
|
-
|
|
510
|
-
if (e.deltaMode === 1) dx *= 16;
|
|
511
|
-
|
|
475
|
+
const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
|
|
512
476
|
setViewport((prev) => {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (newStart < rootS) {
|
|
520
|
-
newStart = rootS;
|
|
521
|
-
newEnd = rootS + prevDuration;
|
|
522
|
-
}
|
|
523
|
-
if (newEnd > rootE) {
|
|
524
|
-
newEnd = rootE;
|
|
525
|
-
newStart = Math.max(rootS, rootE - prevDuration);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
return { start: newStart, end: newEnd };
|
|
477
|
+
const panAmount = (dx / contentWidth) * (prev.end - prev.start);
|
|
478
|
+
return clampToRoot({
|
|
479
|
+
start: prev.start + panAmount,
|
|
480
|
+
end: prev.end + panAmount,
|
|
481
|
+
});
|
|
529
482
|
});
|
|
530
483
|
}
|
|
531
484
|
};
|
|
532
485
|
|
|
533
486
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
534
487
|
return () => el.removeEventListener('wheel', onWheel);
|
|
535
|
-
}, [root.
|
|
488
|
+
}, [root.duration, setViewport, clampToRoot]);
|
|
536
489
|
|
|
537
490
|
return (
|
|
538
491
|
<div
|
|
@@ -542,8 +495,16 @@ function NewTraceViewerContent({
|
|
|
542
495
|
>
|
|
543
496
|
<div
|
|
544
497
|
id="trace-parent"
|
|
545
|
-
className="flex-1 min-w-0 grid grid-rows-[
|
|
498
|
+
className="flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
|
|
546
499
|
>
|
|
500
|
+
<Minimap
|
|
501
|
+
spans={trace.spans}
|
|
502
|
+
root={root}
|
|
503
|
+
viewport={viewport}
|
|
504
|
+
minViewportMs={MIN_VIEWPORT_MS}
|
|
505
|
+
onViewportChange={setViewport}
|
|
506
|
+
onAnimateTo={animateTo}
|
|
507
|
+
/>
|
|
547
508
|
<SplitPane
|
|
548
509
|
scrollContainerRef={scrollContainerRef}
|
|
549
510
|
startHeader={
|
|
@@ -603,6 +564,7 @@ function NewTraceViewerContent({
|
|
|
603
564
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
|
|
604
565
|
<div
|
|
605
566
|
ref={timelineRef}
|
|
567
|
+
id="trace-timeline"
|
|
606
568
|
className="block min-h-0 overflow-visible relative"
|
|
607
569
|
onDoubleClick={resetZoom}
|
|
608
570
|
onMouseMove={handleTimelineMouseMove}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Span, SpanEvent } from './types';
|
|
3
3
|
import {
|
|
4
|
+
clampViewportToRoot,
|
|
4
5
|
computeOffscreenMarkers,
|
|
5
6
|
computeSpanMarkers,
|
|
6
7
|
computeSpanSegments,
|
|
8
|
+
computeTimeMarkers,
|
|
7
9
|
} from './utils';
|
|
8
10
|
|
|
9
11
|
/** Build a high-res timestamp tuple ([seconds, nanoseconds]) for a given ms. */
|
|
@@ -180,3 +182,66 @@ describe('computeOffscreenMarkers', () => {
|
|
|
180
182
|
});
|
|
181
183
|
});
|
|
182
184
|
});
|
|
185
|
+
|
|
186
|
+
describe('clampViewportToRoot', () => {
|
|
187
|
+
const clamp = (next: { start: number; end: number }) =>
|
|
188
|
+
clampViewportToRoot(next, 100, 1100, 10);
|
|
189
|
+
|
|
190
|
+
it('passes through a window already inside the root', () => {
|
|
191
|
+
expect(clamp({ start: 200, end: 400 })).toEqual({ start: 200, end: 400 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('shifts a window past the left edge without changing its duration', () => {
|
|
195
|
+
expect(clamp({ start: 50, end: 250 })).toEqual({ start: 100, end: 300 });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('shifts a window past the right edge without changing its duration', () => {
|
|
199
|
+
expect(clamp({ start: 1000, end: 1200 })).toEqual({
|
|
200
|
+
start: 900,
|
|
201
|
+
end: 1100,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('clamps a window wider than the root to the full extent', () => {
|
|
206
|
+
expect(clamp({ start: 0, end: 5000 })).toEqual({ start: 100, end: 1100 });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('enforces the minimum duration', () => {
|
|
210
|
+
expect(clamp({ start: 500, end: 502 })).toEqual({ start: 500, end: 510 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('keeps a minimum-duration window inside the root near the right edge', () => {
|
|
214
|
+
expect(clamp({ start: 1098, end: 1099 })).toEqual({
|
|
215
|
+
start: 1090,
|
|
216
|
+
end: 1100,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('computeTimeMarkers', () => {
|
|
222
|
+
it('emits distinct, precise labels across a sub-second-step window', () => {
|
|
223
|
+
// A ~3s window drops the tick step to 500ms. Before the fix this rendered
|
|
224
|
+
// duplicate "2s, 2s, 3s, 3s" labels; now each tick is distinct.
|
|
225
|
+
const labels = computeTimeMarkers(3000, 0).map((m) => m.label);
|
|
226
|
+
expect(labels).toEqual(['0s', '500ms', '1s', '1.5s', '2s', '2.5s', '3s']);
|
|
227
|
+
expect(new Set(labels).size).toBe(labels.length);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('keeps clean whole-second labels when the step is >=1s', () => {
|
|
231
|
+
const labels = computeTimeMarkers(10_000, 0).map((m) => m.label);
|
|
232
|
+
expect(labels).toEqual(['0s', '2s', '4s', '6s', '8s', '10s']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('still reads in ms when super zoomed in', () => {
|
|
236
|
+
const labels = computeTimeMarkers(120, 0).map((m) => m.label);
|
|
237
|
+
expect(labels).toEqual([
|
|
238
|
+
'0s',
|
|
239
|
+
'20ms',
|
|
240
|
+
'40ms',
|
|
241
|
+
'60ms',
|
|
242
|
+
'80ms',
|
|
243
|
+
'100ms',
|
|
244
|
+
'120ms',
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
formatDuration,
|
|
3
|
+
formatDurationPrecise,
|
|
4
|
+
getHighResInMs,
|
|
5
|
+
} from '../trace-viewer/util/timing';
|
|
2
6
|
import type { Span, SpanEvent } from './types';
|
|
3
7
|
|
|
4
8
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +41,64 @@ export function getSpanDurationMs(span: Span): number {
|
|
|
37
41
|
);
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
export function isSpanErrored(span: Span): boolean {
|
|
45
|
+
const workflowStatus = (span.attributes.data as Record<string, unknown>)
|
|
46
|
+
?.status as string | undefined;
|
|
47
|
+
return span.status.code === 2 || workflowStatus === 'failed';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Viewport
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface ViewportRange {
|
|
55
|
+
start: number;
|
|
56
|
+
end: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clamp a candidate viewport to the root extent. The requested duration is
|
|
61
|
+
* preserved where possible (clamped to [minDurationMs, root duration]), then
|
|
62
|
+
* the window is shifted back inside the root bounds.
|
|
63
|
+
*/
|
|
64
|
+
export function clampViewportToRoot(
|
|
65
|
+
next: ViewportRange,
|
|
66
|
+
rootStart: number,
|
|
67
|
+
rootEnd: number,
|
|
68
|
+
minDurationMs: number
|
|
69
|
+
): ViewportRange {
|
|
70
|
+
const rootDuration = Math.max(rootEnd - rootStart, minDurationMs);
|
|
71
|
+
const duration = Math.min(
|
|
72
|
+
rootDuration,
|
|
73
|
+
Math.max(minDurationMs, next.end - next.start)
|
|
74
|
+
);
|
|
75
|
+
const maxStart = rootEnd - duration;
|
|
76
|
+
const start = Math.min(Math.max(next.start, rootStart), maxStart);
|
|
77
|
+
return { start, end: start + duration };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Wheel gestures — shared between the timeline and the minimap
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */
|
|
85
|
+
export function wheelDeltaToPixels(delta: number, deltaMode: number): number {
|
|
86
|
+
return deltaMode === 1 ? delta * 16 : delta;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Exponential zoom factor for a wheel gesture. Coarse mouse-wheel steps are
|
|
91
|
+
* damped harder than trackpad pinches so both feel similar.
|
|
92
|
+
*/
|
|
93
|
+
export function wheelZoomScaleFactor(event: {
|
|
94
|
+
deltaY: number;
|
|
95
|
+
deltaMode: number;
|
|
96
|
+
}): number {
|
|
97
|
+
const dy = wheelDeltaToPixels(event.deltaY, event.deltaMode);
|
|
98
|
+
const isMouseWheel = event.deltaMode === 1 || Math.abs(event.deltaY) >= 50;
|
|
99
|
+
return 2 ** (dy / (isMouseWheel ? 200 : 60));
|
|
100
|
+
}
|
|
101
|
+
|
|
40
102
|
// ---------------------------------------------------------------------------
|
|
41
103
|
// Time markers
|
|
42
104
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +118,8 @@ const NICE_INTERVALS = [
|
|
|
56
118
|
|
|
57
119
|
const MAX_MARKERS = 8;
|
|
58
120
|
|
|
121
|
+
const MS_IN_SECOND = 1000;
|
|
122
|
+
|
|
59
123
|
function pickInterval(viewDuration: number, maxTicks: number): number {
|
|
60
124
|
for (const interval of NICE_INTERVALS) {
|
|
61
125
|
if (viewDuration / interval <= maxTicks) return interval;
|
|
@@ -72,6 +136,13 @@ export function computeTimeMarkers(
|
|
|
72
136
|
const maxTicks = 6;
|
|
73
137
|
const interval = pickInterval(viewDuration, maxTicks);
|
|
74
138
|
|
|
139
|
+
// Sub-second steps need fractional labels, or ticks past 1s collide as
|
|
140
|
+
// duplicate whole seconds ("…1s, 2s, 2s, 3s"). Scale decimals to the step.
|
|
141
|
+
const fractionDigits =
|
|
142
|
+
interval >= MS_IN_SECOND
|
|
143
|
+
? 0
|
|
144
|
+
: Math.ceil(-Math.log10(interval / MS_IN_SECOND));
|
|
145
|
+
|
|
75
146
|
const firstTick = Math.ceil(offset / interval) * interval;
|
|
76
147
|
const markers: TimeMarker[] = [];
|
|
77
148
|
|
|
@@ -80,7 +151,10 @@ export function computeTimeMarkers(
|
|
|
80
151
|
if (position < -0.01 || position > 1.01) continue;
|
|
81
152
|
markers.push({
|
|
82
153
|
position: Math.min(Math.max(position, 0), 1),
|
|
83
|
-
label:
|
|
154
|
+
label:
|
|
155
|
+
fractionDigits === 0
|
|
156
|
+
? formatDuration(Math.abs(t), true)
|
|
157
|
+
: formatDurationPrecise(Math.abs(t), fractionDigits),
|
|
84
158
|
value: t,
|
|
85
159
|
});
|
|
86
160
|
if (markers.length >= MAX_MARKERS) break;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatDurationPrecise } from './utils';
|
|
3
|
+
|
|
4
|
+
describe('formatDurationPrecise', () => {
|
|
5
|
+
it('shows whole milliseconds below 1s', () => {
|
|
6
|
+
expect(formatDurationPrecise(0)).toBe('0s');
|
|
7
|
+
expect(formatDurationPrecise(626)).toBe('626ms');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('keeps sub-second precision instead of rounding up to a whole second', () => {
|
|
11
|
+
// The bug this guards: 1626ms must not read as "2s".
|
|
12
|
+
expect(formatDurationPrecise(1626)).toBe('1.63s');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('trims trailing zeros so whole/half seconds read cleanly', () => {
|
|
16
|
+
expect(formatDurationPrecise(2000)).toBe('2s');
|
|
17
|
+
expect(formatDurationPrecise(1500)).toBe('1.5s');
|
|
18
|
+
expect(formatDurationPrecise(1600)).toBe('1.6s');
|
|
19
|
+
expect(formatDurationPrecise(45_000)).toBe('45s');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('honors a custom fraction-digit count (used by the timeline ruler)', () => {
|
|
23
|
+
// Fewer digits also coarsen the rounding: 1 digit rounds to 100ms.
|
|
24
|
+
expect(formatDurationPrecise(1500, 1)).toBe('1.5s');
|
|
25
|
+
expect(formatDurationPrecise(1620, 1)).toBe('1.6s');
|
|
26
|
+
expect(formatDurationPrecise(2000, 1)).toBe('2s');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('decomposes durations of a minute or more', () => {
|
|
30
|
+
expect(formatDurationPrecise(65_000)).toBe('1m 5s');
|
|
31
|
+
expect(formatDurationPrecise(63_450)).toBe('1m 3.45s');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -74,25 +74,20 @@ export function formatDuration(ms: number, compact = false): string {
|
|
|
74
74
|
return parts.join(' ');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Locale-aware formatter that always renders exactly two fraction digits.
|
|
78
|
-
// Used for the seconds component of precise durations so values are never
|
|
79
|
-
// snapped to a whole second (and thousands separators stay correct).
|
|
80
|
-
const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
|
|
81
|
-
minimumFractionDigits: 2,
|
|
82
|
-
maximumFractionDigits: 2,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
77
|
/**
|
|
86
78
|
* Formats a duration in milliseconds without snapping to whole seconds.
|
|
87
79
|
*
|
|
88
|
-
* Unlike {@link formatDuration}, this
|
|
89
|
-
*
|
|
80
|
+
* Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
|
|
81
|
+
* renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
|
|
82
|
+
* decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
|
|
83
|
+
* and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
|
|
84
|
+
* to 10ms; the timeline ruler passes fewer digits to match its tick step.
|
|
90
85
|
*
|
|
91
86
|
* - < 1s: shows whole milliseconds (e.g. "626ms")
|
|
92
|
-
* - 1s – 1m: shows seconds with
|
|
93
|
-
* - >= 1m: decomposes into d/h/m with
|
|
87
|
+
* - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
|
|
88
|
+
* - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
|
|
94
89
|
*/
|
|
95
|
-
export function formatDurationPrecise(ms: number): string {
|
|
90
|
+
export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
|
|
96
91
|
if (ms === 0) {
|
|
97
92
|
return '0s';
|
|
98
93
|
}
|
|
@@ -104,10 +99,11 @@ export function formatDurationPrecise(ms: number): string {
|
|
|
104
99
|
}
|
|
105
100
|
}
|
|
106
101
|
|
|
107
|
-
const
|
|
102
|
+
const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
|
|
103
|
+
const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
|
|
108
104
|
|
|
109
105
|
if (normalizedMs < MS_IN_MINUTE) {
|
|
110
|
-
return `${
|
|
106
|
+
return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
|
|
111
107
|
}
|
|
112
108
|
|
|
113
109
|
const days = Math.floor(normalizedMs / MS_IN_DAY);
|
|
@@ -126,7 +122,7 @@ export function formatDurationPrecise(ms: number): string {
|
|
|
126
122
|
if (minutes > 0) {
|
|
127
123
|
parts.push(`${minutes}m`);
|
|
128
124
|
}
|
|
129
|
-
parts.push(`${
|
|
125
|
+
parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
|
|
130
126
|
|
|
131
127
|
return parts.join(' ');
|
|
132
128
|
}
|