import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { BarChart3, LineChart } from "lucide-react"; import { memo, useMemo } from "react"; import { useMeasure } from "react-use"; import { createValueFormatter } from "~/utils/columnFormat"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; import { selectEvenlySpacedIndices } from "~/components/primitives/charts/useXAxisTicks"; import { ChartBlankState } from "../primitives/charts/ChartBlankState"; import { Callout } from "../primitives/Callout"; import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; import { aggregateValues } from "../primitives/charts/aggregation"; import { getRunStatusChartColor } from "~/components/runs/v3/TaskRunStatus"; import { getSeriesColor } from "./chartColors"; const MAX_SERIES = 50; const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; // Width-aware x-axis label density for date-based line charts: reserve room for // the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX (smaller = denser). const TIME_AXIS_Y_ALLOWANCE_PX = 56; const TIME_AXIS_LABEL_SPACING_PX = 40; const MIN_TIME_AXIS_TICKS = 3; // Categorical (non-date) x-axis: thin labels to fit, middle-truncate long values // (run IDs, task names), and auto-rotate when labels are long. const X_LABEL_PX_PER_CHAR = 6.5; const X_LABEL_GAP_PX = 16; const MIN_CATEGORICAL_LABEL_PX = 36; // Labels longer than this rotate to -45°. const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10; // Middle-ellipsis cap for rotated labels (bounds axis height). const CATEGORICAL_ROTATED_MAX_CHARS = 14; // Rotated labels pack tighter than horizontal ones. const CATEGORICAL_ROTATED_LABEL_PX = 32; const CATEGORICAL_ROTATED_HEIGHT_PX = 80; const MIN_CATEGORICAL_TICKS = 2; /** * Shorten to `maxChars` with a middle ellipsis (e.g. "run_abc…f9c2"), preserving * the distinguishing tail for IDs that share a prefix. */ export function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) return value; if (maxChars <= 1) return value.slice(0, Math.max(0, maxChars)); const keep = maxChars - 1; // room for the ellipsis const head = Math.ceil(keep / 2); const tail = Math.floor(keep / 2); return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; } interface QueryResultsChartProps { rows: Record[]; columns: OutputColumnMetadata[]; config: ChartConfiguration; /** The effective time range from the query filter (used to show the full x-axis period) */ timeRange?: { from: string; to: string }; fullLegend?: boolean; /** Callback when "View all" legend button is clicked */ onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ legendScrollable?: boolean; isLoading?: boolean; } interface TransformedData { data: Record[]; series: string[]; /** Total number of series before any truncation (equals series.length when no truncation) */ totalSeriesCount: number; /** 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" }); } } /** * Snap a millisecond value up to the nearest "nice" interval */ function snapToNiceInterval(ms: number): number { const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; if (ms <= SECOND) return SECOND; if (ms <= 5 * SECOND) return 5 * SECOND; if (ms <= 10 * SECOND) return 10 * SECOND; if (ms <= 15 * SECOND) return 15 * SECOND; if (ms <= 30 * SECOND) return 30 * SECOND; if (ms <= MINUTE) return MINUTE; if (ms <= 5 * MINUTE) return 5 * MINUTE; if (ms <= 10 * MINUTE) return 10 * MINUTE; if (ms <= 15 * MINUTE) return 15 * MINUTE; if (ms <= 30 * MINUTE) return 30 * MINUTE; if (ms <= HOUR) return HOUR; if (ms <= 2 * HOUR) return 2 * HOUR; if (ms <= 4 * HOUR) return 4 * HOUR; if (ms <= 6 * HOUR) return 6 * HOUR; if (ms <= 12 * HOUR) return 12 * HOUR; if (ms <= DAY) return DAY; return ms; } /** * 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 24 * 60 * 60 * 1000; // Default to 1 day 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); return snapToNiceInterval(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[], xDataKey: string, series: string[], minTime: number, maxTime: number, interval: number, granularity: TimeGranularity, aggregation: AggregationType, maxPoints = 1000 ): Record[] { 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 = snapToNiceInterval(Math.ceil(range / maxPoints)); } // Create a map to collect values for each bucket (for aggregation) const bucketData = new Map< number, { values: Record; 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[] = []; 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 = { [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 null-filled data point so gaps appear in line/bar charts // and legend aggregations (avg/min/max) skip these slots const gapPoint: Record = { [xDataKey]: t, __rawDate: new Date(t), __granularity: granularity, __originalX: new Date(t).toISOString(), }; for (const s of series) { gapPoint[s] = null; } filledData.push(gapPoint); } } 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.) */ export 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 and legend headers. * Always includes time when the data point has a non-midnight time, * so hovering a specific bar at e.g. 14:00 shows the full timestamp * even when the axis labels only show the day. * Seconds are shown whenever the granularity is "seconds" or the * specific data point has non-zero seconds. */ function formatDateForTooltip(date: Date, granularity: TimeGranularity): string { const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0; const hasSeconds = date.getSeconds() !== 0; if ( granularity === "seconds" || (hasTime && granularity !== "months" && granularity !== "years") ) { return date.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit", second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined, hour12: false, }); } 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[], config: ChartConfiguration, timeRange?: { from: string; to: string } ): TransformedData { const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config; if (!xAxisColumn || yAxisColumns.length === 0) { return { data: [], series: [], totalSeriesCount: 0, 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) // When there are no results but a timeRange is provided, treat as date-based const isDateBased = rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates // Detect granularity from the full time range when available, otherwise from data const granularity = isDateBased ? timeRange ? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]) : 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 // When a timeRange is provided (from the query filter), use it so the chart // shows the full requested period rather than just the range of returned data. let timeDomain: [number, number] | null = null; let timeTicks: number[] | null = null; // Raw min/max used for gap filling (without padding) let rawMinTime = 0; let rawMaxTime = 0; if (isDateBased && (dateValues.length > 0 || timeRange)) { const dataTimestamps = dateValues.map((d) => d.getTime()); rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps); rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps); // Add a small padding (2% on each side) so points aren't at the very edge const padding = (rawMaxTime - rawMinTime) * 0.02; timeDomain = [rawMinTime - padding, rawMaxTime + padding]; // Generate evenly-spaced ticks across the entire range using nice intervals timeTicks = generateTimeTicks(rawMinTime, rawMaxTime); } // 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; 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 = { [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 const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES); const effectiveMaxPoints = Math.max( MIN_DATA_POINTS, Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget)) ); if (isDateBased && timeDomain) { const timestamps = dateValues.map((d) => d.getTime()); const dataInterval = detectDataInterval(timestamps); const rangeMs = rawMaxTime - rawMinTime; const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0; const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity; const effectiveInterval = Math.min( Math.max(dataInterval, minRangeInterval), maxRangeInterval ); data = fillTimeGaps( data, xDataKey, yAxisColumns, rawMinTime, rawMaxTime, effectiveInterval, granularity, aggregation, effectiveMaxPoints ); } else if (data.length > effectiveMaxPoints) { data = data.slice(0, effectiveMaxPoints); } return { data, series: yAxisColumns, totalSeriesCount: yAxisColumns.length, 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 // First pass: collect all values grouped by (xKey, groupValue) and accumulate // per-group totals so we can pick the top-N groups before building heavy data // objects with thousands of keys. const groupTotals = new Map(); const groupedByX = new Map< string | number, { values: Record; rawDate: Date | null; originalX: unknown } >(); for (const row of rows) { const rawDate = tryParseDate(row[xAxisColumn]); if (isDateBased && !rawDate) continue; const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]); const groupValue = String(row[groupByColumn] ?? "Unknown"); const yValue = toNumber(row[yCol]); groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue)); if (!groupedByX.has(xKey)) { groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] }); } const existing = groupedByX.get(xKey)!; if (!existing.values[groupValue]) { existing.values[groupValue] = []; } existing.values[groupValue].push(yValue); } // Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing // downstream (data objects, gap filling, legend totals, SVG rendering). const totalSeriesCount = groupTotals.size; let series: string[]; if (groupTotals.size <= MAX_SERIES) { series = Array.from(groupTotals.keys()).sort(); } else { series = Array.from(groupTotals.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, MAX_SERIES) .map(([key]) => key) .sort(); } // Convert to array format with aggregation applied (only for kept series) let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => { const point: Record = { [xDataKey]: xKey, __rawDate: rawDate, __granularity: granularity, __originalX: originalX, }; for (const group of series) { point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0; } return point; }); // Dynamic data-point budget based on the (already capped) series count const effectiveMaxPoints = Math.max( MIN_DATA_POINTS, Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length)) ); if (isDateBased && timeDomain) { const timestamps = dateValues.map((d) => d.getTime()); const dataInterval = detectDataInterval(timestamps); const rangeMs = rawMaxTime - rawMinTime; const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0; const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity; const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval); data = fillTimeGaps( data, xDataKey, series, rawMinTime, rawMaxTime, effectiveInterval, granularity, aggregation, effectiveMaxPoints ); } else if (data.length > effectiveMaxPoints) { data = data.slice(0, effectiveMaxPoints); } return { data, series, totalSeriesCount, 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; } /** * Sort data array by a specified column */ function sortData( data: Record[], sortByColumn: string | null, sortDirection: "asc" | "desc", xAxisColumn?: string | null ): Record[] { 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, timeRange, fullLegend = false, onViewAllLegendItems, isLoading = false, legendScrollable = false, }: QueryResultsChartProps) { const { xAxisColumn, yAxisColumns, chartType, groupByColumn, stacked, sortByColumn, sortDirection, } = config; // Transform data for charting const { data: unsortedData, series, totalSeriesCount, dateValues, isDateBased, xDataKey, timeDomain, timeTicks, } = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]); // Measure the chart width so x-axis label density adapts to it (continuous time // scale is kept, so data gaps still show). const [chartMeasureRef, { width: chartWidth }] = useMeasure(); // Width-aware time-axis ticks: choose how many clean intervals to render based // on width. Falls back to the default ticks until width is known (first paint). const widthAwareTimeTicks = useMemo(() => { if (!isDateBased || !timeDomain || !chartWidth) return timeTicks; const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX); const maxTicks = Math.max( MIN_TIME_AXIS_TICKS, Math.floor(plotWidth / TIME_AXIS_LABEL_SPACING_PX) ); return generateTimeTicks(timeDomain[0], timeDomain[1], maxTicks); }, [isDateBased, timeDomain, timeTicks, chartWidth]); // 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]); // Sort series by descending total sum so largest appears at bottom of // stacked charts and first in the legend const sortedSeries = useMemo(() => { if (series.length <= 1) return series; const totals = new Map(); for (const s of series) { let total = 0; for (const point of data) { const val = point[s]; if (typeof val === "number" && isFinite(val)) { total += Math.abs(val); } } totals.set(s, total); } return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0)); }, [series, data]); // Limit SVG-rendered series to MAX_SERIES (top N by total value) const visibleSeries = useMemo( () => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries), [sortedSeries] ); const seriesLimitCallout = totalSeriesCount > series.length ? (
{`Limited to the top ${ series.length } of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
) : null; // Detect time granularity — use the full time range when available so tick // labels are appropriate for the period (e.g. "Jan 5" for a 7-day range // instead of just "16:00:00" when data is sparse) const timeGranularity = useMemo(() => { if (timeRange) { return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]); } return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null; }, [dateValues, timeRange]); // X-axis tick formatter for date-based axes (pure – no deduplication). // Label deduplication is handled inside dateAxisTick below so that the // mutable "lastLabel" state is correctly reset on each Recharts render pass. const xAxisTickFormatter = useMemo(() => { if (!isDateBased || !timeGranularity) return undefined; return (value: number) => { const date = new Date(value); return formatDateByGranularity(date, timeGranularity); }; }, [isDateBased, timeGranularity]); // Resolve the Y-axis column format for formatting const yAxisFormat = useMemo(() => { if (yAxisColumns.length === 0) return undefined; const col = columns.find((c) => c.name === yAxisColumns[0]); return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined; }, [yAxisColumns, columns]); // Create dynamic Y-axis formatter based on data range and format const yAxisFormatter = useMemo( () => createYAxisFormatter(data, series, yAxisFormat), [data, series, yAxisFormat] ); // Create value formatter for tooltips and legend based on column format const tooltipValueFormatter = useMemo(() => createValueFormatter(yAxisFormat), [yAxisFormat]); // Check if the group-by column has a runStatus customRenderType const groupByIsRunStatus = useMemo(() => { if (!groupByColumn) return false; const col = columns.find((c) => c.name === groupByColumn); return col?.customRenderType === "runStatus"; }, [groupByColumn, columns]); // Build chart config for colors/labels const chartConfig = useMemo(() => { const cfg: ChartConfig = {}; sortedSeries.forEach((s, i) => { const statusColor = groupByIsRunStatus ? getRunStatusChartColor(s) : undefined; const originalIndex = config.yAxisColumns.indexOf(s); const colorIndex = originalIndex >= 0 ? originalIndex : i; cfg[s] = { label: s, color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex), }; }); return cfg; }, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]); // Custom tooltip label formatter for better date display const tooltipLabelFormatter = useMemo(() => { return (label: string, payload: Array<{ payload?: Record }>) => { // 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]); // Angle all date-based labels for consistent appearance and to avoid overlap const xAxisAngle = isDateBased ? -45 : 0; const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined; // Check if the data would produce duplicate labels at the current granularity. // Only use the custom tick renderer (with interval:0) when duplicates exist, // otherwise let Recharts handle label spacing to avoid collisions. const hasDuplicateLabels = useMemo(() => { if (!isDateBased || !timeGranularity || data.length === 0) return false; const labels = new Set(); for (const point of data) { const ts = point.__timestamp ?? point[xDataKey]; if (typeof ts === "number") { labels.add(formatDateByGranularity(new Date(ts), timeGranularity)); } } return labels.size < data.length; }, [isDateBased, timeGranularity, data, xDataKey]); // Custom tick renderer for date-based axes: renders a tick mark alongside // each label, and for unlabelled points (de-duplicated) just a subtle tick mark. // De-duplication lives here (not in xAxisTickFormatter) so that the mutable // lastLabel is reset when Recharts starts a new render pass (index === 0). const dateAxisTick = useMemo(() => { if (!isDateBased || !xAxisTickFormatter) return undefined; let lastLabel = ""; return (props: Record) => { const { x, y, payload, index } = props as { x: number; y: number; payload: { value: number }; index: number; }; // Reset dedup state at the start of each Recharts render pass if (index === 0) lastLabel = ""; const formatted = xAxisTickFormatter(payload.value); const label = formatted === lastLabel ? "" : formatted; lastLabel = formatted; // y is the tick text position, offset from the axis by tickMargin + internal padding const axisY = (y as number) - 12; if (label) { return ( {label} ); } // Small tick mark sitting on the axis baseline, pointing upward return ( ); }; }, [isDateBased, xAxisTickFormatter, xAxisAngle]); // Categorical x-axis: thin to fit width, middle-truncate long values, rotate when long. const categoricalXAxisProps = useMemo(() => { if (isDateBased) return null; const labels = data.map((d) => String(d[xDataKey] ?? "")); const maxLabelChars = labels.reduce((max, l) => Math.max(max, l.length), 0); const angled = maxLabelChars > CATEGORICAL_HORIZONTAL_MAX_CHARS; const tickFormatter = angled ? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS) : (value: unknown) => String(value ?? ""); // Rotated labels pack tighter; horizontal ones need roughly their own width. const perLabelPx = angled ? CATEGORICAL_ROTATED_LABEL_PX : Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX); const plotWidth = chartWidth > 0 ? Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX) : 0; const fitCount = plotWidth > 0 ? Math.max(MIN_CATEGORICAL_TICKS, Math.floor(plotWidth / perLabelPx)) : data.length; const ticks = fitCount < data.length ? selectEvenlySpacedIndices(data.length, fitCount).map((i) => data[i][xDataKey] as string) : undefined; return { tickFormatter, angle: angled ? -45 : 0, textAnchor: angled ? ("end" as const) : ("middle" as const), height: angled ? CATEGORICAL_ROTATED_HEIGHT_PX : undefined, ...(ticks ? { ticks, interval: 0 as const } : {}), }; }, [isDateBased, data, xDataKey, chartWidth]); // Validation — all hooks must be above this point const chartIcon = chartType === "bar" ? BarChart3 : LineChart; if (!xAxisColumn) { return ( ); } if (yAxisColumns.length === 0) { return ( ); } if (rows.length === 0) { return ; } if (data.length === 0) { return ; } // Base x-axis props shared by all chart types const baseXAxisProps = { ...(dateAxisTick ? { tick: dateAxisTick, tickLine: false, tickFormatter: undefined, // Only force every tick to render when there are duplicates to de-duplicate; // otherwise let Recharts auto-space to avoid label collisions ...(hasDuplicateLabels ? { interval: 0 } : {}), } : { 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: widthAwareTimeTicks ?? undefined, ...baseXAxisProps, } : (categoricalXAxisProps ?? 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 = isDateBased ? baseXAxisProps : (categoricalXAxisProps ?? baseXAxisProps); const yAxisProps = { tickFormatter: yAxisFormatter, domain: yAxisDomain, }; const showLegend = sortedSeries.length > 0; if (chartType === "bar") { return (
); } // Line or stacked area chart return (
1} tooltipLabelFormatter={tooltipLabelFormatter} tooltipValueFormatter={tooltipValueFormatter} lineType="linear" />
); }); /** * Creates a Y-axis value formatter based on the data range and optional format hint */ function createYAxisFormatter( data: Record[], series: string[], format?: ColumnFormatType ) { // 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; // Format-aware formatters if (format === "bytes" || format === "decimalBytes") { const divisor = format === "bytes" ? 1024 : 1000; const units = format === "bytes" ? ["B", "KiB", "MiB", "GiB", "TiB"] : ["B", "KB", "MB", "GB", "TB"]; return (value: number): string => { if (value === 0) return "0 B"; // Use consistent unit for all ticks based on max value const i = Math.min( Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))), units.length - 1 ); const scaled = value / Math.pow(divisor, i); return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`; }; } if (format === "percent") { return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`; } if (format === "duration") { return (value: number): string => formatDurationMilliseconds(value, { style: "short" }); } if (format === "durationSeconds") { return (value: number): string => formatDurationMilliseconds(value * 1000, { style: "short" }); } if (format === "durationNs") { return (value: number): string => formatDurationMilliseconds(value / 1_000_000, { style: "short" }); } if (format === "costInDollars" || format === "cost") { return (value: number): string => { const dollars = format === "cost" ? value / 100 : value; if (dollars === 0) return "$0"; if (Math.abs(dollars) >= 1000) return `$${(dollars / 1000).toFixed(1)}K`; if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`; if (Math.abs(dollars) >= 0.01) return `$${dollars.toFixed(4)}`; if (Math.abs(dollars) >= 0.0001) return `$${dollars.toFixed(6)}`; return formatCurrencyAccurate(dollars); }; } // Default formatter 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(); }; }