import type { ReactNode } from "react"; import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { cn } from "../utils.js"; import { DataTable } from "./DataTable.js"; const DEFAULT_COLORS = [ "hsl(var(--chart-1, 0 0% 20%))", "hsl(var(--chart-2, 155 36% 42%))", "hsl(var(--chart-3, 32 55% 50%))", "hsl(var(--chart-4, 270 25% 55%))", "hsl(var(--chart-5, 350 38% 55%))", "hsl(var(--chart-6, 190 25% 48%))", ]; export type GenericChartDatum = Record; export type GenericChartType = | "metric" | "line" | "area" | "bar" | "stacked-bar" | "stacked-area" | "pie" | "donut" | "table"; export interface GenericChartConfig { chartType: GenericChartType; xKey?: string; yKey?: string; yKeys?: string[]; colors?: string[]; stacked?: boolean; legend?: boolean; description?: string; columns?: string[]; limit?: number; formatValue?: (value: number) => string; formatXAxis?: (value: string) => string; formatSeriesName?: (value: string) => string; } export interface GenericChartRenderContext { data: TData; config: TConfig; } export interface GenericChartPanelProps { data: TData | null | undefined; config: TConfig; loading?: boolean; error?: ReactNode; isEmpty?: (data: TData) => boolean; /** App-specific rendering takes priority over the portable Recharts renderer. */ render?: (context: GenericChartRenderContext) => ReactNode; /** Opt into Toolkit's source-agnostic Recharts renderer for row data. */ chart?: GenericChartConfig; renderLoading?: (config: TConfig) => ReactNode; renderError?: (error: ReactNode, config: TConfig) => ReactNode; renderEmpty?: (config: TConfig) => ReactNode; emptyMessage?: ReactNode; className?: string; } /** * A render-only chart state boundary. Data acquisition, query serialization, * demos, pivots, and app-specific chart renderers stay with the consuming app. */ export function GenericChartPanel({ data, config, loading = false, error, isEmpty, render, chart, renderLoading, renderError, renderEmpty, emptyMessage = "No data available.", className, }: GenericChartPanelProps) { if (loading) { return ( <> {renderLoading?.(config) ?? ( )} ); } if (error) { return ( <> {renderError?.(error, config) ?? ( {error} )} ); } if (data == null || isEmpty?.(data)) { return ( <> {renderEmpty?.(config) ?? ( {emptyMessage} )} ); } if (render) return <>{render({ data, config })}; if (chart && Array.isArray(data)) { return ( ); } return null; } /** Derives chart axes from provider-neutral row data when keys are not configured. */ export function resolveGenericChartKeys( rows: GenericChartDatum[], config: Pick, ): { xKey: string; yKeys: string[] } { const columns = Object.keys(rows[0] ?? {}); const xKey = config.xKey && columns.includes(config.xKey) ? config.xKey : (columns[0] ?? ""); const configured = ( config.yKeys?.length ? config.yKeys : config.yKey ? [config.yKey] : [] ).filter((key) => columns.includes(key)); const yKeys = configured.length ? configured : columns.filter((key) => key !== xKey && isNumericLike(rows[0]?.[key])); return { xKey, yKeys: yKeys.length ? yKeys : columns.filter((key) => key !== xKey).slice(0, 1), }; } export function formatGenericChartValue(value: unknown): string { if (typeof value === "number") return value.toLocaleString(); if ( typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) ) { return Number(value).toLocaleString(); } return String(value ?? "-"); } function isNumericLike(value: unknown): boolean { return ( typeof value === "number" || (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) ); } export function GenericChartRenderer({ rows, config, }: { rows: GenericChartDatum[]; config: GenericChartConfig; }) { const { xKey, yKeys } = resolveGenericChartKeys(rows, config); const colors = config.colors?.length ? config.colors : DEFAULT_COLORS; const valueFormatter = config.formatValue ?? formatGenericChartValue; const xFormatter = config.formatXAxis ?? ((value: string) => value); const seriesFormatter = config.formatSeriesName ?? ((value: string) => value); if (config.chartType === "metric") { const valueKey = config.yKey && xKey !== config.yKey ? config.yKey : (yKeys[0] ?? xKey); const first = rows[0]?.[valueKey]; const raw = rows.length > 1 && rows.every((row) => isNumericLike(row[valueKey])) ? rows.reduce((sum, row) => sum + Number(row[valueKey]), 0) : first; const value = isNumericLike(raw) ? valueFormatter(Number(raw)) : formatGenericChartValue(raw); return (
{value}
{config.description && (

{config.description}

)}
); } if (config.chartType === "table") { return ( ); } if (!xKey || yKeys.length === 0) { return ( Chart data needs an x-axis and at least one value column. ); } if (config.chartType === "pie" || config.chartType === "donut") { const valueKey = yKeys[0]; return ( `${seriesFormatter(String(name))} ${((percent ?? 0) * 100).toFixed(0)}%` } labelLine={false} > {rows.map((_, index) => ( ))} formatTooltipValue(value, valueFormatter) } /> {config.legend !== false && ( seriesFormatter(String(value))} /> )} ); } const stacked = config.stacked || config.chartType === "stacked-bar" || config.chartType === "stacked-area"; if (config.chartType === "bar" || config.chartType === "stacked-bar") { return ( formatTooltipValue(value, valueFormatter) } labelFormatter={(value) => xFormatter(String(value))} /> {config.legend !== false && ( seriesFormatter(String(value))} /> )} {yKeys.map((key, index) => ( ))} ); } const showFill = config.chartType === "area" || config.chartType === "stacked-area"; return ( {showFill ? ( formatTooltipValue(value, valueFormatter) } labelFormatter={(value) => xFormatter(String(value))} /> {config.legend !== false && ( seriesFormatter(String(value))} /> )} {yKeys.map((key, index) => ( ))} ) : ( formatTooltipValue(value, valueFormatter) } labelFormatter={(value) => xFormatter(String(value))} /> {config.legend !== false && ( seriesFormatter(String(value))} /> )} {yKeys.map((key, index) => ( ))} )} ); } function ChartCanvas({ children }: { children: ReactNode }) { return (
{children}
); } function ChartAxes({ xKey, yFormatter, xFormatter, }: { xKey: string; yFormatter: (value: number) => string; xFormatter: (value: string) => string; }) { return ( <> xFormatter(String(value))} /> yFormatter(Number(value))} /> ); } function formatTooltipValue( value: unknown, formatter: (value: number) => string, ): string { const numeric = Number(value); return Number.isFinite(numeric) ? formatter(numeric) : String(value ?? "-"); } export function ChartPanelPlaceholder({ className }: { className?: string }) { return (
); } function ChartPanelMessage({ children, className, tone = "muted", }: { children: ReactNode; className?: string; tone?: "error" | "muted"; }) { return (
{children}
); }