Files
TJWaterFrontend_Refine/src/components/chat/ChatInlineChart.tsx
T

460 lines
13 KiB
TypeScript

"use client";
import React, { useMemo } from "react";
import ReactECharts from "echarts-for-react";
import * as echarts from "echarts";
import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
/* ------------------------------------------------------------------ */
/* Inline chart rendered inside a chat message bubble. */
/* Accepts structured data produced by the AI tool_call. */
/* ------------------------------------------------------------------ */
export interface ChatChartSeries {
name: string;
data: number[];
type?: "line" | "bar";
}
type RawChartPoint =
| number
| string
| [unknown, unknown]
| RawChartPointObject;
type RawChartPointObject = {
x?: unknown;
y?: unknown;
time?: unknown;
timestamp?: unknown;
label?: unknown;
name?: unknown;
value?: unknown;
};
type RawChartSeries = {
name?: unknown;
data?: unknown;
points?: unknown;
values?: unknown;
type?: unknown;
};
export interface ChatInlineChartProps {
title?: string;
chart_type?: "line" | "bar" | "pie";
x_data?: unknown;
series?: unknown;
y_axis_name?: string;
x_axis_name?: string;
isStreaming?: boolean;
}
export const CHART_HEIGHT = 240;
export const CHART_MIN_HEIGHT = 286;
const COLORS = [
"#5470c6",
"#91cc75",
"#fac858",
"#ee6666",
"#73c0de",
"#3ba272",
"#fc8452",
"#9a60b4",
"#ea7ccc",
];
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
<Stack spacing={1.25} sx={{ p: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Skeleton variant="text" width="34%" height={20} />
{status}
</Stack>
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
<Stack direction="row" spacing={1}>
<Skeleton variant="text" width="24%" height={16} />
<Skeleton variant="text" width="18%" height={16} />
<Skeleton variant="text" width="20%" height={16} />
</Stack>
</Stack>
);
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
const theme = useTheme();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18 }}
style={{ width: "100%" }}
>
<Paper
elevation={0}
sx={{
mt: 1.5,
mb: 1,
minHeight: CHART_MIN_HEIGHT,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
bgcolor: alpha("#fff", 0.78),
overflow: "hidden",
}}
>
<ChartSkeletonContent status={status} />
</Paper>
</motion.div>
);
};
const toFiniteNumber = (value: unknown): number | null => {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
export const pointToLabelValue = (
point: RawChartPoint,
fallbackLabel: string,
): { label: string; value: number } | null => {
const directValue = toFiniteNumber(point);
if (directValue !== null) {
return { label: fallbackLabel, value: directValue };
}
if (Array.isArray(point)) {
const value = toFiniteNumber(point[1]);
if (value === null) return null;
return { label: String(point[0] ?? fallbackLabel), value };
}
if (point && typeof point === "object") {
const value = toFiniteNumber(point.value ?? point.y);
if (value === null) return null;
const label =
point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
return { label: String(label), value };
}
return null;
};
const normalizeXData = (rawXData: unknown): string[] =>
Array.isArray(rawXData)
? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0)
: [];
const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined =>
type === "line" || type === "bar" ? type : undefined;
const isRawChartPoint = (item: unknown): boolean => {
if (toFiniteNumber(item) !== null) return true;
if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null;
if (item && typeof item === "object") {
const rawItem = item as RawChartSeries & RawChartPointObject;
return (
rawItem.data === undefined &&
rawItem.points === undefined &&
rawItem.values === undefined &&
toFiniteNumber(rawItem.value ?? rawItem.y) !== null
);
}
return false;
};
const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => {
if (!Array.isArray(rawSeries)) {
return rawSeries && typeof rawSeries === "object" ? [rawSeries] : [];
}
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint)
? [{ name: "数据", data: rawSeries }]
: rawSeries;
};
export const normalizeChartData = (
rawXData: unknown,
rawSeries: unknown,
): { xData: string[]; series: ChatChartSeries[] } => {
const xData = normalizeXData(rawXData);
const rawSeriesItems = normalizeRawSeriesItems(rawSeries);
if (!rawSeriesItems.length) {
return { xData, series: [] };
}
const normalizedSeries = rawSeriesItems
.map((rawItem, seriesIndex): ChatChartSeries | null => {
const item =
rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
? (rawItem as RawChartSeries)
: ({ data: rawItem } satisfies RawChartSeries);
const rawData = item.data ?? item.points ?? item.values;
if (!Array.isArray(rawData)) return null;
const labelsFromPoints: string[] = [];
const data = rawData
.map((point, index) => {
const parsed = pointToLabelValue(
point as RawChartPoint,
xData[index] ?? `${index + 1}`,
);
if (!parsed) return null;
labelsFromPoints[index] = parsed.label;
return parsed.value;
})
.filter((value): value is number => value !== null);
if (!data.length) return null;
if (!xData.length && labelsFromPoints.length) {
xData.push(...labelsFromPoints);
}
return {
name:
typeof item.name === "string" && item.name.trim()
? item.name
: `系列 ${seriesIndex + 1}`,
data,
type: normalizeSeriesType(item.type),
};
})
.filter((item): item is ChatChartSeries => Boolean(item));
return { xData, series: normalizedSeries };
};
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
title,
chart_type: chartType = "line",
x_data,
series,
y_axis_name: yAxisName,
x_axis_name: xAxisName,
isStreaming = false,
}) => {
const theme = useTheme();
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
const { xData, series: chartSeries } = useMemo(
() => normalizeChartData(x_data, series),
[x_data, series],
);
React.useEffect(() => {
const timer = window.setTimeout(() => {
setShowIntroSkeleton(false);
}, isStreaming ? 360 : 260);
return () => window.clearTimeout(timer);
}, [isStreaming]);
const option = useMemo(() => {
if (!chartSeries.length) return null;
/* ---------- Pie chart ---------- */
if (chartType === "pie") {
const pieData =
chartSeries[0]?.data.map((value, i) => ({
name: xData?.[i] ?? `${i}`,
value,
})) ?? [];
return {
animation: true,
animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
tooltip: { trigger: "item" },
legend: { top: "bottom", textStyle: { fontSize: 11 } },
series: [
{
type: "pie",
radius: ["30%", "60%"],
data: pieData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
label: { fontSize: 11 },
animationType: "expansion",
animationDuration: isStreaming ? 560 : 420,
animationDelay: (idx: number) => idx * 40,
animationDurationUpdate: 240,
},
],
color: COLORS,
};
}
/* ---------- Line / Bar chart ---------- */
return {
animation: true,
animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
tooltip: { trigger: "axis", confine: true },
legend: { top: "top", textStyle: { fontSize: 11 } },
grid: {
left: "5%",
right: "5%",
bottom: "12%",
top: title ? "18%" : "14%",
containLabel: true,
},
xAxis: {
type: "category" as const,
boundaryGap: chartType === "bar",
data: xData ?? [],
axisLabel: {
fontSize: 10,
rotate: xData && xData.length > 10 ? 30 : 0,
},
name: xAxisName,
},
yAxis: {
type: "value" as const,
scale: true,
axisLabel: { fontSize: 10 },
name: yAxisName,
},
dataZoom:
xData && xData.length > 20
? [{ type: "inside", start: 0, end: 100 }]
: undefined,
series: chartSeries.map((s, i) => {
const color = COLORS[i % COLORS.length];
const isLineSeries = chartType === "line";
return {
name: s.name,
type: (s.type ?? chartType) as string,
data: s.data,
symbol: isLineSeries ? "none" : undefined,
smooth: isLineSeries,
itemStyle: { color },
animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationDelay:
chartType === "bar"
? (idx: number) => i * 80 + idx * 18
: i * 80,
animationDelayUpdate: 0,
...(isLineSeries
? {
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: alpha(color, 0.3) },
{ offset: 1, color: alpha(color, 0.05) },
]),
opacity: 0.3,
},
}
: {}),
};
}),
color: COLORS,
};
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
if (!option) {
return (
<Paper
elevation={0}
sx={{
mt: 1.5,
mb: 1,
minHeight: 72,
display: "flex",
alignItems: "center",
px: 2,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
bgcolor: alpha("#fff", 0.72),
}}
>
<Typography variant="caption" color="text.secondary">
图表数据为空
</Typography>
</Paper>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{ width: "100%" }}
>
<Paper
elevation={0}
sx={{
mt: 1.5,
mb: 1,
minHeight: CHART_MIN_HEIGHT,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
bgcolor: alpha("#fff", 0.92),
overflow: "hidden",
position: "relative",
}}
>
<AnimatePresence initial={false}>
{showIntroSkeleton ? (
<Box
key="chart-intro-skeleton"
component={motion.div}
aria-hidden
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
sx={{
position: "absolute",
inset: 0,
zIndex: 2,
bgcolor: alpha("#fff", 0.92),
pointerEvents: "none",
}}
>
<ChartSkeletonContent />
</Box>
) : null}
</AnimatePresence>
{title && (
<Typography
variant="subtitle2"
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
>
{title}
</Typography>
)}
<Box
component={motion.div}
initial={{ opacity: 0 }}
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
transition={{ duration: 0.24, ease: "easeOut" }}
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
>
<ReactECharts
option={option}
style={{ height: CHART_HEIGHT, width: "100%" }}
notMerge
lazyUpdate
/>
</Box>
</Paper>
</motion.div>
);
};