From 91c0b6e5d8de4ca529d7be68803a0fa9893bde28 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 18 Feb 2026 14:09:22 +0000 Subject: [PATCH 1/4] Set limits on the graph rendering --- .../app/components/code/QueryResultsChart.tsx | 138 +++++++++++++----- .../components/primitives/charts/ChartBar.tsx | 4 +- .../primitives/charts/ChartContext.tsx | 13 +- .../primitives/charts/ChartLine.tsx | 8 +- .../primitives/charts/ChartRoot.tsx | 11 ++ 5 files changed, 130 insertions(+), 44 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index c66db5a8f60..cf67dd90150 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -4,11 +4,17 @@ import { memo, useMemo } from "react"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; import { ChartBlankState } from "../primitives/charts/ChartBlankState"; +import { Callout } from "../primitives/Callout"; import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; import { aggregateValues } from "../primitives/charts/aggregation"; import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus"; import { getSeriesColor } from "./chartColors"; +const MAX_SERIES = 20; +const MAX_SVG_ELEMENT_BUDGET = 6_000; +const MIN_DATA_POINTS = 100; +const MAX_DATA_POINTS = 500; + interface QueryResultsChartProps { rows: Record[]; columns: OutputColumnMetadata[]; @@ -26,6 +32,8 @@ interface QueryResultsChartProps { interface TransformedData { data: Record[]; series: string[]; + /** Total number of series before any truncation (equals series.length when no truncation) */ + totalSeriesCount: number; /** Raw date values for determining formatting granularity */ dateValues: Date[]; /** Whether the x-axis is date-based (continuous time scale) */ @@ -447,6 +455,7 @@ function transformDataForChart( return { data: [], series: [], + totalSeriesCount: 0, dateValues: [], isDateBased: false, xDataKey: xAxisColumn || "", @@ -550,17 +559,17 @@ function transformDataForChart( }); // Fill in gaps with zeros for date-based data + const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES); + const effectiveMaxPoints = Math.max( + MIN_DATA_POINTS, + Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget)) + ); + if (isDateBased && timeDomain) { const timestamps = dateValues.map((d) => d.getTime()); const dataInterval = detectDataInterval(timestamps); - // When filling across a full time range, ensure the interval is appropriate - // for the range size (target ~150 points) so we don't create overly dense charts const rangeMs = rawMaxTime - rawMinTime; - const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0; - // Also cap the interval so we get enough data points to visually represent - // the full time range. Without this, limited data (e.g. 1 point) defaults - // to a 1-day interval which can be far too coarse for shorter ranges, - // producing too few bars/points and potentially buckets outside the domain. + const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0; const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity; const effectiveInterval = Math.min( @@ -575,19 +584,32 @@ function transformDataForChart( rawMaxTime, effectiveInterval, granularity, - aggregation + aggregation, + effectiveMaxPoints ); + } else if (data.length > effectiveMaxPoints) { + data = data.slice(0, effectiveMaxPoints); } - return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks }; + return { + data, + series: yAxisColumns, + totalSeriesCount: yAxisColumns.length, + dateValues, + isDateBased, + xDataKey, + timeDomain, + timeTicks, + }; } // With grouping: pivot data so each group value becomes a series const yCol = yAxisColumns[0]; // Use first Y column when grouping - const groupValues = new Set(); - // For date-based, key by timestamp; otherwise by formatted string - // Collect all values for aggregation + // First pass: collect all values grouped by (xKey, groupValue) and accumulate + // per-group totals so we can pick the top-N groups before building heavy data + // objects with thousands of keys. + const groupTotals = new Map(); const groupedByX = new Map< string | number, { values: Record; rawDate: Date | null; originalX: unknown } @@ -596,29 +618,39 @@ function transformDataForChart( for (const row of rows) { const rawDate = tryParseDate(row[xAxisColumn]); - // Skip rows with invalid dates for date-based axes if (isDateBased && !rawDate) continue; const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]); const groupValue = String(row[groupByColumn] ?? "Unknown"); const yValue = toNumber(row[yCol]); - groupValues.add(groupValue); + groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue)); if (!groupedByX.has(xKey)) { groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] }); } const existing = groupedByX.get(xKey)!; - // Collect values for aggregation if (!existing.values[groupValue]) { existing.values[groupValue] = []; } existing.values[groupValue].push(yValue); } - // Convert to array format with aggregation applied - const series = Array.from(groupValues).sort(); + // Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing + // downstream (data objects, gap filling, legend totals, SVG rendering). + const totalSeriesCount = groupTotals.size; + let series: string[]; + if (groupTotals.size <= MAX_SERIES) { + series = Array.from(groupTotals.keys()).sort(); + } else { + series = Array.from(groupTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_SERIES) + .map(([key]) => key) + .sort(); + } + // Convert to array format with aggregation applied (only for kept series) let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => { const point: Record = { [xDataKey]: xKey, @@ -632,24 +664,19 @@ function transformDataForChart( return point; }); - // Fill in gaps with zeros for date-based data + // Dynamic data-point budget based on the (already capped) series count + const effectiveMaxPoints = Math.max( + MIN_DATA_POINTS, + Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length)) + ); + if (isDateBased && timeDomain) { const timestamps = dateValues.map((d) => d.getTime()); const dataInterval = detectDataInterval(timestamps); - // When filling across a full time range, ensure the interval is appropriate - // for the range size (target ~150 points) so we don't create overly dense charts const rangeMs = rawMaxTime - rawMinTime; - const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0; - // Also cap the interval so we get enough data points to visually represent - // the full time range. Without this, limited data (e.g. 1 point) defaults - // to a 1-day interval which can be far too coarse for shorter ranges, - // producing too few bars/points and potentially buckets outside the domain. - const maxRangeInterval = - timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity; - const effectiveInterval = Math.min( - Math.max(dataInterval, minRangeInterval), - maxRangeInterval - ); + const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0; + const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity; + const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval); data = fillTimeGaps( data, xDataKey, @@ -658,11 +685,23 @@ function transformDataForChart( rawMaxTime, effectiveInterval, granularity, - aggregation + aggregation, + effectiveMaxPoints ); + } else if (data.length > effectiveMaxPoints) { + data = data.slice(0, effectiveMaxPoints); } - return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks }; + return { + data, + series, + totalSeriesCount, + dateValues, + isDateBased, + xDataKey, + timeDomain, + timeTicks, + }; } function toNumber(value: unknown): number { @@ -743,6 +782,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ const { data: unsortedData, series, + totalSeriesCount, dateValues, isDateBased, xDataKey, @@ -777,6 +817,23 @@ export const QueryResultsChart = memo(function QueryResultsChart({ return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0)); }, [series, data]); + // Limit SVG-rendered series to MAX_SERIES (top N by total value) + const visibleSeries = useMemo( + () => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries), + [sortedSeries] + ); + + const seriesLimitCallout = + totalSeriesCount > series.length ? ( +
+ + {`Limited to the top ${ + series.length + } of ${totalSeriesCount.toLocaleString()} series for performance reasons.`} + +
+ ) : null; + // Detect time granularity — use the full time range when available so tick // labels are appropriate for the period (e.g. "Jan 5" for a 7-day range // instead of just "16:00:00" when data is sparse) @@ -951,11 +1008,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({ const chartIcon = chartType === "bar" ? BarChart3 : LineChart; if (!xAxisColumn) { - return ; + return ( + + ); } if (yAxisColumns.length === 0) { - return ; + return ( + + ); } if (rows.length === 0) { @@ -1015,6 +1076,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ data={data} dataKey={xDataKey} series={sortedSeries} + visibleSeries={visibleSeries} labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} @@ -1024,6 +1086,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ onViewAllLegendItems={onViewAllLegendItems} legendScrollable={legendScrollable} state={isLoading ? "loading" : "loaded"} + beforeLegend={seriesLimitCallout} > 1} + stacked={stacked && visibleSeries.length > 1} tooltipLabelFormatter={tooltipLabelFormatter} lineType="linear" /> @@ -1115,4 +1180,3 @@ function createYAxisFormatter(data: Record[], series: string[]) return Math.round(value).toString(); }; } - diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 7ab3299e2bd..793091e898a 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -68,7 +68,7 @@ export function ChartBarRenderer({ width, height, }: ChartBarRendererProps) { - const { config, data, dataKey, dataKeys, state, highlight, zoom, showLegend } = useChartContext(); + const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, zoom, showLegend } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); const enableZoom = zoom !== null; @@ -188,7 +188,7 @@ export function ChartBarRenderer({ /> )} - {dataKeys.map((key, index, array) => { + {visibleSeries.map((key, index, array) => { return ( visibleSeriesProp ?? dataKeys, + [visibleSeriesProp, dataKeys] + ); + const value = useMemo( () => ({ config, data, dataKey, dataKeys, + visibleSeries, state, labelFormatter, highlight, @@ -100,7 +111,7 @@ export function ChartProvider({ onZoomChange: enableZoom ? onZoomChange : undefined, showLegend, }), - [config, data, dataKey, dataKeys, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend] + [config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend] ); return {children}; diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index 7bfd9090ca0..fd05a2bd5af 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -78,7 +78,7 @@ export function ChartLineRenderer({ width, height, }: ChartLineRendererProps) { - const { config, data, dataKey, dataKeys, state, highlight, showLegend } = useChartContext(); + const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, showLegend } = useChartContext(); const hasNoData = useHasNoData(); // Render loading/error states @@ -130,7 +130,7 @@ export function ChartLineRenderer({ }; // Render stacked area chart if stacked prop is true - if (stacked && dataKeys.length > 1) { + if (stacked && visibleSeries.length > 1) { return ( {/* Note: Legend is now rendered by ChartRoot outside the chart container */} - {dataKeys.map((key) => ( + {visibleSeries.map((key) => ( {/* Note: Legend is now rendered by ChartRoot outside the chart container */} - {dataKeys.map((key) => ( + {visibleSeries.map((key) => ( ["children"]; }; @@ -67,6 +71,7 @@ export function ChartRoot({ data, dataKey, series, + visibleSeries, state, labelFormatter, enableZoom = false, @@ -80,6 +85,7 @@ export function ChartRoot({ onViewAllLegendItems, legendScrollable = false, fillContainer = false, + beforeLegend, children, }: ChartRootProps) { return ( @@ -88,6 +94,7 @@ export function ChartRoot({ data={data} dataKey={dataKey} series={series} + visibleSeries={visibleSeries} state={state} labelFormatter={labelFormatter} enableZoom={enableZoom} @@ -104,6 +111,7 @@ export function ChartRoot({ onViewAllLegendItems={onViewAllLegendItems} legendScrollable={legendScrollable} fillContainer={fillContainer} + beforeLegend={beforeLegend} > {children} @@ -121,6 +129,7 @@ type ChartRootInnerProps = { onViewAllLegendItems?: () => void; legendScrollable?: boolean; fillContainer?: boolean; + beforeLegend?: React.ReactNode; children: React.ComponentProps["children"]; }; @@ -134,6 +143,7 @@ function ChartRootInner({ onViewAllLegendItems, legendScrollable = false, fillContainer = false, + beforeLegend, children, }: ChartRootInnerProps) { const { config, zoom } = useChartContext(); @@ -167,6 +177,7 @@ function ChartRootInner({ {children} + {beforeLegend} {/* Legend rendered outside the chart container */} {showLegend && ( Date: Wed, 18 Feb 2026 15:23:23 +0000 Subject: [PATCH 2/4] Improve hover performance on bar chart --- .../components/primitives/charts/ChartBar.tsx | 28 ++++++------------- .../primitives/charts/ChartLine.tsx | 6 ++-- .../charts/hooks/useHighlightState.ts | 20 +++++++++---- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 793091e898a..29c5e851aae 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -3,7 +3,6 @@ import { Bar, BarChart, CartesianGrid, - Cell, ReferenceArea, ReferenceLine, XAxis, @@ -15,9 +14,7 @@ import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/chart import { useChartContext } from "./ChartContext"; import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; -// Legend is now rendered by ChartRoot outside the chart container import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; -import { getBarOpacity } from "./hooks/useHighlightState"; //TODO: fix the first and last bars in a stack not having rounded corners @@ -116,9 +113,8 @@ export function ChartBarRenderer({ onMouseDown={zoomHandlers.onMouseDown} onMouseMove={(e: any) => { zoomHandlers.onMouseMove?.(e); - // Update active payload for legend if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload); + highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -189,6 +185,11 @@ export function ChartBarRenderer({ )} {visibleSeries.map((key, index, array) => { + const dimmed = + !zoom?.isSelecting && + highlight.activeBarKey !== null && + highlight.activeBarKey !== key; + return ( handleBarClick(data, e)} onMouseEnter={(entry, index) => { if (entry.tooltipPayload?.[0]) { @@ -214,20 +215,7 @@ export function ChartBarRenderer({ }} onMouseLeave={highlight.reset} isAnimationActive={false} - > - {data.map((_, dataIndex) => { - // Don't dim bars during zoom selection - const opacity = zoom?.isSelecting ? 1 : getBarOpacity(key, dataIndex, highlight); - - return ( - - ); - })} - + /> ); })} diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index fd05a2bd5af..16cccf9852e 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -142,9 +142,8 @@ export function ChartLineRenderer({ right: 12, }} onMouseMove={(e: any) => { - // Update active payload for legend if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload); + highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -191,9 +190,8 @@ export function ChartLineRenderer({ right: 12, }} onMouseMove={(e: any) => { - // Update active payload for legend if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload); + highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); diff --git a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts index 24bf5998653..f96c87c634e 100644 --- a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts +++ b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; export type HighlightState = { /** The currently highlighted series key (e.g., "completed", "failed") */ @@ -16,8 +16,8 @@ export type HighlightActions = { setHoveredBar: (key: string, index: number, payload?: any[]) => void; /** Set the hovered legend item (highlights all bars of that type) */ setHoveredLegendItem: (key: string) => void; - /** Set the active payload (for tooltip data) */ - setActivePayload: (payload: any[] | null) => void; + /** Set the active payload (for tooltip data). Pass tooltipIndex to skip redundant updates. */ + setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void; /** Set tooltip active state */ setTooltipActive: (active: boolean) => void; /** Reset all highlight state */ @@ -39,6 +39,7 @@ const initialState: HighlightState = { */ export function useHighlightState(): UseHighlightStateReturn { const [state, setState] = useState(initialState); + const activeTooltipIndexRef = useRef(null); const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => { setState({ @@ -53,11 +54,19 @@ export function useHighlightState(): UseHighlightStateReturn { setState((prev) => ({ ...prev, activeBarKey: key, - activeDataPointIndex: null, // null indicates legend hover (all bars of this type) + activeDataPointIndex: null, })); }, []); - const setActivePayload = useCallback((payload: any[] | null) => { + const setActivePayload = useCallback((payload: any[] | null, tooltipIndex?: number | null) => { + const idx = tooltipIndex ?? null; + if (idx !== null && idx === activeTooltipIndexRef.current) { + console.log("Tooltip index is the same, skipping update", activeTooltipIndexRef.current); + return; + } + + console.log("Tooltip index changed", idx); + activeTooltipIndexRef.current = idx; setState((prev) => ({ ...prev, activePayload: payload, @@ -72,6 +81,7 @@ export function useHighlightState(): UseHighlightStateReturn { }, []); const reset = useCallback(() => { + activeTooltipIndexRef.current = null; setState(initialState); }, []); From 1724c6a337a00e74f79eac47cf1263617d8f30f9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 18 Feb 2026 15:26:47 +0000 Subject: [PATCH 3/4] Render up to 30 series --- apps/webapp/app/components/code/QueryResultsChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index cf67dd90150..9fe9a2df635 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -10,7 +10,7 @@ import { aggregateValues } from "../primitives/charts/aggregation"; import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus"; import { getSeriesColor } from "./chartColors"; -const MAX_SERIES = 20; +const MAX_SERIES = 30; const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; From df882e5b05a9b23288567120500ee89efb5080c3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 18 Feb 2026 15:40:11 +0000 Subject: [PATCH 4/4] Improved performance more by splitting state out for payload --- .../app/components/code/QueryResultsChart.tsx | 2 +- .../components/primitives/charts/ChartBar.tsx | 4 +- .../primitives/charts/ChartContext.tsx | 58 ++++++++++++++-- .../primitives/charts/ChartLegendCompound.tsx | 23 ++++--- .../primitives/charts/ChartLine.tsx | 6 +- .../charts/hooks/useHighlightState.ts | 67 +++++++------------ .../charts/hooks/useZoomSelection.ts | 25 ++++--- 7 files changed, 109 insertions(+), 76 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 9fe9a2df635..d2893cfb95e 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -10,7 +10,7 @@ import { aggregateValues } from "../primitives/charts/aggregation"; import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus"; import { getSeriesColor } from "./chartColors"; -const MAX_SERIES = 30; +const MAX_SERIES = 50; const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 29c5e851aae..185cb31914e 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -65,7 +65,7 @@ export function ChartBarRenderer({ width, height, }: ChartBarRendererProps) { - const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, zoom, showLegend } = useChartContext(); + const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); const enableZoom = zoom !== null; @@ -114,7 +114,7 @@ export function ChartBarRenderer({ onMouseMove={(e: any) => { zoomHandlers.onMouseMove?.(e); if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); diff --git a/apps/webapp/app/components/primitives/charts/ChartContext.tsx b/apps/webapp/app/components/primitives/charts/ChartContext.tsx index ae5687ee3ab..b26d431dd92 100644 --- a/apps/webapp/app/components/primitives/charts/ChartContext.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useMemo } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; import type { ChartConfig, ChartState } from "./Chart"; import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState"; import { @@ -27,9 +27,12 @@ export type ChartContextValue = { /** Function to format the x-axis label (used in legend, tooltips, etc.) */ labelFormatter?: LabelFormatter; - // Highlight state + // Highlight state (does NOT include activePayload — see PayloadContext) highlight: UseHighlightStateReturn; + /** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */ + setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void; + // Zoom state (only present when zoom is enabled) zoom: UseZoomSelectionReturn | null; @@ -42,6 +45,12 @@ export type ChartContextValue = { const ChartCompoundContext = createContext(null); +/** + * Separate context for activePayload so that frequent payload updates + * only re-render the legend, not the entire chart (bars, lines, etc.). + */ +const PayloadContext = createContext(null); + export function useChartContext(): ChartContextValue { const context = useContext(ChartCompoundContext); if (!context) { @@ -50,6 +59,11 @@ export function useChartContext(): ChartContextValue { return context; } +/** Read the active payload (only re-renders when payload changes). */ +export function useActivePayload(): any[] | null { + return useContext(PayloadContext); +} + export type ChartProviderProps = { config: ChartConfig; data: any[]; @@ -86,6 +100,35 @@ export function ChartProvider({ const highlight = useHighlightState(); const zoomState = useZoomSelection(); + // activePayload lives in its own state + context so updates don't re-render bars + const [activePayload, setActivePayloadRaw] = useState(null); + const activeTooltipIndexRef = useRef(null); + + const setActivePayload = useCallback( + (payload: any[] | null, tooltipIndex?: number | null) => { + const idx = tooltipIndex ?? null; + if (idx !== null && idx === activeTooltipIndexRef.current) { + return; + } + activeTooltipIndexRef.current = idx; + setActivePayloadRaw(payload); + }, + [] + ); + + // Reset the tooltip index ref when highlight resets (mouse leaves chart) + const originalReset = highlight.reset; + const resetWithPayload = useCallback(() => { + activeTooltipIndexRef.current = null; + setActivePayloadRaw(null); + originalReset(); + }, [originalReset]); + + const highlightWithReset = useMemo( + () => ({ ...highlight, reset: resetWithPayload }), + [highlight, resetWithPayload] + ); + // Compute series keys (use provided series or derive from config) const dataKeys = useMemo( () => series ?? Object.keys(config).filter((k) => k !== dataKey), @@ -106,13 +149,18 @@ export function ChartProvider({ visibleSeries, state, labelFormatter, - highlight, + highlight: highlightWithReset, + setActivePayload, zoom: enableZoom ? zoomState : null, onZoomChange: enableZoom ? onZoomChange : undefined, showLegend, }), - [config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend] + [config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlightWithReset, setActivePayload, zoomState, enableZoom, onZoomChange, showLegend] ); - return {children}; + return ( + + {children} + + ); } diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 7e9cf2c8791..ddffdc4575b 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; import type { AggregationType } from "~/components/metrics/QueryWidget"; -import { useChartContext } from "./ChartContext"; +import { useActivePayload, useChartContext } from "./ChartContext"; import { useSeriesTotal } from "./ChartRoot"; import { aggregateValues } from "./aggregation"; import { cn } from "~/utils/cn"; @@ -54,6 +54,7 @@ export function ChartLegendCompound({ scrollable = false, }: ChartLegendCompoundProps) { const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext(); + const activePayload = useActivePayload(); const totals = useSeriesTotal(aggregation); // Derive the effective label from the aggregation type when no explicit label is provided @@ -71,10 +72,10 @@ export function ChartLegendCompound({ // Calculate current total based on hover state (null when hovering a gap-filled point) const currentTotal = useMemo((): number | null => { - if (!highlight.activePayload?.length) return grandTotal; + if (!activePayload?.length) return grandTotal; // Collect all series values from the hovered data point, preserving nulls - const rawValues = highlight.activePayload + const rawValues = activePayload .filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string)) .map((item) => item.value); @@ -91,14 +92,14 @@ export function ChartLegendCompound({ return values.reduce((a, b) => a + b, 0); } return aggregateValues(values, aggregation); - }, [highlight.activePayload, grandTotal, dataKeys, aggregation]); + }, [activePayload, grandTotal, dataKeys, aggregation]); // Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise const currentTotalLabel = useMemo(() => { - if (!highlight.activePayload?.length) return effectiveTotalLabel; + if (!activePayload?.length) return effectiveTotalLabel; // Get the x-axis label from the payload's original data - const firstPayloadItem = highlight.activePayload[0]; + const firstPayloadItem = activePayload[0]; const xAxisValue = firstPayloadItem?.payload?.[dataKey]; if (xAxisValue === undefined) return effectiveTotalLabel; @@ -106,14 +107,14 @@ export function ChartLegendCompound({ // Apply the formatter if provided, otherwise just stringify the value const stringValue = String(xAxisValue); return labelFormatter ? labelFormatter(stringValue) : stringValue; - }, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]); + }, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]); // Get current data for the legend based on hover state (values may be null for gap-filled points) const currentData = useMemo((): Record => { - if (!highlight.activePayload?.length) return totals; + if (!activePayload?.length) return totals; // If we have activePayload data from hovering over a bar/line - const hoverData = highlight.activePayload.reduce( + const hoverData = activePayload.reduce( (acc, item) => { if (item.dataKey && item.value !== undefined) { // Preserve null for gap-filled points instead of coercing to 0 @@ -129,7 +130,7 @@ export function ChartLegendCompound({ ...totals, ...hoverData, }; - }, [highlight.activePayload, totals]); + }, [activePayload, totals]); // Prepare legend items with capped display const legendItems = useMemo(() => { @@ -163,7 +164,7 @@ export function ChartLegendCompound({ return null; } - const isHovering = (highlight.activePayload?.length ?? 0) > 0; + const isHovering = (activePayload?.length ?? 0) > 0; return (
{ if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -191,7 +191,7 @@ export function ChartLineRenderer({ }} onMouseMove={(e: any) => { if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload, e.activeTooltipIndex); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); diff --git a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts index f96c87c634e..02baac0ca8f 100644 --- a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts +++ b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts @@ -1,23 +1,19 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; export type HighlightState = { /** The currently highlighted series key (e.g., "completed", "failed") */ activeBarKey: string | null; /** The index of the specific data point being hovered (null when hovering legend) */ activeDataPointIndex: number | null; - /** The payload data from the hovered element */ - activePayload: any[] | null; /** Whether the tooltip is currently active */ tooltipActive: boolean; }; export type HighlightActions = { /** Set the hovered bar (specific data point) */ - setHoveredBar: (key: string, index: number, payload?: any[]) => void; + setHoveredBar: (key: string, index: number) => void; /** Set the hovered legend item (highlights all bars of that type) */ setHoveredLegendItem: (key: string) => void; - /** Set the active payload (for tooltip data). Pass tooltipIndex to skip redundant updates. */ - setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void; /** Set tooltip active state */ setTooltipActive: (active: boolean) => void; /** Reset all highlight state */ @@ -29,70 +25,55 @@ export type UseHighlightStateReturn = HighlightState & HighlightActions; const initialState: HighlightState = { activeBarKey: null, activeDataPointIndex: null, - activePayload: null, tooltipActive: false, }; /** * Hook to manage highlight state for chart elements. * Handles both bar hover (specific data point) and legend hover (all bars of a type). + * + * activePayload is intentionally NOT managed here — it lives in a separate context + * so that payload updates (frequent during mouse movement) don't cause bar re-renders. */ export function useHighlightState(): UseHighlightStateReturn { const [state, setState] = useState(initialState); - const activeTooltipIndexRef = useRef(null); - const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => { + const setHoveredBar = useCallback((key: string, index: number) => { setState({ activeBarKey: key, activeDataPointIndex: index, - activePayload: payload ?? null, tooltipActive: true, }); }, []); const setHoveredLegendItem = useCallback((key: string) => { - setState((prev) => ({ - ...prev, - activeBarKey: key, - activeDataPointIndex: null, - })); - }, []); - - const setActivePayload = useCallback((payload: any[] | null, tooltipIndex?: number | null) => { - const idx = tooltipIndex ?? null; - if (idx !== null && idx === activeTooltipIndexRef.current) { - console.log("Tooltip index is the same, skipping update", activeTooltipIndexRef.current); - return; - } - - console.log("Tooltip index changed", idx); - activeTooltipIndexRef.current = idx; - setState((prev) => ({ - ...prev, - activePayload: payload, - })); + setState((prev) => { + if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev; + return { ...prev, activeBarKey: key, activeDataPointIndex: null }; + }); }, []); const setTooltipActive = useCallback((active: boolean) => { - setState((prev) => ({ - ...prev, - tooltipActive: active, - })); + setState((prev) => { + if (prev.tooltipActive === active) return prev; + return { ...prev, tooltipActive: active }; + }); }, []); const reset = useCallback(() => { - activeTooltipIndexRef.current = null; setState(initialState); }, []); - return { - ...state, - setHoveredBar, - setHoveredLegendItem, - setActivePayload, - setTooltipActive, - reset, - }; + return useMemo( + () => ({ + ...state, + setHoveredBar, + setHoveredLegendItem, + setTooltipActive, + reset, + }), + [state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset] + ); } /** diff --git a/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts b/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts index 0a31af2cbec..fda078f1183 100644 --- a/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts +++ b/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; export type ZoomRange = { start: string; @@ -175,14 +175,17 @@ export function useZoomSelection(): UseZoomSelectionReturn { setState(initialState); }, []); - return { - ...state, - startSelection, - updateSelection, - finishSelection, - cancelSelection, - toggleInspectionLine, - clearInspectionLine, - reset, - }; + return useMemo( + () => ({ + ...state, + startSelection, + updateSelection, + finishSelection, + cancelSelection, + toggleInspectionLine, + clearInspectionLine, + reset, + }), + [state, startSelection, updateSelection, finishSelection, cancelSelection, toggleInspectionLine, clearInspectionLine, reset] + ); }