Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tooltip-highlight-nearest-series.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 44 additions & 2 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, number> | 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;
Expand Down
157 changes: 155 additions & 2 deletions packages/app/src/HDXMultiSeriesTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ChartTooltipItem,
} from './components/charts/ChartTooltip';
import {
findNearestSeriesKey,
LineData,
MAX_TIME_CHART_SERIES,
toStartOfInterval,
Expand All @@ -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;
Expand All @@ -60,10 +66,14 @@ export const TooltipItem = memo(
p,
previous,
numberFormat,
highlighted,
dimmed,
}: {
p: TooltipPayload;
previous?: TooltipPayload;
numberFormat?: NumberFormat;
highlighted?: boolean;
dimmed?: boolean;
}) => {
return (
<ChartTooltipItem
Expand All @@ -75,6 +85,8 @@ export const TooltipItem = memo(
strokeDasharray={p.strokeDasharray}
opacity={p.opacity}
previous={previous?.value}
highlighted={highlighted}
dimmed={dimmed}
/>
);
},
Expand All @@ -85,6 +97,8 @@ type HDXLineChartTooltipProps = {
previousPeriodOffsetSeconds?: number;
numberFormat?: NumberFormat;
numberFormatByKey: Map<string, NumberFormat>;
/** Per-series active-point pixel Y, captured by the Area active dots. */
activePointYByKeyRef: React.MutableRefObject<Map<string, number>>;
} & Record<string, any>;

const HDXLineChartTooltip = withErrorBoundary(
Expand All @@ -97,6 +111,7 @@ const HDXLineChartTooltip = withErrorBoundary(
numberFormatByKey,
lineDataMap,
previousPeriodOffsetSeconds,
activePointYByKeyRef,
} = props;
const typedPayload = payload as TooltipPayload[];

Expand All @@ -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 (
<ChartTooltipContainer header={header}>
{payload
Expand All @@ -142,6 +177,10 @@ const HDXLineChartTooltip = withErrorBoundary(
p={p}
numberFormat={numberFormatForKey}
previous={previousPayload}
highlighted={p.dataKey === nearestSeriesKey}
dimmed={
nearestSeriesKey != null && p.dataKey !== nearestSeriesKey
}
/>
);
})}
Expand Down Expand Up @@ -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<Map<string, number>>;
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 (
<circle
cx={cx}
cy={cy}
r={r}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
/>
);
}

/**
* Compute the unique set of hexes referenced by `<linearGradient>` defs
* inside MemoChart. Exported so a unit test can pin the dedup-and-union
Expand All @@ -345,7 +436,7 @@ const StackedBarWithOverlap = (props: BarProps) => {
*
* Includes every categorical hex up front so any positional `<Area>`
* 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.
Expand Down Expand Up @@ -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<Map<string, number>>(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],
Expand All @@ -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;
Expand All @@ -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={<CaptureActiveDot captureRef={activePointYByKeyRef} />}
{...(isHovered
? { fill: 'none', strokeDasharray }
: {
Expand All @@ -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;
Expand Down Expand Up @@ -638,6 +765,7 @@ export const MemoChart = memo(function MemoChart({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
setIsHovered(false);
setNearestSeriesKey(undefined);

setHighlightStart(undefined);
setHighlightEnd(undefined);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -785,6 +937,7 @@ export const MemoChart = memo(function MemoChart({
numberFormatByKey={tooltipNumberFormatsByKey}
lineDataMap={lineDataMap}
previousPeriodOffsetSeconds={previousPeriodOffsetSeconds}
activePointYByKeyRef={activePointYByKeyRef}
/>
}
wrapperStyle={{
Expand Down
Loading
Loading