Files
triggerdotdev--trigger.dev/apps/webapp/app/components/code/QueryResultsChart.tsx
T
Matt Aitken 49df40cb11 TRQL and the Query page (#2843)
TRQL (pronounced Treacle like the delicious British dark sweet syrup) is
the TRiggerQueryLanguage. It allows users to safely write queries on
their data. The queries are safely turned into ClickHouse queries which
are tenant-safe and not SQL injectable.


https://github.com/user-attachments/assets/bbfca473-b3fc-4150-8fe6-79e8840a2d29

This started out as a translation of HogQL by PostHog from Python to
TypeScript.

Features
- Tenant safe queries.
- Many underlying ClickHouse features including functions and
aggregations.
- Virtual columns, which are exposed to users as real columns but are
actually expressions.
- Transformations of data types and where clauses.
- Simple JSON path querying.
- Limits on execution time.
- Reporting of query statistics.

## Query page

There’s a new Query page (currently behind a feature flag) where you can
write TRQL queries and execute them against your environment, project or
organization.

Features
- Executing TRQL queries
- Syntax highlighting and errors
- Autocomplete
- AI generation/editing of queries
- Help and examples
- Table with auto-inferred data types from the table schema
- Table cell renderers for our special types like Run ids, environments,
machines, tasks, queues, etc.
- Copy/export as CSV/JSON
- Line and bar graphs with grouping and stacking
- History of queries
2026-01-09 11:39:36 +00:00

990 lines
30 KiB
TypeScript

import type { OutputColumnMetadata } from "@internal/clickhouse";
import { memo, useMemo } from "react";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "~/components/primitives/Chart";
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;
}
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,
}: 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;
};
}, []);
// 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" />;
}
const commonProps = {
data,
margin: { top: 10, right: 10, left: 10, bottom: 10 },
};
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Build xAxisProps - different config for date-based (continuous) vs categorical axes
const xAxisProps = isDateBased
? {
dataKey: xDataKey,
type: "number" as const,
domain: timeDomain ?? ["auto", "auto"],
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
tickFormatter: xAxisTickFormatter,
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
}
: {
dataKey: xDataKey,
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
};
const yAxisProps = {
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
tickFormatter: yAxisFormatter,
};
return (
<ChartContainer config={chartConfig} className="h-full min-h-[300px] w-full">
{chartType === "bar" ? (
<BarChart {...commonProps}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip
content={<ChartTooltipContent />}
labelFormatter={tooltipLabelFormatter}
cursor={{ fill: "var(--color-charcoal-800)", opacity: 0.5 }}
/>
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{series.map((s, i) => (
<Bar
key={s}
dataKey={s}
fill={getSeriesColor(i)}
stackId={stacked ? "stack" : undefined}
radius={stacked ? [0, 0, 0, 0] : [4, 4, 0, 0]}
/>
))}
</BarChart>
) : stacked && series.length > 1 ? (
<AreaChart {...commonProps} stackOffset="none">
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip
content={<ChartTooltipContent indicator="line" />}
labelFormatter={tooltipLabelFormatter}
/>
<ChartLegend content={<ChartLegendContent />} />
{series.map((s, i) => (
<Area
key={s}
type="linear"
dataKey={s}
stroke={getSeriesColor(i)}
fill={getSeriesColor(i)}
fillOpacity={0.6}
strokeWidth={2}
stackId="stack"
/>
))}
</AreaChart>
) : (
<LineChart {...commonProps}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip content={<ChartTooltipContent />} labelFormatter={tooltipLabelFormatter} />
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{series.map((s, i) => (
<Line
key={s}
type="linear"
dataKey={s}
stroke={getSeriesColor(i)}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
)}
</ChartContainer>
);
});
/**
* 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>
);
}