@@ -156,6 +156,29 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
156156 }
157157}
158158
159+ /**
160+ * Snap a millisecond value up to the nearest "nice" interval
161+ */
162+ function snapToNiceInterval ( ms : number ) : number {
163+ const MINUTE = 60 * 1000 ;
164+ const HOUR = 60 * MINUTE ;
165+ const DAY = 24 * HOUR ;
166+
167+ if ( ms <= MINUTE ) return MINUTE ;
168+ if ( ms <= 5 * MINUTE ) return 5 * MINUTE ;
169+ if ( ms <= 10 * MINUTE ) return 10 * MINUTE ;
170+ if ( ms <= 15 * MINUTE ) return 15 * MINUTE ;
171+ if ( ms <= 30 * MINUTE ) return 30 * MINUTE ;
172+ if ( ms <= HOUR ) return HOUR ;
173+ if ( ms <= 2 * HOUR ) return 2 * HOUR ;
174+ if ( ms <= 4 * HOUR ) return 4 * HOUR ;
175+ if ( ms <= 6 * HOUR ) return 6 * HOUR ;
176+ if ( ms <= 12 * HOUR ) return 12 * HOUR ;
177+ if ( ms <= DAY ) return DAY ;
178+
179+ return ms ;
180+ }
181+
159182/**
160183 * Detect the most common interval between consecutive data points
161184 * This helps us understand the natural granularity of the data
@@ -179,25 +202,7 @@ function detectDataInterval(timestamps: number[]): number {
179202 // We use the minimum gap as a heuristic for the data interval
180203 const minGap = Math . min ( ...gaps ) ;
181204
182- // Round to a nice interval
183- const MINUTE = 60 * 1000 ;
184- const HOUR = 60 * MINUTE ;
185- const DAY = 24 * HOUR ;
186-
187- // Snap to common intervals
188- if ( minGap <= MINUTE ) return MINUTE ;
189- if ( minGap <= 5 * MINUTE ) return 5 * MINUTE ;
190- if ( minGap <= 10 * MINUTE ) return 10 * MINUTE ;
191- if ( minGap <= 15 * MINUTE ) return 15 * MINUTE ;
192- if ( minGap <= 30 * MINUTE ) return 30 * MINUTE ;
193- if ( minGap <= HOUR ) return HOUR ;
194- if ( minGap <= 2 * HOUR ) return 2 * HOUR ;
195- if ( minGap <= 4 * HOUR ) return 4 * HOUR ;
196- if ( minGap <= 6 * HOUR ) return 6 * HOUR ;
197- if ( minGap <= 12 * HOUR ) return 12 * HOUR ;
198- if ( minGap <= DAY ) return DAY ;
199-
200- return minGap ;
205+ return snapToNiceInterval ( minGap ) ;
201206}
202207
203208/**
@@ -393,11 +398,18 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
393398}
394399
395400/**
396- * Formats a date for tooltips (always shows full precision)
401+ * Formats a date for tooltips and legend headers.
402+ * Always includes time when the data point has a non-midnight time,
403+ * so hovering a specific bar at e.g. 14:00 shows the full timestamp
404+ * even when the axis labels only show the day.
397405 */
398406function formatDateForTooltip ( date : Date , granularity : TimeGranularity ) : string {
399- // For shorter time ranges, include time
400- if ( granularity === "seconds" || granularity === "minutes" || granularity === "hours" ) {
407+ const hasTime = date . getHours ( ) !== 0 || date . getMinutes ( ) !== 0 || date . getSeconds ( ) !== 0 ;
408+
409+ if (
410+ granularity === "seconds" ||
411+ ( hasTime && granularity !== "months" && granularity !== "years" )
412+ ) {
401413 return date . toLocaleString ( "en-US" , {
402414 month : "short" ,
403415 day : "numeric" ,
@@ -408,7 +420,7 @@ function formatDateForTooltip(date: Date, granularity: TimeGranularity): string
408420 hour12 : false ,
409421 } ) ;
410422 }
411- // For longer ranges, just show date
423+
412424 return date . toLocaleDateString ( "en-US" , {
413425 month : "short" ,
414426 day : "numeric" ,
@@ -581,13 +593,18 @@ function transformDataForChart(
581593 if ( isDateBased && timeDomain ) {
582594 const timestamps = dateValues . map ( ( d ) => d . getTime ( ) ) ;
583595 const dataInterval = detectDataInterval ( timestamps ) ;
596+ // When filling across a full time range, ensure the interval is appropriate
597+ // for the range size (target ~150 points) so we don't create overly dense charts
598+ const rangeMs = rawMaxTime - rawMinTime ;
599+ const minRangeInterval = timeRange ? snapToNiceInterval ( rangeMs / 150 ) : 0 ;
600+ const effectiveInterval = Math . max ( dataInterval , minRangeInterval ) ;
584601 data = fillTimeGaps (
585602 data ,
586603 xDataKey ,
587604 yAxisColumns ,
588605 rawMinTime ,
589606 rawMaxTime ,
590- dataInterval ,
607+ effectiveInterval ,
591608 granularity ,
592609 aggregation
593610 ) ;
@@ -650,13 +667,18 @@ function transformDataForChart(
650667 if ( isDateBased && timeDomain ) {
651668 const timestamps = dateValues . map ( ( d ) => d . getTime ( ) ) ;
652669 const dataInterval = detectDataInterval ( timestamps ) ;
670+ // When filling across a full time range, ensure the interval is appropriate
671+ // for the range size (target ~150 points) so we don't create overly dense charts
672+ const rangeMs = rawMaxTime - rawMinTime ;
673+ const minRangeInterval = timeRange ? snapToNiceInterval ( rangeMs / 150 ) : 0 ;
674+ const effectiveInterval = Math . max ( dataInterval , minRangeInterval ) ;
653675 data = fillTimeGaps (
654676 data ,
655677 xDataKey ,
656678 series ,
657679 rawMinTime ,
658680 rawMaxTime ,
659- dataInterval ,
681+ effectiveInterval ,
660682 granularity ,
661683 aggregation
662684 ) ;
@@ -778,18 +800,28 @@ export const QueryResultsChart = memo(function QueryResultsChart({
778800 return sortData ( unsortedData , sortByColumn , sortDirection , xDataKey ) ;
779801 } , [ unsortedData , sortByColumn , sortDirection , isDateBased , xDataKey ] ) ;
780802
781- // Detect time granularity for the data
782- const timeGranularity = useMemo (
783- ( ) => ( dateValues . length > 0 ? detectTimeGranularity ( dateValues ) : null ) ,
784- [ dateValues ]
785- ) ;
803+ // Detect time granularity — use the full time range when available so tick
804+ // labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
805+ // instead of just "16:00:00" when data is sparse)
806+ const timeGranularity = useMemo ( ( ) => {
807+ if ( timeRange ) {
808+ return detectTimeGranularity ( [ new Date ( timeRange . from ) , new Date ( timeRange . to ) ] ) ;
809+ }
810+ return dateValues . length > 0 ? detectTimeGranularity ( dateValues ) : null ;
811+ } , [ dateValues , timeRange ] ) ;
786812
787813 // X-axis tick formatter for date-based axes
814+ // De-duplicates consecutive labels so e.g. "Feb 4" isn't repeated for every
815+ // data point within the same day
788816 const xAxisTickFormatter = useMemo ( ( ) => {
789817 if ( ! isDateBased || ! timeGranularity ) return undefined ;
818+ let lastLabel = "" ;
790819 return ( value : number ) => {
791820 const date = new Date ( value ) ;
792- return formatDateByGranularity ( date , timeGranularity ) ;
821+ const label = formatDateByGranularity ( date , timeGranularity ) ;
822+ if ( label === lastLabel ) return "" ;
823+ lastLabel = label ;
824+ return label ;
793825 } ;
794826 } , [ isDateBased , timeGranularity ] ) ;
795827
@@ -850,7 +882,54 @@ export const QueryResultsChart = memo(function QueryResultsChart({
850882 return [ min , "auto" ] as [ number , string ] ;
851883 } , [ data , series ] ) ;
852884
853- // Validation
885+ // Determine appropriate angle for X-axis labels based on granularity
886+ const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? - 45 : 0 ;
887+ const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined ;
888+
889+ // Custom tick renderer for date-based axes: shows either a text label or
890+ // a small tick mark, but never both. This avoids duplicate labels while
891+ // still giving visual markers for unlabelled data points.
892+ const dateAxisTick = useMemo ( ( ) => {
893+ if ( ! isDateBased || ! xAxisTickFormatter ) return undefined ;
894+ return ( props : Record < string , unknown > ) => {
895+ const { x, y, payload } = props as { x : number ; y : number ; payload : { value : number } } ;
896+ const label = xAxisTickFormatter ( payload . value ) ;
897+ // y is the tick text position, offset from the axis by tickMargin + internal padding
898+ const axisY = ( y as number ) - 12 ;
899+ if ( label ) {
900+ return (
901+ < g >
902+ < line x1 = { x as number } y1 = { axisY } x2 = { x as number } y2 = { axisY - 3 } stroke = "#878C99" strokeWidth = { 1 } />
903+ < text
904+ x = { x }
905+ y = { axisY }
906+ dy = { 16 }
907+ fill = "#878C99"
908+ fontSize = { 11 }
909+ textAnchor = { xAxisAngle !== 0 ? "end" : "middle" }
910+ style = { { fontVariantNumeric : "tabular-nums" } }
911+ transform = { xAxisAngle !== 0 ? `rotate(${ xAxisAngle } , ${ x } , ${ axisY + 16 } )` : undefined }
912+ >
913+ { label }
914+ </ text >
915+ </ g >
916+ ) ;
917+ }
918+ // Small tick mark sitting on the axis baseline, pointing upward
919+ return (
920+ < line
921+ x1 = { x as number }
922+ y1 = { axisY }
923+ x2 = { x as number }
924+ y2 = { axisY - 3 }
925+ stroke = "#272A2E"
926+ strokeWidth = { 1 }
927+ />
928+ ) ;
929+ } ;
930+ } , [ isDateBased , xAxisTickFormatter , xAxisAngle ] ) ;
931+
932+ // Validation — all hooks must be above this point
854933 if ( ! xAxisColumn ) {
855934 return < EmptyState message = "Select an X-axis column to display the chart" /> ;
856935 }
@@ -867,13 +946,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({
867946 return < EmptyState message = "Unable to transform data for chart" /> ;
868947 }
869948
870- // Determine appropriate angle for X-axis labels based on granularity
871- const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? - 45 : 0 ;
872- const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined ;
873-
874949 // Base x-axis props shared by all chart types
875950 const baseXAxisProps = {
876- tickFormatter : xAxisTickFormatter ,
951+ ...( dateAxisTick
952+ ? { tick : dateAxisTick , tickLine : false , tickFormatter : undefined , interval : 0 }
953+ : { tickFormatter : xAxisTickFormatter } ) ,
877954 angle : xAxisAngle ,
878955 textAnchor : xAxisAngle !== 0 ? ( "end" as const ) : ( "middle" as const ) ,
879956 height : xAxisHeight ,
0 commit comments