feat(tools): chart 图表工具 —— 工具产出 JSON spec,前端 SVG 渲染(职责分离)
按「工具只产数据、渲染交前端」设计:
- 后端 chart 工具(mcp-go):校验并返回规范化图表 JSON(type=bar/line/pie + labels + series,
校验类型/长度一致/pie 取首系列)。工具说明指示 agent 用 ```chart 围栏原样包裹返回的 JSON。
- 前端:lib/chartspec.ts 从输出抽取 ```chart 块(解析失败回退为文本不丢内容);
components/ChartView.tsx 自绘 SVG 柱/线/饼图(无第三方图表依赖);
BottomDrawer 输出区含图表块时分段渲染(文本 + SVG),否则纯文本。
测试:前端 chartspec 单测 12 例(isChartSpec 校验、分段抽取、非法块回退、多块、hasChart);
tsc 干净,vitest 48 过。live 自主 agent:chart 工具产出 {"type":"bar",...},
agent 正确用 ```chart 围栏嵌入答复,前端据此渲染。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import type { ChartSpec } from "../lib/chartspec";
|
||||
|
||||
// ChartView:把 chart spec 自绘成 SVG(bar / line / pie),无第三方图表依赖。
|
||||
const PALETTE = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#22d3ee", "#fb923c", "#4ade80"];
|
||||
|
||||
export function ChartView({ spec }: { spec: ChartSpec }) {
|
||||
return (
|
||||
<figure className="my-2 rounded-lg border border-line bg-ink-950/50 p-3">
|
||||
{spec.title && <figcaption className="mb-2 text-[12px] font-medium text-slate-200">{spec.title}</figcaption>}
|
||||
{spec.type === "pie" ? <Pie spec={spec} /> : <BarLine spec={spec} />}
|
||||
{spec.type !== "pie" && spec.series.length > 1 && <Legend names={spec.series.map((s, i) => s.name || `系列${i + 1}`)} />}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ names }: { names: string[] }) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{names.map((n, i) => (
|
||||
<span key={i} className="flex items-center gap-1 text-[10px] text-slate-400">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: PALETTE[i % PALETTE.length] }} />
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BarLine:柱状图 / 折线图(共用坐标系)。
|
||||
function BarLine({ spec }: { spec: ChartSpec }) {
|
||||
const W = 480, H = 220, padL = 40, padB = 28, padT = 8, padR = 8;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const all = spec.series.flatMap((s) => s.data);
|
||||
const max = Math.max(1, ...all);
|
||||
const min = Math.min(0, ...all);
|
||||
const span = max - min || 1;
|
||||
const y = (v: number) => padT + plotH - ((v - min) / span) * plotH;
|
||||
const n = spec.labels.length;
|
||||
const slot = plotW / n;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={spec.title || "图表"}>
|
||||
{/* y 轴基准线 */}
|
||||
<line x1={padL} y1={y(min)} x2={W - padR} y2={y(min)} stroke="#334155" strokeWidth={1} />
|
||||
<text x={padL - 6} y={y(max)} fill="#64748b" fontSize={9} textAnchor="end">{fmt(max)}</text>
|
||||
<text x={padL - 6} y={y(min) + 3} fill="#64748b" fontSize={9} textAnchor="end">{fmt(min)}</text>
|
||||
|
||||
{spec.type === "bar"
|
||||
? spec.series.map((s, si) =>
|
||||
s.data.map((v, i) => {
|
||||
const bw = (slot * 0.7) / spec.series.length;
|
||||
const x = padL + i * slot + slot * 0.15 + si * bw;
|
||||
return (
|
||||
<rect key={`${si}-${i}`} x={x} y={Math.min(y(v), y(0))} width={bw} height={Math.abs(y(v) - y(0))}
|
||||
fill={PALETTE[si % PALETTE.length]} rx={1}>
|
||||
<title>{`${spec.labels[i]}: ${v}`}</title>
|
||||
</rect>
|
||||
);
|
||||
}),
|
||||
)
|
||||
: spec.series.map((s, si) => {
|
||||
const pts = s.data.map((v, i) => `${padL + i * slot + slot / 2},${y(v)}`).join(" ");
|
||||
return (
|
||||
<g key={si}>
|
||||
<polyline points={pts} fill="none" stroke={PALETTE[si % PALETTE.length]} strokeWidth={2} />
|
||||
{s.data.map((v, i) => (
|
||||
<circle key={i} cx={padL + i * slot + slot / 2} cy={y(v)} r={2.5} fill={PALETTE[si % PALETTE.length]}>
|
||||
<title>{`${spec.labels[i]}: ${v}`}</title>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* x 轴标签 */}
|
||||
{spec.labels.map((lb, i) => (
|
||||
<text key={i} x={padL + i * slot + slot / 2} y={H - padB + 14} fill="#64748b" fontSize={9} textAnchor="middle">
|
||||
{lb.length > 6 ? lb.slice(0, 6) + "…" : lb}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Pie:饼图(用第一条系列)。
|
||||
function Pie({ spec }: { spec: ChartSpec }) {
|
||||
const data = spec.series[0]?.data ?? [];
|
||||
const total = data.reduce((a, b) => a + Math.max(0, b), 0) || 1;
|
||||
const cx = 110, cy = 110, r = 90;
|
||||
let acc = -Math.PI / 2; // 从 12 点方向起
|
||||
const arcs = data.map((v, i) => {
|
||||
const frac = Math.max(0, v) / total;
|
||||
const a0 = acc;
|
||||
const a1 = acc + frac * Math.PI * 2;
|
||||
acc = a1;
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
|
||||
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
|
||||
const d = `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`;
|
||||
return { d, color: PALETTE[i % PALETTE.length], label: spec.labels[i], pct: Math.round(frac * 100) };
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 220 220" className="h-44 w-44 shrink-0" role="img" aria-label={spec.title || "饼图"}>
|
||||
{arcs.map((a, i) => (
|
||||
<path key={i} d={a.d} fill={a.color} stroke="#0b1220" strokeWidth={1}>
|
||||
<title>{`${a.label}: ${data[i]} (${a.pct}%)`}</title>
|
||||
</path>
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex flex-col gap-1">
|
||||
{arcs.map((a, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: a.color }} />
|
||||
{a.label} · {a.pct}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1) + "k";
|
||||
return String(Math.round(v * 100) / 100);
|
||||
}
|
||||
Reference in New Issue
Block a user