Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trace-minimap-brush-zoom-pan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat(trace): add a trace minimap above the waterfall
21 changes: 20 additions & 1 deletion packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1004,8 +1008,22 @@ export function DBTraceWaterfallChartContainer({
const { ref: timelineWrapperRef, height: timelineWrapperHeight } =
useElementSize();

const [viewportController, setViewportController] =
useState<TimelineViewportController | null>(null);

const showMinimap =
!isFetching && !error && rows != null && visibleNodes.length > 0;

return (
<>
{showMinimap && (
<Box mb="md">
<TimelineMinimap
rows={timelineRows}
controller={viewportController}
/>
</Box>
)}
{isFilterExpanded && (
<form onSubmit={handleSubmit(onSubmitFilters)}>
<Box
Expand Down Expand Up @@ -1179,6 +1197,7 @@ export function DBTraceWaterfallChartContainer({
}}
rows={timelineRows}
initialScrollRowIndex={initialScrollRowIndex}
onReady={setViewportController}
/>
)}
</div>
Expand Down
178 changes: 164 additions & 14 deletions packages/app/src/components/TimelineChart/TimelineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,15 +31,51 @@ 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;
maxHeight: number;
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;
Expand All @@ -51,6 +88,7 @@ export const TimelineChart = memo(function (props: TimelineChartProps) {
rowHeight,
rows,
onEventClick,
onReady,
} = props;

const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
Expand Down Expand Up @@ -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<Set<() => void>>(new Set());
const notifyRafRef = useRef<number | null>(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<TimelineViewportController | null>(null);
if (controllerRef.current == null) {
controllerRef.current = {
getState: getViewportState,
subscribe: subscribeViewport,
zoomToRange,
panToOffset,
reset: resetViewport,
};
}

useEffect(() => {
if (controllerRef.current != null) {
onReady?.(controllerRef.current);
}
}, [onReady]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

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
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading