Files
Matt Aitken 3056a51b82 Query improvements (#2905)
What changed
- Upgraded recharts to 2.15.2
- Added multiple chart types and components: big number, line, stacked,
bar (including zoomable & reference line), big dataset bar, and usage
graph
- Implemented custom legend with animated values, tooltip showing x-axis
data, and hover/highlight behaviors for stacks and legend
- Added loading, no-data, and invalid chart states plus loading spinners
and improved loading animations/layout
- Storybook integration: initial charts setup, separate chart files,
alphabetized menu, chart state toggles, and story updates
- Interaction & UX improvements: zooming (drag/select), crosshair
pointer, show/select dates while zooming, prevent text selection on
drag, hide mouse wheel zoom, capped legend items, axis/legend styling
tweaks, better spacing, and min-height for charts
- Data & state handling: moved date data to route for unified zooming,
moved chartState to main Chart component, moved hard-coded/mock data out
of components, and set chart data when zooming to start/end dates
- Performance & animation: turned off/reduced chart animations, sped up
animated numbers, removed hover transitions for bars
- New UI primitives and layout: Card component, small card updates, SVG
icons, improved segmented control and popover variants, table
improvements (resizable columns, filtering, sorting, scrolling fixes)
- Various fixes and polish: tooltip style fixes, legend value updates,
hover/leave state resets, bar width fixes for small datasets,
type/import fixes, and numerous small style/typo tweaks

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-01-21 13:07:07 +00:00

973 lines
30 KiB
TypeScript

import type { OutputColumnMetadata } from "@internal/clickhouse";
import { memo, useMemo } from "react";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
// Color palette for chart series
const CHART_COLORS = [
"#7655fd", // Primary purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
"#06b6d4", // Cyan
"#ec4899", // Pink
"#8b5cf6", // Violet
"#14b8a6", // Teal
"#f97316", // Orange
"#6366f1", // Indigo
];
function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: ChartConfiguration;
fullLegend?: boolean;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
}
interface TransformedData {
data: Record<string, unknown>[];
series: string[];
/** Raw date values for determining formatting granularity */
dateValues: Date[];
/** Whether the x-axis is date-based (continuous time scale) */
isDateBased: boolean;
/** The data key to use for x-axis (column name or '__timestamp' for dates) */
xDataKey: string;
/** Min/max timestamps for domain when date-based */
timeDomain: [number, number] | null;
/** Pre-calculated tick values for the time axis */
timeTicks: number[] | null;
}
/**
* Time granularity levels for date formatting
*/
type TimeGranularity = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years";
/**
* Determines the appropriate time granularity based on the date range
*/
function detectTimeGranularity(dates: Date[]): TimeGranularity {
if (dates.length < 2) return "days";
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
const minDate = sorted[0];
const maxDate = sorted[sorted.length - 1];
const rangeMs = maxDate.getTime() - minDate.getTime();
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
// Choose granularity based on range
if (rangeMs <= 5 * MINUTE) return "seconds"; // < 5 minutes → show seconds
if (rangeMs <= 2 * HOUR) return "minutes"; // < 2 hours → show minutes
if (rangeMs <= 2 * DAY) return "hours"; // < 2 days → show hours
if (rangeMs <= 2 * WEEK) return "days"; // < 2 weeks → show days
if (rangeMs <= 3 * MONTH) return "weeks"; // < 3 months → show weeks
if (rangeMs <= 2 * YEAR) return "months"; // < 2 years → show months
return "years"; // >= 2 years → show years
}
/**
* Formats a date for the X-axis based on the detected granularity
*/
function formatDateByGranularity(date: Date, granularity: TimeGranularity): string {
switch (granularity) {
case "seconds":
// "10:30:45"
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
case "minutes":
// "10:30"
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
case "hours":
// "Jan 15 10:00"
return `${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} ${date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}`;
case "days":
// "Jan 15"
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
case "weeks":
// "Jan 15"
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
case "months":
// "Jan 2024"
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
case "years":
// "2024"
return date.toLocaleDateString("en-US", { year: "numeric" });
default:
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
}
/**
* Detect the most common interval between consecutive data points
* This helps us understand the natural granularity of the data
*/
function detectDataInterval(timestamps: number[]): number {
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
const sorted = [...timestamps].sort((a, b) => a - b);
const gaps: number[] = [];
for (let i = 1; i < sorted.length; i++) {
const gap = sorted[i] - sorted[i - 1];
if (gap > 0) {
gaps.push(gap);
}
}
if (gaps.length === 0) return 60 * 1000;
// Find the most common small gap (this is likely the data's natural interval)
// We use the minimum gap as a heuristic for the data interval
const minGap = Math.min(...gaps);
// Round to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Snap to common intervals
if (minGap <= MINUTE) return MINUTE;
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
if (minGap <= HOUR) return HOUR;
if (minGap <= 2 * HOUR) return 2 * HOUR;
if (minGap <= 4 * HOUR) return 4 * HOUR;
if (minGap <= 6 * HOUR) return 6 * HOUR;
if (minGap <= 12 * HOUR) return 12 * HOUR;
if (minGap <= DAY) return DAY;
return minGap;
}
/**
* Fill in missing time slots with zero values
* This ensures the chart shows gaps as zeros rather than connecting distant points
*/
function fillTimeGaps(
data: Record<string, unknown>[],
xDataKey: string,
series: string[],
minTime: number,
maxTime: number,
interval: number,
granularity: TimeGranularity,
aggregation: AggregationType,
maxPoints = 1000
): Record<string, unknown>[] {
const range = maxTime - minTime;
const estimatedPoints = Math.ceil(range / interval);
// If filling would create too many points, increase the interval to stay within limits
let effectiveInterval = interval;
if (estimatedPoints > maxPoints) {
effectiveInterval = Math.ceil(range / maxPoints);
// Round up to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
else effectiveInterval = 24 * HOUR;
}
// Create a map to collect values for each bucket (for aggregation)
const bucketData = new Map<
number,
{ values: Record<string, number[]>; rawDate: Date; originalX: string }
>();
for (const point of data) {
const timestamp = point[xDataKey] as number;
// Bucket to the nearest interval
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
if (!bucketData.has(bucketedTime)) {
bucketData.set(bucketedTime, {
values: Object.fromEntries(series.map((s) => [s, []])),
rawDate: new Date(bucketedTime),
originalX: new Date(bucketedTime).toISOString(),
});
}
const bucket = bucketData.get(bucketedTime)!;
for (const s of series) {
const val = point[s] as number;
if (typeof val === "number") {
bucket.values[s].push(val);
}
}
}
// Generate all time slots and fill with zeros where missing
const filledData: Record<string, unknown>[] = [];
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
const bucket = bucketData.get(t);
if (bucket) {
// Apply aggregation to collected values
const point: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: bucket.rawDate,
__granularity: granularity,
__originalX: bucket.originalX,
};
for (const s of series) {
point[s] = aggregateValues(bucket.values[s], aggregation);
}
filledData.push(point);
} else {
// Create a zero-filled data point
const zeroPoint: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: new Date(t),
__granularity: granularity,
__originalX: new Date(t).toISOString(),
};
for (const s of series) {
zeroPoint[s] = 0;
}
filledData.push(zeroPoint);
}
}
return filledData;
}
/**
* "Nice" intervals for time axes - these create human-friendly tick marks
*/
const NICE_TIME_INTERVALS = [
{ value: 1000, label: "1s" }, // 1 second
{ value: 5 * 1000, label: "5s" }, // 5 seconds
{ value: 10 * 1000, label: "10s" }, // 10 seconds
{ value: 30 * 1000, label: "30s" }, // 30 seconds
{ value: 60 * 1000, label: "1m" }, // 1 minute
{ value: 5 * 60 * 1000, label: "5m" }, // 5 minutes
{ value: 10 * 60 * 1000, label: "10m" }, // 10 minutes
{ value: 15 * 60 * 1000, label: "15m" }, // 15 minutes
{ value: 30 * 60 * 1000, label: "30m" }, // 30 minutes
{ value: 60 * 60 * 1000, label: "1h" }, // 1 hour
{ value: 2 * 60 * 60 * 1000, label: "2h" }, // 2 hours
{ value: 3 * 60 * 60 * 1000, label: "3h" }, // 3 hours
{ value: 4 * 60 * 60 * 1000, label: "4h" }, // 4 hours
{ value: 6 * 60 * 60 * 1000, label: "6h" }, // 6 hours
{ value: 12 * 60 * 60 * 1000, label: "12h" }, // 12 hours
{ value: 24 * 60 * 60 * 1000, label: "1d" }, // 1 day
{ value: 2 * 24 * 60 * 60 * 1000, label: "2d" }, // 2 days
{ value: 7 * 24 * 60 * 60 * 1000, label: "1w" }, // 1 week
{ value: 14 * 24 * 60 * 60 * 1000, label: "2w" }, // 2 weeks
{ value: 30 * 24 * 60 * 60 * 1000, label: "1mo" }, // ~1 month
{ value: 90 * 24 * 60 * 60 * 1000, label: "3mo" }, // ~3 months
{ value: 180 * 24 * 60 * 60 * 1000, label: "6mo" }, // ~6 months
{ value: 365 * 24 * 60 * 60 * 1000, label: "1y" }, // 1 year
];
/**
* Generate evenly-spaced tick values for a time axis using "nice" intervals
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
*/
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
const range = maxTime - minTime;
if (range <= 0) {
return [minTime];
}
// Find the best "nice" interval that gives us a reasonable number of ticks
// Target: between 4 and maxTicks ticks
let chosenInterval = NICE_TIME_INTERVALS[NICE_TIME_INTERVALS.length - 1].value;
for (const { value: interval } of NICE_TIME_INTERVALS) {
const tickCount = Math.ceil(range / interval);
if (tickCount <= maxTicks && tickCount >= 2) {
chosenInterval = interval;
break;
}
}
// Align the start tick to a nice boundary
// For intervals >= 1 day, align to midnight
// For intervals >= 1 hour, align to hour boundary
// For intervals >= 1 minute, align to minute boundary
const DAY = 24 * 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000;
const MINUTE = 60 * 1000;
let alignTo: number;
if (chosenInterval >= DAY) {
// Align to midnight UTC (or we could use local midnight)
alignTo = DAY;
} else if (chosenInterval >= HOUR) {
alignTo = chosenInterval; // Align to the interval itself for hours
} else if (chosenInterval >= MINUTE) {
alignTo = chosenInterval;
} else {
alignTo = chosenInterval;
}
// Round down to the alignment boundary, then find first tick at or before minTime
const startTick = Math.floor(minTime / alignTo) * alignTo;
// Generate ticks
const ticks: number[] = [];
for (let t = startTick; t <= maxTime + chosenInterval; t += chosenInterval) {
if (t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1) {
ticks.push(t);
}
}
// Ensure we have at least 2 ticks
if (ticks.length < 2) {
return [minTime, maxTime];
}
return ticks;
}
/**
* Formats a date for tooltips (always shows full precision)
*/
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
// For shorter time ranges, include time
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: granularity === "seconds" ? "2-digit" : undefined,
hour12: false,
});
}
// For longer ranges, just show date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Try to parse a value as a Date
*/
function tryParseDate(value: unknown): Date | null {
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
if (typeof value === "number") {
// First, try treating the number as milliseconds
const dateAsMs = new Date(value);
if (
!isNaN(dateAsMs.getTime()) &&
dateAsMs.getFullYear() >= 1970 &&
dateAsMs.getFullYear() <= 2100
) {
return dateAsMs;
}
// If that fails, try treating the number as seconds (Unix timestamp)
const dateAsSec = new Date(value * 1000);
if (
!isNaN(dateAsSec.getTime()) &&
dateAsSec.getFullYear() >= 1970 &&
dateAsSec.getFullYear() <= 2100
) {
return dateAsSec;
}
}
return null;
}
/**
* Transform raw query results into chart-ready data
*
* When grouped:
* - Pivots data so each unique group value becomes a separate series
* - Each row in output has xAxis value + one key per group value
*
* When not grouped:
* - Uses Y-axis columns directly as series
*
* For date-based x-axes:
* - Uses numeric timestamps so the chart renders with a continuous time scale
* - This ensures gaps in data are visually apparent
*/
function transformDataForChart(
rows: Record<string, unknown>[],
config: ChartConfiguration
): TransformedData {
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
if (!xAxisColumn || yAxisColumns.length === 0) {
return {
data: [],
series: [],
dateValues: [],
isDateBased: false,
xDataKey: xAxisColumn || "",
timeDomain: null,
timeTicks: null,
};
}
// Collect date values for granularity detection
const dateValues: Date[] = [];
for (const row of rows) {
const date = tryParseDate(row[xAxisColumn]);
if (date) {
dateValues.push(date);
}
}
// Determine if X-axis is date-based (most values should be parseable as dates)
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
// For date-based axes, use a special key for the timestamp
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
// Calculate time domain and ticks for date-based axes
let timeDomain: [number, number] | null = null;
let timeTicks: number[] | null = null;
if (isDateBased && dateValues.length > 0) {
const timestamps = dateValues.map((d) => d.getTime());
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
// Add a small padding (2% on each side) so points aren't at the very edge
const padding = (maxTime - minTime) * 0.02;
timeDomain = [minTime - padding, maxTime + padding];
// Generate evenly-spaced ticks across the entire range using nice intervals
timeTicks = generateTimeTicks(minTime, maxTime);
}
// Helper to format X value for categorical axes (non-date)
const formatX = (value: unknown): string => {
if (value === null || value === undefined) return "N/A";
return String(value);
};
// No grouping: use Y columns directly as series
// Group rows by X value first, then aggregate
if (!groupByColumn) {
// Group rows by X-axis value to handle duplicates
const groupedByX = new Map<
string | number,
{ yValues: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
>();
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, {
yValues: Object.fromEntries(yAxisColumns.map((col) => [col, []])),
rawDate,
originalX: row[xAxisColumn],
});
}
const existing = groupedByX.get(xKey)!;
for (const yCol of yAxisColumns) {
existing.yValues[yCol].push(toNumber(row[yCol]));
}
}
// Convert to array format with aggregation applied
let data = Array.from(groupedByX.entries()).map(([xKey, { yValues, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
__rawDate: rawDate,
__granularity: granularity,
__originalX: originalX,
};
for (const yCol of yAxisColumns) {
point[yCol] = aggregateValues(yValues[yCol], aggregation);
}
return point;
});
// Fill in gaps with zeros for date-based data
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
data = fillTimeGaps(
data,
xDataKey,
yAxisColumns,
timeDomain[0],
timeDomain[1],
dataInterval,
granularity,
aggregation
);
}
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
}
// With grouping: pivot data so each group value becomes a series
const yCol = yAxisColumns[0]; // Use first Y column when grouping
const groupValues = new Set<string>();
// For date-based, key by timestamp; otherwise by formatted string
// Collect all values for aggregation
const groupedByX = new Map<
string | number,
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
>();
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
const groupValue = String(row[groupByColumn] ?? "Unknown");
const yValue = toNumber(row[yCol]);
groupValues.add(groupValue);
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
}
const existing = groupedByX.get(xKey)!;
// Collect values for aggregation
if (!existing.values[groupValue]) {
existing.values[groupValue] = [];
}
existing.values[groupValue].push(yValue);
}
// Convert to array format with aggregation applied
const series = Array.from(groupValues).sort();
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
__rawDate: rawDate,
__granularity: granularity,
__originalX: originalX,
};
for (const group of series) {
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
}
return point;
});
// Fill in gaps with zeros for date-based data
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
data = fillTimeGaps(
data,
xDataKey,
series,
timeDomain[0],
timeDomain[1],
dataInterval,
granularity,
aggregation
);
}
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
/**
* Sort data array by a specified column
*/
function sortData(
data: Record<string, unknown>[],
sortByColumn: string | null,
sortDirection: "asc" | "desc",
xAxisColumn?: string | null
): Record<string, unknown>[] {
if (!sortByColumn) return data;
return [...data].sort((a, b) => {
const aVal = a[sortByColumn];
const bVal = b[sortByColumn];
// Handle null/undefined
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortDirection === "asc" ? -1 : 1;
if (bVal == null) return sortDirection === "asc" ? 1 : -1;
// Only use date comparison when sorting by the X-axis column
if (sortByColumn === xAxisColumn) {
const aDate = a.__rawDate as Date | null;
const bDate = b.__rawDate as Date | null;
if (aDate && bDate) {
const diff = aDate.getTime() - bDate.getTime();
return sortDirection === "asc" ? diff : -diff;
}
}
// Compare as numbers if possible
const aNum = typeof aVal === "number" ? aVal : parseFloat(String(aVal));
const bNum = typeof bVal === "number" ? bVal : parseFloat(String(bVal));
if (!isNaN(aNum) && !isNaN(bNum)) {
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
}
// Fall back to string comparison
const aStr = String(aVal);
const bStr = String(bVal);
const cmp = aStr.localeCompare(bStr);
return sortDirection === "asc" ? cmp : -cmp;
});
}
export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
fullLegend = false,
onViewAllLegendItems,
}: QueryResultsChartProps) {
const {
xAxisColumn,
yAxisColumns,
chartType,
groupByColumn,
stacked,
sortByColumn,
sortDirection,
} = config;
// Transform data for charting
const {
data: unsortedData,
series,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
if (isDateBased) {
// Always sort by timestamp for date-based axes
return sortData(unsortedData, xDataKey, "asc", xDataKey);
}
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
// Detect time granularity for the data
const timeGranularity = useMemo(
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
[dateValues]
);
// X-axis tick formatter for date-based axes
const xAxisTickFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: number) => {
const date = new Date(value);
return formatDateByGranularity(date, timeGranularity);
};
}, [isDateBased, timeGranularity]);
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Build chart config for colors/labels
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
series.forEach((s, i) => {
cfg[s] = {
label: s,
color: getSeriesColor(i),
};
});
return cfg;
}, [series]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
return (label: string, payload: Array<{ payload?: Record<string, unknown> }>) => {
// Try to get the raw date from the payload for better formatting
const rawDate = payload[0]?.payload?.__rawDate as Date | null | undefined;
const granularity = payload[0]?.payload?.__granularity as TimeGranularity | undefined;
if (rawDate && granularity) {
return formatDateForTooltip(rawDate, granularity);
}
return label;
};
}, []);
// Label formatter for the legend (formats x-axis values)
const legendLabelFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: string) => {
// For date-based axes, the value is a timestamp
const timestamp = Number(value);
if (!isNaN(timestamp)) {
const date = new Date(timestamp);
return formatDateForTooltip(date, timeGranularity);
}
return value;
};
}, [isDateBased, timeGranularity]);
// Y-axis domain calculation - must be before early returns to maintain consistent hook order
const yAxisDomain = useMemo(() => {
let min = 0;
for (const point of data) {
for (const s of series) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
min = Math.min(min, val);
}
}
}
return [min, "auto"] as [number, string];
}, [data, series]);
// Validation
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
}
if (yAxisColumns.length === 0) {
return <EmptyState message="Select a Y-axis column to display the chart" />;
}
if (rows.length === 0) {
return <EmptyState message="No data to display" />;
}
if (data.length === 0) {
return <EmptyState message="Unable to transform data for chart" />;
}
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Base x-axis props shared by all chart types
const baseXAxisProps = {
tickFormatter: xAxisTickFormatter,
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
};
// Line charts use continuous time scale for date-based data
// This properly represents time gaps between data points
const xAxisPropsForLine = isDateBased
? {
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
: baseXAxisProps;
// Bar charts always use categorical axis positioning
// This ensures bars are evenly distributed regardless of data point count
// (prevents massive bars when there are only a few data points)
const xAxisPropsForBar = baseXAxisProps;
const yAxisProps = {
tickFormatter: yAxisFormatter,
domain: yAxisDomain,
};
const showLegend = series.length > 0;
if (chartType === "bar") {
return (
<Chart.Root
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
/>
</Chart.Root>
);
}
// Line or stacked area chart
return (
<Chart.Root
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stacked={stacked && series.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
lineType="linear"
/>
</Chart.Root>
);
});
/**
* Creates a Y-axis value formatter based on the data range
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
for (const point of data) {
for (const s of series) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
minVal = Math.min(minVal, val);
maxVal = Math.max(maxVal, val);
}
}
}
const range = maxVal - minVal;
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`;
}
// Determine decimal places based on range
if (range === 0 || !isFinite(range)) {
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
}
// For small ranges, show more precision
if (range < 0.01) {
return value.toFixed(4);
}
if (range < 0.1) {
return value.toFixed(3);
}
if (range < 10) {
return value.toFixed(2);
}
if (range < 100) {
return value.toFixed(1);
}
// For large ranges, no decimals
return Math.round(value).toString();
};
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center">
<Paragraph variant="small" className="text-text-dimmed">
{message}
</Paragraph>
</div>
);
}