"use client" import { useMemo } from "react" import { useTranslations } from "next-intl" import { Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, XAxis, YAxis, } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/shared/components/ui/chart" import { cn } from "@/shared/lib/utils" import { AiChartSpecSchema } from "../schema" /** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */ const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } const CARTESIAN_GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4, } as const const POLAR_GRID_PROPS = { strokeOpacity: 0.4 } as const const BAR_X_AXIS_PROPS = { tickLine: false, axisLine: false, tickMargin: 8, } as const const BAR_Y_AXIS_PROPS = { allowDecimals: true, tickLine: false, axisLine: false, width: 36, } as const const LINE_Y_AXIS_PROPS = { tickLine: false, axisLine: false, width: 36, } as const const LINE_TOOLTIP_CURSOR = { stroke: "hsl(var(--muted-foreground))", strokeWidth: 1, strokeDasharray: "4 4", } as const const LINE_ACTIVE_DOT = { r: 5, strokeWidth: 0 } as const const POLAR_ANGLE_TICK = { fontSize: 12 } as const const POLAR_RADIUS_TICK = { fontSize: 10 } as const const BAR_RADIUS_TOP: [number, number, number, number] = [4, 4, 0, 0] const DEFAULT_Y_DOMAIN: [number, number] = [0, 100] function formatPercentTick(value: number): string { return `${value}%` } function formatPieLabel(entry: { name?: string; value?: number }): string { return `${entry.name ?? ""}: ${entry.value ?? ""}` } /** * AI 图表渲染器 * * 将 AI 返回的图表 JSON 规格渲染为 recharts 图表。 * 支持 4 种图表类型:bar / line / pie / radar。 * * AI 通过在 Markdown 中返回特殊代码块触发渲染: * * ```chart:bar * { "title": "...", "data": [...], "series": [...] } * ``` * * 规格格式(通用): * { * "title": "图表标题", // 可选 * "description": "说明文字", // 可选 * "data": [ // 数据数组 * { "name": "数学", "score": 85, "fullTitle": "数学科目" } * ], * "xKey": "name", // X 轴字段(bar/line) * "series": [ // 系列配置 * { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" } * ], * "yDomain": [0, 100], // 可选,Y 轴定义域 * "height": 280 // 可选,高度 px * } * * pie 图特有: * { * "data": [{ "name": "及格", "value": 30 }], * "series": [{ "name": "分布" }] * } */ export type AiChartType = "bar" | "line" | "pie" | "radar" export interface AiChartSeries { dataKey: string name: string color?: string /** pie: 填充透明度;radar: 填充透明度 */ fillOpacity?: number /** radar: 线宽 */ strokeWidth?: number /** radar: 虚线 */ strokeDasharray?: string } export interface AiChartSpec { title?: string description?: string type?: AiChartType data: Array> xKey?: string angleKey?: string series: AiChartSeries[] yDomain?: [number, number] height?: number showLegend?: boolean } /** 默认调色板(色盲友好) */ const DEFAULT_PALETTE = [ "hsl(221, 83%, 53%)", // 蓝 "hsl(142, 71%, 45%)", // 绿 "hsl(43, 96%, 56%)", // 黄 "hsl(0, 84%, 60%)", // 红 "hsl(271, 76%, 53%)", // 紫 "hsl(199, 89%, 48%)", // 青 "hsl(25, 95%, 53%)", // 橙 "hsl(280, 65%, 60%)", // 品红 ] interface AiChartRendererProps { /** 图表类型 */ type: AiChartType /** JSON 规格字符串 */ spec: string className?: string } /** * 解析 JSON 规格,失败时返回 null * * 使用 Zod schema 校验,避免 as 断言。 */ function parseSpec(spec: string): AiChartSpec | null { try { const parsed: unknown = JSON.parse(spec) const result = AiChartSpecSchema.safeParse(parsed) if (!result.success) return null return result.data } catch { return null } } /** * 为 series 补充默认颜色 */ function withDefaultColors(series: AiChartSeries[]): AiChartSeries[] { return series.map((s, i) => ({ ...s, color: s.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length], })) } export function AiChartRenderer({ type, spec, className, }: AiChartRendererProps): React.ReactNode { const t = useTranslations("ai") const parsed = useMemo(() => parseSpec(spec), [spec]) if (!parsed) { return (
{t("chart.parseError")}
) } const series = withDefaultColors(parsed.series) const height = parsed.height ?? 280 // 构建 ChartConfig const chartConfig: ChartConfig = {} for (const s of series) { chartConfig[s.dataKey] = { label: s.name, color: s.color, } } return (
{parsed.title ? (

{parsed.title}

{parsed.description ? (

{parsed.description}

) : null}
) : null} {renderChart(type, parsed, series)}
) } function renderChart( type: AiChartType, spec: AiChartSpec, series: AiChartSeries[] ): React.ReactNode { switch (type) { case "bar": return renderBarChart(spec, series) case "line": return renderLineChart(spec, series) case "pie": return renderPieChart(spec, series) case "radar": return renderRadarChart(spec, series) default: return null } } function renderBarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { const xKey = spec.xKey ?? "name" return ( } /> {spec.showLegend ? : null} {series.map((s) => ( ))} ) } function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { const xKey = spec.xKey ?? "name" return ( {/* arbitrary-value: chart canvas fixed size */} } /> {spec.showLegend ? : null} {series.map((s) => ( ))} ) } function renderPieChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { // Pie 图:data 中每项 { name, value },series 仅取第一个作为图例名 const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0]) const dataKey = series[0]?.dataKey ?? "value" return ( } /> {spec.showLegend ? : null} {spec.data.map((_, index) => ( ))} ) } function renderRadarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { const angleKey = spec.angleKey ?? spec.xKey ?? "name" return ( } /> {spec.showLegend ? : null} {series.map((s) => ( ))} ) }