From 5c6f91ceecd2af8996bb962b0441aa8a1ea7e11a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:17:28 +0000 Subject: [PATCH 1/4] feat: bold the series nearest the cursor in time chart tooltips In a multi-series Line/Area chart, the shared tooltip lists every series, so it is hard to tell which row maps to which line when a Group By produces many series. Bold the row for the series whose line is nearest the cursor, updating as the pointer moves vertically and clearing emphasis when the pointer is far from every line. Each Area active dot records its active-point pixel Y into a per-chart ref keyed by dataKey. The tooltip compares the cursor pixel Y (coordinate.y) against those captured positions to pick the nearest series. The comparison is pure pixel space (no axis-scale math), and nothing is highlighted beyond a small distance threshold. Stacked bar and single-series charts are unaffected. Co-Authored-By: Claude Opus 4.7 --- .../tooltip-highlight-nearest-series.md | 5 ++ packages/app/src/ChartUtils.tsx | 42 +++++++++ packages/app/src/HDXMultiSeriesTimeChart.tsx | 87 +++++++++++++++++++ packages/app/src/__tests__/ChartUtils.test.ts | 57 ++++++++++++ .../src/components/charts/ChartTooltip.tsx | 7 +- 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 .changeset/tooltip-highlight-nearest-series.md diff --git a/.changeset/tooltip-highlight-nearest-series.md b/.changeset/tooltip-highlight-nearest-series.md new file mode 100644 index 0000000000..aa8cb2e328 --- /dev/null +++ b/.changeset/tooltip-highlight-nearest-series.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: bold the series nearest the cursor in multi-series time chart tooltips diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index b284670182..614cb554c0 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -466,6 +466,48 @@ export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] { ]; } +/** + * Find the series whose active-point pixel Y is closest to the cursor. + * + * `seriesYByKey` maps each series' dataKey to the pixel Y of its + * active point (captured from the chart's active dots), and `pointerY` + * is the cursor's pixel Y. Both live in the same chart pixel space, so + * the nearest series is the one with the smallest vertical distance. + * Returns that series' dataKey, or `undefined` when the pointer is + * farther than `maxDistancePx` from every line (so nothing is + * highlighted in empty space). Candidates not present in the map are + * skipped, and ties resolve to the first candidate in `candidateKeys`. + */ +export function findNearestSeriesKey( + seriesYByKey: Map | undefined, + candidateKeys: string[], + pointerY: number | undefined, + maxDistancePx: number, +): string | undefined { + if (seriesYByKey == null || pointerY == null) { + return undefined; + } + + let nearestKey: string | undefined; + let nearestDistance = Infinity; + for (const key of candidateKeys) { + const seriesY = seriesYByKey.get(key); + if (seriesY == null) { + continue; + } + const distance = Math.abs(seriesY - pointerY); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestKey = key; + } + } + + if (nearestKey == null || nearestDistance > maxDistancePx) { + return undefined; + } + return nearestKey; +} + export interface LineData { dataKey: string; currentPeriodKey: string; diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index d97285eda4..97afa12b16 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -30,6 +30,7 @@ import { ChartTooltipItem, } from './components/charts/ChartTooltip'; import { + findNearestSeriesKey, LineData, MAX_TIME_CHART_SERIES, toStartOfInterval, @@ -40,6 +41,11 @@ import styles from '../styles/HDXLineChart.module.scss'; const MAX_LEGEND_ITEMS = 4; +// Vertical pixel distance within which a series' line counts as "near" the +// cursor for tooltip highlighting. Beyond this, no row is emphasized so the +// tooltip is not misleading when the pointer is in empty space. +const NEAREST_SERIES_MAX_DISTANCE_PX = 30; + const Y_AXIS_WIDTH = 40; const SINGLE_POINT_BAR_RIGHT_PADDING = 10; const SINGLE_POINT_BAR_WIDTH_RATIO = 0.8; @@ -60,10 +66,12 @@ export const TooltipItem = memo( p, previous, numberFormat, + highlighted, }: { p: TooltipPayload; previous?: TooltipPayload; numberFormat?: NumberFormat; + highlighted?: boolean; }) => { return ( ); }, @@ -85,6 +94,8 @@ type HDXLineChartTooltipProps = { previousPeriodOffsetSeconds?: number; numberFormat?: NumberFormat; numberFormatByKey: Map; + /** Per-series active-point pixel Y, captured by the Area active dots. */ + activePointYByKeyRef: React.MutableRefObject>; } & Record; const HDXLineChartTooltip = withErrorBoundary( @@ -97,6 +108,7 @@ const HDXLineChartTooltip = withErrorBoundary( numberFormatByKey, lineDataMap, previousPeriodOffsetSeconds, + activePointYByKeyRef, } = props; const typedPayload = payload as TooltipPayload[]; @@ -120,6 +132,21 @@ const HDXLineChartTooltip = withErrorBoundary( )} ); + + // `coordinate.y` is the cursor's pixel Y; compare it to each series' + // active-dot pixel Y to bold the nearest line. The active dots wrote + // their positions earlier in this same render (Recharts renders + // graphical items before the tooltip), so the capture is current. + const pointerY: number | undefined = props.coordinate?.y; + // eslint-disable-next-line react-hooks/refs + const activePointYByKey = activePointYByKeyRef?.current ?? undefined; + const nearestSeriesKey = findNearestSeriesKey( + activePointYByKey, + typedPayload.map(p => p.dataKey), + pointerY, + NEAREST_SERIES_MAX_DISTANCE_PX, + ); + return ( {payload @@ -142,6 +169,7 @@ const HDXLineChartTooltip = withErrorBoundary( p={p} numberFormat={numberFormatForKey} previous={previousPayload} + highlighted={p.dataKey === nearestSeriesKey} /> ); })} @@ -337,6 +365,58 @@ const StackedBarWithOverlap = (props: BarProps) => { ); }; +type CaptureActiveDotProps = { + /** Shared ref the tooltip reads to find the series nearest the cursor. */ + captureRef: React.MutableRefObject>; + cx?: number; + cy?: number; + dataKey?: string | number; + r?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; +}; + +/** + * Active dot for an Area series. Records the active point's pixel Y (`cy`) + * into `captureRef`, keyed by dataKey, then draws the same dot Recharts + * renders by default. Recharts clones this element with the active-point + * props (cx, cy, dataKey, r, fill, stroke, strokeWidth) during the render + * that precedes the tooltip, so the ref is current when the tooltip reads + * it to find the series nearest the cursor. + */ +function CaptureActiveDot({ + captureRef, + cx, + cy, + dataKey, + r, + fill, + stroke, + strokeWidth, +}: CaptureActiveDotProps) { + if (dataKey != null && typeof cy === 'number' && Number.isFinite(cy)) { + // Written synchronously during render so the tooltip, which Recharts + // renders after the graphical items in the same commit, reads the + // current frame's positions rather than the previous frame's. + // eslint-disable-next-line react-hooks/refs + captureRef.current.set(String(dataKey), cy); + } + if (typeof cx !== 'number' || typeof cy !== 'number') { + return null; + } + return ( + + ); +} + /** * Compute the unique set of hexes referenced by `` defs * inside MemoChart. Exported so a unit test can pin the dedup-and-union @@ -417,6 +497,11 @@ export const MemoChart = memo(function MemoChart({ const [isHovered, setIsHovered] = useState(false); + // Filled by each Area's active dot with the series' active-point pixel Y, + // keyed by dataKey, so the tooltip can bold the series nearest the cursor. + // Read during the same render that draws the active dots. + const activePointYByKeyRef = useRef>(new Map()); + const ChartComponent = useMemo( () => (displayType === DisplayType.StackedBar ? BarChart : AreaChart), // LineChart; [displayType], @@ -460,6 +545,7 @@ export const MemoChart = memo(function MemoChart({ type="monotone" stroke={color} fillOpacity={1} + activeDot={} {...(isHovered ? { fill: 'none', strokeDasharray } : { @@ -785,6 +871,7 @@ export const MemoChart = memo(function MemoChart({ numberFormatByKey={tooltipNumberFormatsByKey} lineDataMap={lineDataMap} previousPeriodOffsetSeconds={previousPeriodOffsetSeconds} + activePointYByKeyRef={activePointYByKeyRef} /> } wrapperStyle={{ diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index ab9acf58e7..daa6d62ffc 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -8,6 +8,7 @@ import { convertToNumberChartConfig, convertToTableChartConfig, convertToTimeChartConfig, + findNearestSeriesKey, formatResponseForPieChart, formatResponseForTimeChart, } from '@/ChartUtils'; @@ -1061,4 +1062,60 @@ describe('ChartUtils', () => { ]); }); }); + + describe('findNearestSeriesKey', () => { + it('returns the series whose captured Y is nearest the pointer', () => { + const seriesY = new Map([ + ['a', 100], + ['b', 50], + ['c', 10], + ]); + expect(findNearestSeriesKey(seriesY, ['a', 'b', 'c'], 48, 30)).toBe('b'); + }); + + it('returns undefined when no series is within maxDistancePx', () => { + const seriesY = new Map([ + ['a', 100], + ['b', 50], + ]); + expect( + findNearestSeriesKey(seriesY, ['a', 'b'], 200, 30), + ).toBeUndefined(); + }); + + it('includes a series exactly at maxDistancePx', () => { + const seriesY = new Map([['a', 100]]); + expect(findNearestSeriesKey(seriesY, ['a'], 70, 30)).toBe('a'); + }); + + it('returns undefined when pointerY is undefined', () => { + const seriesY = new Map([['a', 100]]); + expect( + findNearestSeriesKey(seriesY, ['a'], undefined, 30), + ).toBeUndefined(); + }); + + it('returns undefined when the captured map is undefined', () => { + expect(findNearestSeriesKey(undefined, ['a'], 100, 30)).toBeUndefined(); + }); + + it('returns undefined when there are no candidate keys', () => { + const seriesY = new Map([['a', 100]]); + expect(findNearestSeriesKey(seriesY, [], 100, 30)).toBeUndefined(); + }); + + it('skips candidates absent from the captured map', () => { + const seriesY = new Map([['b', 105]]); + expect(findNearestSeriesKey(seriesY, ['a', 'b'], 100, 30)).toBe('b'); + }); + + it('resolves ties to the first candidate', () => { + const seriesY = new Map([ + ['a', 90], + ['b', 110], + ]); + // pointer 100 is 10px from both 'a' (90) and 'b' (110) + expect(findNearestSeriesKey(seriesY, ['a', 'b'], 100, 30)).toBe('a'); + }); + }); }); diff --git a/packages/app/src/components/charts/ChartTooltip.tsx b/packages/app/src/components/charts/ChartTooltip.tsx index 1918996ae4..f34183d42c 100644 --- a/packages/app/src/components/charts/ChartTooltip.tsx +++ b/packages/app/src/components/charts/ChartTooltip.tsx @@ -50,6 +50,7 @@ export const ChartTooltipItem = memo( strokeDasharray, opacity, previous, + highlighted, }: { color: string; name: string; @@ -59,9 +60,13 @@ export const ChartTooltipItem = memo( strokeDasharray?: string; opacity?: number; previous?: number; + highlighted?: boolean; }) => { return ( -
+
{indicator === 'square' ? ( From 7d03c0410b65c6423b33430bec7e9d0328f24405 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:19:31 +0000 Subject: [PATCH 2/4] chore: drop two stray em-dashes from chart comments Touched these comments in the same files; replace the em-dashes with plain punctuation to match the rest of the codebase. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/ChartUtils.tsx | 4 ++-- packages/app/src/HDXMultiSeriesTimeChart.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 614cb554c0..3e75f924dd 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -125,8 +125,8 @@ export function convertToTimeChartConfig( // When the range is bucket-aligned, the end is the start of the next bucket, // so end-exclusive is required to avoid double-counting boundary events. - // When alignment is off the end is the user's exact selection — fall back to - // the caller's setting, if there is one. + // When alignment is off the end is the user's exact selection, so fall back + // to the caller's setting, if there is one. const isAligned = config.alignDateRangeToGranularity !== false; const dateRangeEndInclusive = isAligned ? false diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 97afa12b16..9568b4ce10 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -425,7 +425,7 @@ function CaptureActiveDot({ * * Includes every categorical hex up front so any positional `` * fill resolves, then unions in semantic hexes returned by the - * `getChartColor{Info,Success,Warning,Error}` helpers — those land in + * `getChartColor{Info,Success,Warning,Error}` helpers; those land in * `lineData[].color` and would otherwise be missing a matching def. * `undefined` colors are filtered so `c.replace('#', '')` can't throw * on a future caller that leaves a series color unset. From e5a7e3ca07357bb4f328a0c55f3ae59fa9129351 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:56:15 +0000 Subject: [PATCH 3/4] fix: only highlight the nearest series when more than one is shown A single-series tooltip has nothing to map back to a line, so skip the nearest-series highlight when the tooltip has one row. Keeps the single-series and stacked-bar cases visually unchanged. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 9568b4ce10..c9220eaaa8 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -140,12 +140,17 @@ const HDXLineChartTooltip = withErrorBoundary( const pointerY: number | undefined = props.coordinate?.y; // eslint-disable-next-line react-hooks/refs const activePointYByKey = activePointYByKeyRef?.current ?? undefined; - const nearestSeriesKey = findNearestSeriesKey( - activePointYByKey, - typedPayload.map(p => p.dataKey), - pointerY, - NEAREST_SERIES_MAX_DISTANCE_PX, - ); + // Only disambiguate when there is more than one series; a single-series + // tooltip has nothing to map back to a line. + const nearestSeriesKey = + typedPayload.length > 1 + ? findNearestSeriesKey( + activePointYByKey, + typedPayload.map(p => p.dataKey), + pointerY, + NEAREST_SERIES_MAX_DISTANCE_PX, + ) + : undefined; return ( From 3f84570d8cf08855e79840258ec248f7afca77f5 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:46:33 +0000 Subject: [PATCH 4/4] feat: emphasize the nearest series' line, not just its tooltip row Bolding the tooltip row alone still leaves the eye hunting for which line it maps to. Lift the nearest-series key into chart state, computed on mouse-move from the pixel Y the active dots already capture, and use it to thicken the nearest line to 2.5px while fading the others to 50% stroke opacity. The tooltip mirrors this: the nearest row stays bold and the rest dim to 50%. Only applies with more than one line shown, and is cleared on the click-frozen tooltip path, matching the existing scope. Co-Authored-By: Claude Opus 4.7 --- .../tooltip-highlight-nearest-series.md | 2 +- packages/app/src/HDXMultiSeriesTimeChart.tsx | 63 ++++++++++++++++++- .../src/components/charts/ChartTooltip.tsx | 11 +++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.changeset/tooltip-highlight-nearest-series.md b/.changeset/tooltip-highlight-nearest-series.md index aa8cb2e328..c10b29ffcf 100644 --- a/.changeset/tooltip-highlight-nearest-series.md +++ b/.changeset/tooltip-highlight-nearest-series.md @@ -2,4 +2,4 @@ "@hyperdx/app": patch --- -feat: bold the series nearest the cursor in multi-series time chart tooltips +feat: emphasize the series nearest the cursor in multi-series time charts. The nearest line is thickened and the others fade back, and its tooltip row is bolded while the rest dim, so a value is easy to trace back to its line. diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index c9220eaaa8..df52a2765b 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -67,11 +67,13 @@ export const TooltipItem = memo( previous, numberFormat, highlighted, + dimmed, }: { p: TooltipPayload; previous?: TooltipPayload; numberFormat?: NumberFormat; highlighted?: boolean; + dimmed?: boolean; }) => { return ( ); }, @@ -175,6 +178,9 @@ const HDXLineChartTooltip = withErrorBoundary( numberFormat={numberFormatForKey} previous={previousPayload} highlighted={p.dataKey === nearestSeriesKey} + dimmed={ + nearestSeriesKey != null && p.dataKey !== nearestSeriesKey + } /> ); })} @@ -507,6 +513,16 @@ export const MemoChart = memo(function MemoChart({ // Read during the same render that draws the active dots. const activePointYByKeyRef = useRef>(new Map()); + // Key of the series whose line is nearest the cursor, lifted into state so + // the chart can emphasize that line (thicker stroke) and fade the rest. + // Set from the chart's mouse-move using the pixel Y the active dots captured + // on the prior frame; the one-frame lag is imperceptible and settles as soon + // as the pointer stops. The tooltip derives the same nearest row itself, + // same-frame, for its own bolding and dimming. + const [nearestSeriesKey, setNearestSeriesKey] = useState< + string | undefined + >(); + const ChartComponent = useMemo( () => (displayType === DisplayType.StackedBar ? BarChart : AreaChart), // LineChart; [displayType], @@ -525,6 +541,15 @@ export const MemoChart = memo(function MemoChart({ return !hasSelection || selectedSeriesNames.has(seriesName); }); + // When a series is nearest the cursor (only meaningful with more than one + // line shown), thicken its line and fade the others so the eye lands on + // the same series the tooltip bolds. Mirrors the legend's selected style + // (thicker stroke) with a gentle fade that keeps the rest readable. + const hasNearest = + limitedGroupKeys.length > 1 && + nearestSeriesKey != null && + limitedGroupKeys.includes(nearestSeriesKey); + return limitedGroupKeys.map(key => { const lineDataIndex = lineData.findIndex(ld => ld.dataKey === key); const color = lineData[lineDataIndex]?.color; @@ -550,6 +575,10 @@ export const MemoChart = memo(function MemoChart({ type="monotone" stroke={color} fillOpacity={1} + strokeWidth={hasNearest && key === nearestSeriesKey ? 2.5 : undefined} + strokeOpacity={ + hasNearest && key !== nearestSeriesKey ? 0.5 : undefined + } activeDot={} {...(isHovered ? { fill: 'none', strokeDasharray } @@ -563,7 +592,14 @@ export const MemoChart = memo(function MemoChart({ /> ); }); - }, [lineData, displayType, id, isHovered, selectedSeriesNames]); + }, [ + lineData, + displayType, + id, + isHovered, + selectedSeriesNames, + nearestSeriesKey, + ]); const yAxisDomain: AxisDomain = useMemo(() => { const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; @@ -729,6 +765,7 @@ export const MemoChart = memo(function MemoChart({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => { setIsHovered(false); + setNearestSeriesKey(undefined); setHighlightStart(undefined); setHighlightEnd(undefined); @@ -743,6 +780,27 @@ export const MemoChart = memo(function MemoChart({ onMouseMove={e => { setIsHovered(true); + // Track which series' line is nearest the cursor so the lines can + // emphasize it. The active dots captured their pixel Y on the prior + // frame; comparing the pointer's chartY picks the nearest line. Skip + // while a click-frozen tooltip is shown, matching the tooltip, and + // only set state when the key changes to keep re-renders rare. + const activePointYByKey = activePointYByKeyRef.current; + const nextNearest = + isClickActive == null && + activePointYByKey.size > 1 && + e?.chartY != null + ? findNearestSeriesKey( + activePointYByKey, + Array.from(activePointYByKey.keys()), + e.chartY, + NEAREST_SERIES_MAX_DISTANCE_PX, + ) + : undefined; + setNearestSeriesKey(prev => + prev === nextNearest ? prev : nextNearest, + ); + if (highlightStart != null) { setHighlightEnd(e.activeLabel); setIsClickActive(undefined); // Clear out any click state as we're highlighting @@ -813,6 +871,9 @@ export const MemoChart = memo(function MemoChart({ yPerc: state.chartY / sizeRef.current[1], activePayload: state.activePayload, }); + // The click-frozen tooltip hides the live tooltip, so drop any + // line emphasis to match. + setNearestSeriesKey(undefined); } else { // We clicked on the chart but outside of a line setIsClickActive(undefined); diff --git a/packages/app/src/components/charts/ChartTooltip.tsx b/packages/app/src/components/charts/ChartTooltip.tsx index f34183d42c..ff9f275f8a 100644 --- a/packages/app/src/components/charts/ChartTooltip.tsx +++ b/packages/app/src/components/charts/ChartTooltip.tsx @@ -51,6 +51,7 @@ export const ChartTooltipItem = memo( opacity, previous, highlighted, + dimmed, }: { color: string; name: string; @@ -61,11 +62,19 @@ export const ChartTooltipItem = memo( opacity?: number; previous?: number; highlighted?: boolean; + dimmed?: boolean; }) => { return (
{indicator === 'square' ? (