@@ -51,6 +51,14 @@ interface TransformedData {
5151 series : string [ ] ;
5252 /** Raw date values for determining formatting granularity */
5353 dateValues : Date [ ] ;
54+ /** Whether the x-axis is date-based (continuous time scale) */
55+ isDateBased : boolean ;
56+ /** The data key to use for x-axis (column name or '__timestamp' for dates) */
57+ xDataKey : string ;
58+ /** Min/max timestamps for domain when date-based */
59+ timeDomain : [ number , number ] | null ;
60+ /** Pre-calculated tick values for the time axis */
61+ timeTicks : number [ ] | null ;
5462}
5563
5664/**
@@ -134,6 +142,97 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
134142 }
135143}
136144
145+ /**
146+ * "Nice" intervals for time axes - these create human-friendly tick marks
147+ */
148+ const NICE_TIME_INTERVALS = [
149+ { value : 1000 , label : "1s" } , // 1 second
150+ { value : 5 * 1000 , label : "5s" } , // 5 seconds
151+ { value : 10 * 1000 , label : "10s" } , // 10 seconds
152+ { value : 30 * 1000 , label : "30s" } , // 30 seconds
153+ { value : 60 * 1000 , label : "1m" } , // 1 minute
154+ { value : 5 * 60 * 1000 , label : "5m" } , // 5 minutes
155+ { value : 10 * 60 * 1000 , label : "10m" } , // 10 minutes
156+ { value : 15 * 60 * 1000 , label : "15m" } , // 15 minutes
157+ { value : 30 * 60 * 1000 , label : "30m" } , // 30 minutes
158+ { value : 60 * 60 * 1000 , label : "1h" } , // 1 hour
159+ { value : 2 * 60 * 60 * 1000 , label : "2h" } , // 2 hours
160+ { value : 3 * 60 * 60 * 1000 , label : "3h" } , // 3 hours
161+ { value : 4 * 60 * 60 * 1000 , label : "4h" } , // 4 hours
162+ { value : 6 * 60 * 60 * 1000 , label : "6h" } , // 6 hours
163+ { value : 12 * 60 * 60 * 1000 , label : "12h" } , // 12 hours
164+ { value : 24 * 60 * 60 * 1000 , label : "1d" } , // 1 day
165+ { value : 2 * 24 * 60 * 60 * 1000 , label : "2d" } , // 2 days
166+ { value : 7 * 24 * 60 * 60 * 1000 , label : "1w" } , // 1 week
167+ { value : 14 * 24 * 60 * 60 * 1000 , label : "2w" } , // 2 weeks
168+ { value : 30 * 24 * 60 * 60 * 1000 , label : "1mo" } , // ~1 month
169+ { value : 90 * 24 * 60 * 60 * 1000 , label : "3mo" } , // ~3 months
170+ { value : 180 * 24 * 60 * 60 * 1000 , label : "6mo" } , // ~6 months
171+ { value : 365 * 24 * 60 * 60 * 1000 , label : "1y" } , // 1 year
172+ ] ;
173+
174+ /**
175+ * Generate evenly-spaced tick values for a time axis using "nice" intervals
176+ * that align to natural time boundaries (midnight, noon, hour marks, etc.)
177+ */
178+ function generateTimeTicks ( minTime : number , maxTime : number , maxTicks = 8 ) : number [ ] {
179+ const range = maxTime - minTime ;
180+
181+ if ( range <= 0 ) {
182+ return [ minTime ] ;
183+ }
184+
185+ // Find the best "nice" interval that gives us a reasonable number of ticks
186+ // Target: between 4 and maxTicks ticks
187+ let chosenInterval = NICE_TIME_INTERVALS [ NICE_TIME_INTERVALS . length - 1 ] . value ;
188+
189+ for ( const { value : interval } of NICE_TIME_INTERVALS ) {
190+ const tickCount = Math . ceil ( range / interval ) ;
191+ if ( tickCount <= maxTicks && tickCount >= 2 ) {
192+ chosenInterval = interval ;
193+ break ;
194+ }
195+ }
196+
197+ // Align the start tick to a nice boundary
198+ // For intervals >= 1 day, align to midnight
199+ // For intervals >= 1 hour, align to hour boundary
200+ // For intervals >= 1 minute, align to minute boundary
201+ const DAY = 24 * 60 * 60 * 1000 ;
202+ const HOUR = 60 * 60 * 1000 ;
203+ const MINUTE = 60 * 1000 ;
204+
205+ let alignTo : number ;
206+ if ( chosenInterval >= DAY ) {
207+ // Align to midnight UTC (or we could use local midnight)
208+ alignTo = DAY ;
209+ } else if ( chosenInterval >= HOUR ) {
210+ alignTo = chosenInterval ; // Align to the interval itself for hours
211+ } else if ( chosenInterval >= MINUTE ) {
212+ alignTo = chosenInterval ;
213+ } else {
214+ alignTo = chosenInterval ;
215+ }
216+
217+ // Round down to the alignment boundary, then find first tick at or before minTime
218+ const startTick = Math . floor ( minTime / alignTo ) * alignTo ;
219+
220+ // Generate ticks
221+ const ticks : number [ ] = [ ] ;
222+ for ( let t = startTick ; t <= maxTime + chosenInterval ; t += chosenInterval ) {
223+ if ( t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1 ) {
224+ ticks . push ( t ) ;
225+ }
226+ }
227+
228+ // Ensure we have at least 2 ticks
229+ if ( ticks . length < 2 ) {
230+ return [ minTime , maxTime ] ;
231+ }
232+
233+ return ticks ;
234+ }
235+
137236/**
138237 * Formats a date for tooltips (always shows full precision)
139238 */
@@ -201,6 +300,10 @@ function tryParseDate(value: unknown): Date | null {
201300 *
202301 * When not grouped:
203302 * - Uses Y-axis columns directly as series
303+ *
304+ * For date-based x-axes:
305+ * - Uses numeric timestamps so the chart renders with a continuous time scale
306+ * - This ensures gaps in data are visually apparent
204307 */
205308function transformDataForChart (
206309 rows : Record < string , unknown > [ ] ,
@@ -209,7 +312,15 @@ function transformDataForChart(
209312 const { xAxisColumn, yAxisColumns, groupByColumn } = config ;
210313
211314 if ( ! xAxisColumn || yAxisColumns . length === 0 ) {
212- return { data : [ ] , series : [ ] , dateValues : [ ] } ;
315+ return {
316+ data : [ ] ,
317+ series : [ ] ,
318+ dateValues : [ ] ,
319+ isDateBased : false ,
320+ xDataKey : xAxisColumn || "" ,
321+ timeDomain : null ,
322+ timeTicks : null ,
323+ } ;
213324 }
214325
215326 // Collect date values for granularity detection
@@ -221,75 +332,104 @@ function transformDataForChart(
221332 }
222333 }
223334
224- // Determine if X-axis is date-based and detect granularity
225- const isDateBased = dateValues . length > 0 ;
335+ // Determine if X-axis is date-based (most values should be parseable as dates)
336+ const isDateBased = dateValues . length >= rows . length * 0.8 ; // At least 80% are dates
226337 const granularity = isDateBased ? detectTimeGranularity ( dateValues ) : "days" ;
227338
228- // Helper to format X value (keeps raw value for non-dates, formats dates)
339+ // For date-based axes, use a special key for the timestamp
340+ const xDataKey = isDateBased ? "__timestamp" : xAxisColumn ;
341+
342+ // Calculate time domain and ticks for date-based axes
343+ let timeDomain : [ number , number ] | null = null ;
344+ let timeTicks : number [ ] | null = null ;
345+ if ( isDateBased && dateValues . length > 0 ) {
346+ const timestamps = dateValues . map ( ( d ) => d . getTime ( ) ) ;
347+ const minTime = Math . min ( ...timestamps ) ;
348+ const maxTime = Math . max ( ...timestamps ) ;
349+ // Add a small padding (2% on each side) so points aren't at the very edge
350+ const padding = ( maxTime - minTime ) * 0.02 ;
351+ timeDomain = [ minTime - padding , maxTime + padding ] ;
352+ // Generate evenly-spaced ticks across the entire range using nice intervals
353+ timeTicks = generateTimeTicks ( minTime , maxTime ) ;
354+ }
355+
356+ // Helper to format X value for categorical axes (non-date)
229357 const formatX = ( value : unknown ) : string => {
230358 if ( value === null || value === undefined ) return "N/A" ;
231- const date = tryParseDate ( value ) ;
232- if ( date ) {
233- return formatDateByGranularity ( date , granularity ) ;
234- }
235359 return String ( value ) ;
236360 } ;
237361
238362 // No grouping: use Y columns directly as series
239363 if ( ! groupByColumn ) {
240- const data = rows . map ( ( row ) => {
241- const point : Record < string , unknown > = {
242- [ xAxisColumn ] : formatX ( row [ xAxisColumn ] ) ,
243- // Store raw date for tooltip
244- __rawDate : tryParseDate ( row [ xAxisColumn ] ) ,
245- __granularity : granularity ,
246- } ;
247- for ( const yCol of yAxisColumns ) {
248- point [ yCol ] = toNumber ( row [ yCol ] ) ;
249- }
250- return point ;
251- } ) ;
252-
253- return { data, series : yAxisColumns , dateValues } ;
364+ const data = rows
365+ . map ( ( row ) => {
366+ const rawDate = tryParseDate ( row [ xAxisColumn ] ) ;
367+ const point : Record < string , unknown > = {
368+ // For date-based, use timestamp; otherwise use formatted string
369+ [ xDataKey ] : isDateBased && rawDate ? rawDate . getTime ( ) : formatX ( row [ xAxisColumn ] ) ,
370+ // Store raw date and original value for tooltip
371+ __rawDate : rawDate ,
372+ __granularity : granularity ,
373+ __originalX : row [ xAxisColumn ] ,
374+ } ;
375+ for ( const yCol of yAxisColumns ) {
376+ point [ yCol ] = toNumber ( row [ yCol ] ) ;
377+ }
378+ return point ;
379+ } )
380+ // Filter out rows with invalid dates for date-based axes
381+ . filter ( ( point ) => ! isDateBased || point . __rawDate !== null ) ;
382+
383+ return { data, series : yAxisColumns , dateValues, isDateBased, xDataKey, timeDomain, timeTicks } ;
254384 }
255385
256386 // With grouping: pivot data so each group value becomes a series
257387 const yCol = yAxisColumns [ 0 ] ; // Use first Y column when grouping
258388 const groupValues = new Set < string > ( ) ;
259- const groupedByX = new Map < string , { values : Record < string , number > ; rawDate : Date | null } > ( ) ;
389+
390+ // For date-based, key by timestamp; otherwise by formatted string
391+ const groupedByX = new Map <
392+ string | number ,
393+ { values : Record < string , number > ; rawDate : Date | null ; originalX : unknown }
394+ > ( ) ;
260395
261396 for ( const row of rows ) {
262- const xValue = formatX ( row [ xAxisColumn ] ) ;
263397 const rawDate = tryParseDate ( row [ xAxisColumn ] ) ;
398+
399+ // Skip rows with invalid dates for date-based axes
400+ if ( isDateBased && ! rawDate ) continue ;
401+
402+ const xKey = isDateBased && rawDate ? rawDate . getTime ( ) : formatX ( row [ xAxisColumn ] ) ;
264403 const groupValue = String ( row [ groupByColumn ] ?? "Unknown" ) ;
265404 const yValue = toNumber ( row [ yCol ] ) ;
266405
267406 groupValues . add ( groupValue ) ;
268407
269- if ( ! groupedByX . has ( xValue ) ) {
270- groupedByX . set ( xValue , { values : { } , rawDate } ) ;
408+ if ( ! groupedByX . has ( xKey ) ) {
409+ groupedByX . set ( xKey , { values : { } , rawDate, originalX : row [ xAxisColumn ] } ) ;
271410 }
272411
273- const existing = groupedByX . get ( xValue ) ! ;
412+ const existing = groupedByX . get ( xKey ) ! ;
274413 // Sum values if there are multiple rows with same x + group
275414 existing . values [ groupValue ] = ( existing . values [ groupValue ] ?? 0 ) + yValue ;
276415 }
277416
278417 // Convert to array format
279418 const series = Array . from ( groupValues ) . sort ( ) ;
280- const data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xValue , { values, rawDate } ] ) => {
419+ const data = Array . from ( groupedByX . entries ( ) ) . map ( ( [ xKey , { values, rawDate, originalX } ] ) => {
281420 const point : Record < string , unknown > = {
282- [ xAxisColumn ] : xValue ,
421+ [ xDataKey ] : xKey ,
283422 __rawDate : rawDate ,
284423 __granularity : granularity ,
424+ __originalX : originalX ,
285425 } ;
286426 for ( const group of series ) {
287427 point [ group ] = values [ group ] ?? 0 ;
288428 }
289429 return point ;
290430 } ) ;
291431
292- return { data, series, dateValues } ;
432+ return { data, series, dateValues, isDateBased , xDataKey , timeDomain , timeTicks } ;
293433}
294434
295435function toNumber ( value : unknown ) : number {
@@ -363,20 +503,36 @@ export const QueryResultsChart = memo(function QueryResultsChart({
363503 data : unsortedData ,
364504 series,
365505 dateValues,
506+ isDateBased,
507+ xDataKey,
508+ timeDomain,
509+ timeTicks,
366510 } = useMemo ( ( ) => transformDataForChart ( rows , config ) , [ rows , config ] ) ;
367511
368- // Apply sorting
369- const data = useMemo (
370- ( ) => sortData ( unsortedData , sortByColumn , sortDirection ) ,
371- [ unsortedData , sortByColumn , sortDirection ]
372- ) ;
512+ // Apply sorting (for date-based, sort by timestamp to ensure correct order)
513+ const data = useMemo ( ( ) => {
514+ if ( isDateBased ) {
515+ // Always sort by timestamp for date-based axes
516+ return sortData ( unsortedData , xDataKey , "asc" ) ;
517+ }
518+ return sortData ( unsortedData , sortByColumn , sortDirection ) ;
519+ } , [ unsortedData , sortByColumn , sortDirection , isDateBased , xDataKey ] ) ;
373520
374521 // Detect time granularity for the data
375522 const timeGranularity = useMemo (
376523 ( ) => ( dateValues . length > 0 ? detectTimeGranularity ( dateValues ) : null ) ,
377524 [ dateValues ]
378525 ) ;
379526
527+ // X-axis tick formatter for date-based axes
528+ const xAxisTickFormatter = useMemo ( ( ) => {
529+ if ( ! isDateBased || ! timeGranularity ) return undefined ;
530+ return ( value : number ) => {
531+ const date = new Date ( value ) ;
532+ return formatDateByGranularity ( date , timeGranularity ) ;
533+ } ;
534+ } , [ isDateBased , timeGranularity ] ) ;
535+
380536 // Create dynamic Y-axis formatter based on data range
381537 const yAxisFormatter = useMemo ( ( ) => createYAxisFormatter ( data , series ) , [ data , series ] ) ;
382538
@@ -432,17 +588,36 @@ export const QueryResultsChart = memo(function QueryResultsChart({
432588 const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? - 45 : 0 ;
433589 const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined ;
434590
435- const xAxisProps = {
436- dataKey : xAxisColumn ,
437- fontSize : 12 ,
438- tickLine : false ,
439- tickMargin : 8 ,
440- axisLine : false ,
441- tick : { fill : "var(--color-text-dimmed)" } ,
442- angle : xAxisAngle ,
443- textAnchor : xAxisAngle !== 0 ? ( "end" as const ) : ( "middle" as const ) ,
444- height : xAxisHeight ,
445- } ;
591+ // Build xAxisProps - different config for date-based (continuous) vs categorical axes
592+ const xAxisProps = isDateBased
593+ ? {
594+ dataKey : xDataKey ,
595+ type : "number" as const ,
596+ domain : timeDomain ?? [ "auto" , "auto" ] ,
597+ scale : "time" as const ,
598+ // Explicitly specify tick positions so labels appear across the entire range
599+ ticks : timeTicks ?? undefined ,
600+ fontSize : 12 ,
601+ tickLine : false ,
602+ tickMargin : 8 ,
603+ axisLine : false ,
604+ tick : { fill : "var(--color-text-dimmed)" } ,
605+ tickFormatter : xAxisTickFormatter ,
606+ angle : xAxisAngle ,
607+ textAnchor : xAxisAngle !== 0 ? ( "end" as const ) : ( "middle" as const ) ,
608+ height : xAxisHeight ,
609+ }
610+ : {
611+ dataKey : xDataKey ,
612+ fontSize : 12 ,
613+ tickLine : false ,
614+ tickMargin : 8 ,
615+ axisLine : false ,
616+ tick : { fill : "var(--color-text-dimmed)" } ,
617+ angle : xAxisAngle ,
618+ textAnchor : xAxisAngle !== 0 ? ( "end" as const ) : ( "middle" as const ) ,
619+ height : xAxisHeight ,
620+ } ;
446621
447622 const yAxisProps = {
448623 fontSize : 12 ,
0 commit comments