Files
Blizzard 592b2a3d97 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>
2026-06-24 17:24:41 +08:00

127 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ChartSpec } from "../lib/chartspec";
// ChartView:把 chart spec 自绘成 SVGbar / 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);
}