1346 lines
45 KiB
TypeScript
1346 lines
45 KiB
TypeScript
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<string, unknown>[];
|
||
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<string, unknown>[];
|
||
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<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 = snapToNiceInterval(Math.ceil(range / maxPoints));
|
||
}
|
||
|
||
// 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 null-filled data point so gaps appear in line/bar charts
|
||
// and legend aggregations (avg/min/max) skip these slots
|
||
const gapPoint: Record<string, unknown> = {
|
||
[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<string, unknown>[],
|
||
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<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
|
||
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<string, number>();
|
||
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]);
|
||
|
||
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<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;
|
||
});
|
||
|
||
// 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<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,
|
||
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<HTMLDivElement>();
|
||
|
||
// 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<string, number>();
|
||
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 ? (
|
||
<div className="mt-1 px-2">
|
||
<Callout variant="warning">
|
||
{`Limited to the top ${
|
||
series.length
|
||
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
|
||
</Callout>
|
||
</div>
|
||
) : 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<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]);
|
||
|
||
// 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<string>();
|
||
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<string, unknown>) => {
|
||
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 (
|
||
<g>
|
||
<line
|
||
x1={x as number}
|
||
y1={axisY}
|
||
x2={x as number}
|
||
y2={axisY - 3}
|
||
stroke="var(--color-text-dimmed)"
|
||
strokeWidth={1}
|
||
/>
|
||
<text
|
||
x={x}
|
||
y={axisY}
|
||
dy={10}
|
||
fill="var(--color-text-dimmed)"
|
||
fontSize={11}
|
||
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
|
||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||
transform={
|
||
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
|
||
}
|
||
>
|
||
{label}
|
||
</text>
|
||
</g>
|
||
);
|
||
}
|
||
// Small tick mark sitting on the axis baseline, pointing upward
|
||
return (
|
||
<line
|
||
x1={x as number}
|
||
y1={axisY}
|
||
x2={x as number}
|
||
y2={axisY - 3}
|
||
stroke="var(--color-grid-bright)"
|
||
strokeWidth={1}
|
||
/>
|
||
);
|
||
};
|
||
}, [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 (
|
||
<ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />
|
||
);
|
||
}
|
||
|
||
if (yAxisColumns.length === 0) {
|
||
return (
|
||
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
|
||
);
|
||
}
|
||
|
||
if (rows.length === 0) {
|
||
return <ChartBlankState icon={chartIcon} message="No data to display" />;
|
||
}
|
||
|
||
if (data.length === 0) {
|
||
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
|
||
}
|
||
|
||
// 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 (
|
||
<div ref={chartMeasureRef} className="h-full w-full">
|
||
<Chart.Root
|
||
config={chartConfig}
|
||
data={data}
|
||
dataKey={xDataKey}
|
||
series={sortedSeries}
|
||
visibleSeries={visibleSeries}
|
||
labelFormatter={legendLabelFormatter}
|
||
showLegend={showLegend}
|
||
maxLegendItems={fullLegend ? Infinity : 5}
|
||
legendAggregation={config.aggregation}
|
||
legendValueFormatter={tooltipValueFormatter}
|
||
minHeight="300px"
|
||
fillContainer
|
||
onViewAllLegendItems={onViewAllLegendItems}
|
||
legendScrollable={legendScrollable}
|
||
state={isLoading ? "loading" : "loaded"}
|
||
beforeLegend={seriesLimitCallout}
|
||
>
|
||
<Chart.Bar
|
||
xAxisProps={xAxisPropsForBar}
|
||
yAxisProps={yAxisProps}
|
||
stackId={stacked ? "stack" : undefined}
|
||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||
tooltipValueFormatter={tooltipValueFormatter}
|
||
/>
|
||
</Chart.Root>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Line or stacked area chart
|
||
return (
|
||
<div ref={chartMeasureRef} className="h-full w-full">
|
||
<Chart.Root
|
||
config={chartConfig}
|
||
data={data}
|
||
dataKey={xDataKey}
|
||
series={sortedSeries}
|
||
visibleSeries={visibleSeries}
|
||
labelFormatter={legendLabelFormatter}
|
||
showLegend={showLegend}
|
||
maxLegendItems={fullLegend ? Infinity : 5}
|
||
legendAggregation={config.aggregation}
|
||
legendValueFormatter={tooltipValueFormatter}
|
||
minHeight="300px"
|
||
fillContainer
|
||
onViewAllLegendItems={onViewAllLegendItems}
|
||
legendScrollable={legendScrollable}
|
||
state={isLoading ? "loading" : "loaded"}
|
||
beforeLegend={seriesLimitCallout}
|
||
>
|
||
<Chart.Line
|
||
xAxisProps={xAxisPropsForLine}
|
||
yAxisProps={yAxisProps}
|
||
stacked={stacked && visibleSeries.length > 1}
|
||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||
tooltipValueFormatter={tooltipValueFormatter}
|
||
lineType="linear"
|
||
/>
|
||
</Chart.Root>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Creates a Y-axis value formatter based on the data range and optional format hint
|
||
*/
|
||
function createYAxisFormatter(
|
||
data: Record<string, unknown>[],
|
||
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();
|
||
};
|
||
}
|