ai-chatbot/components/elements/openclaw-chart-card.tsx

541 lines
17 KiB
TypeScript

"use client";
import { BarChart3Icon, DownloadIcon } from "lucide-react";
type OpenClawChartType = "bar" | "line" | "combo";
type OpenClawChartSeries = {
key: string;
name?: string;
type?: "bar" | "line";
unit?: string;
color?: string;
yAxis?: "left" | "right";
};
export type OpenClawChartSpec = {
openclaw_chart?: boolean;
type?: OpenClawChartType;
title?: string;
subtitle?: string;
xKey: string;
yAxisLabel?: string;
rightYAxisLabel?: string;
series: OpenClawChartSeries[];
data: Array<Record<string, unknown>>;
note?: string;
};
export type OpenClawResponseSegment =
| { type: "text"; value: string }
| { type: "chart"; value: OpenClawChartSpec };
const CHART_COLORS = [
"#4f8cff",
"#14b8a6",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#06b6d4",
];
function normalizeChartKey(value: unknown) {
return String(value || "").trim();
}
function escapeCsvCell(value: string) {
const normalized = value.replace(/\s+/g, " ").trim();
const escaped = normalized.replace(/"/g, '""');
return /[",\r\n]/.test(escaped) ? `"${escaped}"` : escaped;
}
function downloadTextFile(filename: string, content: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function parseChartNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value !== "string") {
return null;
}
const normalized = value
.replace(/,/g, "")
.replace(/%/g, "")
.replace(/[^\d.+-]/g, "")
.trim();
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function formatChartValue(value: number, unit?: string) {
const abs = Math.abs(value);
const formatted =
abs >= 100
? value.toFixed(0)
: abs >= 10
? value.toFixed(1)
: value.toFixed(2);
return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1")}${unit || ""}`;
}
function truncateChartLabel(value: unknown, maxLength = 8) {
const label = String(value ?? "");
if (label.length <= maxLength) return label;
return `${label.slice(0, maxLength - 1)}…`;
}
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function normalizeOpenClawChartSpec(value: unknown): OpenClawChartSpec | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const xKey = normalizeChartKey(raw.xKey || raw.x_key);
const rawSeries = Array.isArray(raw.series) ? raw.series : [];
const data = Array.isArray(raw.data) ? raw.data : [];
if (!xKey || rawSeries.length === 0 || data.length === 0) {
return null;
}
const series = rawSeries
.map((item, index): OpenClawChartSeries | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const rawSeriesItem = item as Record<string, unknown>;
const key = normalizeChartKey(rawSeriesItem.key);
if (!key) return null;
const rawType = String(rawSeriesItem.type || "").trim();
const rawYAxis = String(rawSeriesItem.yAxis || rawSeriesItem.y_axis || "").trim();
return {
key,
name: normalizeChartKey(rawSeriesItem.name) || key,
type: rawType === "bar" || rawType === "line" ? rawType : undefined,
unit: normalizeChartKey(rawSeriesItem.unit) || undefined,
color:
normalizeChartKey(rawSeriesItem.color) ||
CHART_COLORS[index % CHART_COLORS.length],
yAxis: rawYAxis === "right" ? "right" : "left",
};
})
.filter((item): item is OpenClawChartSeries => Boolean(item))
.slice(0, 6);
if (series.length === 0) return null;
const rawType = String(raw.type || "").trim();
const type: OpenClawChartType =
rawType === "bar" || rawType === "line" || rawType === "combo"
? rawType
: "combo";
const normalizedData = data
.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === "object" && !Array.isArray(item))
)
.slice(0, 80);
if (normalizedData.length === 0) return null;
return {
openclaw_chart: true,
type,
title: normalizeChartKey(raw.title) || "数据图表",
subtitle: normalizeChartKey(raw.subtitle) || undefined,
xKey,
yAxisLabel: normalizeChartKey(raw.yAxisLabel || raw.y_axis_label) || undefined,
rightYAxisLabel:
normalizeChartKey(raw.rightYAxisLabel || raw.right_y_axis_label) || undefined,
series,
data: normalizedData,
note: normalizeChartKey(raw.note) || undefined,
};
}
export function splitOpenClawChartBlocks(content: string): OpenClawResponseSegment[] {
const chartBlockPattern =
/```(?:openclaw-chart|openclaw_chart|claw-chart|claw_chart|chart)\s*\n([\s\S]*?)```/gi;
const segments: OpenClawResponseSegment[] = [];
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = chartBlockPattern.exec(content)) !== null) {
const parsed = normalizeOpenClawChartSpec(safeJsonParse(match[1] || ""));
if (!parsed) continue;
if (match.index > cursor) {
segments.push({ type: "text", value: content.slice(cursor, match.index) });
}
segments.push({ type: "chart", value: parsed });
cursor = chartBlockPattern.lastIndex;
}
if (cursor < content.length) {
segments.push({ type: "text", value: content.slice(cursor) });
}
return segments.length > 0 ? segments : [{ type: "text", value: content }];
}
export function chartDataToCsv(chart: OpenClawChartSpec) {
const headers = [chart.xKey, ...chart.series.map((series) => series.name || series.key)];
const rows = chart.data.map((row) =>
[
row[chart.xKey],
...chart.series.map((series) => row[series.key]),
]
.map((cell) => escapeCsvCell(String(cell ?? "")))
.join(",")
);
return [headers.map(escapeCsvCell).join(","), ...rows].join("\r\n");
}
export function OpenClawChartCard({ chart }: { chart: OpenClawChartSpec }) {
const width = 720;
const height = 320;
const margin = { top: 28, right: 58, bottom: 54, left: 58 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const data = chart.data;
const getSeriesType = (series: OpenClawChartSeries) =>
series.type || (chart.type === "bar" ? "bar" : "line");
const barSeries = chart.series.filter((series) => getSeriesType(series) === "bar");
const lineSeries = chart.series.filter((series) => getSeriesType(series) !== "bar");
const hasRightAxis = chart.series.some((series) => series.yAxis === "right");
const getSeriesValues = (axis: "left" | "right") =>
chart.series
.filter((series) => (series.yAxis || "left") === axis)
.flatMap((series) =>
data
.map((row) => parseChartNumber(row[series.key]))
.filter((value): value is number => value !== null)
);
const buildDomain = (axis: "left" | "right") => {
const values = getSeriesValues(axis);
if (values.length === 0) return { min: 0, max: 1 };
let min = Math.min(...values);
let max = Math.max(...values);
const hasBarOnAxis = chart.series.some(
(series) => (series.yAxis || "left") === axis && getSeriesType(series) === "bar"
);
if (hasBarOnAxis) {
min = Math.min(0, min);
max = Math.max(0, max);
}
if (min === max) {
const pad = Math.abs(max || 1) * 0.2;
min -= pad;
max += pad;
}
const padding = (max - min) * 0.12;
return { min: min - padding, max: max + padding };
};
const leftDomain = buildDomain("left");
const rightDomain = buildDomain("right");
const scaleX = (index: number) =>
data.length <= 1
? margin.left + plotWidth / 2
: margin.left + (index / (data.length - 1)) * plotWidth;
const scaleY = (value: number, axis: "left" | "right" = "left") => {
const domain = axis === "right" ? rightDomain : leftDomain;
const ratio = (value - domain.min) / (domain.max - domain.min || 1);
return margin.top + plotHeight - ratio * plotHeight;
};
const ticks = Array.from({ length: 5 }, (_, index) => index / 4);
const xLabelEvery = Math.max(1, Math.ceil(data.length / 6));
const barGap = 4;
const barWidth = Math.max(
6,
Math.min(
26,
(plotWidth / Math.max(1, data.length) - barGap * 2) /
Math.max(1, barSeries.length)
)
);
const handleDownload = () => {
downloadTextFile(
`chart-data-${Date.now()}.csv`,
`\uFEFF${chartDataToCsv(chart)}`,
"text/csv;charset=utf-8"
);
};
return (
<div className="my-3 w-full min-w-0 max-w-full overflow-hidden rounded-xl border border-border bg-background/70 shadow-sm whitespace-normal">
<div className="flex items-start justify-between gap-3 border-b border-border/70 bg-muted/30 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<BarChart3Icon className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{chart.title || "数据图表"}</span>
</div>
{chart.subtitle ? (
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{chart.subtitle}
</div>
) : null}
</div>
<button
type="button"
onClick={handleDownload}
data-pdf-exclude="true"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
title="下载图表数据 CSV"
>
<DownloadIcon className="h-3.5 w-3.5" />
<span>下载数据</span>
</button>
</div>
<div className="chat-card-x-scroll w-full min-w-0 overflow-x-auto px-3 pb-4 pt-3">
<svg
className="min-w-[720px] max-w-none"
role="img"
viewBox={`0 0 ${width} ${height}`}
>
<title>{chart.title || "数据图表"}</title>
<rect
fill="transparent"
height={plotHeight}
width={plotWidth}
x={margin.left}
y={margin.top}
/>
{ticks.map((tick) => {
const y = margin.top + tick * plotHeight;
const leftValue = leftDomain.max - tick * (leftDomain.max - leftDomain.min);
const rightValue =
rightDomain.max - tick * (rightDomain.max - rightDomain.min);
return (
<g key={`tick-${tick}`}>
<line
stroke="currentColor"
strokeOpacity="0.08"
x1={margin.left}
x2={margin.left + plotWidth}
y1={y}
y2={y}
/>
<text
fill="currentColor"
fontSize="11"
opacity="0.55"
textAnchor="end"
x={margin.left - 8}
y={y + 4}
>
{formatChartValue(leftValue)}
</text>
{hasRightAxis ? (
<text
fill="currentColor"
fontSize="11"
opacity="0.55"
textAnchor="start"
x={margin.left + plotWidth + 8}
y={y + 4}
>
{formatChartValue(rightValue)}
</text>
) : null}
</g>
);
})}
<line
stroke="currentColor"
strokeOpacity="0.18"
x1={margin.left}
x2={margin.left + plotWidth}
y1={margin.top + plotHeight}
y2={margin.top + plotHeight}
/>
<line
stroke="currentColor"
strokeOpacity="0.18"
x1={margin.left}
x2={margin.left}
y1={margin.top}
y2={margin.top + plotHeight}
/>
{hasRightAxis ? (
<line
stroke="currentColor"
strokeOpacity="0.18"
x1={margin.left + plotWidth}
x2={margin.left + plotWidth}
y1={margin.top}
y2={margin.top + plotHeight}
/>
) : null}
{barSeries.map((series, seriesIndex) => {
const axis = series.yAxis || "left";
return data.map((row, index) => {
const value = parseChartNumber(row[series.key]);
if (value === null) return null;
const xCenter = scaleX(index);
const groupWidth =
barSeries.length * barWidth + (barSeries.length - 1) * barGap;
const x = xCenter - groupWidth / 2 + seriesIndex * (barWidth + barGap);
const zeroY = scaleY(0, axis);
const valueY = scaleY(value, axis);
const y = Math.min(zeroY, valueY);
const barHeight = Math.max(1, Math.abs(zeroY - valueY));
return (
<rect
fill={series.color || CHART_COLORS[seriesIndex % CHART_COLORS.length]}
height={barHeight}
key={`${series.key}-${index}`}
opacity="0.78"
rx="3"
width={barWidth}
x={x}
y={y}
>
<title>
{`${row[chart.xKey] ?? ""} ${series.name || series.key}: ${formatChartValue(value, series.unit)}`}
</title>
</rect>
);
});
})}
{lineSeries.map((series, seriesIndex) => {
const axis = series.yAxis || "left";
const points = data
.map((row, index) => {
const value = parseChartNumber(row[series.key]);
if (value === null) return null;
return {
x: scaleX(index),
y: scaleY(value, axis),
value,
label: row[chart.xKey],
};
})
.filter((item): item is { x: number; y: number; value: number; label: unknown } =>
Boolean(item)
);
if (points.length === 0) return null;
const path = points
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`)
.join(" ");
const color =
series.color ||
CHART_COLORS[(barSeries.length + seriesIndex) % CHART_COLORS.length];
return (
<g key={series.key}>
<path
d={path}
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
/>
{points.map((point, index) => (
<circle
cx={point.x}
cy={point.y}
fill={color}
key={`${series.key}-${index}`}
r="4"
>
<title>
{`${point.label ?? ""} ${series.name || series.key}: ${formatChartValue(point.value, series.unit)}`}
</title>
</circle>
))}
</g>
);
})}
{data.map((row, index) =>
index % xLabelEvery === 0 || index === data.length - 1 ? (
<text
fill="currentColor"
fontSize="11"
key={`x-${index}`}
opacity="0.62"
textAnchor="middle"
x={scaleX(index)}
y={height - 22}
>
{truncateChartLabel(row[chart.xKey])}
</text>
) : null
)}
{chart.yAxisLabel ? (
<text fill="currentColor" fontSize="11" opacity="0.58" x={margin.left} y={16}>
{chart.yAxisLabel}
</text>
) : null}
{hasRightAxis && chart.rightYAxisLabel ? (
<text
fill="currentColor"
fontSize="11"
opacity="0.58"
textAnchor="end"
x={margin.left + plotWidth}
y={16}
>
{chart.rightYAxisLabel}
</text>
) : null}
</svg>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/60 px-4 py-3">
{chart.series.map((series, index) => (
<div
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
key={series.key}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{
backgroundColor: series.color || CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span>{series.name || series.key}</span>
{series.unit ? <span>({series.unit})</span> : null}
</div>
))}
</div>
{chart.note ? (
<div className="border-t border-border/60 px-4 py-2 text-xs leading-5 text-muted-foreground">
{chart.note}
</div>
) : null}
</div>
);
}