From 077a0f438cca79fc6fb6bd64a30364d726ee9382 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Thu, 26 Feb 2026 17:08:07 +0100 Subject: [PATCH] chore: separate timeline components to own modules, fix lint issues --- .changeset/little-walls-behave.md | 5 + packages/app/src/TimelineChart.tsx | 745 ------------------ .../src/components/DBTraceWaterfallChart.tsx | 8 +- .../TimelineChart}/TimelineChart.module.scss | 0 .../TimelineChart/TimelineChart.tsx | 298 +++++++ .../TimelineChart/TimelineChartRowEvents.tsx | 112 +++ .../TimelineChart/TimelineCursor.tsx | 67 ++ .../TimelineChart/TimelineMouseCursor.tsx | 70 ++ .../TimelineChart/TimelineSpanEventMarker.tsx | 116 +++ .../TimelineChart/TimelineXAxis.tsx | 64 ++ .../TimelineChart/__tests__/utils.test.ts | 62 ++ .../app/src/components/TimelineChart/index.ts | 1 + .../app/src/components/TimelineChart/utils.ts | 30 + .../__tests__/DBTraceWaterfallChart.test.tsx | 6 +- 14 files changed, 830 insertions(+), 754 deletions(-) create mode 100644 .changeset/little-walls-behave.md delete mode 100644 packages/app/src/TimelineChart.tsx rename packages/app/{styles => src/components/TimelineChart}/TimelineChart.module.scss (100%) create mode 100644 packages/app/src/components/TimelineChart/TimelineChart.tsx create mode 100644 packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx create mode 100644 packages/app/src/components/TimelineChart/TimelineCursor.tsx create mode 100644 packages/app/src/components/TimelineChart/TimelineMouseCursor.tsx create mode 100644 packages/app/src/components/TimelineChart/TimelineSpanEventMarker.tsx create mode 100644 packages/app/src/components/TimelineChart/TimelineXAxis.tsx create mode 100644 packages/app/src/components/TimelineChart/__tests__/utils.test.ts create mode 100644 packages/app/src/components/TimelineChart/index.ts create mode 100644 packages/app/src/components/TimelineChart/utils.ts diff --git a/.changeset/little-walls-behave.md b/.changeset/little-walls-behave.md new file mode 100644 index 0000000000..dd9c7b7037 --- /dev/null +++ b/.changeset/little-walls-behave.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +chore: separate timeline components to own modules, fix lint issues diff --git a/packages/app/src/TimelineChart.tsx b/packages/app/src/TimelineChart.tsx deleted file mode 100644 index 0674df39af..0000000000 --- a/packages/app/src/TimelineChart.tsx +++ /dev/null @@ -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; -}; - -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 = ( -
- - {formatTime(new Date(marker.timestamp), { format: 'withMs' })} - - {marker.name} - {attributeEntries.length > 0 && ( -
- {attributeEntries.slice(0, 5).map(([key, value]) => ( -
- {key}:{' '} - {String(value).length > 50 - ? String(value).substring(0, 50) + '...' - : String(value)} -
- ))} - {attributeEntries.length > 5 && ( -
- ...and {attributeEntries.length - 5} more -
- )} -
- )} -
- ); - - return ( - -
e.stopPropagation()} - onClick={e => e.stopPropagation()} - > - {/* Diamond shape marker */} -
- {/* Vertical line extending above and below */} -
-
- - ); -}); - -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 ( -
-
- {(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 ( - -
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), - }} - > -
- {e.body} -
- {/* Render span event markers */} - {e.markers?.map((marker, idx) => ( - - ))} -
-
- ); - })} -
- ); - }, - // 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( -
-
{renderMs(i * interval)}
-
, - ); - } - - return ( -
-
-
-
-
- {ticks} -
-
-
- ); -} - -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 ( -
-
-
-
-
- {overlay != null && ( -
-
- - {overlay} - -
-
- )} -
-
-
-
-
- ); -} - -function TimelineMouseCursor({ - containerRef, - maxVal, - labelWidth, - height, - scale, - offset, - xPerc, - setXPerc, -}: { - containerRef: RefObject; - 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 ? ( - - ) : 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(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[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 ( -
{ - onMouseEvent(e, onClick); - }} - onMouseMove={e => { - onMouseEvent(e, onMouseMove); - }} - > - {(cursors ?? ([] as const)).map(cursor => { - const xPerc = (cursor.start / maxVal - offset / 100) * scale; - return ( - - ); - })} - - - -
-
- {rowVirtualizer.getVirtualItems().map(virtualRow => { - const row = rows?.[virtualRow.index] as Row; - return ( -
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, - }} - > -
- {row.label} -
-
- ({ - 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} - /> -
- ); - })} -
-
-
- ); -} diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 86a104468e..8502bf7b4c 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -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; diff --git a/packages/app/styles/TimelineChart.module.scss b/packages/app/src/components/TimelineChart/TimelineChart.module.scss similarity index 100% rename from packages/app/styles/TimelineChart.module.scss rename to packages/app/src/components/TimelineChart/TimelineChart.module.scss diff --git a/packages/app/src/components/TimelineChart/TimelineChart.tsx b/packages/app/src/components/TimelineChart/TimelineChart.tsx new file mode 100644 index 0000000000..798ad378e4 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineChart.tsx @@ -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(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[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 ( +
{ + onMouseEvent(e, onClick); + }} + onMouseMove={e => { + onMouseEvent(e, onMouseMove); + }} + > + {(cursors ?? ([] as const)).map(cursor => { + const xPerc = (cursor.start / maxVal - offset / 100) * scale; + return ( + + ); + })} + + + +
+
+ {rowVirtualizer.getVirtualItems().map(virtualRow => { + const row = rows[virtualRow.index]; + + return ( +
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} + > +
+ {row.label} +
+
+ ({ + 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} + /> +
+ ); + })} +
+
+
+ ); +}); + +TimelineChart.displayName = 'TimelineChart'; diff --git a/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx b/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx new file mode 100644 index 0000000000..a21ed4579b --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx @@ -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 ( +
+
+ {(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 ( + +
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), + }} + > +
+ {e.body} +
+ {e.markers?.map((marker, idx) => ( + + ))} +
+
+ ); + })} +
+ ); +}); + +TimelineChartRowEvents.displayName = 'TimelineChartRowEvents'; diff --git a/packages/app/src/components/TimelineChart/TimelineCursor.tsx b/packages/app/src/components/TimelineChart/TimelineCursor.tsx new file mode 100644 index 0000000000..57bb63e010 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineCursor.tsx @@ -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 ( +
+
+
+
+
+ {overlay != null && ( +
+
+ + {overlay} + +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/packages/app/src/components/TimelineChart/TimelineMouseCursor.tsx b/packages/app/src/components/TimelineChart/TimelineMouseCursor.tsx new file mode 100644 index 0000000000..bb5bb60930 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineMouseCursor.tsx @@ -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; + 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 ? ( + + ) : null; +} diff --git a/packages/app/src/components/TimelineChart/TimelineSpanEventMarker.tsx b/packages/app/src/components/TimelineChart/TimelineSpanEventMarker.tsx new file mode 100644 index 0000000000..4aade86096 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineSpanEventMarker.tsx @@ -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; +}; + +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 = ( +
+ + {formatTime(new Date(marker.timestamp), { format: 'withMs' })} + + {marker.name} + {attributeEntries.length > 0 && ( +
+ {attributeEntries.slice(0, 5).map(([key, value]) => ( +
+ {key}:{' '} + {String(value).length > 50 + ? String(value).substring(0, 50) + '...' + : String(value)} +
+ ))} + {attributeEntries.length > 5 && ( +
+ ...and {attributeEntries.length - 5} more +
+ )} +
+ )} +
+ ); + + return ( + +
e.stopPropagation()} + onClick={e => e.stopPropagation()} + > + {/* Diamond shape marker */} +
+ {/* Vertical line extending above and below */} +
+
+ + ); +}); + +TimelineSpanEventMarker.displayName = 'TimelineSpanEventMarker'; diff --git a/packages/app/src/components/TimelineChart/TimelineXAxis.tsx b/packages/app/src/components/TimelineChart/TimelineXAxis.tsx new file mode 100644 index 0000000000..cd7df93d01 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineXAxis.tsx @@ -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( +
+
{renderMs(i * interval)}
+
, + ); + } + + return ( +
+
+
+
+
+ {ticks} +
+
+
+ ); +} diff --git a/packages/app/src/components/TimelineChart/__tests__/utils.test.ts b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts new file mode 100644 index 0000000000..25bb5bf9ed --- /dev/null +++ b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/components/TimelineChart/index.ts b/packages/app/src/components/TimelineChart/index.ts new file mode 100644 index 0000000000..c059823eb9 --- /dev/null +++ b/packages/app/src/components/TimelineChart/index.ts @@ -0,0 +1 @@ +export { TimelineChart } from './TimelineChart'; diff --git a/packages/app/src/components/TimelineChart/utils.ts b/packages/app/src/components/TimelineChart/utils.ts new file mode 100644 index 0000000000..68570f5b71 --- /dev/null +++ b/packages/app/src/components/TimelineChart/utils.ts @@ -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; +} diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 82f632cea6..012a2711c2 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -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
TimelineChart
; }; mockComponent.latestProps = {}; - return mockComponent; + return { TimelineChart: mockComponent }; }); jest.mock('@/hooks/useOffsetPaginatedQuery');