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
10 changes: 10 additions & 0 deletions .changeset/timechart-drilldown-zero-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hyperdx/app': patch
---

fix: keep the drill-down value filter when the clicked point is zero

Clicking a time-chart point with a value of exactly 0 dropped the value filter
and searched every event in the bucket instead of the matching ones. Zero is a
real point — the chart deliberately keeps it rather than treating it as absent —
so the guard now tests for a missing value rather than a falsy one.
174 changes: 174 additions & 0 deletions packages/app/src/HDXMultiSeriesTimeChart/ChartLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { memo, useMemo, useState } from 'react';
import cx from 'classnames';
import { Popover } from '@mantine/core';

import { type LineData } from '@/ChartUtils';
import { truncateMiddle } from '@/utils';

import { hasSeriesSelection } from './chartData';
import { MAX_LEGEND_ITEMS } from './constants';

import styles from '@styles/HDXLineChart.module.scss';

function ExpandableLegendItem({
entry,
expanded,
isSelected,
isDisabled,
onToggle,
}: {
entry: any;
expanded?: boolean;
isSelected?: boolean;
isDisabled?: boolean;
onToggle?: (isShiftKey: boolean) => void;
}) {
const [_expanded, setExpanded] = useState(false);
const isExpanded = _expanded || expanded;

return (
<span
className={`d-flex gap-1 items-center justify-center ${styles.legendItem}`}
style={{
color: entry.color,
opacity: isDisabled ? 0.3 : 1,
fontWeight: isSelected ? 600 : 400,
cursor: 'pointer',
}}
role="button"
onClick={e => {
if (onToggle) {
onToggle(e.shiftKey);
} else {
setExpanded(v => !v);
}
}}
title={
isSelected
? 'Click to show all (Shift+click to deselect)'
: 'Click to show only this (Shift+click for multi-select)'
}
>
<div>
<svg width="12" height="4">
<line
x1="0"
y1="2"
x2="12"
y2="2"
stroke={entry.color}
opacity={isDisabled ? 0.3 : 1}
strokeDasharray={entry.payload?.strokeDasharray}
strokeWidth={isSelected ? 2.5 : 1.5}
/>
</svg>
</div>
{isExpanded || isSelected
? entry.value
: truncateMiddle(`${entry.value}`, 35)}
</span>
);
}

export const LegendRenderer = memo<{
payload?: {
dataKey: string;
value: string;
color: string;
}[];
lineDataMap: { [key: string]: LineData };
allLineData?: LineData[];
selectedSeries?: Set<string>;
onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void;
}>(props => {
const { payload, lineDataMap, allLineData, selectedSeries, onToggleSeries } =
props;

const hasSelection = hasSeriesSelection(selectedSeries);

// Use allLineData to ensure all series are always shown in legend
const allSeriesPayload = useMemo(() => {
if (allLineData?.length) {
return allLineData.map(ld => ({
dataKey: ld.dataKey,
value: ld.displayName || ld.dataKey,
color: ld.color,
payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' },
}));
}
return payload ?? [];
}, [allLineData, payload]);

const sortedLegendItems = useMemo(() => {
// Order items such that current and previous period lines are consecutive
const currentPeriodKeyIndex = new Map<string, number>();
allSeriesPayload.forEach((line, index) => {
const currentPeriodKey =
lineDataMap[line.dataKey]?.currentPeriodKey || '';
if (!currentPeriodKeyIndex.has(currentPeriodKey)) {
currentPeriodKeyIndex.set(currentPeriodKey, index);
}
});

// Copy before sorting: when this comes from Recharts' legend payload it is
// kept in the Immer-backed store and frozen, so an in-place sort throws.
return [...allSeriesPayload].sort((a, b) => {
const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? '';
const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? '';

const indexA = currentPeriodKeyIndex.get(keyA) ?? 0;
const indexB = currentPeriodKeyIndex.get(keyB) ?? 0;

return indexB - indexA || a.dataKey.localeCompare(b.dataKey);
});
}, [allSeriesPayload, lineDataMap]);

const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS);
const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS);

return (
<div className={styles.legend}>
{shownItems.map((entry, index) => {
const isSelected = !!selectedSeries?.has(entry.value);
const isDisabled = hasSelection && !isSelected;
return (
<ExpandableLegendItem
key={`item-${index}`}
entry={entry}
isSelected={isSelected}
isDisabled={isDisabled}
onToggle={isShiftKey => onToggleSeries?.(entry.value, isShiftKey)}
/>
);
})}
{restItems.length ? (
<Popover withinPortal withArrow closeOnEscape closeOnClickOutside>
<Popover.Target>
<div className={cx(styles.legendItem, styles.legendMoreLink)}>
+{restItems.length} more
</div>
</Popover.Target>
<Popover.Dropdown p="xs">
<div className={styles.legendTooltipContent}>
{restItems.map((entry, index) => {
const isSelected = !!selectedSeries?.has(entry.value);
const isDisabled = hasSelection && !isSelected;
return (
<ExpandableLegendItem
key={`item-${index}`}
entry={entry}
isSelected={isSelected}
isDisabled={isDisabled}
onToggle={isShiftKey =>
onToggleSeries?.(entry.value, isShiftKey)
}
/>
);
})}
</div>
</Popover.Dropdown>
</Popover>
) : null}
</div>
);
});
170 changes: 170 additions & 0 deletions packages/app/src/HDXMultiSeriesTimeChart/ChartTooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { memo, useMemo } from 'react';
import { withErrorBoundary } from 'react-error-boundary';

import { findNearestSeriesKey, type LineData } from '@/ChartUtils';
import {
ChartTooltipContainer,
ChartTooltipHeader,
toViewportPoint,
useChartTooltipZIndex,
} from '@/components/charts/ChartTooltip';
import type { NumberFormat } from '@/types';

import {
NEAREST_SERIES_MAX_DISTANCE_PX,
TOOLTIP_POINT_OFFSET_PX,
} from './constants';
import { TooltipItem, type TooltipPayload } from './TooltipItem';

import styles from '@styles/HDXLineChart.module.scss';

type HDXLineChartTooltipProps = {
lineDataMap: { [keyName: string]: LineData };
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>>;
/** The chart's outer container; its viewport rect anchors this tooltip. */
containerRef: React.MutableRefObject<HTMLDivElement | null>;
} & Record<string, any>;

/** Stable stand-in for a missing payload, so the memo below keeps its identity. */
const EMPTY_PAYLOAD: TooltipPayload[] = [];

/**
* The recharts `<Tooltip>` content used for the HOVER tooltip (on the hovered
* chart and its synced followers). Clicking pins ChartSeriesTooltip instead.
*
* Because it's given `portal={document.body}`, recharts skips its own transform
* positioning, so this content self-anchors at the active point with
* `position: fixed` (container rect + `coordinate`) — matching the pinned
* tooltip's anchor, and escaping the chart's bounds so edges aren't clipped.
*/
export const HDXLineChartTooltip = withErrorBoundary(
memo((props: HDXLineChartTooltipProps) => {
const {
active,
payload,
label,
numberFormat,
numberFormatByKey,
lineDataMap,
previousPeriodOffsetSeconds,
activePointYByKeyRef,
containerRef,
} = props;
// recharts calls this with no payload when nothing is hovered, and the memo
// below runs before the `active && payload` guard. Mapping over undefined
// there throws inside the render, which withErrorBoundary latches for the
// rest of this instance's life — the chart never recovers. A shared constant
// rather than `?? []` so the memo's dependency stays referentially stable.
const typedPayload = (payload ?? EMPTY_PAYLOAD) as TooltipPayload[];

const tooltipZIndex = useChartTooltipZIndex();

const payloadByKey = useMemo(
() => new Map(typedPayload.map(p => [p.dataKey, p])),
[typedPayload],
);

if (active && payload && payload.length) {
// No onClose: hover renders the X hidden (kept for layout parity).
const header = (
<ChartTooltipHeader
labelSeconds={label}
previousPeriodOffsetSeconds={previousPeriodOffsetSeconds}
/>
);

// Bold the line nearest the cursor by comparing pointer Y to each series'
// active-dot Y. The dots write their positions earlier in this same render
// (Recharts draws graphical items before the tooltip), so it's current.
const pointerY: number | undefined = props.coordinate?.y;
// eslint-disable-next-line react-hooks/refs
const activePointYByKey = activePointYByKeyRef?.current ?? undefined;
const nearestSeriesKey =
typedPayload.length > 1
? findNearestSeriesKey(
activePointYByKey,
typedPayload.map(p => p.dataKey),
pointerY,
NEAREST_SERIES_MAX_DISTANCE_PX,
)
: undefined;

// Anchor at the active point (see the component docblock for why fixed).
const pointX = props.coordinate?.x;
const pointY = props.coordinate?.y;
// eslint-disable-next-line react-hooks/refs
const containerRect = containerRef?.current?.getBoundingClientRect();
const anchor =
typeof pointX === 'number' &&
typeof pointY === 'number' &&
containerRect != null
? toViewportPoint(containerRect, { x: pointX, y: pointY })
: undefined;
const anchorStyle: React.CSSProperties =
anchor != null
? {
position: 'fixed',
left: anchor.x,
top: anchor.y + TOOLTIP_POINT_OFFSET_PX,
transform: 'translateX(-50%)',
pointerEvents: 'none',
// z-index must live here: recharts leaves the portaled wrapper
// `position: static`, where z-index has no effect.
zIndex: tooltipZIndex,
}
: {};

return (
<div style={anchorStyle}>
<ChartTooltipContainer
header={header}
contentClassName={styles.chartTooltipContentClipped}
>
{/* Copy before sorting: Recharts 3 freezes the payload, so an
in-place sort throws "this object has been frozen". */}
{[...payload]
.sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value)
.map((p: TooltipPayload) => {
const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey;
const isPreviousPeriod = previousKey === p.dataKey;
const previousPayload =
!isPreviousPeriod && previousKey
? payloadByKey.get(previousKey)
: undefined;
const valueColumnName =
lineDataMap[p.dataKey]?.valueColumnName ?? p.dataKey;
const numberFormatForKey =
numberFormatByKey.get(valueColumnName) ?? numberFormat;

return (
<TooltipItem
key={p.dataKey}
p={p}
numberFormat={numberFormatForKey}
previous={previousPayload}
highlighted={p.dataKey === nearestSeriesKey}
dimmed={
nearestSeriesKey != null && p.dataKey !== nearestSeriesKey
}
/>
);
})}
</ChartTooltipContainer>
</div>
);
}
return null;
}),
{
onError: console.error,
fallback: (
<div className="text-danger px-2 py-1 m-2 fs-8 font-monospace bg-danger-transparent">
An error occurred while rendering the tooltip.
</div>
),
},
);
Loading