mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add chart legend series filtering (#1572)
## Changes This PR implements chart legend series filtering feature as part of #1558. ## Features **Chart Legend Series Filtering** - Click on legend labels (GET, POST, PUT, etc.) 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 to fit only visible series data range ### How It Works **Single Series:** - Click GET → Only GET visible in graph, others hidden - Click GET again → All series visible **Multi-Select:** - Click GET → Only GET visible - Shift+Click POST → Both GET and POST visible - Shift+Click GET → Only POST remains visible ### Technical Changes - Modified `HDXMultiSeriesTimeChart` to support series selection with visual feedback - Enhanced `DBTimeChart` with selection state management - Legend renderer now always shows all series regardless of visibility - Y-axis auto-scales when series are filtered Part of #1558 Co-authored-by: Brandon Pereira <7552738+brandon-pereira@users.noreply.github.com>
This commit is contained in:
parent
1a9362e736
commit
8b5e80daa7
3 changed files with 197 additions and 30 deletions
6
.changeset/chart-legend-filtering.md
Normal file
6
.changeset/chart-legend-filtering.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@hyperdx/app': minor
|
||||
---
|
||||
|
||||
Add chart legend series filtering with click and shift-click selection
|
||||
|
||||
|
|
@ -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 (
|
||||
<span
|
||||
className={`d-flex gap-1 items-center justify-center ${styles.legendItem}`}
|
||||
style={{ color: entry.color }}
|
||||
style={{
|
||||
color: entry.color,
|
||||
opacity: isDisabled ? 0.3 : 1,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
role="button"
|
||||
onClick={() => 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)'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<svg width="12" height="4">
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -258,13 +286,37 @@ export const LegendRenderer = memo<{
|
|||
color: string;
|
||||
}[];
|
||||
lineDataMap: { [key: string]: LineData };
|
||||
allLineData?: LineData[];
|
||||
selectedSeries?: Set<string>;
|
||||
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<string, number>();
|
||||
payload.forEach((line, index) => {
|
||||
allSeriesPayload.forEach((line, index) => {
|
||||
const currentPeriodKey =
|
||||
lineDataMap[line.dataKey]?.currentPeriodKey || '';
|
||||
if (!currentPeriodKeyIndex.has(currentPeriodKey)) {
|
||||
|
|
@ -272,7 +324,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 ?? '';
|
||||
|
||||
|
|
@ -281,20 +333,26 @@ 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);
|
||||
|
||||
return (
|
||||
<div className={styles.legend}>
|
||||
{shownItems.map((entry, index) => (
|
||||
<ExpandableLegendItem
|
||||
key={`item-${index}`}
|
||||
value={entry.value}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
|
|
@ -304,13 +362,21 @@ export const LegendRenderer = memo<{
|
|||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<div className={styles.legendTooltipContent}>
|
||||
{restItems.map((entry, index) => (
|
||||
<CopyableLegendItem
|
||||
key={`item-${index}`}
|
||||
value={entry.value}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
|
|
@ -336,6 +402,8 @@ export const MemoChart = memo(function MemoChart({
|
|||
onTimeRangeSelect,
|
||||
showLegend = true,
|
||||
previousPeriodOffsetSeconds,
|
||||
selectedSeriesNames,
|
||||
onToggleSeries,
|
||||
}: {
|
||||
graphResults: any[];
|
||||
setIsClickActive: (v: any) => void;
|
||||
|
|
@ -351,6 +419,8 @@ export const MemoChart = memo(function MemoChart({
|
|||
onTimeRangeSelect?: (start: Date, end: Date) => void;
|
||||
showLegend?: boolean;
|
||||
previousPeriodOffsetSeconds?: number;
|
||||
selectedSeriesNames?: Set<string>;
|
||||
onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void;
|
||||
}) {
|
||||
const _id = useId();
|
||||
const id = _id.replace(/:/g, '');
|
||||
|
|
@ -361,13 +431,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 +468,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 +488,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 +724,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 +754,14 @@ export const MemoChart = memo(function MemoChart({
|
|||
<Legend
|
||||
iconSize={10}
|
||||
verticalAlign="bottom"
|
||||
content={<LegendRenderer lineDataMap={lineDataMap} />}
|
||||
content={
|
||||
<LegendRenderer
|
||||
lineDataMap={lineDataMap}
|
||||
allLineData={lineData}
|
||||
selectedSeries={selectedSeriesNames || new Set()}
|
||||
onToggleSeries={onToggleSeries}
|
||||
/>
|
||||
}
|
||||
offset={-100}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,39 @@ function DBTimeChartComponent({
|
|||
showMVOptimizationIndicator = true,
|
||||
}: DBTimeChartComponentProps) {
|
||||
const [isErrorExpanded, errorExpansion] = useDisclosure(false);
|
||||
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
|
||||
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 +716,8 @@ function DBTimeChartComponent({
|
|||
showLegend={showLegend}
|
||||
timestampKey={timestampColumn?.name}
|
||||
previousPeriodOffsetSeconds={previousPeriodOffsetSeconds}
|
||||
selectedSeriesNames={selectedSeriesSet}
|
||||
onToggleSeries={handleToggleSeries}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue