Skip to content

Commit bc5cd00

Browse files
feat: highlight the series nearest the cursor in time chart tooltips (#2456)
Multi-series Line and Area chart tooltips list every series at the hovered time. When a Group By produces many series, it is hard to tell which tooltip row maps to which line. This bolds the row for the series whose line is nearest the cursor and updates it as the pointer moves up and down, so a row is easy to trace back to its line. HDX-4531 ## Summary In the shared tooltip for `HDXMultiSeriesTimeChart`, the row for the series nearest the cursor renders at `font-weight: 600` (the same weight the legend uses for a selected series). The highlighted row updates as the pointer moves vertically and clears when the pointer is farther than a small threshold from every line, so nothing is emphasized in empty space. The nearest series is found in pure pixel space. Each Area's active dot records its active-point pixel Y, and the tooltip compares those positions against the cursor's pixel Y (`coordinate.y`). No axis-scale reconstruction is involved, which matters because the y domain is often `[0, 'auto']`. ## Changes - `ChartUtils.tsx`: new pure helper `findNearestSeriesKey(seriesYByKey, candidateKeys, pointerY, maxDistancePx)` that returns the nearest series' key within the threshold, or `undefined`. Isolated so the geometry is unit-testable without rendering Recharts. - `HDXMultiSeriesTimeChart.tsx`: a small capturing active dot on the `<Area>` branch records each series' active-point pixel Y into a per-chart ref, keyed by `dataKey`. It still draws the same dot Recharts renders by default. The tooltip reads the ref to bold the nearest row. Highlighting only applies when more than one series is shown. - `ChartTooltip.tsx`: `ChartTooltipItem` takes an optional `highlighted` prop that sets the row weight to 600. Backward compatible; existing callers are unaffected. ## Why a ref read during render Recharts renders the graphical items and their active dots before it renders the tooltip in the same commit, so the captured dot positions are current when the tooltip reads them. Both the capture write and the tooltip read touch a ref during render; this mirrors the existing pattern in `TimelineMouseCursor` and `TimelineXAxis`, and the `react-hooks/refs` disable is annotated with the reason. ## Out of scope Stacked bar uses `activeBar` rather than `activeDot`, and vertical stacking makes "nearest line" meaningless, so it is intentionally untouched. Single-series charts have nothing to disambiguate, so highlighting is skipped there. Both cases were verified to render with no bold row. ## Test plan - [x] `yarn lint:fix`, `make ci-lint` - [x] `make ci-unit`, with new unit tests for `findNearestSeriesKey`: nearest within threshold, none within threshold, undefined pointer, undefined map, empty candidate list, candidate absent from the map skipped, tie resolves to the first candidate, inclusive boundary at `maxDistancePx` - [x] Manual in Chart Explorer, a Logs Line chart grouped by `ServiceName` with several series: - hovering bolds exactly one row, the series nearest the cursor - moving the pointer down moves the bold through the lines in order - nothing bolds when the pointer is far above every line or below the baseline - confirmed in both light and dark themes - stacked-bar tooltip shows no bold row; single-series tooltip shows no bold row Before this change every tooltip row rendered at the same weight. Before/after captures in light and dark are ready to attach. [ui-states: allow] [viewport: allow] [no-story: allow]
1 parent 34aa906 commit bc5cd00

5 files changed

Lines changed: 276 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
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.

‎packages/app/src/ChartUtils.tsx‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ export function convertToTimeChartConfig(
130130

131131
// When the range is bucket-aligned, the end is the start of the next bucket,
132132
// so end-exclusive is required to avoid double-counting boundary events.
133-
// When alignment is off the end is the user's exact selection — fall back to
134-
// the caller's setting, if there is one.
133+
// When alignment is off the end is the user's exact selection, so fall back
134+
// to the caller's setting, if there is one.
135135
const isAligned = config.alignDateRangeToGranularity !== false;
136136
const dateRangeEndInclusive = isAligned
137137
? false
@@ -473,6 +473,48 @@ export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] {
473473
];
474474
}
475475

476+
/**
477+
* Find the series whose active-point pixel Y is closest to the cursor.
478+
*
479+
* `seriesYByKey` maps each series' dataKey to the pixel Y of its
480+
* active point (captured from the chart's active dots), and `pointerY`
481+
* is the cursor's pixel Y. Both live in the same chart pixel space, so
482+
* the nearest series is the one with the smallest vertical distance.
483+
* Returns that series' dataKey, or `undefined` when the pointer is
484+
* farther than `maxDistancePx` from every line (so nothing is
485+
* highlighted in empty space). Candidates not present in the map are
486+
* skipped, and ties resolve to the first candidate in `candidateKeys`.
487+
*/
488+
export function findNearestSeriesKey(
489+
seriesYByKey: Map<string, number> | undefined,
490+
candidateKeys: string[],
491+
pointerY: number | undefined,
492+
maxDistancePx: number,
493+
): string | undefined {
494+
if (seriesYByKey == null || pointerY == null) {
495+
return undefined;
496+
}
497+
498+
let nearestKey: string | undefined;
499+
let nearestDistance = Infinity;
500+
for (const key of candidateKeys) {
501+
const seriesY = seriesYByKey.get(key);
502+
if (seriesY == null) {
503+
continue;
504+
}
505+
const distance = Math.abs(seriesY - pointerY);
506+
if (distance < nearestDistance) {
507+
nearestDistance = distance;
508+
nearestKey = key;
509+
}
510+
}
511+
512+
if (nearestKey == null || nearestDistance > maxDistancePx) {
513+
return undefined;
514+
}
515+
return nearestKey;
516+
}
517+
476518
export interface LineData {
477519
dataKey: string;
478520
currentPeriodKey: string;

‎packages/app/src/HDXMultiSeriesTimeChart.tsx‎

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
ChartTooltipItem,
3131
} from './components/charts/ChartTooltip';
3232
import {
33+
findNearestSeriesKey,
3334
LineData,
3435
MAX_TIME_CHART_SERIES,
3536
toStartOfInterval,
@@ -40,6 +41,11 @@ import styles from '../styles/HDXLineChart.module.scss';
4041

4142
const MAX_LEGEND_ITEMS = 4;
4243

44+
// Vertical pixel distance within which a series' line counts as "near" the
45+
// cursor for tooltip highlighting. Beyond this, no row is emphasized so the
46+
// tooltip is not misleading when the pointer is in empty space.
47+
const NEAREST_SERIES_MAX_DISTANCE_PX = 30;
48+
4349
const Y_AXIS_WIDTH = 40;
4450
const SINGLE_POINT_BAR_RIGHT_PADDING = 10;
4551
const SINGLE_POINT_BAR_WIDTH_RATIO = 0.8;
@@ -60,10 +66,14 @@ export const TooltipItem = memo(
6066
p,
6167
previous,
6268
numberFormat,
69+
highlighted,
70+
dimmed,
6371
}: {
6472
p: TooltipPayload;
6573
previous?: TooltipPayload;
6674
numberFormat?: NumberFormat;
75+
highlighted?: boolean;
76+
dimmed?: boolean;
6777
}) => {
6878
return (
6979
<ChartTooltipItem
@@ -75,6 +85,8 @@ export const TooltipItem = memo(
7585
strokeDasharray={p.strokeDasharray}
7686
opacity={p.opacity}
7787
previous={previous?.value}
88+
highlighted={highlighted}
89+
dimmed={dimmed}
7890
/>
7991
);
8092
},
@@ -85,6 +97,8 @@ type HDXLineChartTooltipProps = {
8597
previousPeriodOffsetSeconds?: number;
8698
numberFormat?: NumberFormat;
8799
numberFormatByKey: Map<string, NumberFormat>;
100+
/** Per-series active-point pixel Y, captured by the Area active dots. */
101+
activePointYByKeyRef: React.MutableRefObject<Map<string, number>>;
88102
} & Record<string, any>;
89103

90104
const HDXLineChartTooltip = withErrorBoundary(
@@ -97,6 +111,7 @@ const HDXLineChartTooltip = withErrorBoundary(
97111
numberFormatByKey,
98112
lineDataMap,
99113
previousPeriodOffsetSeconds,
114+
activePointYByKeyRef,
100115
} = props;
101116
const typedPayload = payload as TooltipPayload[];
102117

@@ -120,6 +135,26 @@ const HDXLineChartTooltip = withErrorBoundary(
120135
)}
121136
</>
122137
);
138+
139+
// `coordinate.y` is the cursor's pixel Y; compare it to each series'
140+
// active-dot pixel Y to bold the nearest line. The active dots wrote
141+
// their positions earlier in this same render (Recharts renders
142+
// graphical items before the tooltip), so the capture is current.
143+
const pointerY: number | undefined = props.coordinate?.y;
144+
// eslint-disable-next-line react-hooks/refs
145+
const activePointYByKey = activePointYByKeyRef?.current ?? undefined;
146+
// Only disambiguate when there is more than one series; a single-series
147+
// tooltip has nothing to map back to a line.
148+
const nearestSeriesKey =
149+
typedPayload.length > 1
150+
? findNearestSeriesKey(
151+
activePointYByKey,
152+
typedPayload.map(p => p.dataKey),
153+
pointerY,
154+
NEAREST_SERIES_MAX_DISTANCE_PX,
155+
)
156+
: undefined;
157+
123158
return (
124159
<ChartTooltipContainer header={header}>
125160
{payload
@@ -142,6 +177,10 @@ const HDXLineChartTooltip = withErrorBoundary(
142177
p={p}
143178
numberFormat={numberFormatForKey}
144179
previous={previousPayload}
180+
highlighted={p.dataKey === nearestSeriesKey}
181+
dimmed={
182+
nearestSeriesKey != null && p.dataKey !== nearestSeriesKey
183+
}
145184
/>
146185
);
147186
})}
@@ -337,6 +376,58 @@ const StackedBarWithOverlap = (props: BarProps) => {
337376
);
338377
};
339378

379+
type CaptureActiveDotProps = {
380+
/** Shared ref the tooltip reads to find the series nearest the cursor. */
381+
captureRef: React.MutableRefObject<Map<string, number>>;
382+
cx?: number;
383+
cy?: number;
384+
dataKey?: string | number;
385+
r?: number;
386+
fill?: string;
387+
stroke?: string;
388+
strokeWidth?: number;
389+
};
390+
391+
/**
392+
* Active dot for an Area series. Records the active point's pixel Y (`cy`)
393+
* into `captureRef`, keyed by dataKey, then draws the same dot Recharts
394+
* renders by default. Recharts clones this element with the active-point
395+
* props (cx, cy, dataKey, r, fill, stroke, strokeWidth) during the render
396+
* that precedes the tooltip, so the ref is current when the tooltip reads
397+
* it to find the series nearest the cursor.
398+
*/
399+
function CaptureActiveDot({
400+
captureRef,
401+
cx,
402+
cy,
403+
dataKey,
404+
r,
405+
fill,
406+
stroke,
407+
strokeWidth,
408+
}: CaptureActiveDotProps) {
409+
if (dataKey != null && typeof cy === 'number' && Number.isFinite(cy)) {
410+
// Written synchronously during render so the tooltip, which Recharts
411+
// renders after the graphical items in the same commit, reads the
412+
// current frame's positions rather than the previous frame's.
413+
// eslint-disable-next-line react-hooks/refs
414+
captureRef.current.set(String(dataKey), cy);
415+
}
416+
if (typeof cx !== 'number' || typeof cy !== 'number') {
417+
return null;
418+
}
419+
return (
420+
<circle
421+
cx={cx}
422+
cy={cy}
423+
r={r}
424+
fill={fill}
425+
stroke={stroke}
426+
strokeWidth={strokeWidth}
427+
/>
428+
);
429+
}
430+
340431
/**
341432
* Compute the unique set of hexes referenced by `<linearGradient>` defs
342433
* inside MemoChart. Exported so a unit test can pin the dedup-and-union
@@ -345,7 +436,7 @@ const StackedBarWithOverlap = (props: BarProps) => {
345436
*
346437
* Includes every categorical hex up front so any positional `<Area>`
347438
* fill resolves, then unions in semantic hexes returned by the
348-
* `getChartColor{Info,Success,Warning,Error}` helpers — those land in
439+
* `getChartColor{Info,Success,Warning,Error}` helpers; those land in
349440
* `lineData[].color` and would otherwise be missing a matching def.
350441
* `undefined` colors are filtered so `c.replace('#', '')` can't throw
351442
* on a future caller that leaves a series color unset.
@@ -417,6 +508,21 @@ export const MemoChart = memo(function MemoChart({
417508

418509
const [isHovered, setIsHovered] = useState(false);
419510

511+
// Filled by each Area's active dot with the series' active-point pixel Y,
512+
// keyed by dataKey, so the tooltip can bold the series nearest the cursor.
513+
// Read during the same render that draws the active dots.
514+
const activePointYByKeyRef = useRef<Map<string, number>>(new Map());
515+
516+
// Key of the series whose line is nearest the cursor, lifted into state so
517+
// the chart can emphasize that line (thicker stroke) and fade the rest.
518+
// Set from the chart's mouse-move using the pixel Y the active dots captured
519+
// on the prior frame; the one-frame lag is imperceptible and settles as soon
520+
// as the pointer stops. The tooltip derives the same nearest row itself,
521+
// same-frame, for its own bolding and dimming.
522+
const [nearestSeriesKey, setNearestSeriesKey] = useState<
523+
string | undefined
524+
>();
525+
420526
const ChartComponent = useMemo(
421527
() => (displayType === DisplayType.StackedBar ? BarChart : AreaChart), // LineChart;
422528
[displayType],
@@ -435,6 +541,15 @@ export const MemoChart = memo(function MemoChart({
435541
return !hasSelection || selectedSeriesNames.has(seriesName);
436542
});
437543

544+
// When a series is nearest the cursor (only meaningful with more than one
545+
// line shown), thicken its line and fade the others so the eye lands on
546+
// the same series the tooltip bolds. Mirrors the legend's selected style
547+
// (thicker stroke) with a gentle fade that keeps the rest readable.
548+
const hasNearest =
549+
limitedGroupKeys.length > 1 &&
550+
nearestSeriesKey != null &&
551+
limitedGroupKeys.includes(nearestSeriesKey);
552+
438553
return limitedGroupKeys.map(key => {
439554
const lineDataIndex = lineData.findIndex(ld => ld.dataKey === key);
440555
const color = lineData[lineDataIndex]?.color;
@@ -460,6 +575,11 @@ export const MemoChart = memo(function MemoChart({
460575
type="monotone"
461576
stroke={color}
462577
fillOpacity={1}
578+
strokeWidth={hasNearest && key === nearestSeriesKey ? 2.5 : undefined}
579+
strokeOpacity={
580+
hasNearest && key !== nearestSeriesKey ? 0.5 : undefined
581+
}
582+
activeDot={<CaptureActiveDot captureRef={activePointYByKeyRef} />}
463583
{...(isHovered
464584
? { fill: 'none', strokeDasharray }
465585
: {
@@ -472,7 +592,14 @@ export const MemoChart = memo(function MemoChart({
472592
/>
473593
);
474594
});
475-
}, [lineData, displayType, id, isHovered, selectedSeriesNames]);
595+
}, [
596+
lineData,
597+
displayType,
598+
id,
599+
isHovered,
600+
selectedSeriesNames,
601+
nearestSeriesKey,
602+
]);
476603

477604
const yAxisDomain: AxisDomain = useMemo(() => {
478605
const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0;
@@ -638,6 +765,7 @@ export const MemoChart = memo(function MemoChart({
638765
onMouseEnter={() => setIsHovered(true)}
639766
onMouseLeave={() => {
640767
setIsHovered(false);
768+
setNearestSeriesKey(undefined);
641769

642770
setHighlightStart(undefined);
643771
setHighlightEnd(undefined);
@@ -652,6 +780,27 @@ export const MemoChart = memo(function MemoChart({
652780
onMouseMove={e => {
653781
setIsHovered(true);
654782

783+
// Track which series' line is nearest the cursor so the lines can
784+
// emphasize it. The active dots captured their pixel Y on the prior
785+
// frame; comparing the pointer's chartY picks the nearest line. Skip
786+
// while a click-frozen tooltip is shown, matching the tooltip, and
787+
// only set state when the key changes to keep re-renders rare.
788+
const activePointYByKey = activePointYByKeyRef.current;
789+
const nextNearest =
790+
isClickActive == null &&
791+
activePointYByKey.size > 1 &&
792+
e?.chartY != null
793+
? findNearestSeriesKey(
794+
activePointYByKey,
795+
Array.from(activePointYByKey.keys()),
796+
e.chartY,
797+
NEAREST_SERIES_MAX_DISTANCE_PX,
798+
)
799+
: undefined;
800+
setNearestSeriesKey(prev =>
801+
prev === nextNearest ? prev : nextNearest,
802+
);
803+
655804
if (highlightStart != null) {
656805
setHighlightEnd(e.activeLabel);
657806
setIsClickActive(undefined); // Clear out any click state as we're highlighting
@@ -722,6 +871,9 @@ export const MemoChart = memo(function MemoChart({
722871
yPerc: state.chartY / sizeRef.current[1],
723872
activePayload: state.activePayload,
724873
});
874+
// The click-frozen tooltip hides the live tooltip, so drop any
875+
// line emphasis to match.
876+
setNearestSeriesKey(undefined);
725877
} else {
726878
// We clicked on the chart but outside of a line
727879
setIsClickActive(undefined);
@@ -785,6 +937,7 @@ export const MemoChart = memo(function MemoChart({
785937
numberFormatByKey={tooltipNumberFormatsByKey}
786938
lineDataMap={lineDataMap}
787939
previousPeriodOffsetSeconds={previousPeriodOffsetSeconds}
940+
activePointYByKeyRef={activePointYByKeyRef}
788941
/>
789942
}
790943
wrapperStyle={{

0 commit comments

Comments
 (0)