@@ -142,6 +142,138 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
142142 }
143143}
144144
145+ /**
146+ * Detect the most common interval between consecutive data points
147+ * This helps us understand the natural granularity of the data
148+ */
149+ function detectDataInterval ( timestamps : number [ ] ) : number {
150+ if ( timestamps . length < 2 ) return 60 * 1000 ; // Default to 1 minute
151+
152+ const sorted = [ ...timestamps ] . sort ( ( a , b ) => a - b ) ;
153+ const gaps : number [ ] = [ ] ;
154+
155+ for ( let i = 1 ; i < sorted . length ; i ++ ) {
156+ const gap = sorted [ i ] - sorted [ i - 1 ] ;
157+ if ( gap > 0 ) {
158+ gaps . push ( gap ) ;
159+ }
160+ }
161+
162+ if ( gaps . length === 0 ) return 60 * 1000 ;
163+
164+ // Find the most common small gap (this is likely the data's natural interval)
165+ // We use the minimum gap as a heuristic for the data interval
166+ const minGap = Math . min ( ...gaps ) ;
167+
168+ // Round to a nice interval
169+ const MINUTE = 60 * 1000 ;
170+ const HOUR = 60 * MINUTE ;
171+ const DAY = 24 * HOUR ;
172+
173+ // Snap to common intervals
174+ if ( minGap <= MINUTE ) return MINUTE ;
175+ if ( minGap <= 5 * MINUTE ) return 5 * MINUTE ;
176+ if ( minGap <= 10 * MINUTE ) return 10 * MINUTE ;
177+ if ( minGap <= 15 * MINUTE ) return 15 * MINUTE ;
178+ if ( minGap <= 30 * MINUTE ) return 30 * MINUTE ;
179+ if ( minGap <= HOUR ) return HOUR ;
180+ if ( minGap <= 2 * HOUR ) return 2 * HOUR ;
181+ if ( minGap <= 4 * HOUR ) return 4 * HOUR ;
182+ if ( minGap <= 6 * HOUR ) return 6 * HOUR ;
183+ if ( minGap <= 12 * HOUR ) return 12 * HOUR ;
184+ if ( minGap <= DAY ) return DAY ;
185+
186+ return minGap ;
187+ }
188+
189+ /**
190+ * Fill in missing time slots with zero values
191+ * This ensures the chart shows gaps as zeros rather than connecting distant points
192+ */
193+ function fillTimeGaps (
194+ data : Record < string , unknown > [ ] ,
195+ xDataKey : string ,
196+ series : string [ ] ,
197+ minTime : number ,
198+ maxTime : number ,
199+ interval : number ,
200+ granularity : TimeGranularity ,
201+ maxPoints = 1000
202+ ) : Record < string , unknown > [ ] {
203+ const range = maxTime - minTime ;
204+ const estimatedPoints = Math . ceil ( range / interval ) ;
205+
206+ // If filling would create too many points, increase the interval to stay within limits
207+ let effectiveInterval = interval ;
208+ if ( estimatedPoints > maxPoints ) {
209+ effectiveInterval = Math . ceil ( range / maxPoints ) ;
210+ // Round up to a nice interval
211+ const MINUTE = 60 * 1000 ;
212+ const HOUR = 60 * MINUTE ;
213+ if ( effectiveInterval < 5 * MINUTE ) effectiveInterval = 5 * MINUTE ;
214+ else if ( effectiveInterval < 10 * MINUTE ) effectiveInterval = 10 * MINUTE ;
215+ else if ( effectiveInterval < 15 * MINUTE ) effectiveInterval = 15 * MINUTE ;
216+ else if ( effectiveInterval < 30 * MINUTE ) effectiveInterval = 30 * MINUTE ;
217+ else if ( effectiveInterval < HOUR ) effectiveInterval = HOUR ;
218+ else if ( effectiveInterval < 2 * HOUR ) effectiveInterval = 2 * HOUR ;
219+ else if ( effectiveInterval < 4 * HOUR ) effectiveInterval = 4 * HOUR ;
220+ else if ( effectiveInterval < 6 * HOUR ) effectiveInterval = 6 * HOUR ;
221+ else if ( effectiveInterval < 12 * HOUR ) effectiveInterval = 12 * HOUR ;
222+ else effectiveInterval = 24 * HOUR ;
223+ }
224+
225+ // Create a map of existing data points by timestamp (bucketed to the effective interval)
226+ const existingData = new Map < number , Record < string , unknown > > ( ) ;
227+ for ( const point of data ) {
228+ const timestamp = point [ xDataKey ] as number ;
229+ // Bucket to the nearest interval
230+ const bucketedTime = Math . floor ( timestamp / effectiveInterval ) * effectiveInterval ;
231+
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 ) ,
247+ } ) ;
248+ }
249+ }
250+
251+ // Generate all time slots and fill with zeros where missing
252+ const filledData : Record < string , unknown > [ ] = [ ] ;
253+ const startTime = Math . floor ( minTime / effectiveInterval ) * effectiveInterval ;
254+
255+ for ( let t = startTime ; t <= maxTime ; t += effectiveInterval ) {
256+ const existing = existingData . get ( t ) ;
257+ if ( existing ) {
258+ filledData . push ( existing ) ;
259+ } else {
260+ // Create a zero-filled data point
261+ const zeroPoint : Record < string , unknown > = {
262+ [ xDataKey ] : t ,
263+ __rawDate : new Date ( t ) ,
264+ __granularity : granularity ,
265+ __originalX : new Date ( t ) . toISOString ( ) ,
266+ } ;
267+ for ( const s of series ) {
268+ zeroPoint [ s ] = 0 ;
269+ }
270+ filledData . push ( zeroPoint ) ;
271+ }
272+ }
273+
274+ return filledData ;
275+ }
276+
145277/**
146278 * "Nice" intervals for time axes - these create human-friendly tick marks
147279 */
@@ -361,7 +493,7 @@ function transformDataForChart(
361493
362494 // No grouping: use Y columns directly as series
363495 if ( ! groupByColumn ) {
364- const data = rows
496+ let data = rows
365497 . map ( ( row ) => {
366498 const rawDate = tryParseDate ( row [ xAxisColumn ] ) ;
367499 const point : Record < string , unknown > = {
@@ -380,6 +512,21 @@ function transformDataForChart(
380512 // Filter out rows with invalid dates for date-based axes
381513 . filter ( ( point ) => ! isDateBased || point . __rawDate !== null ) ;
382514
515+ // Fill in gaps with zeros for date-based data
516+ if ( isDateBased && timeDomain ) {
517+ const timestamps = dateValues . map ( ( d ) => d . getTime ( ) ) ;
518+ const dataInterval = detectDataInterval ( timestamps ) ;
519+ data = fillTimeGaps (
520+ data ,
521+ xDataKey ,
522+ yAxisColumns ,
523+ timeDomain [ 0 ] ,
524+ timeDomain [ 1 ] ,
525+ dataInterval ,
526+ granularity
527+ ) ;
528+ }
529+
383530 return { data, series : yAxisColumns , dateValues, isDateBased, xDataKey, timeDomain, timeTicks } ;
384531 }
385532
@@ -416,7 +563,7 @@ function transformDataForChart(
416563
417564 // Convert to array format
418565 const series = Array . from ( groupValues ) . sort ( ) ;
419- const data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xKey , { values, rawDate, originalX } ] ) => {
566+ let data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xKey , { values, rawDate, originalX } ] ) => {
420567 const point : Record < string , unknown > = {
421568 [ xDataKey ] : xKey ,
422569 __rawDate : rawDate ,
@@ -429,6 +576,21 @@ function transformDataForChart(
429576 return point ;
430577 } ) ;
431578
579+ // Fill in gaps with zeros for date-based data
580+ if ( isDateBased && timeDomain ) {
581+ const timestamps = dateValues . map ( ( d ) => d . getTime ( ) ) ;
582+ const dataInterval = detectDataInterval ( timestamps ) ;
583+ data = fillTimeGaps (
584+ data ,
585+ xDataKey ,
586+ series ,
587+ timeDomain [ 0 ] ,
588+ timeDomain [ 1 ] ,
589+ dataInterval ,
590+ granularity
591+ ) ;
592+ }
593+
432594 return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks } ;
433595}
434596
0 commit comments