@@ -20,7 +20,7 @@ import {
2020 ChartTooltipContent ,
2121} from "~/components/primitives/Chart" ;
2222import { Paragraph } from "../primitives/Paragraph" ;
23- import type { ChartConfiguration } from "./ChartConfigPanel" ;
23+ import type { AggregationType , ChartConfiguration } from "./ChartConfigPanel" ;
2424
2525// Color palette for chart series
2626const CHART_COLORS = [
@@ -198,6 +198,7 @@ function fillTimeGaps(
198198 maxTime : number ,
199199 interval : number ,
200200 granularity : TimeGranularity ,
201+ aggregation : AggregationType ,
201202 maxPoints = 1000
202203) : Record < string , unknown > [ ] {
203204 const range = maxTime - minTime ;
@@ -222,40 +223,52 @@ function fillTimeGaps(
222223 else effectiveInterval = 24 * HOUR ;
223224 }
224225
225- // Create a map of existing data points by timestamp (bucketed to the effective interval)
226- const existingData = new Map < number , Record < string , unknown > > ( ) ;
226+ // Create a map to collect values for each bucket (for aggregation)
227+ const bucketData = new Map <
228+ number ,
229+ { values : Record < string , number [ ] > ; rawDate : Date ; originalX : string }
230+ > ( ) ;
231+
227232 for ( const point of data ) {
228233 const timestamp = point [ xDataKey ] as number ;
229234 // Bucket to the nearest interval
230235 const bucketedTime = Math . floor ( timestamp / effectiveInterval ) * effectiveInterval ;
231236
232- // If there's already data for this bucket, aggregate it
233- const existing = existingData . get ( bucketedTime ) ;
234- if ( existing ) {
235- // Sum the values
236- for ( const s of series ) {
237- const existingVal = ( existing [ s ] as number ) || 0 ;
238- const newVal = ( point [ s ] as number ) || 0 ;
239- existing [ s ] = existingVal + newVal ;
240- }
241- } else {
242- // Clone the point with the bucketed timestamp
243- existingData . set ( bucketedTime , {
244- ...point ,
245- [ xDataKey ] : bucketedTime ,
246- __rawDate : new Date ( bucketedTime ) ,
237+ if ( ! bucketData . has ( bucketedTime ) ) {
238+ bucketData . set ( bucketedTime , {
239+ values : Object . fromEntries ( series . map ( ( s ) => [ s , [ ] ] ) ) ,
240+ rawDate : new Date ( bucketedTime ) ,
241+ originalX : new Date ( bucketedTime ) . toISOString ( ) ,
247242 } ) ;
248243 }
244+
245+ const bucket = bucketData . get ( bucketedTime ) ! ;
246+ for ( const s of series ) {
247+ const val = point [ s ] as number ;
248+ if ( typeof val === "number" ) {
249+ bucket . values [ s ] . push ( val ) ;
250+ }
251+ }
249252 }
250253
251254 // Generate all time slots and fill with zeros where missing
252255 const filledData : Record < string , unknown > [ ] = [ ] ;
253256 const startTime = Math . floor ( minTime / effectiveInterval ) * effectiveInterval ;
254257
255258 for ( let t = startTime ; t <= maxTime ; t += effectiveInterval ) {
256- const existing = existingData . get ( t ) ;
257- if ( existing ) {
258- filledData . push ( existing ) ;
259+ const bucket = bucketData . get ( t ) ;
260+ if ( bucket ) {
261+ // Apply aggregation to collected values
262+ const point : Record < string , unknown > = {
263+ [ xDataKey ] : t ,
264+ __rawDate : bucket . rawDate ,
265+ __granularity : granularity ,
266+ __originalX : bucket . originalX ,
267+ } ;
268+ for ( const s of series ) {
269+ point [ s ] = aggregateValues ( bucket . values [ s ] , aggregation ) ;
270+ }
271+ filledData . push ( point ) ;
259272 } else {
260273 // Create a zero-filled data point
261274 const zeroPoint : Record < string , unknown > = {
@@ -441,7 +454,7 @@ function transformDataForChart(
441454 rows : Record < string , unknown > [ ] ,
442455 config : ChartConfiguration
443456) : TransformedData {
444- const { xAxisColumn, yAxisColumns, groupByColumn } = config ;
457+ const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config ;
445458
446459 if ( ! xAxisColumn || yAxisColumns . length === 0 ) {
447460 return {
@@ -492,25 +505,49 @@ function transformDataForChart(
492505 } ;
493506
494507 // No grouping: use Y columns directly as series
508+ // Group rows by X value first, then aggregate
495509 if ( ! groupByColumn ) {
496- let data = rows
497- . map ( ( row ) => {
498- const rawDate = tryParseDate ( row [ xAxisColumn ] ) ;
499- const point : Record < string , unknown > = {
500- // For date-based, use timestamp; otherwise use formatted string
501- [ xDataKey ] : isDateBased && rawDate ? rawDate . getTime ( ) : formatX ( row [ xAxisColumn ] ) ,
502- // Store raw date and original value for tooltip
503- __rawDate : rawDate ,
504- __granularity : granularity ,
505- __originalX : row [ xAxisColumn ] ,
506- } ;
507- for ( const yCol of yAxisColumns ) {
508- point [ yCol ] = toNumber ( row [ yCol ] ) ;
509- }
510- return point ;
511- } )
512- // Filter out rows with invalid dates for date-based axes
513- . filter ( ( point ) => ! isDateBased || point . __rawDate !== null ) ;
510+ // Group rows by X-axis value to handle duplicates
511+ const groupedByX = new Map <
512+ string | number ,
513+ { yValues : Record < string , number [ ] > ; rawDate : Date | null ; originalX : unknown }
514+ > ( ) ;
515+
516+ for ( const row of rows ) {
517+ const rawDate = tryParseDate ( row [ xAxisColumn ] ) ;
518+
519+ // Skip rows with invalid dates for date-based axes
520+ if ( isDateBased && ! rawDate ) continue ;
521+
522+ const xKey = isDateBased && rawDate ? rawDate . getTime ( ) : formatX ( row [ xAxisColumn ] ) ;
523+
524+ if ( ! groupedByX . has ( xKey ) ) {
525+ groupedByX . set ( xKey , {
526+ yValues : Object . fromEntries ( yAxisColumns . map ( ( col ) => [ col , [ ] ] ) ) ,
527+ rawDate,
528+ originalX : row [ xAxisColumn ] ,
529+ } ) ;
530+ }
531+
532+ const existing = groupedByX . get ( xKey ) ! ;
533+ for ( const yCol of yAxisColumns ) {
534+ existing . yValues [ yCol ] . push ( toNumber ( row [ yCol ] ) ) ;
535+ }
536+ }
537+
538+ // Convert to array format with aggregation applied
539+ let data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xKey , { yValues, rawDate, originalX } ] ) => {
540+ const point : Record < string , unknown > = {
541+ [ xDataKey ] : xKey ,
542+ __rawDate : rawDate ,
543+ __granularity : granularity ,
544+ __originalX : originalX ,
545+ } ;
546+ for ( const yCol of yAxisColumns ) {
547+ point [ yCol ] = aggregateValues ( yValues [ yCol ] , aggregation ) ;
548+ }
549+ return point ;
550+ } ) ;
514551
515552 // Fill in gaps with zeros for date-based data
516553 if ( isDateBased && timeDomain ) {
@@ -523,7 +560,8 @@ function transformDataForChart(
523560 timeDomain [ 0 ] ,
524561 timeDomain [ 1 ] ,
525562 dataInterval ,
526- granularity
563+ granularity ,
564+ aggregation
527565 ) ;
528566 }
529567
@@ -535,9 +573,10 @@ function transformDataForChart(
535573 const groupValues = new Set < string > ( ) ;
536574
537575 // For date-based, key by timestamp; otherwise by formatted string
576+ // Collect all values for aggregation
538577 const groupedByX = new Map <
539578 string | number ,
540- { values : Record < string , number > ; rawDate : Date | null ; originalX : unknown }
579+ { values : Record < string , number [ ] > ; rawDate : Date | null ; originalX : unknown }
541580 > ( ) ;
542581
543582 for ( const row of rows ) {
@@ -557,11 +596,14 @@ function transformDataForChart(
557596 }
558597
559598 const existing = groupedByX . get ( xKey ) ! ;
560- // Sum values if there are multiple rows with same x + group
561- existing . values [ groupValue ] = ( existing . values [ groupValue ] ?? 0 ) + yValue ;
599+ // Collect values for aggregation
600+ if ( ! existing . values [ groupValue ] ) {
601+ existing . values [ groupValue ] = [ ] ;
602+ }
603+ existing . values [ groupValue ] . push ( yValue ) ;
562604 }
563605
564- // Convert to array format
606+ // Convert to array format with aggregation applied
565607 const series = Array . from ( groupValues ) . sort ( ) ;
566608 let data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xKey , { values, rawDate, originalX } ] ) => {
567609 const point : Record < string , unknown > = {
@@ -571,7 +613,7 @@ function transformDataForChart(
571613 __originalX : originalX ,
572614 } ;
573615 for ( const group of series ) {
574- point [ group ] = values [ group ] ?? 0 ;
616+ point [ group ] = values [ group ] ? aggregateValues ( values [ group ] , aggregation ) : 0 ;
575617 }
576618 return point ;
577619 } ) ;
@@ -587,7 +629,8 @@ function transformDataForChart(
587629 timeDomain [ 0 ] ,
588630 timeDomain [ 1 ] ,
589631 dataInterval ,
590- granularity
632+ granularity ,
633+ aggregation
591634 ) ;
592635 }
593636
@@ -603,6 +646,25 @@ function toNumber(value: unknown): number {
603646 return 0 ;
604647}
605648
649+ /**
650+ * Aggregate an array of numbers using the specified aggregation function
651+ */
652+ function aggregateValues ( values : number [ ] , aggregation : AggregationType ) : number {
653+ if ( values . length === 0 ) return 0 ;
654+ switch ( aggregation ) {
655+ case "sum" :
656+ return values . reduce ( ( a , b ) => a + b , 0 ) ;
657+ case "avg" :
658+ return values . reduce ( ( a , b ) => a + b , 0 ) / values . length ;
659+ case "count" :
660+ return values . length ;
661+ case "min" :
662+ return Math . min ( ...values ) ;
663+ case "max" :
664+ return Math . max ( ...values ) ;
665+ }
666+ }
667+
606668/**
607669 * Sort data array by a specified column
608670 */
0 commit comments