diff --git a/.changeset/trace-minimap-brush-zoom-pan.md b/.changeset/trace-minimap-brush-zoom-pan.md new file mode 100644 index 0000000000..f76050780d --- /dev/null +++ b/.changeset/trace-minimap-brush-zoom-pan.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat(trace): add a trace minimap above the waterfall diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index be1c75a2fa..cc1e786820 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -40,7 +40,11 @@ import { import { ContactSupportText } from '@/components/ContactSupportText'; import SearchInputV2 from '@/components/SearchInput/SearchInputV2'; -import { TimelineChart } from '@/components/TimelineChart'; +import { + TimelineChart, + TimelineMinimap, + type TimelineViewportController, +} from '@/components/TimelineChart'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import useRowWhere, { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; @@ -1004,8 +1008,22 @@ export function DBTraceWaterfallChartContainer({ const { ref: timelineWrapperRef, height: timelineWrapperHeight } = useElementSize(); + const [viewportController, setViewportController] = + useState(null); + + const showMinimap = + !isFetching && !error && rows != null && visibleNodes.length > 0; + return ( <> + {showMinimap && ( + + + + )} {isFilterExpanded && (
)} diff --git a/packages/app/src/components/TimelineChart/TimelineChart.tsx b/packages/app/src/components/TimelineChart/TimelineChart.tsx index 6f340f1f78..732b61f2d7 100644 --- a/packages/app/src/components/TimelineChart/TimelineChart.tsx +++ b/packages/app/src/components/TimelineChart/TimelineChart.tsx @@ -18,6 +18,7 @@ import { TimelineChartRowEvents, type TTimelineEvent, } from './TimelineChartRowEvents'; +import { getMaxEventValue } from './utils'; import styles from './TimelineChart.module.scss'; import resizeStyles from '@styles/ResizablePanel.module.scss'; @@ -30,6 +31,37 @@ type Row = { type?: string; }; +/** + * Imperative bridge between the scroll-based zoom/pan model owned by + * TimelineChart and the TimelineMinimap (which renders above the controls row, + * outside this component). The minimap reads the current viewport via + * getState(), reflects live scroll/zoom changes via subscribe(), and drives + * zoom/pan through zoomToRange/panToOffset/reset. + * + * All fractions are in [0, 1] over the events timeline (the label column is + * excluded from the events area, matching how spans are positioned). + */ +export type TimelineViewportState = { + // Current zoom scale (>= 1; 1 = fully zoomed out). + scale: number; + // Fraction of the timeline scrolled past the left edge of the events area. + offsetFrac: number; + // Fraction of the timeline currently visible in the events area. + viewportWidthFrac: number; +}; + +export type TimelineViewportController = { + getState: () => TimelineViewportState; + // Subscribe to scroll/zoom changes (rAF-throttled). Returns an unsubscribe. + subscribe: (cb: () => void) => () => void; + // Zoom so the events area shows exactly [startFrac, endFrac]. + zoomToRange: (startFrac: number, endFrac: number) => void; + // Pan (at the current scale) so the events area's left edge is at offsetFrac. + panToOffset: (offsetFrac: number) => void; + // Return to the fully zoomed-out view. + reset: () => void; +}; + type TimelineChartProps = { initialScrollRowIndex: number; labelWidth: number; @@ -37,8 +69,13 @@ type TimelineChartProps = { rowHeight: number; rows: Row[]; onEventClick: (e: Row) => void; + onReady?: (controller: TimelineViewportController) => void; }; +// Smallest selectable viewport width as a timeline fraction. Guards against +// divide-by-zero / runaway scale when brushing or resizing to a tiny range. +const MIN_VIEWPORT_FRAC = 0.02; + const axisHeight = 24; const rowsMarginTop = 32; const maxScale = 100; @@ -51,6 +88,7 @@ export const TimelineChart = memo(function (props: TimelineChartProps) { rowHeight, rows, onEventClick, + onReady, } = props; const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100; @@ -127,17 +165,133 @@ export const TimelineChart = memo(function (props: TimelineChartProps) { } }, [initialScrollRowIndex, rowVirtualizer]); - const maxVal = useMemo(() => { - let max = 0; + const maxVal = useMemo(() => getMaxEventValue(rows), [rows]); - for (const row of rows) { - for (const event of row.events) { - max = Math.max(max, event.end); - } + // Subscribers (the minimap) notified on scroll/zoom so they can re-read the + // viewport. rAF-throttled so native scroll bursts collapse to one read. + const subscribersRef = useRef void>>(new Set()); + const notifyRafRef = useRef(null); + + const notifyViewport = useStableCallback(() => { + if (notifyRafRef.current != null) return; + notifyRafRef.current = requestAnimationFrame(() => { + notifyRafRef.current = null; + subscribersRef.current.forEach(cb => cb()); + }); + }); + + // Low-level commit shared by wheel-zoom and the minimap: set the scale, the + // scroller width, and the scroll position, then re-layout the axis/cursor and + // notify viewport subscribers. The browser clamps scrollLeft to a valid range. + const commitViewport = useStableCallback( + (newScale: number, newScrollLeft: number) => { + const container = timelineRef.current; + const scroller = timelineScrollerRef.current; + if (!container || !scroller) return; + + const clamped = Math.min(Math.max(newScale, 1), maxScale); + scaleRef.current = clamped; + scroller.style.width = `${100 * clamped}%`; + container.scrollLeft = newScrollLeft; + + xAxisHandleRef.current?.recompute(); + mouseCursorHandleRef.current?.recompute(); + notifyViewport(); + }, + ); + + const getViewportState = useStableCallback((): TimelineViewportState => { + const container = timelineRef.current; + const scale = scaleRef.current; + const clientW = container?.clientWidth ?? 1; + const labelW = labelWidthRef.current; + // Events area width at the current scale; matches the geometry used by + // flushWheel (scroller width is 100*scale% of the container). + const eventsW = Math.max(1, clientW * scale - labelW); + const offsetFrac = container ? container.scrollLeft / eventsW : 0; + const viewportWidthFrac = Math.min(1, (clientW - labelW) / eventsW); + return { scale, offsetFrac, viewportWidthFrac }; + }); + + const zoomToRange = useStableCallback( + (startFrac: number, endFrac: number) => { + const container = timelineRef.current; + if (!container) return; + const clientW = container.clientWidth; + const labelW = labelWidthRef.current; + const widthFrac = Math.max(endFrac - startFrac, MIN_VIEWPORT_FRAC); + // Invert viewportWidthFrac = (W - labelW) / (W*scale - labelW). + const newScale = (labelW + (clientW - labelW) / widthFrac) / clientW; + const clamped = Math.min(Math.max(newScale, 1), maxScale); + const eventsWNew = Math.max(1, clientW * clamped - labelW); + commitViewport(clamped, startFrac * eventsWNew); + }, + ); + + const panToOffset = useStableCallback((offsetFrac: number) => { + const container = timelineRef.current; + if (!container) return; + const clientW = container.clientWidth; + const labelW = labelWidthRef.current; + const eventsW = Math.max(1, clientW * scaleRef.current - labelW); + commitViewport(scaleRef.current, offsetFrac * eventsW); + }); + + const resetViewport = useStableCallback(() => { + commitViewport(1, 0); + }); + + const subscribeViewport = useStableCallback((cb: () => void) => { + subscribersRef.current.add(cb); + return () => { + subscribersRef.current.delete(cb); + }; + }); + + // Stable controller object created once; methods are stable (useStableCallback). + const controllerRef = useRef(null); + if (controllerRef.current == null) { + controllerRef.current = { + getState: getViewportState, + subscribe: subscribeViewport, + zoomToRange, + panToOffset, + reset: resetViewport, + }; + } + + useEffect(() => { + if (controllerRef.current != null) { + onReady?.(controllerRef.current); } + }, [onReady]); - return max * 1.1; - }, [rows]); + // Native horizontal scroll (trackpad / scrollbar) pans the events area; keep + // the minimap viewport rectangle in sync. + useEffect(() => { + const element = timelineRef.current; + if (element == null) return; + const onScroll = () => notifyViewport(); + element.addEventListener('scroll', onScroll, { passive: true }); + return () => element.removeEventListener('scroll', onScroll); + }, [notifyViewport]); + + // Re-notify subscribers when the label column is resized, because the + // events-area width fraction changes even though scale and scrollLeft don't. + // Without this the minimap viewport rect keeps stale offset/width fractions + // until the next scroll or wheel-zoom. + useEffect(() => { + notifyViewport(); + }, [labelWidth, notifyViewport]); + + useEffect(() => { + return () => { + if (notifyRafRef.current != null) { + cancelAnimationFrame(notifyRafRef.current); + notifyRafRef.current = null; + } + }; + }, []); // Wheel deltas accumulate into a pending state and the zoom commit runs // once per frame via rAF, so a fast scroll in one frame collapses to a @@ -191,12 +345,8 @@ export const TimelineChart = memo(function (props: TimelineChartProps) { const fraction = (container.scrollLeft + cursorPx) / eventsWOld; - scaleRef.current = newScale; - scroller.style.width = `${100 * newScale}%`; - container.scrollLeft = fraction * eventsWNew - cursorPx; - - xAxisHandleRef.current?.recompute(); - mouseCursorHandleRef.current?.recompute(); + // Keep the cursor anchored to the same timeline fraction across the zoom. + commitViewport(newScale, fraction * eventsWNew - cursorPx); }); const onWheel = useStableCallback((e: WheelEvent) => { diff --git a/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss b/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss new file mode 100644 index 0000000000..3144eebd00 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss @@ -0,0 +1,91 @@ +.container { + position: relative; + border: 1px solid var(--color-border); + background-color: var(--color-bg-surface); + overflow: hidden; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + border-left: 1px solid var(--color-border-muted); + pointer-events: none; +} + +.tickLabel { + font-size: 10px; + color: var(--color-text-muted); + margin-left: 3px; + white-space: nowrap; + user-select: none; +} + +.bar { + position: absolute; + border-radius: 1px; + pointer-events: none; +} + +.dimmedOverlay { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-viz-range-overlay); + pointer-events: none; +} + +.viewportFrame { + position: absolute; + top: 0; + height: 100%; + box-sizing: border-box; + border: 1px solid var(--color-viz-range-border); + pointer-events: none; +} + +.handleGrip { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 20px; + background-color: var(--color-viz-range-handle); + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + gap: 1px; + + &Left { + left: -3px; + } + + &Right { + right: -3px; + } +} + +.handleGripLine { + width: 0; + height: 8px; + border-left: 1px solid var(--color-viz-range-handle-grip); +} + +.brushOverlay { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-viz-range-brush); + border: 1px solid var(--color-viz-range-brush-border); + box-sizing: border-box; + pointer-events: none; +} + +.resetButton { + position: absolute; + top: 3px; + right: 3px; + z-index: 4; + pointer-events: auto; +} diff --git a/packages/app/src/components/TimelineChart/TimelineMinimap.tsx b/packages/app/src/components/TimelineChart/TimelineMinimap.tsx new file mode 100644 index 0000000000..ba93fffe6e --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineMinimap.tsx @@ -0,0 +1,370 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActionIcon, Tooltip } from '@mantine/core'; +import { IconZoomReset } from '@tabler/icons-react'; + +import type { TimelineViewportController } from './TimelineChart'; +import type { TTimelineEvent } from './TimelineChartRowEvents'; +import { getMaxEventValue, renderMs } from './utils'; + +import styles from './TimelineMinimap.module.scss'; + +type MinimapRow = { + events: TTimelineEvent[]; +}; + +type DragMode = 'pan' | 'resize-left' | 'resize-right' | 'brush' | null; + +type TimelineMinimapProps = { + rows: MinimapRow[]; + controller: TimelineViewportController | null; +}; + +const TICK_HEIGHT = 18; +const BAR_AREA_HEIGHT = 34; +const MINIMAP_HEIGHT = TICK_HEIGHT + BAR_AREA_HEIGHT; +// Pixel width of the grab zone on each side of the viewport for resize handles. +const HANDLE_HIT_AREA = 8; +const BAR_HEIGHT = 2; +// Below this brushed width (timeline fraction) a brush is treated as a click, +// not a zoom — avoids accidental extreme zooms on a stray drag. +const MIN_BRUSH_FRAC = 0.01; +// Smallest range a resize handle can produce; mirrors MIN_VIEWPORT_FRAC in +// TimelineChart so the two never disagree about the minimum window. +const MIN_RANGE_FRAC = 0.02; +// scale must exceed this to count as "zoomed" (guards float noise near 1). +const ZOOMED_EPSILON = 1.01; + +function getXFraction(clientX: number, containerEl: HTMLDivElement): number { + const rect = containerEl.getBoundingClientRect(); + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); +} + +const HandleGrip = ({ side }: { side: 'left' | 'right' }) => ( +
+
+
+
+); + +export const TimelineMinimap = memo(function ({ + rows, + controller, +}: TimelineMinimapProps) { + const containerRef = useRef(null); + const dragModeRef = useRef(null); + const dragStartXRef = useRef(0); + // Viewport at drag start, captured from the controller so pan/resize math is + // relative to a stable origin even as the controller mutates mid-drag. + const dragStartOffsetFracRef = useRef(0); + const dragStartWidthFracRef = useRef(1); + const brushStartFracRef = useRef(0); + const brushRangeRef = useRef<{ start: number; end: number } | null>(null); + + const [brushRange, setBrushRange] = useState<{ + start: number; + end: number; + } | null>(null); + + // Local mirror of the chart's viewport, refreshed via the controller + // subscription. Keeps the rectangle in sync with native scroll + wheel zoom + // without re-rendering the (virtualized) waterfall rows. + const [viewport, setViewport] = useState({ + offsetFrac: 0, + widthFrac: 1, + scale: 1, + }); + + useEffect(() => { + if (!controller) return; + const update = () => { + const s = controller.getState(); + setViewport({ + offsetFrac: s.offsetFrac, + widthFrac: s.viewportWidthFrac, + scale: s.scale, + }); + }; + update(); + return controller.subscribe(update); + }, [controller]); + + const maxVal = useMemo(() => getMaxEventValue(rows), [rows]); + + const viewportStartFrac = viewport.offsetFrac; + const viewportWidthFrac = viewport.widthFrac; + const viewportEndFrac = Math.min(viewportStartFrac + viewportWidthFrac, 1); + const isZoomed = viewport.scale > ZOOMED_EPSILON; + + const ticks = useMemo(() => { + if (maxVal <= 0) return []; + const TARGET_TICKS = 6; + const rawInterval = maxVal / TARGET_TICKS; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval))); + let interval = magnitude; + if (rawInterval >= 2 * magnitude) interval = 2 * magnitude; + if (rawInterval >= 5 * magnitude) interval = 5 * magnitude; + + const result = []; + for (let i = 0; ; i++) { + const val = i * interval; + const frac = val / maxVal; + if (frac > 1) break; + result.push({ frac, label: renderMs(val) }); + } + return result; + }, [maxVal]); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + const el = containerRef.current; + if (!el || !controller) return; + + e.preventDefault(); + e.stopPropagation(); + el.setPointerCapture(e.pointerId); + + const x = getXFraction(e.clientX, el); + const handleFrac = HANDLE_HIT_AREA / el.getBoundingClientRect().width; + + const s = controller.getState(); + const startEdge = s.offsetFrac; + const endEdge = Math.min(s.offsetFrac + s.viewportWidthFrac, 1); + const zoomed = s.scale > ZOOMED_EPSILON; + + dragStartXRef.current = e.clientX; + dragStartOffsetFracRef.current = s.offsetFrac; + dragStartWidthFracRef.current = s.viewportWidthFrac; + + if (zoomed && Math.abs(x - startEdge) < handleFrac) { + dragModeRef.current = 'resize-left'; + } else if (zoomed && Math.abs(x - endEdge) < handleFrac) { + dragModeRef.current = 'resize-right'; + } else if (zoomed && x >= startEdge && x <= endEdge) { + dragModeRef.current = 'pan'; + } else { + dragModeRef.current = 'brush'; + brushStartFracRef.current = x; + const range = { start: x, end: x }; + brushRangeRef.current = range; + setBrushRange(range); + } + }, + [controller], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragModeRef.current || !controller) return; + const el = containerRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + + if (dragModeRef.current === 'brush') { + const x = getXFraction(e.clientX, el); + const start = Math.min(brushStartFracRef.current, x); + const end = Math.max(brushStartFracRef.current, x); + const range = { start, end }; + brushRangeRef.current = range; + setBrushRange(range); + return; + } + + const deltaFrac = (e.clientX - dragStartXRef.current) / rect.width; + + if (dragModeRef.current === 'pan') { + const maxOffset = Math.max(0, 1 - dragStartWidthFracRef.current); + const newOffset = Math.min( + Math.max(dragStartOffsetFracRef.current + deltaFrac, 0), + maxOffset, + ); + controller.panToOffset(newOffset); + } else if (dragModeRef.current === 'resize-left') { + const currentStart = dragStartOffsetFracRef.current; + const currentEnd = Math.min( + currentStart + dragStartWidthFracRef.current, + 1, + ); + const newStart = Math.max( + 0, + Math.min(currentStart + deltaFrac, currentEnd - MIN_RANGE_FRAC), + ); + controller.zoomToRange(newStart, currentEnd); + } else if (dragModeRef.current === 'resize-right') { + const currentStart = dragStartOffsetFracRef.current; + const currentEnd = Math.min( + currentStart + dragStartWidthFracRef.current, + 1, + ); + const newEnd = Math.min( + 1, + Math.max(currentEnd + deltaFrac, currentStart + MIN_RANGE_FRAC), + ); + controller.zoomToRange(currentStart, newEnd); + } + }, + [controller], + ); + + const handlePointerUp = useCallback(() => { + if (dragModeRef.current === 'brush' && controller) { + const range = brushRangeRef.current; + if (range && range.end - range.start > MIN_BRUSH_FRAC) { + controller.zoomToRange(range.start, range.end); + } + } + dragModeRef.current = null; + brushRangeRef.current = null; + setBrushRange(null); + }, [controller]); + + const handleDoubleClick = useCallback(() => { + if (controller && isZoomed) { + controller.reset(); + } + }, [controller, isZoomed]); + + const handleReset = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + controller?.reset(); + }, + [controller], + ); + + const getCursor = useCallback( + (e: React.PointerEvent) => { + if (!isZoomed) return 'crosshair'; + const el = containerRef.current; + if (!el) return 'crosshair'; + const x = getXFraction(e.clientX, el); + const handleFrac = HANDLE_HIT_AREA / el.getBoundingClientRect().width; + + if (Math.abs(x - viewportStartFrac) < handleFrac) return 'col-resize'; + if (Math.abs(x - viewportEndFrac) < handleFrac) return 'col-resize'; + if (x >= viewportStartFrac && x <= viewportEndFrac) return 'grab'; + return 'crosshair'; + }, + [viewportStartFrac, viewportEndFrac, isZoomed], + ); + + return ( +
{ + handlePointerMove(e); + if (!dragModeRef.current) { + e.currentTarget.style.cursor = getCursor(e); + } else if (dragModeRef.current === 'pan') { + e.currentTarget.style.cursor = 'grabbing'; + } else if (dragModeRef.current === 'brush') { + e.currentTarget.style.cursor = 'crosshair'; + } else { + e.currentTarget.style.cursor = 'col-resize'; + } + }} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + > + {ticks.map(({ frac, label }) => ( +
+ {label} +
+ ))} + + {maxVal > 0 && + rows.map((row, rowIdx) => + row.events.map(event => { + const left = (event.start / maxVal) * 100; + const width = Math.max( + ((event.end - event.start) / maxVal) * 100, + 0.15, + ); + return ( +
+ ); + }), + )} + + {isZoomed && ( + <> +
+
+
+ + +
+ + )} + + {brushRange && ( +
+ )} + + {isZoomed && ( + + e.stopPropagation()} + onDoubleClick={e => e.stopPropagation()} + > + + + + )} +
+ ); +}); + +TimelineMinimap.displayName = 'TimelineMinimap'; diff --git a/packages/app/src/components/TimelineChart/__tests__/TimelineMinimap.test.tsx b/packages/app/src/components/TimelineChart/__tests__/TimelineMinimap.test.tsx new file mode 100644 index 0000000000..687b476ed7 --- /dev/null +++ b/packages/app/src/components/TimelineChart/__tests__/TimelineMinimap.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import type { TimelineViewportController } from '@/components/TimelineChart'; +import { TimelineMinimap } from '@/components/TimelineChart/TimelineMinimap'; + +// jsdom has no layout engine, so getBoundingClientRect returns zeros and +// setPointerCapture is unimplemented. Pin the container to a known 1000px-wide +// box and stub pointer capture so getXFraction math is deterministic +// (clientX / 1000 = fraction). +const CONTAINER_WIDTH = 1000; + +beforeAll(() => { + // jsdom doesn't implement PointerEvent, so fireEvent.pointerXxx would drop + // clientX. Back it with MouseEvent (which honors clientX from init). + if (typeof window.PointerEvent === 'undefined') { + // @ts-expect-error minimal polyfill for tests + window.PointerEvent = class extends MouseEvent { + constructor(type: string, params: MouseEventInit = {}) { + super(type, params); + } + }; + } + Element.prototype.setPointerCapture = jest.fn(); + Element.prototype.releasePointerCapture = jest.fn(); + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: CONTAINER_WIDTH, + bottom: 52, + width: CONTAINER_WIDTH, + height: 52, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +const rows = [ + { + events: [ + { + id: 'a', + start: 0, + end: 100, + tooltip: '', + color: '#fff', + backgroundColor: 'rgb(10, 20, 30)', + body: null, + }, + { + id: 'b', + start: 100, + end: 300, + tooltip: '', + color: '#fff', + backgroundColor: 'rgb(40, 50, 60)', + body: null, + }, + ], + }, +]; + +function makeController(state: { + scale: number; + offsetFrac: number; + viewportWidthFrac: number; +}): TimelineViewportController { + return { + getState: jest.fn(() => state), + subscribe: jest.fn(() => () => {}), + zoomToRange: jest.fn(), + panToOffset: jest.fn(), + reset: jest.fn(), + }; +} + +describe('TimelineMinimap', () => { + it('renders service-colored bars from event.backgroundColor', () => { + const controller = makeController({ + scale: 1, + offsetFrac: 0, + viewportWidthFrac: 1, + }); + renderWithMantine(); + const minimap = screen.getByTestId('timeline-minimap'); + const bars = Array.from(minimap.querySelectorAll('div')).filter(el => + /rgb\(/.test(el.style.backgroundColor), + ); + expect(bars.length).toBe(2); + expect(bars.map(b => b.style.backgroundColor)).toEqual( + expect.arrayContaining(['rgb(10, 20, 30)', 'rgb(40, 50, 60)']), + ); + }); + + it('hides viewport chrome (reset button) when fully zoomed out', () => { + const controller = makeController({ + scale: 1, + offsetFrac: 0, + viewportWidthFrac: 1, + }); + renderWithMantine(); + expect( + screen.queryByRole('button', { name: /reset minimap zoom/i }), + ).not.toBeInTheDocument(); + }); + + it('shows the reset button when zoomed and resets on click', () => { + const controller = makeController({ + scale: 4, + offsetFrac: 0.25, + viewportWidthFrac: 0.25, + }); + renderWithMantine(); + const resetBtn = screen.getByRole('button', { + name: /reset minimap zoom/i, + }); + expect(resetBtn).toBeInTheDocument(); + fireEvent.click(resetBtn); + expect(controller.reset).toHaveBeenCalledTimes(1); + }); + + it('brush-to-zoom: dragging a range calls zoomToRange with that range', () => { + const controller = makeController({ + scale: 1, + offsetFrac: 0, + viewportWidthFrac: 1, + }); + renderWithMantine(); + const minimap = screen.getByTestId('timeline-minimap'); + + fireEvent.pointerDown(minimap, { clientX: 200, pointerId: 1 }); + fireEvent.pointerMove(minimap, { clientX: 600, pointerId: 1 }); + fireEvent.pointerUp(minimap, { clientX: 600, pointerId: 1 }); + + expect(controller.zoomToRange).toHaveBeenCalledTimes(1); + const [start, end] = (controller.zoomToRange as jest.Mock).mock.calls[0]; + expect(start).toBeCloseTo(0.2, 5); + expect(end).toBeCloseTo(0.6, 5); + }); + + it('drag-to-pan: dragging inside the viewport when zoomed calls panToOffset', () => { + const controller = makeController({ + scale: 4, + offsetFrac: 0.25, + viewportWidthFrac: 0.25, + }); + renderWithMantine(); + const minimap = screen.getByTestId('timeline-minimap'); + + // viewport spans frac [0.25, 0.5]; start the drag at its middle (0.375). + fireEvent.pointerDown(minimap, { clientX: 375, pointerId: 1 }); + fireEvent.pointerMove(minimap, { clientX: 475, pointerId: 1 }); + + expect(controller.panToOffset).toHaveBeenCalled(); + const lastCall = (controller.panToOffset as jest.Mock).mock.calls.at(-1); + // moved +0.1 frac → offset 0.25 -> ~0.35 + expect(lastCall[0]).toBeCloseTo(0.35, 5); + expect(controller.zoomToRange).not.toHaveBeenCalled(); + }); + + it('resize handle: dragging the right edge re-zooms to the new range', () => { + const controller = makeController({ + scale: 4, + offsetFrac: 0.25, + viewportWidthFrac: 0.25, + }); + renderWithMantine(); + const minimap = screen.getByTestId('timeline-minimap'); + + // right edge at frac 0.5 (px 500); grab it and drag right by 0.2 (200px). + fireEvent.pointerDown(minimap, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(minimap, { clientX: 700, pointerId: 1 }); + + expect(controller.zoomToRange).toHaveBeenCalled(); + const lastCall = (controller.zoomToRange as jest.Mock).mock.calls.at(-1); + expect(lastCall[0]).toBeCloseTo(0.25, 5); // start unchanged + expect(lastCall[1]).toBeCloseTo(0.7, 5); // end pushed to 0.7 + }); + + it('double-click resets when zoomed', () => { + const controller = makeController({ + scale: 4, + offsetFrac: 0.25, + viewportWidthFrac: 0.25, + }); + renderWithMantine(); + fireEvent.doubleClick(screen.getByTestId('timeline-minimap')); + expect(controller.reset).toHaveBeenCalledTimes(1); + }); + + it('double-click does nothing when not zoomed', () => { + const controller = makeController({ + scale: 1, + offsetFrac: 0, + viewportWidthFrac: 1, + }); + renderWithMantine(); + fireEvent.doubleClick(screen.getByTestId('timeline-minimap')); + expect(controller.reset).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/TimelineChart/__tests__/utils.test.ts b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts index 2975775189..8b05166549 100644 --- a/packages/app/src/components/TimelineChart/__tests__/utils.test.ts +++ b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts @@ -1,4 +1,8 @@ -import { calculateInterval, renderMs } from '@/components/TimelineChart/utils'; +import { + calculateInterval, + getMaxEventValue, + renderMs, +} from '@/components/TimelineChart/utils'; describe('renderMs', () => { it('formats sub-second values as ms', () => { @@ -106,3 +110,27 @@ describe('calculateInterval', () => { expect(calculateInterval(-5)).toBe(1); }); }); + +describe('getMaxEventValue', () => { + it('returns the max event end plus 10% padding', () => { + const rows = [ + { events: [{ end: 100 }, { end: 250 }] }, + { events: [{ end: 300 }] }, + ]; + expect(getMaxEventValue(rows)).toBeCloseTo(330); + }); + + it('ignores rows with no events and finds the global max', () => { + const rows = [ + { events: [] }, + { events: [{ end: 50 }] }, + { events: [{ end: 10 }, { end: 500 }] }, + ]; + expect(getMaxEventValue(rows)).toBeCloseTo(550); + }); + + it('returns 0 for empty input', () => { + expect(getMaxEventValue([])).toBe(0); + expect(getMaxEventValue([{ events: [] }])).toBe(0); + }); +}); diff --git a/packages/app/src/components/TimelineChart/index.ts b/packages/app/src/components/TimelineChart/index.ts index c059823eb9..37eac98d67 100644 --- a/packages/app/src/components/TimelineChart/index.ts +++ b/packages/app/src/components/TimelineChart/index.ts @@ -1 +1,3 @@ +export type { TimelineViewportController } from './TimelineChart'; export { TimelineChart } from './TimelineChart'; +export { TimelineMinimap } from './TimelineMinimap'; diff --git a/packages/app/src/components/TimelineChart/utils.ts b/packages/app/src/components/TimelineChart/utils.ts index 998827e997..0bf8cd3dfc 100644 --- a/packages/app/src/components/TimelineChart/utils.ts +++ b/packages/app/src/components/TimelineChart/utils.ts @@ -1,3 +1,18 @@ +// Shared maxVal used to position events across the waterfall and the minimap. +// Adds 10% right padding so the last span never touches the edge. Kept here so +// TimelineChart and TimelineMinimap derive an identical timeline range. +export function getMaxEventValue( + rows: { events: { end: number }[] }[], +): number { + let max = 0; + for (const row of rows) { + for (const event of row.events) { + max = Math.max(max, event.end); + } + } + return max * 1.1; +} + export function renderMs(ms: number) { if (ms < 1) { const µsRounded = Math.round(ms * 1000); diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 60b591b706..b7df1ae082 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -55,7 +55,14 @@ jest.mock('@/components/TimelineChart', () => { ); }; mockComponent.latestProps = {}; - return { TimelineChart: mockComponent }; + + const MockTimelineMinimap = function MockTimelineMinimap(props: any) { + MockTimelineMinimap.latestProps = props; + return
TimelineMinimap
; + }; + MockTimelineMinimap.latestProps = {}; + + return { TimelineChart: mockComponent, TimelineMinimap: MockTimelineMinimap }; }); jest.mock('@/hooks/useOffsetPaginatedQuery');