openclaw/apps/web/app/components/charts/chart-panel.tsx
2026-02-11 18:35:35 -08:00

415 lines
12 KiB
TypeScript

"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 = [
"#e85d3a", // 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<string, unknown>[];
/** Compact mode for inline chat cards */
compact?: boolean;
};
// --- Shared tooltip/axis styles ---
const axisStyle = {
fontSize: 11,
fill: "#888",
};
const gridStyle = {
stroke: "#262626",
strokeDasharray: "3 3",
};
function tooltipStyle() {
return {
contentStyle: {
background: "#141414",
border: "1px solid #262626",
borderRadius: 8,
fontSize: 12,
color: "#ededed",
},
itemStyle: { color: "#ededed" },
labelStyle: { color: "#888", marginBottom: 4 },
};
}
// --- Formatters ---
function formatValue(val: unknown): string {
if (val === null || val === undefined) {return "";}
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 String(val);
}
function formatLabel(val: unknown): string {
if (val === null || val === undefined) {return "";}
const str = String(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<string, unknown>[];
compact?: boolean;
ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart;
SeriesComponent: typeof Bar | typeof Line | typeof Area;
areaProps?: Record<string, unknown>;
}) {
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 (
<ResponsiveContainer width="100%" height={height}>
<ChartComponent data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid {...gridStyle} />
<XAxis
dataKey={xKey}
tick={axisStyle}
tickFormatter={formatLabel}
axisLine={{ stroke: "#262626" }}
tickLine={false}
/>
<YAxis
tick={axisStyle}
tickFormatter={formatValue}
axisLine={false}
tickLine={false}
width={48}
/>
<Tooltip {...ttStyle} formatter={formatValue} labelFormatter={formatLabel} />
{yKeys.length > 1 && !compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
{yKeys.map((key, i) => {
const color = colors[i % colors.length];
const props: Record<string, unknown> = {
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 <SeriesComponent {...props} />;
})}
</ChartComponent>
</ResponsiveContainer>
);
}
function PieDonutChart({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={data}
dataKey={valueKey}
nameKey={nameKey}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={compact ? 70 : 110}
paddingAngle={2}
label={compact ? undefined : ((props: unknown) => {
const p = props as Record<string, unknown>;
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) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Pie>
<Tooltip {...ttStyle} formatter={formatValue} />
{!compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
</PieChart>
</ResponsiveContainer>
);
}
function RadarChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
<PolarGrid stroke="#262626" />
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "#888" }} />
<PolarRadiusAxis tick={{ fontSize: 10, fill: "#888" }} />
{valueKeys.map((key, i) => (
<Radar
key={key}
name={key}
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.2}
/>
))}
<Tooltip {...ttStyle} />
{!compact && valueKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
</RadarChart>
</ResponsiveContainer>
);
}
function ScatterChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "#262626" }} tickLine={false} />
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
<Tooltip {...ttStyle} />
{yKeys.map((key, i) => (
<Scatter
key={key}
name={key}
data={data}
fill={colors[i % colors.length]}
/>
))}
{!compact && yKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
</ScatterChart>
</ResponsiveContainer>
);
}
function FunnelChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<FunnelChart>
<Tooltip {...ttStyle} />
<Funnel
data={funnelData}
dataKey={valueKey}
nameKey={nameKey}
isAnimationActive
>
<LabelList
position="right"
fill="#888"
stroke="none"
fontSize={11}
dataKey={nameKey}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
);
}
// --- 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<string, unknown> = { ...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 (
<div
className="flex items-center justify-center rounded-xl"
style={{
height: compact ? 200 : 320,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
fontSize: 13,
}}
>
No data
</div>
);
}
switch (config.type) {
case "bar":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
case "line":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={LineChart} SeriesComponent={Line} />;
case "area":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={AreaChart} SeriesComponent={Area} />;
case "pie":
return <PieDonutChart config={config} data={processedData} compact={compact} />;
case "donut":
return <PieDonutChart config={config} data={processedData} compact={compact} />;
case "radar":
case "radialBar":
return <RadarChartPanel config={config} data={processedData} compact={compact} />;
case "scatter":
return <ScatterChartPanel config={config} data={processedData} compact={compact} />;
case "funnel":
return <FunnelChartPanel config={config} data={processedData} compact={compact} />;
default:
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
}
}