chore: separate timeline components to own modules, fix lint issues (#1813)

Closes HDX-3518

## Changes
- Separates the different Timeline related components into their own modules inside `packages/app/src/components/TimelineChart`.
- Fixes lint and TS errors / warnings.

## Testing
- Trace waterfall view should work just like before.
This commit is contained in:
Karl Power 2026-03-04 21:22:24 +01:00 committed by GitHub
parent 53a4b67262
commit f889c34997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 830 additions and 754 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
chore: separate timeline components to own modules, fix lint issues

View file

@ -1,745 +0,0 @@
import { memo, RefObject, useEffect, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { Text, Tooltip } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { color } from '@uiw/react-codemirror';
import { useFormatTime } from '@/useFormatTime';
import useResizable from './hooks/useResizable';
import { useDrag, usePrevious } from './utils';
import resizeStyles from '../styles/ResizablePanel.module.scss';
import styles from '../styles/TimelineChart.module.scss';
type SpanEventMarker = {
timestamp: number; // ms offset from minOffset
name: string;
attributes: Record<string, any>;
};
type TimelineEventT = {
id: string;
start: number;
end: number;
tooltip: string;
color: string;
body: React.ReactNode;
minWidthPerc?: number;
isError?: boolean;
markers?: SpanEventMarker[];
};
const SpanEventMarkerComponent = memo(function SpanEventMarkerComponent({
marker,
eventStart,
eventEnd,
height,
}: {
marker: SpanEventMarker;
eventStart: number;
eventEnd: number;
height: number;
}) {
const formatTime = useFormatTime();
// Calculate marker position as percentage within the span bar (0-100%)
const spanDuration = eventEnd - eventStart;
const markerOffsetFromStart = marker.timestamp - eventStart;
const markerPosition =
spanDuration > 0 ? (markerOffsetFromStart / spanDuration) * 100 : 0;
// Format attributes for tooltip
const attributeEntries = Object.entries(marker.attributes);
const tooltipContent = (
<div>
<Text size="xxs" c="dimmed" mb="xxs">
{formatTime(new Date(marker.timestamp), { format: 'withMs' })}
</Text>
<Text size="xs">{marker.name}</Text>
{attributeEntries.length > 0 && (
<div style={{ fontSize: 10, marginTop: 4 }}>
{attributeEntries.slice(0, 5).map(([key, value]) => (
<div key={key}>
<span style={{ color: 'var(--color-text-primary)' }}>{key}:</span>{' '}
{String(value).length > 50
? String(value).substring(0, 50) + '...'
: String(value)}
</div>
))}
{attributeEntries.length > 5 && (
<div style={{ fontStyle: 'italic' }}>
...and {attributeEntries.length - 5} more
</div>
)}
</div>
)}
</div>
);
return (
<Tooltip
label={tooltipContent}
color="var(--color-bg-surface)"
withArrow
multiline
transitionProps={{ transition: 'fade' }}
style={{
fontSize: 11,
maxWidth: 350,
wordBreak: 'break-word',
}}
>
<div
style={{
position: 'absolute',
left: `${markerPosition.toFixed(6)}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
width: 8,
height: 8,
cursor: 'pointer',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
{/* Diamond shape marker */}
<div
style={{
width: 8,
height: 8,
backgroundColor: 'var(--color-bg-success)',
transform: 'rotate(45deg)',
border: '1px solid #333',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
/>
{/* Vertical line extending above and below */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 1,
height: height,
backgroundColor: 'var(--color-bg-success)',
opacity: 0.4,
zIndex: -1,
}}
/>
</div>
</Tooltip>
);
});
const NewTimelineRow = memo(
function NewTimelineRow({
events,
maxVal,
height,
eventStyles,
onEventHover,
scale,
offset,
}: {
events: TimelineEventT[] | undefined;
maxVal: number;
height: number;
scale: number;
offset: number;
eventStyles?:
| React.CSSProperties
| ((event: TimelineEventT) => React.CSSProperties);
onEventHover?: Function;
onEventClick?: (event: any) => any;
}) {
const onHover = onEventHover ?? (() => {});
return (
<div
className="d-flex overflow-hidden"
style={{ width: 0, flexGrow: 1, height, position: 'relative' }}
>
<div
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
></div>
{(events ?? []).map((e: TimelineEventT, i, arr) => {
const minWidth = (e.minWidthPerc ?? 0) / 100;
const lastEvent = arr[i - 1];
const lastEventMinEnd =
lastEvent?.start != null ? lastEvent?.start + maxVal * minWidth : 0;
const lastEventEnd = Math.max(lastEvent?.end ?? 0, lastEventMinEnd);
const percWidth =
scale * Math.max((e.end - e.start) / maxVal, minWidth) * 100;
const percMarginLeft =
scale * (((e.start - lastEventEnd) / maxVal) * 100);
return (
<Tooltip
key={e.id}
label={e.tooltip}
color="gray"
withArrow
multiline
transitionProps={{ transition: 'fade-right' }}
style={{
fontSize: 11,
maxWidth: 300,
wordBreak: 'break-word',
}}
>
<div
onMouseEnter={() => onHover(e.id)}
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
style={{
userSelect: 'none',
backgroundColor: e.color,
minWidth: `${percWidth.toFixed(6)}%`,
width: `${percWidth.toFixed(6)}%`,
marginLeft: `${percMarginLeft.toFixed(6)}%`,
position: 'relative',
...(typeof eventStyles === 'function'
? eventStyles(e)
: eventStyles),
}}
>
<div style={{ margin: 'auto' }} className="px-2">
{e.body}
</div>
{/* Render span event markers */}
{e.markers?.map((marker, idx) => (
<SpanEventMarkerComponent
key={`${e.id}-marker-${idx}`}
marker={marker}
eventStart={e.start}
eventEnd={e.end}
height={height}
/>
))}
</div>
</Tooltip>
);
})}
</div>
);
},
// TODO: Revisit this?
// (prev, next) => {
// // TODO: This is a hack for cheap comparisons
// return (
// prev.maxVal === next.maxVal &&
// prev.events?.length === next.events?.length &&
// prev.scale === next.scale &&
// prev.offset === next.offset
// );
// },
);
function renderMs(ms: number) {
return ms < 1000
? `${Math.round(ms)}ms`
: ms % 1000 === 0
? `${Math.floor(ms / 1000)}s`
: `${(ms / 1000).toFixed(3)}s`;
}
function calculateInterval(value: number) {
// Calculate the approximate interval by dividing the value by 10
const interval = value / 10;
// Round the interval to the nearest power of 10 to make it a human-friendly number
const magnitude = Math.pow(10, Math.floor(Math.log10(interval)));
// Adjust the interval to the nearest standard bucket size
let bucketSize = magnitude;
if (interval >= 2 * magnitude) {
bucketSize = 2 * magnitude;
}
if (interval >= 5 * magnitude) {
bucketSize = 5 * magnitude;
}
return bucketSize;
}
function TimelineXAxis({
maxVal,
labelWidth,
height,
scale,
offset,
}: {
maxVal: number;
labelWidth: number;
height: number;
scale: number;
offset: number;
}) {
const scaledMaxVal = maxVal / scale;
// TODO: Turn this into a function
const interval = calculateInterval(scaledMaxVal);
const numTicks = Math.floor(maxVal / interval);
const percSpacing = (interval / maxVal) * 100 * scale;
const ticks = [];
for (let i = 0; i < numTicks; i++) {
ticks.push(
<div
key={i}
style={{
height,
width: 1,
marginRight: -1,
marginLeft: i === 0 ? 0 : `${percSpacing.toFixed(6)}%`,
background: 'var(--color-border-muted)',
}}
>
<div className="ms-2 fs-8.5">{renderMs(i * interval)}</div>
</div>,
);
}
return (
<div
style={{
position: 'sticky',
top: 0,
height: 24,
paddingTop: 4,
zIndex: 200,
pointerEvents: 'none',
background: 'var(--color-bg-body)',
}}
>
<div className={`${cx('d-flex align-items-center')}`}>
<div style={{ width: labelWidth, minWidth: labelWidth }}></div>
<div className="d-flex w-100 overflow-hidden">
<div
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
></div>
{ticks}
</div>
</div>
</div>
);
}
function TimelineCursor({
xPerc,
overlay,
labelWidth,
color,
height,
}: {
xPerc: number;
overlay?: React.ReactNode;
labelWidth: number;
color: string;
height: number;
}) {
// Bound [-1,100] to 6 digits as a percent, -1 so it can slide off the right side of the screen
const cursorMarginLeft = `${Math.min(Math.max(xPerc * 100, -1), 100).toFixed(
6,
)}%`;
return (
<div
style={{
position: 'sticky',
top: 0,
height: 0,
zIndex: 250,
pointerEvents: 'none',
display: xPerc <= 0 ? 'none' : 'block',
}}
>
<div className="d-flex">
<div style={{ width: labelWidth, minWidth: labelWidth }} />
<div className="w-100 overflow-hidden">
<div style={{ marginLeft: cursorMarginLeft }}>
{overlay != null && (
<div
style={{
height: 0,
marginLeft: xPerc < 0.5 ? 12 : -150,
top: 12,
position: 'relative',
}}
>
<div>
<span
className="p-2 rounded border"
style={{ background: 'var(--color-bg-surface)' }}
>
{overlay}
</span>
</div>
</div>
)}
<div
style={{
height,
width: 1,
background: color,
}}
></div>
</div>
</div>
</div>
</div>
);
}
function TimelineMouseCursor({
containerRef,
maxVal,
labelWidth,
height,
scale,
offset,
xPerc,
setXPerc,
}: {
containerRef: RefObject<HTMLDivElement | null>;
maxVal: number;
labelWidth: number;
height: number;
scale: number;
offset: number;
xPerc: number;
setXPerc: (p: number) => any;
}) {
const [showCursor, setShowCursor] = useState(false);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (containerRef.current != null) {
const timelineContainer = containerRef.current;
const rect = timelineContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Remove label width from calculations
// Use clientWidth as that removes scroll bars
const xPerc =
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
if (onMouseMove != null) {
setXPerc(xPerc);
}
}
};
const onMouseEnter = () => setShowCursor(true);
const onMouseLeave = () => setShowCursor(false);
const element = containerRef.current;
element?.addEventListener('mousemove', onMouseMove);
element?.addEventListener('mouseleave', onMouseLeave);
element?.addEventListener('mouseenter', onMouseEnter);
return () => {
element?.removeEventListener('mousemove', onMouseMove);
element?.removeEventListener('mouseleave', onMouseLeave);
element?.removeEventListener('mouseenter', onMouseEnter);
};
}, [containerRef, labelWidth, setXPerc]);
const cursorTime = (offset / 100 + Math.max(xPerc, 0) / scale) * maxVal;
return showCursor ? (
<TimelineCursor
xPerc={Math.max(xPerc, 0)}
overlay={renderMs(Math.max(cursorTime, 0))}
height={height}
labelWidth={labelWidth}
color="var(--color-bg-neutral)"
/>
) : null;
}
type Row = {
id: string;
label: React.ReactNode;
events: TimelineEventT[];
style?: any;
type?: string;
className?: string;
isActive?: boolean;
};
export default function TimelineChart({
rows,
cursors,
rowHeight,
onMouseMove,
onEventClick,
labelWidth: initialLabelWidth,
className,
style,
onClick,
scale = 1,
setScale = () => {},
initialScrollRowIndex,
scaleWithScroll: scaleWithScroll = false,
}: {
rows: Row[] | undefined;
cursors?: {
id: string;
start: number;
color: string;
}[];
scale?: number;
rowHeight: number;
onMouseMove?: (ts: number) => any;
onClick?: (ts: number) => any;
onEventClick?: (e: Row) => any;
labelWidth: number;
className?: string;
style?: any;
setScale?: (cb: (scale: number) => number) => any;
scaleWithScroll?: boolean;
initialScrollRowIndex?: number;
}) {
const [offset, setOffset] = useState(0);
const prevScale = usePrevious(scale);
const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
const { size: labelWidthPercent, startResize } = useResizable(
initialWidthPercent,
'left',
);
const labelWidth = (labelWidthPercent / 100) * window.innerWidth;
const timelineRef = useRef<HTMLDivElement>(null);
const onMouseEvent = (
e: { clientX: number; clientY: number },
cb: Function | undefined,
) => {
if (timelineRef.current != null && cb != null) {
const timelineContainer = timelineRef.current;
const rect = timelineContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Remove label width from calculations
// Use clientWidth as that removes scroll bars
const xPerc =
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
cb(Math.max((offset / 100 + xPerc / scale) * maxVal));
}
};
const useDragOptions: Parameters<typeof useDrag>[1] = useMemo(
() => ({
onDrag: e => {
setOffset(v =>
Math.min(
Math.max(v - e.movementX * (0.125 / scale), 0),
100 - 100 / scale,
),
);
},
}),
[scale, setOffset],
);
useDrag(timelineRef, useDragOptions);
const [cursorXPerc, setCursorXPerc] = useState(0);
const onWheel = (e: WheelEvent) => {
if (scaleWithScroll) {
e.preventDefault();
setScale(v => Math.max(v - e.deltaY * 0.001, 1));
}
};
useEffect(() => {
if (prevScale != null && prevScale != scale) {
setOffset(offset => {
const newScale = scale;
// we try to calculate the new offset we need to keep the cursor's
// abs % the same between current scale and new scale
// cursor abs % = cursorTime/maxVal = offset / 100 + xPerc / scale
const boundedCursorXPerc = Math.max(Math.min(cursorXPerc, 1), 0);
const newOffset =
offset +
(100 * boundedCursorXPerc) / prevScale -
(100 * boundedCursorXPerc) / newScale;
return Math.min(Math.max(newOffset, 0), 100 - 100 / scale);
});
}
}, [scale, prevScale, cursorXPerc]);
useEffect(() => {
const element = timelineRef.current;
if (element != null) {
element.addEventListener('wheel', onWheel, {
passive: false,
});
return () => {
element.removeEventListener('wheel', onWheel);
};
}
});
const maxVal = useMemo(() => {
let max = 0;
for (const row of rows ?? []) {
for (const event of row.events) {
max = Math.max(max, event.end);
}
}
return max * 1.1; // add 10% padding
}, [rows]);
const rowVirtualizer = useVirtualizer({
count: rows?.length ?? 0,
getScrollElement: () => timelineRef.current,
estimateSize: () => rowHeight,
overscan: 5,
});
const items = rowVirtualizer.getVirtualItems();
const TIMELINE_AXIS_HEIGHT = 32;
const [initialScrolled, setInitialScrolled] = useState(false);
useEffect(() => {
if (
initialScrollRowIndex != null &&
!initialScrolled &&
initialScrollRowIndex >= 0
) {
setInitialScrolled(true);
rowVirtualizer.scrollToIndex(initialScrollRowIndex, {
align: 'center',
});
}
}, [initialScrollRowIndex, initialScrolled, rowVirtualizer]);
return (
<div
style={{ position: 'relative', ...style }}
className={className}
ref={timelineRef}
onClick={e => {
onMouseEvent(e, onClick);
}}
onMouseMove={e => {
onMouseEvent(e, onMouseMove);
}}
>
{(cursors ?? ([] as const)).map(cursor => {
const xPerc = (cursor.start / maxVal - offset / 100) * scale;
return (
<TimelineCursor
key={cursor.id}
xPerc={xPerc}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
color={cursor.color}
/>
);
})}
<TimelineMouseCursor
containerRef={timelineRef}
maxVal={maxVal}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
scale={scale}
offset={offset}
xPerc={cursorXPerc}
setXPerc={setCursorXPerc}
/>
<TimelineXAxis
maxVal={maxVal}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
scale={scale}
offset={offset}
/>
<div
style={{
height: `${rowVirtualizer.getTotalSize() + TIMELINE_AXIS_HEIGHT}px`,
width: '100%',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${items?.[0]?.start ?? 0}px)`,
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows?.[virtualRow.index] as Row;
return (
<div
onClick={() => onEventClick?.(row)}
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={`${cx(
'd-flex align-items-center overflow-hidden',
row.className,
styles.timelineRow,
row.isActive && styles.timelineRowActive,
)}`}
style={{
// position: 'absolute',
// top: 0,
// left: 0,
// width: '100%',
// height: `${virtualRow.size}px`,
// transform: `translateY(${virtualRow.start}px)`,
...row.style,
}}
>
<div
className={styles.labelContainer}
style={{
width: labelWidth,
minWidth: labelWidth,
}}
>
{row.label}
<div
className={resizeStyles.resizeHandle}
onMouseDown={startResize}
style={{ backgroundColor: 'var(--color-bg-neutral)' }}
/>
</div>
<NewTimelineRow
events={row.events}
height={rowHeight}
maxVal={maxVal}
eventStyles={(event: TimelineEventT) => ({
borderRadius: 2,
fontSize: rowHeight * 0.5,
backgroundColor: event.isError
? 'var(--color-bg-danger)'
: 'var(--color-bg-inverted)',
color: 'var(--color-text-inverted)',
})}
scale={scale}
offset={offset}
/>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -28,9 +28,10 @@ import {
import { ContactSupportText } from '@/components/ContactSupportText';
import SearchInputV2 from '@/components/SearchInput/SearchInputV2';
import { TimelineChart } from '@/components/TimelineChart';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import useResizable from '@/hooks/useResizable';
import useRowWhere, { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import useRowWhere, { WithClause } from '@/hooks/useRowWhere';
import useWaterfallSearchState from '@/hooks/useWaterfallSearchState';
import {
getDisplayedTimestampValueExpression,
@ -38,7 +39,6 @@ import {
getEventBody,
getSpanEventBody,
} from '@/source';
import TimelineChart from '@/TimelineChart';
import { useFormatTime } from '@/useFormatTime';
import {
getChartColorError,
@ -940,12 +940,8 @@ export function DBTraceWaterfallChartContainer({
maxHeight: `${heightPx}px`,
}}
scale={1}
setScale={() => {}}
rowHeight={22}
labelWidth={300}
onClick={ts => {
// onTimeClick(ts + startedAt);
}}
onEventClick={(event: {
id: string;
type?: string;

View file

@ -0,0 +1,298 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
import useResizable from '../../hooks/useResizable';
import { useDrag, usePrevious } from '../../utils';
import {
TimelineChartRowEvents,
type TTimelineEvent,
} from './TimelineChartRowEvents';
import { TimelineCursor } from './TimelineCursor';
import { TimelineMouseCursor } from './TimelineMouseCursor';
import { TimelineXAxis } from './TimelineXAxis';
import resizeStyles from '../../../styles/ResizablePanel.module.scss';
import styles from './TimelineChart.module.scss';
type Row = {
id: string;
label: React.ReactNode;
events: TTimelineEvent[];
style?: any;
type?: string;
className?: string;
isActive?: boolean;
};
type Cursor = {
id: string;
start: number;
color: string;
};
type TimelineChartProps = {
rows: Row[];
cursors?: Cursor[];
scale?: number;
rowHeight: number;
onMouseMove?: (ts: number) => void;
onClick?: (ts: number) => void;
onEventClick?: (e: Row) => void;
labelWidth: number;
className?: string;
style?: any;
setScale?: (cb: (scale: number) => number) => void;
scaleWithScroll?: boolean;
initialScrollRowIndex?: number;
};
export const TimelineChart = memo(function ({
rows,
cursors,
rowHeight,
onMouseMove,
onEventClick,
labelWidth: initialLabelWidth,
className,
style,
onClick,
scale = 1,
setScale,
initialScrollRowIndex,
scaleWithScroll = false,
}: TimelineChartProps) {
const [offset, setOffset] = useState(0);
const prevScale = usePrevious(scale);
const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
const { size: labelWidthPercent, startResize } = useResizable(
initialWidthPercent,
'left',
);
const labelWidth = (labelWidthPercent / 100) * window.innerWidth;
const timelineRef = useRef<HTMLDivElement>(null);
const onMouseEvent = (
e: { clientX: number; clientY: number },
cb: typeof onClick | typeof onMouseMove,
) => {
if (timelineRef.current != null && cb != null) {
const timelineContainer = timelineRef.current;
const rect = timelineContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
// Remove label width from calculations
// Use clientWidth as that removes scroll bars
const xPerc =
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
cb(Math.max((offset / 100 + xPerc / scale) * maxVal));
}
};
const useDragOptions: Parameters<typeof useDrag>[1] = useMemo(
() => ({
onDrag: e => {
setOffset(v =>
Math.min(
Math.max(v - e.movementX * (0.125 / scale), 0),
100 - 100 / scale,
),
);
},
}),
[scale, setOffset],
);
useDrag(timelineRef, useDragOptions);
const [cursorXPerc, setCursorXPerc] = useState(0);
const onWheel = (e: WheelEvent) => {
if (scaleWithScroll) {
e.preventDefault();
setScale?.(v => Math.max(v - e.deltaY * 0.001, 1));
}
};
useEffect(() => {
if (prevScale != null && prevScale != scale) {
setOffset(offset => {
const newScale = scale;
// we try to calculate the new offset we need to keep the cursor's
// abs % the same between current scale and new scale
// cursor abs % = cursorTime/maxVal = offset / 100 + xPerc / scale
const boundedCursorXPerc = Math.max(Math.min(cursorXPerc, 1), 0);
const newOffset =
offset +
(100 * boundedCursorXPerc) / prevScale -
(100 * boundedCursorXPerc) / newScale;
return Math.min(Math.max(newOffset, 0), 100 - 100 / scale);
});
}
}, [scale, prevScale, cursorXPerc]);
useEffect(() => {
const element = timelineRef.current;
if (element != null) {
element.addEventListener('wheel', onWheel, {
passive: false,
});
return () => {
element.removeEventListener('wheel', onWheel);
};
}
});
const maxVal = useMemo(() => {
let max = 0;
for (const row of rows) {
for (const event of row.events) {
max = Math.max(max, event.end);
}
}
return max * 1.1; // add 10% padding
}, [rows]);
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => timelineRef.current,
estimateSize: () => rowHeight,
overscan: 5,
});
const items = rowVirtualizer.getVirtualItems();
const TIMELINE_AXIS_HEIGHT = 32;
const [initialScrolled, setInitialScrolled] = useState(false);
useEffect(() => {
if (
initialScrollRowIndex != null &&
!initialScrolled &&
initialScrollRowIndex >= 0
) {
setInitialScrolled(true);
rowVirtualizer.scrollToIndex(initialScrollRowIndex, {
align: 'center',
});
}
}, [initialScrollRowIndex, initialScrolled, rowVirtualizer]);
return (
<div
style={{ position: 'relative', ...style }}
className={className}
ref={timelineRef}
onClick={e => {
onMouseEvent(e, onClick);
}}
onMouseMove={e => {
onMouseEvent(e, onMouseMove);
}}
>
{(cursors ?? ([] as const)).map(cursor => {
const xPerc = (cursor.start / maxVal - offset / 100) * scale;
return (
<TimelineCursor
key={cursor.id}
xPerc={xPerc}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
color={cursor.color}
/>
);
})}
<TimelineMouseCursor
containerRef={timelineRef}
maxVal={maxVal}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
scale={scale}
offset={offset}
xPerc={cursorXPerc}
setXPerc={setCursorXPerc}
/>
<TimelineXAxis
maxVal={maxVal}
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
labelWidth={labelWidth}
scale={scale}
offset={offset}
/>
<div
style={{
height: `${rowVirtualizer.getTotalSize() + TIMELINE_AXIS_HEIGHT}px`,
width: '100%',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${items?.[0]?.start ?? 0}px)`,
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index];
return (
<div
onClick={() => onEventClick?.(row)}
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={`${cx(
'd-flex align-items-center overflow-hidden',
row.className,
styles.timelineRow,
row.isActive && styles.timelineRowActive,
)}`}
style={row.style}
>
<div
className={styles.labelContainer}
style={{
width: labelWidth,
minWidth: labelWidth,
}}
>
{row.label}
<div
className={resizeStyles.resizeHandle}
onMouseDown={startResize}
style={{ backgroundColor: 'var(--color-bg-neutral)' }}
/>
</div>
<TimelineChartRowEvents
events={row.events}
height={rowHeight}
maxVal={maxVal}
eventStyles={(event: TTimelineEvent) => ({
borderRadius: 2,
fontSize: rowHeight * 0.5,
backgroundColor: event.isError
? 'var(--color-bg-danger)'
: 'var(--color-bg-inverted)',
color: 'var(--color-text-inverted)',
})}
scale={scale}
offset={offset}
/>
</div>
);
})}
</div>
</div>
</div>
);
});
TimelineChart.displayName = 'TimelineChart';

View file

@ -0,0 +1,112 @@
import { memo } from 'react';
import { Tooltip } from '@mantine/core';
import {
TimelineSpanEventMarker,
type TTimelineSpanEventMarker,
} from './TimelineSpanEventMarker';
export type TTimelineEvent = {
id: string;
start: number;
end: number;
tooltip: string;
color: string;
body: React.ReactNode;
minWidthPerc?: number;
isError?: boolean;
markers?: TTimelineSpanEventMarker[];
};
type TimelineChartRowProps = {
events: TTimelineEvent[] | undefined;
maxVal: number;
height: number;
scale: number;
offset: number;
eventStyles?:
| React.CSSProperties
| ((event: TTimelineEvent) => React.CSSProperties);
onEventHover?: (eventId: string) => void;
onEventClick?: (event: TTimelineEvent) => void;
};
export const TimelineChartRowEvents = memo(function ({
events,
maxVal,
height,
eventStyles,
onEventHover,
scale,
offset,
}: TimelineChartRowProps) {
return (
<div
className="d-flex overflow-hidden"
style={{ width: 0, flexGrow: 1, height, position: 'relative' }}
>
<div
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
></div>
{(events ?? []).map((e: TTimelineEvent, i, arr) => {
const minWidth = (e.minWidthPerc ?? 0) / 100;
const lastEvent = arr[i - 1];
const lastEventMinEnd =
lastEvent?.start != null ? lastEvent?.start + maxVal * minWidth : 0;
const lastEventEnd = Math.max(lastEvent?.end ?? 0, lastEventMinEnd);
const percWidth =
scale * Math.max((e.end - e.start) / maxVal, minWidth) * 100;
const percMarginLeft =
scale * (((e.start - lastEventEnd) / maxVal) * 100);
return (
<Tooltip
key={e.id}
label={e.tooltip}
color="gray"
withArrow
multiline
transitionProps={{ transition: 'fade-right' }}
style={{
fontSize: 11,
maxWidth: 300,
wordBreak: 'break-word',
}}
>
<div
onMouseEnter={() => onEventHover?.(e.id)}
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
style={{
userSelect: 'none',
backgroundColor: e.color,
minWidth: `${percWidth.toFixed(6)}%`,
width: `${percWidth.toFixed(6)}%`,
marginLeft: `${percMarginLeft.toFixed(6)}%`,
position: 'relative',
...(typeof eventStyles === 'function'
? eventStyles(e)
: eventStyles),
}}
>
<div style={{ margin: 'auto' }} className="px-2">
{e.body}
</div>
{e.markers?.map((marker, idx) => (
<TimelineSpanEventMarker
key={`${e.id}-marker-${idx}`}
marker={marker}
eventStart={e.start}
eventEnd={e.end}
height={height}
/>
))}
</div>
</Tooltip>
);
})}
</div>
);
});
TimelineChartRowEvents.displayName = 'TimelineChartRowEvents';

View file

@ -0,0 +1,67 @@
type TimelineCursorProps = {
xPerc: number;
overlay?: React.ReactNode;
labelWidth: number;
color: string;
height: number;
};
export function TimelineCursor({
xPerc,
overlay,
labelWidth,
color,
height,
}: TimelineCursorProps) {
// Bound [-1,100] to 6 digits as a percent, -1 so it can slide off the right side of the screen
const cursorMarginLeft = `${Math.min(Math.max(xPerc * 100, -1), 100).toFixed(
6,
)}%`;
return (
<div
style={{
position: 'sticky',
top: 0,
height: 0,
zIndex: 250,
pointerEvents: 'none',
display: xPerc <= 0 ? 'none' : 'block',
}}
>
<div className="d-flex">
<div style={{ width: labelWidth, minWidth: labelWidth }} />
<div className="w-100 overflow-hidden">
<div style={{ marginLeft: cursorMarginLeft }}>
{overlay != null && (
<div
style={{
height: 0,
marginLeft: xPerc < 0.5 ? 12 : -150,
top: 12,
position: 'relative',
}}
>
<div>
<span
className="p-2 rounded border"
style={{ background: 'var(--color-bg-surface)' }}
>
{overlay}
</span>
</div>
</div>
)}
<div
style={{
height,
width: 1,
background: color,
}}
></div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { RefObject, useEffect, useState } from 'react';
import { TimelineCursor } from './TimelineCursor';
import { renderMs } from './utils';
export function TimelineMouseCursor({
containerRef,
maxVal,
labelWidth,
height,
scale,
offset,
xPerc,
setXPerc,
}: {
containerRef: RefObject<HTMLDivElement | null>;
maxVal: number;
labelWidth: number;
height: number;
scale: number;
offset: number;
xPerc: number;
setXPerc: (p: number) => void;
}) {
const [showCursor, setShowCursor] = useState(false);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (containerRef.current != null) {
const timelineContainer = containerRef.current;
const rect = timelineContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Remove label width from calculations
// Use clientWidth as that removes scroll bars
const xPerc =
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
if (onMouseMove != null) {
setXPerc(xPerc);
}
}
};
const onMouseEnter = () => setShowCursor(true);
const onMouseLeave = () => setShowCursor(false);
const element = containerRef.current;
element?.addEventListener('mousemove', onMouseMove);
element?.addEventListener('mouseleave', onMouseLeave);
element?.addEventListener('mouseenter', onMouseEnter);
return () => {
element?.removeEventListener('mousemove', onMouseMove);
element?.removeEventListener('mouseleave', onMouseLeave);
element?.removeEventListener('mouseenter', onMouseEnter);
};
}, [containerRef, labelWidth, setXPerc]);
const cursorTime = (offset / 100 + Math.max(xPerc, 0) / scale) * maxVal;
return showCursor ? (
<TimelineCursor
xPerc={Math.max(xPerc, 0)}
overlay={renderMs(Math.max(cursorTime, 0))}
height={height}
labelWidth={labelWidth}
color="var(--color-bg-neutral)"
/>
) : null;
}

View file

@ -0,0 +1,116 @@
import { memo } from 'react';
import { Text, Tooltip } from '@mantine/core';
import { useFormatTime } from '@/useFormatTime';
export type TTimelineSpanEventMarker = {
timestamp: number; // ms offset from minOffset
name: string;
attributes: Record<string, any>;
};
export const TimelineSpanEventMarker = memo(function ({
marker,
eventStart,
eventEnd,
height,
}: {
marker: TTimelineSpanEventMarker;
eventStart: number;
eventEnd: number;
height: number;
}) {
const formatTime = useFormatTime();
// Calculate marker position as percentage within the span bar (0-100%)
const spanDuration = eventEnd - eventStart;
const markerOffsetFromStart = marker.timestamp - eventStart;
const markerPosition =
spanDuration > 0 ? (markerOffsetFromStart / spanDuration) * 100 : 0;
// Format attributes for tooltip
const attributeEntries = Object.entries(marker.attributes);
const tooltipContent = (
<div>
<Text size="xxs" c="dimmed" mb="xxs">
{formatTime(new Date(marker.timestamp), { format: 'withMs' })}
</Text>
<Text size="xs">{marker.name}</Text>
{attributeEntries.length > 0 && (
<div style={{ fontSize: 10, marginTop: 4 }}>
{attributeEntries.slice(0, 5).map(([key, value]) => (
<div key={key}>
<span style={{ color: 'var(--color-text-primary)' }}>{key}:</span>{' '}
{String(value).length > 50
? String(value).substring(0, 50) + '...'
: String(value)}
</div>
))}
{attributeEntries.length > 5 && (
<div style={{ fontStyle: 'italic' }}>
...and {attributeEntries.length - 5} more
</div>
)}
</div>
)}
</div>
);
return (
<Tooltip
label={tooltipContent}
color="var(--color-bg-surface)"
withArrow
multiline
transitionProps={{ transition: 'fade' }}
style={{
fontSize: 11,
maxWidth: 350,
wordBreak: 'break-word',
}}
>
<div
style={{
position: 'absolute',
left: `${markerPosition.toFixed(6)}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
width: 8,
height: 8,
cursor: 'pointer',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
{/* Diamond shape marker */}
<div
style={{
width: 8,
height: 8,
backgroundColor: 'var(--color-bg-success)',
transform: 'rotate(45deg)',
border: '1px solid #333',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
/>
{/* Vertical line extending above and below */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 1,
height: height,
backgroundColor: 'var(--color-bg-success)',
opacity: 0.4,
zIndex: -1,
}}
/>
</div>
</Tooltip>
);
});
TimelineSpanEventMarker.displayName = 'TimelineSpanEventMarker';

View file

@ -0,0 +1,64 @@
import { calculateInterval, renderMs } from './utils';
export function TimelineXAxis({
maxVal,
labelWidth,
height,
scale,
offset,
}: {
maxVal: number;
labelWidth: number;
height: number;
scale: number;
offset: number;
}) {
const scaledMaxVal = maxVal / scale;
// TODO: Turn this into a function
const interval = calculateInterval(scaledMaxVal);
const numTicks = Math.floor(maxVal / interval);
const percSpacing = (interval / maxVal) * 100 * scale;
const ticks = [];
for (let i = 0; i < numTicks; i++) {
ticks.push(
<div
key={i}
style={{
height,
width: 1,
marginRight: -1,
marginLeft: i === 0 ? 0 : `${percSpacing.toFixed(6)}%`,
background: 'var(--color-border-muted)',
}}
>
<div className="ms-2 fs-8.5">{renderMs(i * interval)}</div>
</div>,
);
}
return (
<div
style={{
position: 'sticky',
top: 0,
height: 24,
paddingTop: 4,
zIndex: 200,
pointerEvents: 'none',
background: 'var(--color-bg-body)',
}}
>
<div className="d-flex align-items-center">
<div style={{ width: labelWidth, minWidth: labelWidth }}></div>
<div className="d-flex w-100 overflow-hidden">
<div
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
></div>
{ticks}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { calculateInterval, renderMs } from '../utils';
describe('renderMs', () => {
it('returns "0ms" for 0', () => {
expect(renderMs(0)).toBe('0ms');
});
it('formats sub-second values as ms', () => {
expect(renderMs(500)).toBe('500ms');
expect(renderMs(999)).toBe('999ms');
});
it('rounds sub-second values', () => {
expect(renderMs(999.4)).toBe('999ms');
expect(renderMs(999.6)).toBe('1000ms');
});
it('formats whole seconds without decimals', () => {
expect(renderMs(1000)).toBe('1s');
expect(renderMs(2000)).toBe('2s');
expect(renderMs(5000)).toBe('5s');
});
it('formats fractional seconds with three decimals', () => {
expect(renderMs(1500)).toBe('1.500s');
expect(renderMs(1234.567)).toBe('1.235s');
});
});
describe('calculateInterval', () => {
it('returns 0.5 for value 5 (small values)', () => {
expect(calculateInterval(5)).toBe(0.5);
});
it('returns magnitude 1 bucket for value 10', () => {
expect(calculateInterval(10)).toBe(1);
});
it('returns 2x magnitude for value 25', () => {
expect(calculateInterval(25)).toBe(2);
});
it('returns 5x magnitude for value 50', () => {
expect(calculateInterval(50)).toBe(5);
});
it('returns magnitude 10 for value 100', () => {
expect(calculateInterval(100)).toBe(10);
});
it('returns 2x10 for value 200', () => {
expect(calculateInterval(200)).toBe(20);
});
it('returns 5x10 for value 500', () => {
expect(calculateInterval(500)).toBe(50);
});
it('returns magnitude 100 for value 1000', () => {
expect(calculateInterval(1000)).toBe(100);
});
});

View file

@ -0,0 +1 @@
export { TimelineChart } from './TimelineChart';

View file

@ -0,0 +1,30 @@
export function renderMs(ms: number) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
if (ms % 1000 === 0) {
return `${Math.floor(ms / 1000)}s`;
}
return `${(ms / 1000).toFixed(3)}s`;
}
export function calculateInterval(value: number) {
// Calculate the approximate interval by dividing the value by 10
const interval = value / 10;
// Round the interval to the nearest power of 10 to make it a human-friendly number
const magnitude = Math.pow(10, Math.floor(Math.log10(interval)));
// Adjust the interval to the nearest standard bucket size
let bucketSize = magnitude;
if (interval >= 2 * magnitude) {
bucketSize = 2 * magnitude;
}
if (interval >= 5 * magnitude) {
bucketSize = 5 * magnitude;
}
return bucketSize;
}

View file

@ -3,9 +3,9 @@ import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { screen, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { TimelineChart } from '@/components/TimelineChart';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import useRowWhere from '@/hooks/useRowWhere';
import TimelineChart from '@/TimelineChart';
import { RowSidePanelContext } from '../DBRowSidePanel';
import {
@ -15,13 +15,13 @@ import {
} from '../DBTraceWaterfallChart';
// Mock setup
jest.mock('@/TimelineChart', () => {
jest.mock('@/components/TimelineChart', () => {
const mockComponent = function MockTimelineChart(props: any) {
mockComponent.latestProps = props;
return <div data-testid="timeline-chart">TimelineChart</div>;
};
mockComponent.latestProps = {};
return mockComponent;
return { TimelineChart: mockComponent };
});
jest.mock('@/hooks/useOffsetPaginatedQuery');