mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 21:40:33 +08:00
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
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<EnhancedTrafficGraphRef>(
|
|
(props, ref) => {
|
|
const theme = useTheme();
|
|
const { t } = useTranslation();
|
|
|
|
// 基础状态
|
|
const [timeRange, setTimeRange] = useState<TimeRange>(10);
|
|
const [chartStyle, setChartStyle] = useState<"line" | "area">("area");
|
|
const [displayData, setDisplayData] = useState<DataPoint[]>([]);
|
|
|
|
// 数据缓冲区
|
|
const dataBufferRef = useRef<DataPoint[]>([]);
|
|
|
|
// 根据时间范围计算保留的数据点数量
|
|
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<SVGTextElement>) => {
|
|
event.stopPropagation();
|
|
setTimeRange((prevRange) => {
|
|
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;
|
|
|
|
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 (
|
|
<Box
|
|
sx={{
|
|
width: "100%",
|
|
height: "100%",
|
|
position: "relative",
|
|
bgcolor: "action.hover",
|
|
borderRadius: 1,
|
|
cursor: "pointer",
|
|
}}
|
|
onClick={handleToggleStyleClick}
|
|
>
|
|
<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={handleToggleStyleClick}
|
|
style={{ cursor: "pointer", pointerEvents: 'all' }}
|
|
>
|
|
{t("Download")}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
</Box>
|
|
);
|
|
},
|
|
));
|
|
|
|
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";
|