import type { OutputColumnMetadata } from "@internal/clickhouse"; import { IconSortAscending, IconSortDescending } from "@tabler/icons-react"; import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "~/utils/cn"; import { type AggregationType, type ChartConfiguration, type SortDirection, } from "../metrics/QueryWidget"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover"; import SegmentedControl from "../primitives/SegmentedControl"; import { Select, SelectItem } from "../primitives/Select"; import { Switch } from "../primitives/Switch"; import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors"; export const defaultChartConfig: ChartConfiguration = { chartType: "bar", xAxisColumn: null, yAxisColumns: [], groupByColumn: null, stacked: false, sortByColumn: null, sortDirection: "asc", aggregation: "sum", seriesColors: {}, }; interface ChartConfigPanelProps { columns: OutputColumnMetadata[]; config: ChartConfiguration; onChange: (config: ChartConfiguration) => void; className?: string; } // Type detection helpers function isNumericType(type: string): boolean { return ( type.startsWith("Int") || type.startsWith("UInt") || type.startsWith("Float") || type.startsWith("Decimal") || type.startsWith("Nullable(Int") || type.startsWith("Nullable(UInt") || type.startsWith("Nullable(Float") || type.startsWith("Nullable(Decimal") ); } function isDateTimeType(type: string): boolean { return ( type === "DateTime" || type === "DateTime64" || type === "Date" || type === "Date32" || type.startsWith("DateTime64(") || type.startsWith("Nullable(DateTime") || type.startsWith("Nullable(Date") ); } function isStringType(type: string): boolean { return ( type === "String" || type === "LowCardinality(String)" || type === "Nullable(String)" || type.startsWith("Enum") || type.startsWith("FixedString") ); } export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) { // Categorize columns by type const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => { const numeric: OutputColumnMetadata[] = []; const dateTime: OutputColumnMetadata[] = []; const categorical: OutputColumnMetadata[] = []; for (const col of columns) { if (isNumericType(col.type)) { numeric.push(col); } if (isDateTimeType(col.type)) { dateTime.push(col); } if (isStringType(col.type) || isDateTimeType(col.type)) { categorical.push(col); } } return { numericColumns: numeric, dateTimeColumns: dateTime, categoricalColumns: categorical, allColumns: columns, }; }, [columns]); // Create a stable key from column names and types to detect actual changes const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]); // Use refs to access current config/onChange without adding them as dependencies const configRef = useRef(config); const onChangeRef = useRef(onChange); useEffect(() => { configRef.current = config; onChangeRef.current = onChange; }); // Auto-select defaults when columns change useEffect(() => { if (columns.length === 0) return; const currentConfig = configRef.current; let needsUpdate = false; const updates: Partial = {}; // Auto-select X-axis (prefer datetime, then first categorical) if (!currentConfig.xAxisColumn) { const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0]; if (defaultX) { updates.xAxisColumn = defaultX.name; needsUpdate = true; } } // Auto-select Y-axis (first numeric column) if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) { updates.yAxisColumns = [numericColumns[0].name]; needsUpdate = true; } // Determine the effective x-axis column (either existing or newly selected) const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn; // Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured if ( effectiveXAxis && !currentConfig.sortByColumn && dateTimeColumns.some((col) => col.name === effectiveXAxis) ) { updates.sortByColumn = effectiveXAxis; updates.sortDirection = "asc"; needsUpdate = true; } if (needsUpdate) { onChangeRef.current({ ...currentConfig, ...updates }); } // Only re-run when the actual column structure changes, not on every config change. // columnsKey (a string) is stable when columns match, so this won't re-fire // unnecessarily when the same query is re-run with identical columns. // eslint-disable-next-line react-hooks/exhaustive-deps }, [columnsKey]); const updateConfig = useCallback( (updates: Partial) => { onChange({ ...config, ...updates }); }, [config, onChange] ); // X-axis options: prefer datetime and string columns at the top const xAxisOptions = useMemo(() => { const preferred = [ ...dateTimeColumns, ...categoricalColumns.filter((c) => !isDateTimeType(c.type)), ]; const preferredNames = new Set(preferred.map((c) => c.name)); const other = allColumns.filter((c) => !preferredNames.has(c.name)); const options: Array<{ value: string; label: string; type: string }> = []; for (const col of preferred) { options.push({ value: col.name, label: col.name, type: col.type }); } for (const col of other) { options.push({ value: col.name, label: col.name, type: col.type }); } return options; }, [allColumns, dateTimeColumns, categoricalColumns]); // Y-axis options: numeric columns only const yAxisOptions = useMemo(() => { return numericColumns.map((col) => ({ value: col.name, label: col.name, type: col.type, })); }, [numericColumns]); // Aggregation options const aggregationOptions = [ { value: "sum", label: "Sum" }, { value: "avg", label: "Average" }, { value: "count", label: "Count" }, { value: "min", label: "Min" }, { value: "max", label: "Max" }, ]; // Group by options: categorical columns (excluding selected X axis) const groupByOptions = useMemo(() => { const options = categoricalColumns .filter((col) => col.name !== config.xAxisColumn) .map((col) => ({ value: col.name, label: col.name, type: col.type, })); return [{ value: "__none__", label: "None", type: "" }, ...options]; }, [categoricalColumns, config.xAxisColumn]); // Sort by options: all columns const sortByOptions = useMemo(() => { const options = allColumns.map((col) => ({ value: col.name, label: col.name, type: col.type, })); return [{ value: "__none__", label: "None", type: "" }, ...options]; }, [allColumns]); if (columns.length === 0) { return (
Run a query to configure the chart
); } return (
{/* Chart Type */}
Bar ), value: "bar", }, { label: ( Line ), value: "line", }, ]} onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })} />
{/* X-Axis */} {/* Y-Axis / Series */} 1 ? "Series" : "Y-Axis"}> {yAxisOptions.length === 0 ? ( No numeric columns ) : (
{/* Always show at least one dropdown, even if yAxisColumns is empty */} {(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
{col && !config.groupByColumn && ( { updateConfig({ seriesColors: { ...config.seriesColors, [col]: color }, }); }} /> )} {index > 0 && ( )}
))} {/* Add another series button - only show when we have at least one series and not grouped */} {config.yAxisColumns.length > 0 && config.yAxisColumns.length < yAxisOptions.length && !config.groupByColumn && ( )} {config.groupByColumn && config.yAxisColumns.length === 1 && ( Remove group by to add multiple series )}
)}
{/* Aggregation */} {/* Group By - disabled when multiple series are selected */} {config.yAxisColumns.length > 1 ? ( Not available with multiple series ) : ( )} {/* Stacked toggle (when grouped or multiple series) */} {(config.groupByColumn || config.yAxisColumns.length > 1) && ( updateConfig({ stacked: checked })} /> )} {/* Order By */} {/* Sort Direction (only when sorting) */} {config.sortByColumn && ( Asc ), value: "asc", }, { label: ( Desc ), value: "desc", }, ]} onChange={(value) => updateConfig({ sortDirection: value as SortDirection })} /> )}
); } function ConfigField({ label, children }: { label: string; children: React.ReactNode }) { return (
{label && {label}} {children}
); } function SeriesColorPicker({ color, onColorChange, }: { color: string; onColorChange: (color: string) => void; }) { const [open, setOpen] = useState(false); return (
{CHART_COLORS_BY_HUE.map((c) => ( ))}
); } function TypeBadge({ type }: { type: string }) { // Simplify type for display let displayType = type; if (type.startsWith("Nullable(")) { displayType = type.slice(9, -1) + "?"; } if (type.startsWith("LowCardinality(")) { displayType = type.slice(15, -1); } // Shorten long type names if (displayType.length > 12) { displayType = displayType.slice(0, 10) + "…"; } return ( {displayType} ); }