feat: replace traffic chart rendering component for performance improvement and React 19 compatibility

This commit is contained in:
wonfen
2025-05-14 12:16:59 +08:00
Unverified
parent 1993e5dd51
commit becc51bcd2
3 changed files with 299 additions and 196 deletions

View File

@@ -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",

32
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<EnhancedTrafficGraphRef>(
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<SVGTextElement>) => {
event.stopPropagation();
setTimeRange((prevRange) => {
// 在1、5、10分钟之间循环切换
return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1;
});
}, []);
// 点击图表主体或图例时切换样式
const handleToggleStyleClick = useCallback((event: React.MouseEvent<SVGTextElement | HTMLDivElement>) => {
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<EnhancedTrafficGraphRef>(
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<EnhancedTrafficGraphRef>(
// 更新显示数据
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<EnhancedTrafficGraphRef>(
[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 (
<Box
@@ -230,141 +386,57 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
borderRadius: 1,
cursor: "pointer",
}}
onClick={toggleStyle}
onClick={handleToggleStyleClick}
>
<ResponsiveContainer width="100%" height="100%">
{/* 根据chartStyle动态选择图表类型 */}
{(() => {
// 创建共享的图表组件
const commonChartComponents = (
<>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.grid} opacity={0.3} />
<XAxis
dataKey="name"
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
interval="preserveStart"
tickFormatter={formatXLabel}
minTickGap={30}
/>
<YAxis
tickFormatter={formatYAxis}
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
width={44}
domain={[0, "auto"]}
padding={{ top: 8, bottom: 0 }}
/>
<Tooltip
formatter={formatTooltip}
labelFormatter={(label) => `${t("Time")}: ${label}`}
contentStyle={{
backgroundColor: colors.tooltip,
borderColor: colors.grid,
borderRadius: 4,
}}
itemStyle={{ color: colors.text }}
isAnimationActive={false}
/>
{/* 可点击的时间范围标签 */}
<text
x="1%"
y="11%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer" }}
>
{getTimeRangeText()}
</text>
{/* 上传标签 - 右上角 */}
<text
x="99%"
y="11%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
onClick={toggleStyle}
style={{ cursor: "pointer" }}
>
{t("Upload")}
</text>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{displayData.length > 0 && (
<ChartJsLine data={chartData} options={chartOptions} />
)}
<svg width="100%" height="100%" style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<text
x="3.5%"
y="10%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{getTimeRangeText()}
</text>
<text
x="99%"
y="10%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{t("Upload")}
</text>
{/* 下载标签 - 右上角下方 */}
<text
x="99%"
y="19%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
onClick={toggleStyle}
style={{ cursor: "pointer" }}
>
{t("Download")}
</text>
</>
);
// 根据chartStyle返回相应的图表类型
if (chartStyle === "line") {
return (
<LineChart {...chartConfig}>
{commonChartComponents}
<Line
type={curveType}
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
/>
<Line
type={curveType}
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
/>
</LineChart>
);
} else {
return (
<AreaChart {...chartConfig}>
{commonChartComponents}
<Area
type={curveType}
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
fill={colors.up}
fillOpacity={0.2}
/>
<Area
type={curveType}
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
fill={colors.down}
fillOpacity={0.3}
/>
</AreaChart>
);
}
})()}
</ResponsiveContainer>
<text
x="99%"
y="19%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{t("Download")}
</text>
</svg>
</div>
</Box>
);
},
));
// 添加显示名称以便调试
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";