diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index c66db5a8f60..d2893cfb95e 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 = 50; +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..185cb31914e 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 @@ -68,7 +65,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, setActivePayload, zoom, showLegend } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); const enableZoom = zoom !== null; @@ -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); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -188,7 +184,12 @@ export function ChartBarRenderer({ /> )} - {dataKeys.map((key, index, array) => { + {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/ChartContext.tsx b/apps/webapp/app/components/primitives/charts/ChartContext.tsx index e4b33c96cec..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 { @@ -17,6 +17,8 @@ export type ChartContextValue = { dataKey: string; /** Computed series keys (all config keys except dataKey) */ dataKeys: string[]; + /** Subset of dataKeys actually rendered as SVG elements (defaults to dataKeys) */ + visibleSeries: string[]; // Display state state?: ChartState; @@ -25,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; @@ -40,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) { @@ -48,12 +59,19 @@ 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[]; dataKey: string; /** Series keys to render (if not provided, derived from config keys) */ series?: string[]; + /** Subset of series to render as SVG elements on the chart (legend still shows all series) */ + visibleSeries?: string[]; state?: ChartState; /** Function to format the x-axis label (used in legend, tooltips, etc.) */ labelFormatter?: LabelFormatter; @@ -71,6 +89,7 @@ export function ChartProvider({ data, dataKey, series, + visibleSeries: visibleSeriesProp, state, labelFormatter, enableZoom = false, @@ -81,27 +100,67 @@ 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), [series, config, dataKey] ); + const visibleSeries = useMemo( + () => visibleSeriesProp ?? dataKeys, + [visibleSeriesProp, dataKeys] + ); + const value = useMemo( () => ({ config, data, dataKey, dataKeys, + visibleSeries, state, labelFormatter, - highlight, + highlight: highlightWithReset, + setActivePayload, zoom: enableZoom ? zoomState : null, onZoomChange: enableZoom ? onZoomChange : undefined, showLegend, }), - [config, data, dataKey, dataKeys, 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 (
1) { + if (stacked && visibleSeries.length > 1) { return ( { - // Update active payload for legend if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -162,7 +161,7 @@ export function ChartLineRenderer({ labelFormatter={tooltipLabelFormatter} /> {/* Note: Legend is now rendered by ChartRoot outside the chart container */} - {dataKeys.map((key) => ( + {visibleSeries.map((key) => ( { - // Update active payload for legend if (e?.activePayload?.length) { - highlight.setActivePayload(e.activePayload); + setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); } else { highlight.setTooltipActive(false); @@ -211,7 +209,7 @@ export function ChartLineRenderer({ labelFormatter={tooltipLabelFormatter} /> {/* 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 && ( 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) */ - setActivePayload: (payload: any[] | null) => void; /** Set tooltip active state */ setTooltipActive: (active: boolean) => void; /** Reset all highlight state */ @@ -29,60 +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 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, // null indicates legend hover (all bars of this type) - })); - }, []); - - const setActivePayload = useCallback((payload: any[] | null) => { - 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(() => { 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] + ); }