49df40cb11
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
990 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|