Skip to content

Commit 8bbd97a

Browse files
committed
Improved the date granularity and rendering with ticks
1 parent b24bf81 commit 8bbd97a

File tree

1 file changed

+114
-37
lines changed

1 file changed

+114
-37
lines changed

apps/webapp/app/components/code/QueryResultsChart.tsx

Lines changed: 114 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
398406
function 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

Comments
 (0)