From 519acc277e1f7837a5e301e9e269ee7b37f78b38 Mon Sep 17 00:00:00 2001 From: Alok Kumar Singh Date: Thu, 8 Jan 2026 09:57:36 +0530 Subject: [PATCH 1/3] feat: Add chart legend series filtering - Click on legend labels to show only that series in the chart - Shift+Click to select multiple series for comparison - All legend items remain visible with clear visual feedback: - Selected: Bold font, thick line, full opacity - Non-selected: Dimmed (30% opacity) but still visible - Click selected item again to clear filter and show all series - Auto-scale Y-axis when filtering chart series to fit visible data Part of #1558 --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 178 ++++++++++++++++--- packages/app/src/components/DBTimeChart.tsx | 30 ++++ 2 files changed, 179 insertions(+), 29 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 057dacb4c8..bdb98153b7 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -221,17 +221,44 @@ function CopyableLegendItem({ entry }: any) { ); } -function ExpandableLegendItem({ entry, expanded }: any) { +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 ( setExpanded(v => !v)} - title="Click to expand" + 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)" + } >
@@ -241,8 +268,9 @@ function ExpandableLegendItem({ entry, expanded }: any) { x2="12" y2="2" stroke={entry.color} - opacity={entry.opacity} + opacity={isDisabled ? 0.3 : 1} strokeDasharray={entry.payload?.strokeDasharray} + strokeWidth={isSelected ? 2.5 : 1.5} />
@@ -258,13 +286,31 @@ export const LegendRenderer = memo<{ color: string; }[]; lineDataMap: { [key: string]: LineData }; + allLineData?: LineData[]; + selectedSeries?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }>(props => { - const { payload = [], lineDataMap } = props; + const { payload = [], lineDataMap, allLineData = [], selectedSeries = new Set(), onToggleSeries } = props; + + const hasSelection = selectedSeries.size > 0; + + // Use allLineData to ensure all series are always shown in legend + const allSeriesPayload = useMemo(() => { + if (allLineData.length > 0) { + 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(); - payload.forEach((line, index) => { + allSeriesPayload.forEach((line, index) => { const currentPeriodKey = lineDataMap[line.dataKey]?.currentPeriodKey || ''; if (!currentPeriodKeyIndex.has(currentPeriodKey)) { @@ -272,7 +318,7 @@ export const LegendRenderer = memo<{ } }); - return payload.sort((a, b) => { + return allSeriesPayload.sort((a, b) => { const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? ''; const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? ''; @@ -288,13 +334,20 @@ export const LegendRenderer = memo<{ return (
- {shownItems.map((entry, index) => ( - - ))} + {shownItems.map((entry, index) => { + const isSelected = selectedSeries.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + onToggleSeries?.(entry.value, isShiftKey)} + /> + ); + })} {restItems.length ? ( @@ -304,13 +357,20 @@ export const LegendRenderer = memo<{
- {restItems.map((entry, index) => ( - - ))} + {restItems.map((entry, index) => { + const isSelected = selectedSeries.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + onToggleSeries?.(entry.value, isShiftKey)} + /> + ); + })}
@@ -336,6 +396,8 @@ export const MemoChart = memo(function MemoChart({ onTimeRangeSelect, showLegend = true, previousPeriodOffsetSeconds, + selectedSeriesNames, + onToggleSeries, }: { graphResults: any[]; setIsClickActive: (v: any) => void; @@ -351,6 +413,8 @@ export const MemoChart = memo(function MemoChart({ onTimeRangeSelect?: (start: Date, end: Date) => void; showLegend?: boolean; previousPeriodOffsetSeconds?: number; + selectedSeriesNames?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -361,13 +425,23 @@ export const MemoChart = memo(function MemoChart({ displayType === DisplayType.StackedBar ? BarChart : AreaChart; // LineChart; const lines = useMemo(() => { + const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; + const limitedGroupKeys = lineData .map(ld => ld.dataKey) - .slice(0, HARD_LINES_LIMIT); + .slice(0, HARD_LINES_LIMIT) + .filter((key, i) => { + const seriesName = lineData[i]?.displayName ?? key; + // If there's a selection, only show selected series + // If no selection, show all series + return !hasSelection || selectedSeriesNames.has(seriesName); + }); return limitedGroupKeys.map((key, i) => { - const color = lineData[i]?.color; - const strokeDasharray = lineData[i]?.isDashed ? '4 3' : '0'; + const lineDataIndex = lineData.findIndex(ld => ld.dataKey === key); + const color = lineData[lineDataIndex]?.color; + const strokeDasharray = lineData[lineDataIndex]?.isDashed ? '4 3' : '0'; + const seriesName = lineData[lineDataIndex]?.displayName ?? key; const StackedBarWithOverlap = (props: BarProps) => { const { x, y, width, height, fill } = props; @@ -388,7 +462,7 @@ export const MemoChart = memo(function MemoChart({ key={key} type="monotone" dataKey={key} - name={lineData[i]?.displayName ?? key} + name={seriesName} fill={color} opacity={1} stackId="1" @@ -408,12 +482,50 @@ export const MemoChart = memo(function MemoChart({ fill: `url(#time-chart-lin-grad-${id}-${color.replace('#', '').toLowerCase()})`, strokeDasharray, })} - name={lineData[i]?.displayName ?? key} + name={seriesName} isAnimationActive={false} /> ); }); - }, [lineData, displayType, id, isHovered]); + }, [lineData, displayType, id, isHovered, selectedSeriesNames]); + + const yAxisDomain = useMemo(() => { + const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; + + if (!hasSelection) { + // No selection, let Recharts auto-calculate based on all data + return ['auto', 'auto']; + } + + // When series are selected, calculate domain based only on visible series + let minValue = Infinity; + let maxValue = -Infinity; + + graphResults.forEach(dataPoint => { + lineData.forEach(ld => { + const seriesName = ld.displayName || ld.dataKey; + // Only consider selected series + if (selectedSeriesNames.has(seriesName)) { + const value = dataPoint[ld.dataKey]; + if (typeof value === 'number' && !isNaN(value)) { + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + } + } + }); + }); + + // If we found valid values, return them with some padding + if (minValue !== Infinity && maxValue !== -Infinity) { + const padding = (maxValue - minValue) * 0.1; // 10% padding + return [ + Math.max(0, minValue - padding), // Don't go below 0 + maxValue + padding + ]; + } + + return ['auto', 'auto']; + }, [graphResults, lineData, selectedSeriesNames]); const sizeRef = useRef<[number, number]>([0, 0]); @@ -606,6 +718,7 @@ export const MemoChart = memo(function MemoChart({ minTickGap={25} tickFormatter={tickFormatter} tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} + domain={yAxisDomain} /> {lines} {isClickActive == null && ( @@ -635,7 +748,14 @@ export const MemoChart = memo(function MemoChart({ } + content={ + + } offset={-100} /> )} diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 2a884fe071..20360c75a2 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -243,6 +243,34 @@ function DBTimeChartComponent({ showMVOptimizationIndicator = true, }: DBTimeChartComponentProps) { const [isErrorExpanded, errorExpansion] = useDisclosure(false); + const [selectedSeriesSet, setSelectedSeriesSet] = useState>(new Set()); + + const handleToggleSeries = useCallback((seriesName: string, isShiftKey?: boolean) => { + setSelectedSeriesSet(prev => { + const newSet = new Set(prev); + + if (isShiftKey) { + // Shift-click: add to selection + if (newSet.has(seriesName)) { + newSet.delete(seriesName); + } else { + newSet.add(seriesName); + } + } else { + // Regular click: toggle selection + if (newSet.has(seriesName) && newSet.size === 1) { + // If this is the only selected item, clear selection (show all) + newSet.clear(); + } else { + // Otherwise, select only this one + newSet.clear(); + newSet.add(seriesName); + } + } + + return newSet; + }); + }, []); const originalDateRange = config.dateRange; const { @@ -683,6 +711,8 @@ function DBTimeChartComponent({ showLegend={showLegend} timestampKey={timestampColumn?.name} previousPeriodOffsetSeconds={previousPeriodOffsetSeconds} + selectedSeriesNames={selectedSeriesSet} + onToggleSeries={handleToggleSeries} /> )} From f54c250dce1f46ad277c949b742f9f432ca12f2c Mon Sep 17 00:00:00 2001 From: Alok Kumar Singh Date: Thu, 8 Jan 2026 09:57:44 +0530 Subject: [PATCH 2/3] chore: Add changeset for chart legend series filtering --- .changeset/chart-legend-filtering.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/chart-legend-filtering.md diff --git a/.changeset/chart-legend-filtering.md b/.changeset/chart-legend-filtering.md new file mode 100644 index 0000000000..0dae24ba69 --- /dev/null +++ b/.changeset/chart-legend-filtering.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': minor +--- + +Add chart legend series filtering with click and shift-click selection + From 53cc9715369992739f4aabeb0d59cae838e34d02 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 8 Jan 2026 09:02:26 -0700 Subject: [PATCH 3/3] linting / formatting --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 54 ++++++++++--------- packages/app/src/components/DBTimeChart.tsx | 57 +++++++++++--------- 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index bdb98153b7..e004c7e934 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -221,15 +221,15 @@ function CopyableLegendItem({ entry }: any) { ); } -function ExpandableLegendItem({ - entry, - expanded, +function ExpandableLegendItem({ + entry, + expanded, isSelected, isDisabled, - onToggle -}: { - entry: any; - expanded?: boolean; + onToggle, +}: { + entry: any; + expanded?: boolean; isSelected?: boolean; isDisabled?: boolean; onToggle?: (isShiftKey: boolean) => void; @@ -240,14 +240,14 @@ function ExpandableLegendItem({ return ( { + onClick={e => { if (onToggle) { onToggle(e.shiftKey); } else { @@ -255,9 +255,9 @@ function ExpandableLegendItem({ } }} title={ - isSelected - ? "Click to show all (Shift+click to deselect)" - : "Click to show only this (Shift+click for multi-select)" + isSelected + ? 'Click to show all (Shift+click to deselect)' + : 'Click to show only this (Shift+click for multi-select)' } >
@@ -290,8 +290,14 @@ export const LegendRenderer = memo<{ selectedSeries?: Set; onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }>(props => { - const { payload = [], lineDataMap, allLineData = [], selectedSeries = new Set(), onToggleSeries } = props; - + const { + payload = [], + lineDataMap, + allLineData = [], + selectedSeries = new Set(), + onToggleSeries, + } = props; + const hasSelection = selectedSeries.size > 0; // Use allLineData to ensure all series are always shown in legend @@ -301,7 +307,7 @@ export const LegendRenderer = memo<{ dataKey: ld.dataKey, value: ld.displayName || ld.dataKey, color: ld.color, - payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' } + payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' }, })); } return payload; @@ -327,7 +333,7 @@ export const LegendRenderer = memo<{ return indexB - indexA || a.dataKey.localeCompare(b.dataKey); }); - }, [payload, lineDataMap]); + }, [allSeriesPayload, lineDataMap]); const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS); const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS); @@ -340,11 +346,10 @@ export const LegendRenderer = memo<{ return ( onToggleSeries?.(entry.value, isShiftKey)} + onToggle={isShiftKey => onToggleSeries?.(entry.value, isShiftKey)} /> ); })} @@ -363,11 +368,12 @@ export const LegendRenderer = memo<{ return ( onToggleSeries?.(entry.value, isShiftKey)} + onToggle={isShiftKey => + onToggleSeries?.(entry.value, isShiftKey) + } /> ); })} @@ -426,7 +432,7 @@ export const MemoChart = memo(function MemoChart({ const lines = useMemo(() => { const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; - + const limitedGroupKeys = lineData .map(ld => ld.dataKey) .slice(0, HARD_LINES_LIMIT) @@ -491,7 +497,7 @@ export const MemoChart = memo(function MemoChart({ const yAxisDomain = useMemo(() => { const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; - + if (!hasSelection) { // No selection, let Recharts auto-calculate based on all data return ['auto', 'auto']; @@ -520,7 +526,7 @@ export const MemoChart = memo(function MemoChart({ const padding = (maxValue - minValue) * 0.1; // 10% padding return [ Math.max(0, minValue - padding), // Don't go below 0 - maxValue + padding + maxValue + padding, ]; } @@ -749,7 +755,7 @@ export const MemoChart = memo(function MemoChart({ iconSize={10} verticalAlign="bottom" content={ - >(new Set()); - - const handleToggleSeries = useCallback((seriesName: string, isShiftKey?: boolean) => { - setSelectedSeriesSet(prev => { - const newSet = new Set(prev); - - if (isShiftKey) { - // Shift-click: add to selection - if (newSet.has(seriesName)) { - newSet.delete(seriesName); - } else { - newSet.add(seriesName); - } - } else { - // Regular click: toggle selection - if (newSet.has(seriesName) && newSet.size === 1) { - // If this is the only selected item, clear selection (show all) - newSet.clear(); + const [selectedSeriesSet, setSelectedSeriesSet] = useState>( + new Set(), + ); + + const handleToggleSeries = useCallback( + (seriesName: string, isShiftKey?: boolean) => { + setSelectedSeriesSet(prev => { + const newSet = new Set(prev); + + if (isShiftKey) { + // Shift-click: add to selection + if (newSet.has(seriesName)) { + newSet.delete(seriesName); + } else { + newSet.add(seriesName); + } } else { - // Otherwise, select only this one - newSet.clear(); - newSet.add(seriesName); + // Regular click: toggle selection + if (newSet.has(seriesName) && newSet.size === 1) { + // If this is the only selected item, clear selection (show all) + newSet.clear(); + } else { + // Otherwise, select only this one + newSet.clear(); + newSet.add(seriesName); + } } - } - - return newSet; - }); - }, []); + + return newSet; + }); + }, + [], + ); const originalDateRange = config.dateRange; const {