@workflow/web-shared 5.0.0-beta.35 → 5.0.0-beta.36

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.
@@ -22,10 +22,12 @@ import type {
22
22
  OffscreenMarkers,
23
23
  Segment,
24
24
  SegmentStatus,
25
+ SpanDelta,
25
26
  TimeMarker,
26
27
  } from '../utils';
27
28
  import {
28
29
  computeOffscreenMarkers,
30
+ computeSpanDelta,
29
31
  computeSpanGaps,
30
32
  computeSpanMarkers,
31
33
  computeSpanSegments,
@@ -223,6 +225,11 @@ function projectSegments(
223
225
  // Small render helpers
224
226
  // ---------------------------------------------------------------------------
225
227
 
228
+ /** Estimated rendered width of a 10px-mono duration label (6px/glyph + padding). */
229
+ function estimateLabelWidthPx(label: string): number {
230
+ return label.length * 6 + 12;
231
+ }
232
+
226
233
  function DurationLabel({
227
234
  label,
228
235
  className,
@@ -317,7 +324,7 @@ function SegmentBar({
317
324
  const leadInLabel = formatDurationPrecise(seg.fullDurationMs);
318
325
  const showLeadInLabel =
319
326
  showLabels &&
320
- seg.pixelWidth >= Math.max(40, leadInLabel.length * 6 + 12);
327
+ seg.pixelWidth >= Math.max(40, estimateLabelWidthPx(leadInLabel));
321
328
  const isFullWidthQueued = segments.length === 1;
322
329
  return (
323
330
  <Fragment key={i}>
@@ -341,7 +348,8 @@ function SegmentBar({
341
348
  const label = formatDurationPrecise(seg.fullDurationMs);
342
349
  // Only render the label when there's enough room for it without clipping.
343
350
  const showLabel =
344
- showLabels && seg.pixelWidth >= Math.max(40, label.length * 6 + 12);
351
+ showLabels &&
352
+ seg.pixelWidth >= Math.max(40, estimateLabelWidthPx(label));
345
353
 
346
354
  return (
347
355
  <div
@@ -462,7 +470,8 @@ const TimelineBar = memo(function TimelineBar({
462
470
 
463
471
  const totalLabel = formatDurationPrecise(totalDurationMs);
464
472
  const showTotalLabel =
465
- geometry.visiblePixelWidth >= Math.max(40, totalLabel.length * 6 + 12);
473
+ geometry.visiblePixelWidth >=
474
+ Math.max(40, estimateLabelWidthPx(totalLabel));
466
475
 
467
476
  const handleClick = useCallback(() => {
468
477
  onSelect(span.spanId);
@@ -542,45 +551,99 @@ const TimelineBar = memo(function TimelineBar({
542
551
  export { TimelineBar };
543
552
 
544
553
  // ---------------------------------------------------------------------------
545
- // DeltaIndicator (Alt-key gap overlay)
554
+ // DeltaMeasureLine (Alt-key measurement overlays: the selected ↔ hovered
555
+ // measurement, and the ambient consecutive-gap indicators shown without a
556
+ // selection)
546
557
  // ---------------------------------------------------------------------------
547
558
 
548
- const DELTA_CAP_HEIGHT_PX = 8;
549
- // Vertical offset to sit the indicator inside the gap between row N and N+1,
550
- // aligned with where the bar starts in the next row (rows center a 24px bar
551
- // inside 40px, so bars start ~8px from the top of the row).
552
- const DELTA_ROW_OFFSET_PX = 8;
559
+ // Horizontal distance between the anchor bar's measured edge and the vertical
560
+ // guide also the width of the connector stub bridging the two.
561
+ const MEASURE_GUIDE_OUTSET_PX = 4;
553
562
 
554
- const DeltaIndicator = memo(function DeltaIndicator({
555
- leftFrac,
556
- rightFrac,
557
- label,
558
- rowIndex,
563
+ const DeltaMeasureLine = memo(function DeltaMeasureLine({
564
+ delta,
565
+ anchorRowIndex,
566
+ hoveredRowIndex,
567
+ timelineWidth,
559
568
  }: {
560
- leftFrac: number;
561
- rightFrac: number;
562
- label: string;
563
- rowIndex: number;
569
+ delta: SpanDelta;
570
+ anchorRowIndex: number;
571
+ hoveredRowIndex: number;
572
+ timelineWidth: number;
564
573
  }) {
565
- const centerY = DELTA_ROW_OFFSET_PX + (rowIndex + 1) * ROW_HEIGHT_PX;
574
+ // Both ends of the measurement align with the vertical middle of the bars
575
+ // (bars are centered in their rows, so bar center == row center).
576
+ const anchorCenterY = anchorRowIndex * ROW_HEIGHT_PX + ROW_HEIGHT_PX / 2;
577
+ const lineY = hoveredRowIndex * ROW_HEIGHT_PX + ROW_HEIGHT_PX / 2;
578
+
579
+ // Guide connecting the middle of the anchor bar down/up to the line, so
580
+ // the measurement's origin stays legible when the rows are far apart.
581
+ // It sits just outside the anchor bar's measured edge (so it doesn't blend
582
+ // into the bar's border), joined to the bar by a short horizontal stub. The
583
+ // line runs from the elbow corner (the guide's x) to the hovered span's
584
+ // measured edge — pulled short of the edge arrow when the hovered span is
585
+ // fully off-screen.
586
+ const guideTop = Math.min(anchorCenterY, lineY);
587
+ const guideBottom = Math.max(anchorCenterY, lineY);
588
+ const anchorX = delta.anchorFrac * timelineWidth;
589
+ const guideX =
590
+ anchorX +
591
+ (delta.anchorEdge === 'end'
592
+ ? MEASURE_GUIDE_OUTSET_PX
593
+ : -MEASURE_GUIDE_OUTSET_PX);
594
+ const arrowClearance =
595
+ delta.hoveredOffscreen === 'right'
596
+ ? -(TINY_BAR_BOX_SIZE_PX + 4)
597
+ : delta.hoveredOffscreen === 'left'
598
+ ? TINY_BAR_BOX_SIZE_PX + 4
599
+ : 0;
600
+ const hoveredX = delta.hoveredFrac * timelineWidth + arrowClearance;
601
+ const startX = Math.min(guideX, hoveredX);
602
+ const endX = Math.max(guideX, hoveredX);
603
+
604
+ const label = formatDurationPrecise(delta.deltaMs);
605
+ const labelWidthPx = estimateLabelWidthPx(label);
606
+ // Center the label on the line; when the line is too short, place it beside
607
+ // the right endpoint, flipping left near the viewport's right edge.
608
+ const labelPlacement =
609
+ endX - startX >= labelWidthPx
610
+ ? { left: (startX + endX) / 2, translate: '-translate-x-1/2' }
611
+ : endX + 4 + labelWidthPx <= timelineWidth
612
+ ? { left: endX + 4, translate: '' }
613
+ : { left: startX - 4, translate: '-translate-x-full' };
566
614
 
567
615
  return (
568
- <div
569
- className="absolute pointer-events-none"
570
- style={{
571
- left: `${leftFrac * 100}%`,
572
- width: `${(rightFrac - leftFrac) * 100}%`,
573
- top: centerY - DELTA_CAP_HEIGHT_PX / 2,
574
- height: DELTA_CAP_HEIGHT_PX,
575
- }}
576
- >
577
- <div className="absolute left-0 top-0 w-px h-full bg-amber-800" />
578
- <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-800" />
579
- <div className="absolute right-0 top-0 w-px h-full bg-amber-800" />
580
- <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-label-12 leading-none whitespace-nowrap rounded-xs px-1 py-0.5 text-gray-100 bg-amber-800">
616
+ <>
617
+ <div
618
+ className="absolute h-px bg-amber-800"
619
+ style={{
620
+ left: Math.min(anchorX, guideX),
621
+ width: MEASURE_GUIDE_OUTSET_PX,
622
+ top: anchorCenterY,
623
+ }}
624
+ />
625
+ <div
626
+ className="absolute w-px bg-amber-800"
627
+ style={{
628
+ left: guideX,
629
+ top: guideTop,
630
+ height: guideBottom - guideTop,
631
+ }}
632
+ />
633
+ <div
634
+ className="absolute h-px bg-amber-800"
635
+ style={{ left: startX, width: Math.max(endX - startX, 1), top: lineY }}
636
+ />
637
+ <span
638
+ className={cn(
639
+ 'absolute -translate-y-1/2 font-mono text-[10px] font-medium leading-none tabular-nums whitespace-nowrap rounded-xs bg-background-100 px-1 py-0.5 text-amber-800',
640
+ labelPlacement.translate
641
+ )}
642
+ style={{ left: labelPlacement.left, top: lineY }}
643
+ >
581
644
  {label}
582
645
  </span>
583
- </div>
646
+ </>
584
647
  );
585
648
  });
586
649
 
@@ -624,6 +687,13 @@ export function TimelineHeader({
624
687
  // Timeline
625
688
  // ---------------------------------------------------------------------------
626
689
 
690
+ export interface TimelineHover {
691
+ /** Pointer x as a fraction of the timeline's content width, in [0, 1]. */
692
+ fraction: number;
693
+ /** Row index under the pointer; may be past the last row — not validated. */
694
+ rowIndex: number;
695
+ }
696
+
627
697
  export function Timeline({
628
698
  spans,
629
699
  viewStart,
@@ -633,7 +703,7 @@ export function Timeline({
633
703
  searchResult,
634
704
  onSelect,
635
705
  onRevealTime,
636
- hoverFraction,
706
+ hover,
637
707
  altHeld = false,
638
708
  }: {
639
709
  spans: Span[];
@@ -644,7 +714,7 @@ export function Timeline({
644
714
  searchResult: SpanSearchResult;
645
715
  onSelect: (spanId: string) => void;
646
716
  onRevealTime?: (timeMs: number) => void;
647
- hoverFraction?: number | null;
717
+ hover?: TimelineHover | null;
648
718
  altHeld?: boolean;
649
719
  }): ReactNode {
650
720
  const containerRef = useRef<HTMLDivElement>(null);
@@ -667,11 +737,45 @@ export function Timeline({
667
737
  return () => ro.disconnect();
668
738
  }, []);
669
739
 
670
- const gaps = useMemo(
671
- () => computeSpanGaps(spans, viewStart, viewEnd),
740
+ // Each consecutive gap renders as a measurement from the earlier span's end
741
+ // edge to the next span's start, in the same visual language as the
742
+ // selected ↔ hovered measurement.
743
+ const gapMeasurements = useMemo(
744
+ () =>
745
+ computeSpanGaps(spans, viewStart, viewEnd).map((gap) => ({
746
+ delta: {
747
+ deltaMs: gap.gapMs,
748
+ anchorFrac: gap.leftFrac,
749
+ hoveredFrac: gap.rightFrac,
750
+ anchorEdge: 'end',
751
+ hoveredOffscreen: null,
752
+ } satisfies SpanDelta,
753
+ anchorRowIndex: gap.rowIndex,
754
+ hoveredRowIndex: gap.rowIndex + 1,
755
+ })),
672
756
  [spans, viewStart, viewEnd]
673
757
  );
674
758
 
759
+ // With a span selected, Alt+hover measures selected ↔ hovered instead of
760
+ // showing the all-sibling-gaps overlay.
761
+ const anchorIndex = useMemo(
762
+ () => (selectedId ? spans.findIndex((s) => s.spanId === selectedId) : -1),
763
+ [spans, selectedId]
764
+ );
765
+
766
+ const measurement = useMemo(() => {
767
+ if (!altHeld || hover == null || hover.rowIndex === anchorIndex) {
768
+ return null;
769
+ }
770
+ const anchorSpan = spans[anchorIndex];
771
+ const hoveredSpan = spans[hover.rowIndex];
772
+ if (!anchorSpan || !hoveredSpan) return null;
773
+ const delta = computeSpanDelta(anchorSpan, hoveredSpan, viewStart, viewEnd);
774
+ return delta
775
+ ? { delta, anchorRowIndex: anchorIndex, hoveredRowIndex: hover.rowIndex }
776
+ : null;
777
+ }, [altHeld, anchorIndex, hover, spans, viewStart, viewEnd]);
778
+
675
779
  return (
676
780
  <div
677
781
  ref={containerRef}
@@ -694,14 +798,14 @@ export function Timeline({
694
798
  ) : null
695
799
  )}
696
800
  </div>
697
- {hoverFraction != null && (
801
+ {hover != null && (
698
802
  <div
699
803
  className="absolute inset-y-0 pointer-events-none z-10"
700
804
  style={TIMELINE_INSET_STYLE}
701
805
  >
702
806
  <div
703
807
  className="absolute top-0 bottom-0 w-px bg-gray-alpha-500"
704
- style={{ left: `${hoverFraction * 100}%` }}
808
+ style={{ left: `${hover.fraction * 100}%` }}
705
809
  />
706
810
  </div>
707
811
  )}
@@ -720,23 +824,30 @@ export function Timeline({
720
824
  />
721
825
  ))}
722
826
  </div>
723
- {altHeld && (
827
+ {altHeld && anchorIndex < 0 && (
724
828
  <div
725
829
  aria-hidden
726
830
  className="absolute inset-y-0 pointer-events-none"
727
831
  style={TIMELINE_INSET_STYLE}
728
832
  >
729
- {gaps.map((gap) => (
730
- <DeltaIndicator
731
- key={gap.rowIndex}
732
- leftFrac={gap.leftFrac}
733
- rightFrac={gap.rightFrac}
734
- label={formatDurationPrecise(gap.gapMs)}
735
- rowIndex={gap.rowIndex}
833
+ {gapMeasurements.map((gap) => (
834
+ <DeltaMeasureLine
835
+ key={gap.anchorRowIndex}
836
+ {...gap}
837
+ timelineWidth={timelineWidth}
736
838
  />
737
839
  ))}
738
840
  </div>
739
841
  )}
842
+ {measurement && (
843
+ <div
844
+ aria-hidden
845
+ className="absolute inset-y-0 pointer-events-none z-20"
846
+ style={TIMELINE_INSET_STYLE}
847
+ >
848
+ <DeltaMeasureLine {...measurement} timelineWidth={timelineWidth} />
849
+ </div>
850
+ )}
740
851
  </div>
741
852
  );
742
853
  }
@@ -86,7 +86,7 @@ export function TraceShortcutHelper({
86
86
  };
87
87
 
88
88
  return (
89
- <div className="group sticky bottom-3 z-10 ml-4 mb-3 hidden h-8 w-fit items-center gap-1 text-xs leading-none text-gray-900 @min-[480px]:flex">
89
+ <div className="group pointer-events-auto hidden h-8 w-fit items-center gap-1 text-xs leading-none text-gray-900 @min-[480px]:flex">
90
90
  <span
91
91
  aria-live="polite"
92
92
  aria-atomic="true"
@@ -28,6 +28,7 @@ import {
28
28
  TIMELINE_PADDING_PX,
29
29
  Timeline,
30
30
  TimelineHeader,
31
+ type TimelineHover,
31
32
  } from './components/timeline';
32
33
  import { TraceShortcutHelper } from './components/trace-shortcut-helper';
33
34
  import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
@@ -400,14 +401,14 @@ function NewTraceViewerContent({
400
401
  }, [handleClearActiveSpan]);
401
402
 
402
403
  const timelineRef = useRef<HTMLDivElement>(null);
403
- const [hoverFraction, setHoverFraction] = useState<number | null>(null);
404
+ const [hover, setHover] = useState<TimelineHover | null>(null);
404
405
 
405
406
  const hoverInfo = useMemo(() => {
406
- if (hoverFraction == null) return null;
407
- const absTime = viewport.start + hoverFraction * viewDuration;
407
+ if (hover == null) return null;
408
+ const absTime = viewport.start + hover.fraction * viewDuration;
408
409
  const offset = absTime - root.startTime;
409
- return { fraction: hoverFraction, label: formatDurationPrecise(offset) };
410
- }, [hoverFraction, viewport.start, viewDuration, root.startTime]);
410
+ return { fraction: hover.fraction, label: formatDurationPrecise(offset) };
411
+ }, [hover, viewport.start, viewDuration, root.startTime]);
411
412
 
412
413
  const handleTimelineMouseMove = useCallback(
413
414
  (e: React.MouseEvent<HTMLDivElement>) => {
@@ -423,13 +424,16 @@ function NewTraceViewerContent({
423
424
  (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
424
425
  )
425
426
  );
426
- setHoverFraction(fraction);
427
+ setHover({
428
+ fraction,
429
+ rowIndex: Math.floor((e.clientY - rect.top) / ROW_HEIGHT_PX),
430
+ });
427
431
  },
428
432
  []
429
433
  );
430
434
 
431
435
  const handleTimelineMouseLeave = useCallback(() => {
432
- setHoverFraction(null);
436
+ setHover(null);
433
437
  }, []);
434
438
 
435
439
  useEffect(() => {
@@ -495,7 +499,7 @@ function NewTraceViewerContent({
495
499
  >
496
500
  <div
497
501
  id="trace-parent"
498
- className="flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
502
+ className="@container flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
499
503
  >
500
504
  <Minimap
501
505
  spans={trace.spans}
@@ -579,43 +583,45 @@ function NewTraceViewerContent({
579
583
  searchResult={searchResult}
580
584
  onSelect={handleSelectSpan}
581
585
  onRevealTime={handleRevealTime}
582
- hoverFraction={hoverFraction}
586
+ hover={hover}
583
587
  altHeld={altHeld}
584
588
  />
589
+ </div>
590
+ <>
585
591
  <TraceShortcutHelper
586
592
  hasMultipleSpans={trace.spans.length > 1}
587
593
  reducedMotion={reducedMotion}
588
594
  />
589
- </div>
595
+ <div className="pointer-events-auto flex items-center border border-gray-alpha-400 rounded-md bg-background-100 shadow-sm overflow-hidden divide-x divide-gray-alpha-400">
596
+ <IconButton
597
+ variant="muted"
598
+ size="small"
599
+ onClick={zoomOut}
600
+ disabled={isAtMinZoom}
601
+ aria-label="Zoom out"
602
+ >
603
+ <ZoomOut className="w-4 h-4" />
604
+ </IconButton>
605
+ <IconButton
606
+ variant="muted"
607
+ size="small"
608
+ onClick={resetZoom}
609
+ aria-label="Reset zoom"
610
+ >
611
+ <RotateCcw className="w-3.5 h-3.5" />
612
+ </IconButton>
613
+ <IconButton
614
+ variant="muted"
615
+ size="small"
616
+ onClick={zoomIn}
617
+ disabled={isAtMaxZoom}
618
+ aria-label="Zoom in"
619
+ >
620
+ <ZoomIn className="w-4 h-4" />
621
+ </IconButton>
622
+ </div>
623
+ </>
590
624
  </SplitPane>
591
- <div className="absolute right-3 bottom-3 z-[5] flex items-center border border-gray-alpha-400 rounded-md bg-background-100 shadow-sm overflow-hidden divide-x divide-gray-alpha-400">
592
- <IconButton
593
- variant="muted"
594
- size="small"
595
- onClick={zoomOut}
596
- disabled={isAtMinZoom}
597
- aria-label="Zoom out"
598
- >
599
- <ZoomOut className="w-4 h-4" />
600
- </IconButton>
601
- <IconButton
602
- variant="muted"
603
- size="small"
604
- onClick={resetZoom}
605
- aria-label="Reset zoom"
606
- >
607
- <RotateCcw className="w-3.5 h-3.5" />
608
- </IconButton>
609
- <IconButton
610
- variant="muted"
611
- size="small"
612
- onClick={zoomIn}
613
- disabled={isAtMaxZoom}
614
- aria-label="Zoom in"
615
- >
616
- <ZoomIn className="w-4 h-4" />
617
- </IconButton>
618
- </div>
619
625
  </div>
620
626
 
621
627
  <TraceDetailPanel
@@ -3,6 +3,7 @@ import type { Span, SpanEvent } from './types';
3
3
  import {
4
4
  clampViewportToRoot,
5
5
  computeOffscreenMarkers,
6
+ computeSpanDelta,
6
7
  computeSpanMarkers,
7
8
  computeSpanSegments,
8
9
  computeTimeMarkers,
@@ -257,3 +258,201 @@ describe('getResourceColor', () => {
257
258
  });
258
259
  });
259
260
  });
261
+
262
+ describe('computeSpanDelta', () => {
263
+ function span(id: string, startMs: number, endMs: number): Span {
264
+ return {
265
+ name: id,
266
+ kind: 0,
267
+ resource: 'step',
268
+ library: { name: 'workflow' },
269
+ spanId: id,
270
+ status: { code: 1 },
271
+ traceFlags: 0,
272
+ attributes: {},
273
+ links: [],
274
+ events: [],
275
+ startTime: ts(startMs),
276
+ endTime: ts(endMs),
277
+ duration: ts(endMs - startMs),
278
+ };
279
+ }
280
+
281
+ it('measures the gap between disjoint spans (anchor earlier)', () => {
282
+ const delta = computeSpanDelta(
283
+ span('a', 0, 100),
284
+ span('b', 300, 400),
285
+ 0,
286
+ 1000
287
+ );
288
+ expect(delta).toEqual({
289
+ deltaMs: 200,
290
+ anchorFrac: 0.1,
291
+ hoveredFrac: 0.3,
292
+ anchorEdge: 'end',
293
+ hoveredOffscreen: null,
294
+ });
295
+ });
296
+
297
+ it('is direction-agnostic: anchor later gives the same delta with edges swapped', () => {
298
+ const delta = computeSpanDelta(
299
+ span('b', 300, 400),
300
+ span('a', 0, 100),
301
+ 0,
302
+ 1000
303
+ );
304
+ expect(delta).toEqual({
305
+ deltaMs: 200,
306
+ anchorFrac: 0.3,
307
+ hoveredFrac: 0.1,
308
+ anchorEdge: 'start',
309
+ hoveredOffscreen: null,
310
+ });
311
+ });
312
+
313
+ it('measures start-to-start offset for overlapping spans', () => {
314
+ const delta = computeSpanDelta(
315
+ span('a', 0, 500),
316
+ span('b', 200, 800),
317
+ 0,
318
+ 1000
319
+ );
320
+ expect(delta).toEqual({
321
+ deltaMs: 200,
322
+ anchorFrac: 0,
323
+ hoveredFrac: 0.2,
324
+ anchorEdge: 'start',
325
+ hoveredOffscreen: null,
326
+ });
327
+ });
328
+
329
+ it('measures start-to-start offset for contained spans (step inside run)', () => {
330
+ const delta = computeSpanDelta(
331
+ span('run', 0, 1000),
332
+ span('step', 400, 600),
333
+ 0,
334
+ 1000
335
+ );
336
+ expect(delta).toEqual({
337
+ deltaMs: 400,
338
+ anchorFrac: 0,
339
+ hoveredFrac: 0.4,
340
+ anchorEdge: 'start',
341
+ hoveredOffscreen: null,
342
+ });
343
+ });
344
+
345
+ it('returns a zero delta for identical starts', () => {
346
+ const delta = computeSpanDelta(
347
+ span('a', 200, 500),
348
+ span('b', 200, 300),
349
+ 0,
350
+ 1000
351
+ );
352
+ expect(delta).toEqual({
353
+ deltaMs: 0,
354
+ anchorFrac: 0.2,
355
+ hoveredFrac: 0.2,
356
+ anchorEdge: 'start',
357
+ hoveredOffscreen: null,
358
+ });
359
+ });
360
+
361
+ it('keeps a zero delta sitting exactly on the viewport edge (root span anchor at default zoom)', () => {
362
+ const delta = computeSpanDelta(
363
+ span('run', 0, 1000),
364
+ span('step', 0, 400),
365
+ 0,
366
+ 1000
367
+ );
368
+ expect(delta).toEqual({
369
+ deltaMs: 0,
370
+ anchorFrac: 0,
371
+ hoveredFrac: 0,
372
+ anchorEdge: 'start',
373
+ hoveredOffscreen: null,
374
+ });
375
+ });
376
+
377
+ it('treats touching spans as a zero gap, not an overlap', () => {
378
+ const delta = computeSpanDelta(
379
+ span('a', 0, 300),
380
+ span('b', 300, 400),
381
+ 0,
382
+ 1000
383
+ );
384
+ expect(delta).toEqual({
385
+ deltaMs: 0,
386
+ anchorFrac: 0.3,
387
+ hoveredFrac: 0.3,
388
+ anchorEdge: 'end',
389
+ hoveredOffscreen: null,
390
+ });
391
+ });
392
+
393
+ it('handles zero-duration spans as points', () => {
394
+ const delta = computeSpanDelta(
395
+ span('a', 100, 100),
396
+ span('b', 400, 400),
397
+ 0,
398
+ 1000
399
+ );
400
+ expect(delta).toEqual({
401
+ deltaMs: 300,
402
+ anchorFrac: 0.1,
403
+ hoveredFrac: 0.4,
404
+ anchorEdge: 'end',
405
+ hoveredOffscreen: null,
406
+ });
407
+ });
408
+
409
+ it('clamps edges to the viewport but keeps the true delta', () => {
410
+ const delta = computeSpanDelta(
411
+ span('a', 0, 100),
412
+ span('b', 900, 950),
413
+ 500,
414
+ 800
415
+ );
416
+ expect(delta).toEqual({
417
+ deltaMs: 800,
418
+ anchorFrac: 0,
419
+ hoveredFrac: 1,
420
+ anchorEdge: 'end',
421
+ hoveredOffscreen: 'right',
422
+ });
423
+ });
424
+
425
+ it('flags a hovered span lying fully left of the viewport', () => {
426
+ const delta = computeSpanDelta(
427
+ span('anchor', 600, 700),
428
+ span('hovered', 0, 100),
429
+ 200,
430
+ 1200
431
+ );
432
+ expect(delta).toEqual({
433
+ deltaMs: 500,
434
+ anchorFrac: 0.4,
435
+ hoveredFrac: 0,
436
+ anchorEdge: 'start',
437
+ hoveredOffscreen: 'left',
438
+ });
439
+ });
440
+
441
+ it('returns null when the measurement is entirely left of the viewport', () => {
442
+ expect(
443
+ computeSpanDelta(span('a', 0, 100), span('b', 200, 300), 500, 1000)
444
+ ).toBeNull();
445
+ });
446
+
447
+ it('returns null when the measurement is entirely right of the viewport', () => {
448
+ expect(
449
+ computeSpanDelta(span('a', 600, 700), span('b', 900, 950), 0, 500)
450
+ ).toBeNull();
451
+ });
452
+
453
+ it('returns null for an empty viewport range', () => {
454
+ expect(
455
+ computeSpanDelta(span('a', 0, 100), span('b', 200, 300), 500, 500)
456
+ ).toBeNull();
457
+ });
458
+ });