import { forwardRef, useImperativeHandle, useState, useEffect, useCallback, useMemo, useRef, memo, } from "react"; import { Box, useTheme } from "@mui/material"; import parseTraffic from "@/utils/parse-traffic"; import { useTranslation } from "react-i18next"; import { Line as ChartJsLine } from "react-chartjs-2"; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Filler, Scale, Tick, } from "chart.js"; ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Filler ); // 流量数据项接口 export interface ITrafficItem { up: number; down: number; timestamp?: number; } // 对外暴露的接口 export interface EnhancedTrafficGraphRef { appendData: (data: ITrafficItem) => void; toggleStyle: () => void; } type TimeRange = 1 | 5 | 10; // 分钟 // 数据点类型 type DataPoint = ITrafficItem & { name: string; timestamp: number }; /** * 增强型流量图表组件 */ export const EnhancedTrafficGraph = memo(forwardRef( (props, ref) => { const theme = useTheme(); const { t } = useTranslation(); // 基础状态 const [timeRange, setTimeRange] = useState(10); const [chartStyle, setChartStyle] = useState<"line" | "area">("area"); const [displayData, setDisplayData] = useState([]); // 数据缓冲区 const dataBufferRef = useRef([]); // 根据时间范围计算保留的数据点数量 const getMaxPointsByTimeRange = useCallback( (minutes: TimeRange): number => minutes * 60, [] ); // 最大数据点数量 const MAX_BUFFER_SIZE = useMemo( () => getMaxPointsByTimeRange(10), [getMaxPointsByTimeRange] ); // 颜色配置 const colors = useMemo( () => ({ up: theme.palette.secondary.main, down: theme.palette.primary.main, grid: theme.palette.divider, tooltipBg: theme.palette.background.paper, text: theme.palette.text.primary, tooltipBorder: theme.palette.divider, }), [theme] ); // 切换时间范围 const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setTimeRange((prevRange) => { return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1; }); }, []); // 点击图表主体或图例时切换样式 const handleToggleStyleClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setChartStyle((prev) => (prev === "line" ? "area" : "line")); }, []); // 初始化数据缓冲区 useEffect(() => { const now = Date.now(); const tenMinutesAgo = now - 10 * 60 * 1000; const initialBuffer = Array.from( { length: MAX_BUFFER_SIZE }, (_, index) => { const pointTime = tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE); const date = new Date(pointTime); let nameValue: string; try { if (isNaN(date.getTime())) { console.warn(`Initial data generation: Invalid date for timestamp ${pointTime}`); nameValue = "??:??:??"; } else { nameValue = date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", }); } } catch (e) { console.error("Error in toLocaleTimeString during initial data gen:", e, "Date:", date, "Timestamp:", pointTime); nameValue = "Err:Time"; } return { up: 0, down: 0, timestamp: pointTime, name: nameValue, }; } ); dataBufferRef.current = initialBuffer; // 更新显示数据 const pointsToShow = getMaxPointsByTimeRange(timeRange); setDisplayData(initialBuffer.slice(-pointsToShow)); }, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange, timeRange]); // 添加数据点方法 const appendData = useCallback((data: ITrafficItem) => { const safeData = { up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0, down: typeof data.down === "number" && !isNaN(data.down) ? data.down : 0, }; const timestamp = data.timestamp || Date.now(); const date = new Date(timestamp); let nameValue: string; try { if (isNaN(date.getTime())) { console.warn(`appendData: Invalid date for timestamp ${timestamp}`); nameValue = "??:??:??"; } else { nameValue = date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", }); } } catch (e) { console.error("Error in toLocaleTimeString in appendData:", e, "Date:", date, "Timestamp:", timestamp); nameValue = "Err:Time"; } // 带时间标签的新数据点 const newPoint: DataPoint = { ...safeData, name: nameValue, timestamp: timestamp, }; const newBuffer = [...dataBufferRef.current.slice(1), newPoint]; dataBufferRef.current = newBuffer; const pointsToShow = getMaxPointsByTimeRange(timeRange); setDisplayData(newBuffer.slice(-pointsToShow)); }, [timeRange, getMaxPointsByTimeRange]); // 监听时间范围变化 useEffect(() => { const pointsToShow = getMaxPointsByTimeRange(timeRange); if (dataBufferRef.current.length > 0) { setDisplayData(dataBufferRef.current.slice(-pointsToShow)); } }, [timeRange, getMaxPointsByTimeRange]); // 切换图表样式 const toggleStyle = useCallback(() => { setChartStyle((prev) => prev === "line" ? "area" : "line"); }, []); // 暴露方法给父组件 useImperativeHandle( ref, () => ({ appendData, toggleStyle, }), [appendData, toggleStyle] ); const formatYAxis = useCallback((value: number | string): string => { if (typeof value !== 'number') return String(value); const [num, unit] = parseTraffic(value); return `${num}${unit}`; }, []); const formatXLabel = useCallback((tickValue: string | number, index: number, ticks: any[]) => { const dataPoint = displayData[index as number]; if (dataPoint && dataPoint.name) { const parts = dataPoint.name.split(":"); return `${parts[0]}:${parts[1]}`; } if(typeof tickValue === 'string') { const parts = tickValue.split(":"); if (parts.length >= 2) return `${parts[0]}:${parts[1]}`; return tickValue; } return ''; }, [displayData]); // 获取当前时间范围文本 const getTimeRangeText = useCallback(() => { return t("{{time}} Minutes", { time: timeRange }); }, [timeRange, t]); const chartData = useMemo(() => { const labels = displayData.map(d => d.name); return { labels, datasets: [ { label: t("Upload"), data: displayData.map(d => d.up), borderColor: colors.up, backgroundColor: chartStyle === "area" ? colors.up : colors.up, fill: chartStyle === "area", tension: 0.4, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, }, { label: t("Download"), data: displayData.map(d => d.down), borderColor: colors.down, backgroundColor: chartStyle === "area" ? colors.down : colors.down, fill: chartStyle === "area", tension: 0.4, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, } ] }; }, [displayData, colors.up, colors.down, t, chartStyle]); const chartOptions = useMemo(() => ({ responsive: true, maintainAspectRatio: false, animation: false as false, scales: { x: { display: true, type: 'category' as const, labels: displayData.map(d => d.name), ticks: { display: true, color: colors.text, font: { size: 10 }, callback: function(this: Scale, tickValue: string | number, index: number, ticks: Tick[]): string | undefined { let labelToFormat: string | undefined = undefined; const currentDisplayTick = ticks[index]; if (currentDisplayTick && typeof currentDisplayTick.label === 'string') { labelToFormat = currentDisplayTick.label; } else { const sourceLabels = displayData.map(d => d.name); if (typeof tickValue === 'number' && tickValue >= 0 && tickValue < sourceLabels.length) { labelToFormat = sourceLabels[tickValue]; } else if (typeof tickValue === 'string') { labelToFormat = tickValue; } } if (typeof labelToFormat !== 'string') { return undefined; } const parts: string[] = labelToFormat.split(':'); return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : labelToFormat; }, autoSkip: true, maxTicksLimit: Math.max(5, Math.floor(displayData.length / (timeRange * 2))), minRotation: 0, maxRotation: 0, }, grid: { display: true, drawOnChartArea: false, drawTicks: true, tickLength: 2, color: colors.text, }, }, y: { beginAtZero: true, ticks: { color: colors.text, font: { size: 10 }, callback: formatYAxis, }, grid: { display: true, drawTicks: true, tickLength: 3, color: colors.grid, }, } }, plugins: { tooltip: { enabled: true, mode: 'index' as const, intersect: false, backgroundColor: colors.tooltipBg, titleColor: colors.text, bodyColor: colors.text, borderColor: colors.tooltipBorder, borderWidth: 1, cornerRadius: 4, padding: 8, callbacks: { title: (tooltipItems: any[]) => { return `${t("Time")}: ${tooltipItems[0].label}`; }, label: (context: any): string => { const label = context.dataset.label || ''; const value = context.parsed.y; const [num, unit] = parseTraffic(value); return `${label}: ${num} ${unit}/s`; } } }, legend: { display: false } }, layout: { padding: { top: 16, right: 7, left: 3, } } }), [colors, t, formatYAxis, timeRange, displayData]); return (
{displayData.length > 0 && ( )} {getTimeRangeText()} {t("Upload")} {t("Download")}
); }, )); EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";