Skip to content

Commit eb0f963

Browse files
authored
feat(metrics): Dashboard charts performance improvements (#3083)
Summary - Only render the top 50 series - Improved rendering performance on bar charts
1 parent 59b6eb9 commit eb0f963

File tree

8 files changed

+242
-127
lines changed

8 files changed

+242
-127
lines changed

apps/webapp/app/components/code/QueryResultsChart.tsx

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ import { memo, useMemo } from "react";
44
import type { ChartConfig } from "~/components/primitives/charts/Chart";
55
import { Chart } from "~/components/primitives/charts/ChartCompound";
66
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
7+
import { Callout } from "../primitives/Callout";
78
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
89
import { aggregateValues } from "../primitives/charts/aggregation";
910
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
1011
import { getSeriesColor } from "./chartColors";
1112

13+
const MAX_SERIES = 50;
14+
const MAX_SVG_ELEMENT_BUDGET = 6_000;
15+
const MIN_DATA_POINTS = 100;
16+
const MAX_DATA_POINTS = 500;
17+
1218
interface QueryResultsChartProps {
1319
rows: Record<string, unknown>[];
1420
columns: OutputColumnMetadata[];
@@ -26,6 +32,8 @@ interface QueryResultsChartProps {
2632
interface TransformedData {
2733
data: Record<string, unknown>[];
2834
series: string[];
35+
/** Total number of series before any truncation (equals series.length when no truncation) */
36+
totalSeriesCount: number;
2937
/** Raw date values for determining formatting granularity */
3038
dateValues: Date[];
3139
/** Whether the x-axis is date-based (continuous time scale) */
@@ -447,6 +455,7 @@ function transformDataForChart(
447455
return {
448456
data: [],
449457
series: [],
458+
totalSeriesCount: 0,
450459
dateValues: [],
451460
isDateBased: false,
452461
xDataKey: xAxisColumn || "",
@@ -550,17 +559,17 @@ function transformDataForChart(
550559
});
551560

552561
// Fill in gaps with zeros for date-based data
562+
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
563+
const effectiveMaxPoints = Math.max(
564+
MIN_DATA_POINTS,
565+
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
566+
);
567+
553568
if (isDateBased && timeDomain) {
554569
const timestamps = dateValues.map((d) => d.getTime());
555570
const dataInterval = detectDataInterval(timestamps);
556-
// When filling across a full time range, ensure the interval is appropriate
557-
// for the range size (target ~150 points) so we don't create overly dense charts
558571
const rangeMs = rawMaxTime - rawMinTime;
559-
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
560-
// Also cap the interval so we get enough data points to visually represent
561-
// the full time range. Without this, limited data (e.g. 1 point) defaults
562-
// to a 1-day interval which can be far too coarse for shorter ranges,
563-
// producing too few bars/points and potentially buckets outside the domain.
572+
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
564573
const maxRangeInterval =
565574
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
566575
const effectiveInterval = Math.min(
@@ -575,19 +584,32 @@ function transformDataForChart(
575584
rawMaxTime,
576585
effectiveInterval,
577586
granularity,
578-
aggregation
587+
aggregation,
588+
effectiveMaxPoints
579589
);
590+
} else if (data.length > effectiveMaxPoints) {
591+
data = data.slice(0, effectiveMaxPoints);
580592
}
581593

582-
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
594+
return {
595+
data,
596+
series: yAxisColumns,
597+
totalSeriesCount: yAxisColumns.length,
598+
dateValues,
599+
isDateBased,
600+
xDataKey,
601+
timeDomain,
602+
timeTicks,
603+
};
583604
}
584605

585606
// With grouping: pivot data so each group value becomes a series
586607
const yCol = yAxisColumns[0]; // Use first Y column when grouping
587-
const groupValues = new Set<string>();
588608

589-
// For date-based, key by timestamp; otherwise by formatted string
590-
// Collect all values for aggregation
609+
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
610+
// per-group totals so we can pick the top-N groups before building heavy data
611+
// objects with thousands of keys.
612+
const groupTotals = new Map<string, number>();
591613
const groupedByX = new Map<
592614
string | number,
593615
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
@@ -596,29 +618,39 @@ function transformDataForChart(
596618
for (const row of rows) {
597619
const rawDate = tryParseDate(row[xAxisColumn]);
598620

599-
// Skip rows with invalid dates for date-based axes
600621
if (isDateBased && !rawDate) continue;
601622

602623
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
603624
const groupValue = String(row[groupByColumn] ?? "Unknown");
604625
const yValue = toNumber(row[yCol]);
605626

606-
groupValues.add(groupValue);
627+
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
607628

608629
if (!groupedByX.has(xKey)) {
609630
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
610631
}
611632

612633
const existing = groupedByX.get(xKey)!;
613-
// Collect values for aggregation
614634
if (!existing.values[groupValue]) {
615635
existing.values[groupValue] = [];
616636
}
617637
existing.values[groupValue].push(yValue);
618638
}
619639

620-
// Convert to array format with aggregation applied
621-
const series = Array.from(groupValues).sort();
640+
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
641+
// downstream (data objects, gap filling, legend totals, SVG rendering).
642+
const totalSeriesCount = groupTotals.size;
643+
let series: string[];
644+
if (groupTotals.size <= MAX_SERIES) {
645+
series = Array.from(groupTotals.keys()).sort();
646+
} else {
647+
series = Array.from(groupTotals.entries())
648+
.sort((a, b) => b[1] - a[1])
649+
.slice(0, MAX_SERIES)
650+
.map(([key]) => key)
651+
.sort();
652+
}
653+
// Convert to array format with aggregation applied (only for kept series)
622654
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
623655
const point: Record<string, unknown> = {
624656
[xDataKey]: xKey,
@@ -632,24 +664,19 @@ function transformDataForChart(
632664
return point;
633665
});
634666

635-
// Fill in gaps with zeros for date-based data
667+
// Dynamic data-point budget based on the (already capped) series count
668+
const effectiveMaxPoints = Math.max(
669+
MIN_DATA_POINTS,
670+
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
671+
);
672+
636673
if (isDateBased && timeDomain) {
637674
const timestamps = dateValues.map((d) => d.getTime());
638675
const dataInterval = detectDataInterval(timestamps);
639-
// When filling across a full time range, ensure the interval is appropriate
640-
// for the range size (target ~150 points) so we don't create overly dense charts
641676
const rangeMs = rawMaxTime - rawMinTime;
642-
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
643-
// Also cap the interval so we get enough data points to visually represent
644-
// the full time range. Without this, limited data (e.g. 1 point) defaults
645-
// to a 1-day interval which can be far too coarse for shorter ranges,
646-
// producing too few bars/points and potentially buckets outside the domain.
647-
const maxRangeInterval =
648-
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
649-
const effectiveInterval = Math.min(
650-
Math.max(dataInterval, minRangeInterval),
651-
maxRangeInterval
652-
);
677+
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
678+
const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
679+
const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval);
653680
data = fillTimeGaps(
654681
data,
655682
xDataKey,
@@ -658,11 +685,23 @@ function transformDataForChart(
658685
rawMaxTime,
659686
effectiveInterval,
660687
granularity,
661-
aggregation
688+
aggregation,
689+
effectiveMaxPoints
662690
);
691+
} else if (data.length > effectiveMaxPoints) {
692+
data = data.slice(0, effectiveMaxPoints);
663693
}
664694

665-
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
695+
return {
696+
data,
697+
series,
698+
totalSeriesCount,
699+
dateValues,
700+
isDateBased,
701+
xDataKey,
702+
timeDomain,
703+
timeTicks,
704+
};
666705
}
667706

668707
function toNumber(value: unknown): number {
@@ -743,6 +782,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
743782
const {
744783
data: unsortedData,
745784
series,
785+
totalSeriesCount,
746786
dateValues,
747787
isDateBased,
748788
xDataKey,
@@ -777,6 +817,23 @@ export const QueryResultsChart = memo(function QueryResultsChart({
777817
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
778818
}, [series, data]);
779819

820+
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
821+
const visibleSeries = useMemo(
822+
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
823+
[sortedSeries]
824+
);
825+
826+
const seriesLimitCallout =
827+
totalSeriesCount > series.length ? (
828+
<div className="mt-1 px-2">
829+
<Callout variant="warning">
830+
{`Limited to the top ${
831+
series.length
832+
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
833+
</Callout>
834+
</div>
835+
) : null;
836+
780837
// Detect time granularity — use the full time range when available so tick
781838
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
782839
// instead of just "16:00:00" when data is sparse)
@@ -951,11 +1008,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
9511008
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
9521009

9531010
if (!xAxisColumn) {
954-
return <ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />;
1011+
return (
1012+
<ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />
1013+
);
9551014
}
9561015

9571016
if (yAxisColumns.length === 0) {
958-
return <ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />;
1017+
return (
1018+
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
1019+
);
9591020
}
9601021

9611022
if (rows.length === 0) {
@@ -1015,6 +1076,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10151076
data={data}
10161077
dataKey={xDataKey}
10171078
series={sortedSeries}
1079+
visibleSeries={visibleSeries}
10181080
labelFormatter={legendLabelFormatter}
10191081
showLegend={showLegend}
10201082
maxLegendItems={fullLegend ? Infinity : 5}
@@ -1024,6 +1086,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10241086
onViewAllLegendItems={onViewAllLegendItems}
10251087
legendScrollable={legendScrollable}
10261088
state={isLoading ? "loading" : "loaded"}
1089+
beforeLegend={seriesLimitCallout}
10271090
>
10281091
<Chart.Bar
10291092
xAxisProps={xAxisPropsForBar}
@@ -1042,6 +1105,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10421105
data={data}
10431106
dataKey={xDataKey}
10441107
series={sortedSeries}
1108+
visibleSeries={visibleSeries}
10451109
labelFormatter={legendLabelFormatter}
10461110
showLegend={showLegend}
10471111
maxLegendItems={fullLegend ? Infinity : 5}
@@ -1051,11 +1115,12 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10511115
onViewAllLegendItems={onViewAllLegendItems}
10521116
legendScrollable={legendScrollable}
10531117
state={isLoading ? "loading" : "loaded"}
1118+
beforeLegend={seriesLimitCallout}
10541119
>
10551120
<Chart.Line
10561121
xAxisProps={xAxisPropsForLine}
10571122
yAxisProps={yAxisProps}
1058-
stacked={stacked && sortedSeries.length > 1}
1123+
stacked={stacked && visibleSeries.length > 1}
10591124
tooltipLabelFormatter={tooltipLabelFormatter}
10601125
lineType="linear"
10611126
/>
@@ -1115,4 +1180,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
11151180
return Math.round(value).toString();
11161181
};
11171182
}
1118-

apps/webapp/app/components/primitives/charts/ChartBar.tsx

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
Bar,
44
BarChart,
55
CartesianGrid,
6-
Cell,
76
ReferenceArea,
87
ReferenceLine,
98
XAxis,
@@ -15,9 +14,7 @@ import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/chart
1514
import { useChartContext } from "./ChartContext";
1615
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
1716
import { useHasNoData } from "./ChartRoot";
18-
// Legend is now rendered by ChartRoot outside the chart container
1917
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
20-
import { getBarOpacity } from "./hooks/useHighlightState";
2118

2219
//TODO: fix the first and last bars in a stack not having rounded corners
2320

@@ -68,7 +65,7 @@ export function ChartBarRenderer({
6865
width,
6966
height,
7067
}: ChartBarRendererProps) {
71-
const { config, data, dataKey, dataKeys, state, highlight, zoom, showLegend } = useChartContext();
68+
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext();
7269
const hasNoData = useHasNoData();
7370
const zoomHandlers = useZoomHandlers();
7471
const enableZoom = zoom !== null;
@@ -116,9 +113,8 @@ export function ChartBarRenderer({
116113
onMouseDown={zoomHandlers.onMouseDown}
117114
onMouseMove={(e: any) => {
118115
zoomHandlers.onMouseMove?.(e);
119-
// Update active payload for legend
120116
if (e?.activePayload?.length) {
121-
highlight.setActivePayload(e.activePayload);
117+
setActivePayload(e.activePayload, e.activeTooltipIndex);
122118
highlight.setTooltipActive(true);
123119
} else {
124120
highlight.setTooltipActive(false);
@@ -188,7 +184,12 @@ export function ChartBarRenderer({
188184
/>
189185
)}
190186

191-
{dataKeys.map((key, index, array) => {
187+
{visibleSeries.map((key, index, array) => {
188+
const dimmed =
189+
!zoom?.isSelecting &&
190+
highlight.activeBarKey !== null &&
191+
highlight.activeBarKey !== key;
192+
192193
return (
193194
<Bar
194195
key={key}
@@ -204,7 +205,7 @@ export function ChartBarRenderer({
204205
] as [number, number, number, number]
205206
}
206207
activeBar={false}
207-
fillOpacity={1}
208+
fillOpacity={dimmed ? 0.2 : 1}
208209
onClick={(data, index, e) => handleBarClick(data, e)}
209210
onMouseEnter={(entry, index) => {
210211
if (entry.tooltipPayload?.[0]) {
@@ -214,20 +215,7 @@ export function ChartBarRenderer({
214215
}}
215216
onMouseLeave={highlight.reset}
216217
isAnimationActive={false}
217-
>
218-
{data.map((_, dataIndex) => {
219-
// Don't dim bars during zoom selection
220-
const opacity = zoom?.isSelecting ? 1 : getBarOpacity(key, dataIndex, highlight);
221-
222-
return (
223-
<Cell
224-
key={`cell-${key}-${dataIndex}`}
225-
fill={config[key]?.color}
226-
fillOpacity={opacity}
227-
/>
228-
);
229-
})}
230-
</Bar>
218+
/>
231219
);
232220
})}
233221

0 commit comments

Comments
 (0)