"use client"; import { useMemo } from "react"; import { BarChart, Bar, LineChart, Line, AreaChart, Area, PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, FunnelChart, Funnel, LabelList, } from "recharts"; import type { PanelConfig } from "./types"; // --- Color palette derived from CSS variables + accessible defaults --- const CHART_PALETTE = [ "#2563eb", // accent "#60a5fa", // blue "#22c55e", // green "#f59e0b", // amber "#c084fc", // purple "#fb923c", // orange "#14b8a6", // teal "#f43f5e", // rose "#a78bfa", // violet "#38bdf8", // sky ]; type ChartPanelProps = { config: PanelConfig; data: Record[]; /** Compact mode for inline chat cards */ compact?: boolean; }; // --- Shared tooltip/axis styles --- const axisStyle = { fontSize: 11, fill: "var(--color-text-muted)", }; const gridStyle = { stroke: "var(--color-border-strong)", strokeDasharray: "3 3", }; function tooltipStyle() { return { contentStyle: { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12, color: "var(--color-text)", }, itemStyle: { color: "var(--color-text)" }, labelStyle: { color: "var(--color-text-muted)", marginBottom: 4 }, }; } // --- Formatters --- /** Safe string conversion for chart values (handles objects via JSON.stringify). */ function toDisplayStr(val: unknown): string { if (val == null) {return "";} if (typeof val === "object") {return JSON.stringify(val);} if (typeof val === "string") {return val;} if (typeof val === "number" || typeof val === "boolean") {return String(val);} // symbol, bigint, function — val is narrowed (object already handled above) // eslint-disable-next-line @typescript-eslint/no-base-to-string return String(val); } function formatValue(val: unknown): string { if (val === null || val === undefined) {return "";} if (typeof val === "object") {return JSON.stringify(val);} if (typeof val === "number") { if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;} if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;} return Number.isInteger(val) ? String(val) : val.toFixed(2); } return toDisplayStr(val); } function formatLabel(val: unknown): string { if (val === null || val === undefined) {return "";} const str = toDisplayStr(val); // Truncate long date strings if (str.length > 16 && !isNaN(Date.parse(str))) { return str.slice(0, 10); } // Truncate long labels if (str.length > 20) {return str.slice(0, 18) + "...";} return str; } // --- Chart renderers --- function CartesianChart({ config, data, compact, ChartComponent, SeriesComponent, areaProps, }: { config: PanelConfig; data: Record[]; compact?: boolean; ChartComponent: typeof BarChart ; SeriesComponent: typeof Bar | typeof Line | typeof Area; areaProps?: Record; }) { const { mapping } = config; const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x"; const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey); const colors = mapping.colors ?? CHART_PALETTE; const height = compact ? 200 : 320; const ttStyle = tooltipStyle(); return ( {yKeys.length > 1 && !compact && } {yKeys.map((key, i) => { const color = colors[i % colors.length]; const props: Record = { key, dataKey: key, fill: color, stroke: color, name: key, ...areaProps, }; if (SeriesComponent === Bar) { props.radius = [4, 4, 0, 0]; props.maxBarSize = 48; } if (SeriesComponent === Line) { props.strokeWidth = 2; props.dot = { r: 3, fill: color }; props.activeDot = { r: 5 }; } if (SeriesComponent === Area) { props.fillOpacity = 0.15; props.strokeWidth = 2; } // @ts-expect-error - dynamic component props return ; })} ); } function PieDonutChart({ config, data, compact, }: { config: PanelConfig; data: Record[]; compact?: boolean; }) { const { mapping, type } = config; const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value"; const colors = mapping.colors ?? CHART_PALETTE; const height = compact ? 200 : 320; const ttStyle = tooltipStyle(); const innerRadius = type === "donut" ? "50%" : 0; return ( { const p = props as Record; const name = p.name; const percent = typeof p.percent === "number" ? p.percent : 0; return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`; }) as never} labelLine={!compact} style={{ fontSize: 11 }} > {data.map((_, i) => ( ))} {!compact && } ); } function RadarChartPanel({ config, data, compact, }: { config: PanelConfig; data: Record[]; compact?: boolean; }) { const { mapping } = config; const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"]; const colors = mapping.colors ?? CHART_PALETTE; const height = compact ? 200 : 320; const ttStyle = tooltipStyle(); return ( {valueKeys.map((key, i) => ( ))} {!compact && valueKeys.length > 1 && } ); } function ScatterChartPanel({ config, data, compact, }: { config: PanelConfig; data: Record[]; compact?: boolean; }) { const { mapping } = config; const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x"; const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"]; const colors = mapping.colors ?? CHART_PALETTE; const height = compact ? 200 : 320; const ttStyle = tooltipStyle(); return ( {yKeys.map((key, i) => ( ))} {!compact && yKeys.length > 1 && } ); } function FunnelChartPanel({ config, data, compact, }: { config: PanelConfig; data: Record[]; compact?: boolean; }) { const { mapping } = config; const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value"; const colors = mapping.colors ?? CHART_PALETTE; const height = compact ? 200 : 320; const ttStyle = tooltipStyle(); // Funnel expects data with fill colors const funnelData = data.map((row, i) => ({ ...row, fill: colors[i % colors.length], })); return ( ); } // --- Main ChartPanel component --- export function ChartPanel({ config, data, compact }: ChartPanelProps) { // Coerce numeric values for Recharts const processedData = useMemo(() => { if (!data || data.length === 0) {return [];} const { mapping } = config; const numericKeys = new Set([ ...(mapping.yAxis ?? []), ...(mapping.valueKey ? [mapping.valueKey] : []), ]); return data.map((row) => { const out: Record = { ...row }; for (const key of numericKeys) { if (key in out) { const v = out[key]; if (typeof v === "string" && v !== "" && !isNaN(Number(v))) { out[key] = Number(v); } } } return out; }); }, [data, config]); if (processedData.length === 0) { return (
No data
); } switch (config.type) { case "bar": return ; case "line": return ; case "area": return ; case "pie": return ; case "donut": return ; case "radar": case "radialBar": return ; case "scatter": return ; case "funnel": return ; default: return ; } }