@@ -4,11 +4,17 @@ import { memo, useMemo } from "react";
44import type { ChartConfig } from "~/components/primitives/charts/Chart" ;
55import { Chart } from "~/components/primitives/charts/ChartCompound" ;
66import { ChartBlankState } from "../primitives/charts/ChartBlankState" ;
7+ import { Callout } from "../primitives/Callout" ;
78import type { AggregationType , ChartConfiguration } from "../metrics/QueryWidget" ;
89import { aggregateValues } from "../primitives/charts/aggregation" ;
910import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus" ;
1011import { 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+
1218interface QueryResultsChartProps {
1319 rows : Record < string , unknown > [ ] ;
1420 columns : OutputColumnMetadata [ ] ;
@@ -26,6 +32,8 @@ interface QueryResultsChartProps {
2632interface 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
668707function 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-
0 commit comments