From becc51bcd281b6f0c7ef4c27717e95cfbcd2fa04 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 14 May 2025 12:16:59 +0800 Subject: [PATCH] feat: replace traffic chart rendering component for performance improvement and React 19 compatibility --- package.json | 3 +- pnpm-lock.yaml | 32 +- .../home/enhanced-traffic-graph.tsx | 460 ++++++++++-------- 3 files changed, 299 insertions(+), 196 deletions(-) diff --git a/package.json b/package.json index 8c8a8ead..1afe89c6 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/json-schema": "^7.0.15", "ahooks": "^3.8.4", "axios": "^1.8.3", + "chart.js": "^4.4.9", "cli-color": "^2.0.4", "d3-shape": "^3.2.0", "dayjs": "1.11.13", @@ -61,6 +62,7 @@ "nanoid": "^5.1.5", "peggy": "^5.0.0", "react": "19.1.0", + "react-chartjs-2": "^5.3.0", "react-dom": "19.1.0", "react-error-boundary": "6.0.0", "react-hook-form": "^7.54.2", @@ -69,7 +71,6 @@ "react-monaco-editor": "0.58.0", "react-router-dom": "7.6.0", "react-virtuoso": "^4.12.7", - "recharts": "^2.15.1", "sockette": "^2.0.6", "swr": "^2.3.3", "tar": "^7.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2ae70e6..1299d40a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: axios: specifier: ^1.8.3 version: 1.9.0 + chart.js: + specifier: ^4.4.9 + version: 4.4.9 cli-color: specifier: ^2.0.4 version: 2.0.4 @@ -122,6 +125,9 @@ importers: react: specifier: 19.1.0 version: 19.1.0 + react-chartjs-2: + specifier: ^5.3.0 + version: 5.3.0(chart.js@4.4.9)(react@19.1.0) react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) @@ -147,7 +153,7 @@ importers: specifier: ^4.12.7 version: 4.12.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) recharts: - specifier: ^2.15.1 + specifier: ^2.15.3 version: 2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) sockette: specifier: ^2.0.6 @@ -1006,6 +1012,9 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mui/core-downloads-tracker@7.1.0': resolution: {integrity: sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==} @@ -1786,6 +1795,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chart.js@4.4.9: + resolution: {integrity: sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==} + engines: {pnpm: '>=8'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2615,6 +2628,12 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + react-chartjs-2@5.3.0: + resolution: {integrity: sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -4020,6 +4039,8 @@ snapshots: '@juggle/resize-observer@3.4.0': {} + '@kurkle/color@0.3.4': {} + '@mui/core-downloads-tracker@7.1.0': {} '@mui/icons-material@7.1.0(@mui/material@7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)': @@ -4740,6 +4761,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.4.9: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5687,6 +5712,11 @@ snapshots: proxy-from-env@1.1.0: {} + react-chartjs-2@5.3.0(chart.js@4.4.9)(react@19.1.0): + dependencies: + chart.js: 4.4.9 + react: 19.1.0 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/components/home/enhanced-traffic-graph.tsx b/src/components/home/enhanced-traffic-graph.tsx index 7e2bc2e6..f0cfbe8a 100644 --- a/src/components/home/enhanced-traffic-graph.tsx +++ b/src/components/home/enhanced-traffic-graph.tsx @@ -11,17 +11,27 @@ import { 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 { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, Tooltip, - ResponsiveContainer, - AreaChart, - Area, -} from "recharts"; + Filler, + Scale, + Tick, +} from "chart.js"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + Filler +); // 流量数据项接口 export interface ITrafficItem { @@ -30,13 +40,12 @@ export interface ITrafficItem { timestamp?: number; } -// 组件对外暴露的方法 +// 对外暴露的接口 export interface EnhancedTrafficGraphRef { appendData: (data: ITrafficItem) => void; toggleStyle: () => void; } -// 时间范围类型 type TimeRange = 1 | 5 | 10; // 分钟 // 数据点类型 @@ -76,23 +85,30 @@ export const EnhancedTrafficGraph = memo(forwardRef( up: theme.palette.secondary.main, down: theme.palette.primary.main, grid: theme.palette.divider, - tooltip: theme.palette.background.paper, + tooltipBg: theme.palette.background.paper, text: theme.palette.text.primary, + tooltipBorder: theme.palette.divider, }), [theme] ); // 切换时间范围 - const handleTimeRangeClick = useCallback(() => { + const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); setTimeRange((prevRange) => { - // 在1、5、10分钟之间循环切换 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; @@ -102,17 +118,29 @@ export const EnhancedTrafficGraph = memo(forwardRef( 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: date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), + name: nameValue, }; } ); @@ -122,45 +150,54 @@ export const EnhancedTrafficGraph = memo(forwardRef( // 更新显示数据 const pointsToShow = getMaxPointsByTimeRange(timeRange); setDisplayData(initialBuffer.slice(-pointsToShow)); - }, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]); - + }, [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: date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), + 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); - setDisplayData(dataBufferRef.current.slice(-pointsToShow)); + if (dataBufferRef.current.length > 0) { + setDisplayData(dataBufferRef.current.slice(-pointsToShow)); + } }, [timeRange, getMaxPointsByTimeRange]); // 切换图表样式 @@ -178,47 +215,166 @@ export const EnhancedTrafficGraph = memo(forwardRef( [appendData, toggleStyle] ); - // 格式化工具提示内容 - const formatTooltip = useCallback((value: number, name: string, props: any) => { - const [num, unit] = parseTraffic(value); - return [`${num} ${unit}/s`, props?.dataKey === "up" ? t("Upload") : t("Download")]; - }, [t]); - // Y轴刻度格式化 - const formatYAxis = useCallback((value: number) => { + const formatYAxis = useCallback((value: number | string): string => { + if (typeof value !== 'number') return String(value); const [num, unit] = parseTraffic(value); return `${num}${unit}`; }, []); - // 格式化X轴标签 - const formatXLabel = useCallback((value: string) => { - if (!value) return ""; - const parts = value.split(":"); - return `${parts[0]}:${parts[1]}`; - }, []); + 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 chartConfig = useMemo(() => ({ - data: displayData, - margin: { top: 20, right: 10, left: 0, bottom: -10 }, - }), [displayData]); + 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 commonLineProps = useMemo(() => ({ - dot: false, - strokeWidth: 2, - connectNulls: false, - activeDot: { r: 4, strokeWidth: 1 }, - isAnimationActive: false, // 禁用动画以减少CPU使用 - }), []); + 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]); - // 曲线类型 - const curveType = "monotone"; return ( ( borderRadius: 1, cursor: "pointer", }} - onClick={toggleStyle} + onClick={handleToggleStyleClick} > - - {/* 根据chartStyle动态选择图表类型 */} - {(() => { - // 创建共享的图表组件 - const commonChartComponents = ( - <> - - - - `${t("Time")}: ${label}`} - contentStyle={{ - backgroundColor: colors.tooltip, - borderColor: colors.grid, - borderRadius: 4, - }} - itemStyle={{ color: colors.text }} - isAnimationActive={false} - /> - - {/* 可点击的时间范围标签 */} - - {getTimeRangeText()} - - - {/* 上传标签 - 右上角 */} - - {t("Upload")} - +
+ {displayData.length > 0 && ( + + )} + + + + {getTimeRangeText()} + + + + {t("Upload")} + - {/* 下载标签 - 右上角下方 */} - - {t("Download")} - - - ); - - // 根据chartStyle返回相应的图表类型 - if (chartStyle === "line") { - return ( - - {commonChartComponents} - - - - ); - } else { - return ( - - {commonChartComponents} - - - - ); - } - })()} - + + {t("Download")} + + +
); }, )); -// 添加显示名称以便调试 EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";