diff --git a/.changeset/tooltip-highlight-nearest-series.md b/.changeset/tooltip-highlight-nearest-series.md new file mode 100644 index 0000000000..c10b29ffcf --- /dev/null +++ b/.changeset/tooltip-highlight-nearest-series.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +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/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index b284670182..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 @@ -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..df52a2765b 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,14 @@ export const TooltipItem = memo( p, previous, numberFormat, + highlighted, + dimmed, }: { p: TooltipPayload; previous?: TooltipPayload; numberFormat?: NumberFormat; + highlighted?: boolean; + dimmed?: boolean; }) => { return ( ); }, @@ -85,6 +97,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 +111,7 @@ const HDXLineChartTooltip = withErrorBoundary( numberFormatByKey, lineDataMap, previousPeriodOffsetSeconds, + activePointYByKeyRef, } = props; const typedPayload = payload as TooltipPayload[]; @@ -120,6 +135,26 @@ 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; + // 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 ( {payload @@ -142,6 +177,10 @@ const HDXLineChartTooltip = withErrorBoundary( p={p} numberFormat={numberFormatForKey} previous={previousPayload} + highlighted={p.dataKey === nearestSeriesKey} + dimmed={ + nearestSeriesKey != null && p.dataKey !== nearestSeriesKey + } /> ); })} @@ -337,6 +376,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 @@ -345,7 +436,7 @@ const StackedBarWithOverlap = (props: BarProps) => { * * 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. @@ -417,6 +508,21 @@ 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()); + + // 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], @@ -435,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; @@ -460,6 +575,11 @@ 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 } : { @@ -472,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; @@ -638,6 +765,7 @@ export const MemoChart = memo(function MemoChart({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => { setIsHovered(false); + setNearestSeriesKey(undefined); setHighlightStart(undefined); setHighlightEnd(undefined); @@ -652,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 @@ -722,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); @@ -785,6 +937,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..ff9f275f8a 100644 --- a/packages/app/src/components/charts/ChartTooltip.tsx +++ b/packages/app/src/components/charts/ChartTooltip.tsx @@ -50,6 +50,8 @@ export const ChartTooltipItem = memo( strokeDasharray, opacity, previous, + highlighted, + dimmed, }: { color: string; name: string; @@ -59,9 +61,21 @@ export const ChartTooltipItem = memo( strokeDasharray?: string; opacity?: number; previous?: number; + highlighted?: boolean; + dimmed?: boolean; }) => { return ( -
+
{indicator === 'square' ? (