mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
* feat: add tauri-plugin-mihomo
* refactor: invock mihomo api by use tauri-plugin-mihomo
* chore: todo
* chore: update
* chore: update
* chore: update
* chore: update
* fix: incorrect delay status and update pretty config
* chore: update
* chore: remove cache
* chore: update
* chore: update
* fix: app freezed when change group proxy
* chore: update
* chore: update
* chore: add rustfmt.toml to tauri-plugin-mihomo
* chore: happy clippy
* refactor: connect mihomo websocket
* chore: update
* chore: update
* fix: parse bigint to number
* chore: update
* Revert "fix: parse bigint to number"
This reverts commit 74c006522e.
* chore: use number instead of bigint
* chore: cleanup
* fix: rule data not refresh when switch profile
* chore: update
* chore: cleanup
* chore: update
* fix: traffic graph data display
* feat: add ipc connection pool
* chore: update
* chore: clippy
* fix: incorrect delay status
* fix: typo
* fix: empty proxies tray menu
* chore: clippy
* chore: import tauri-plugin-mihomo by using git repo
* chore: cleanup
* fix: mihomo api
* fix: incorrect delay status
* chore: update tauri-plugin-mihomo dep
chore: update
1013 lines
30 KiB
TypeScript
1013 lines
30 KiB
TypeScript
import { Box, useTheme } from "@mui/material";
|
||
import type { Ref } from "react";
|
||
import {
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import {
|
||
useTrafficGraphDataEnhanced,
|
||
type ITrafficDataPoint,
|
||
} from "@/hooks/use-traffic-monitor";
|
||
import parseTraffic from "@/utils/parse-traffic";
|
||
|
||
// 流量数据项接口
|
||
interface ITrafficItem {
|
||
up: number;
|
||
down: number;
|
||
timestamp?: number;
|
||
}
|
||
|
||
// 对外暴露的接口
|
||
export interface EnhancedCanvasTrafficGraphRef {
|
||
appendData: (data: ITrafficItem) => void;
|
||
toggleStyle: () => void;
|
||
}
|
||
|
||
type TimeRange = 1 | 5 | 10; // 分钟
|
||
|
||
// 悬浮提示数据接口
|
||
interface TooltipData {
|
||
x: number;
|
||
y: number;
|
||
upSpeed: string;
|
||
downSpeed: string;
|
||
timestamp: string;
|
||
visible: boolean;
|
||
dataIndex: number; // 添加数据索引用于高亮
|
||
highlightY: number; // 高亮Y轴位置
|
||
}
|
||
|
||
const MAX_POINTS = 300;
|
||
const TARGET_FPS = 15; // 降低帧率减少闪烁
|
||
const LINE_WIDTH_UP = 2.5;
|
||
const LINE_WIDTH_DOWN = 2.5;
|
||
const LINE_WIDTH_GRID = 0.5;
|
||
const ALPHA_GRADIENT = 0.15; // 降低渐变透明度
|
||
const ALPHA_LINE = 0.9;
|
||
const PADDING_TOP = 16;
|
||
const PADDING_RIGHT = 16; // 增加右边距确保时间戳完整显示
|
||
const PADDING_BOTTOM = 32; // 进一步增加底部空间给时间轴和统计信息
|
||
const PADDING_LEFT = 35; // 增加左边距为Y轴标签留出空间
|
||
|
||
const GRAPH_CONFIG = {
|
||
maxPoints: MAX_POINTS,
|
||
targetFPS: TARGET_FPS,
|
||
lineWidth: {
|
||
up: LINE_WIDTH_UP,
|
||
down: LINE_WIDTH_DOWN,
|
||
grid: LINE_WIDTH_GRID,
|
||
},
|
||
alpha: {
|
||
gradient: ALPHA_GRADIENT,
|
||
line: ALPHA_LINE,
|
||
},
|
||
padding: {
|
||
top: PADDING_TOP,
|
||
right: PADDING_RIGHT,
|
||
bottom: PADDING_BOTTOM,
|
||
left: PADDING_LEFT,
|
||
},
|
||
};
|
||
|
||
interface EnhancedCanvasTrafficGraphProps {
|
||
ref?: Ref<EnhancedCanvasTrafficGraphRef>;
|
||
}
|
||
|
||
/**
|
||
* 稳定版Canvas流量图表组件
|
||
* 修复闪烁问题,添加时间轴显示
|
||
*/
|
||
export const EnhancedCanvasTrafficGraph = memo(
|
||
function EnhancedCanvasTrafficGraph({
|
||
ref,
|
||
}: EnhancedCanvasTrafficGraphProps) {
|
||
const theme = useTheme();
|
||
const { t } = useTranslation();
|
||
|
||
// 使用增强版全局流量数据管理
|
||
const { dataPoints, getDataForTimeRange, samplerStats } =
|
||
useTrafficGraphDataEnhanced();
|
||
|
||
// 基础状态
|
||
const [timeRange, setTimeRange] = useState<TimeRange>(10);
|
||
const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier");
|
||
|
||
// 悬浮提示状态
|
||
const [tooltipData, setTooltipData] = useState<TooltipData>({
|
||
x: 0,
|
||
y: 0,
|
||
upSpeed: "",
|
||
downSpeed: "",
|
||
timestamp: "",
|
||
visible: false,
|
||
dataIndex: -1,
|
||
highlightY: 0,
|
||
});
|
||
|
||
// Canvas引用和渲染状态
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||
const lastRenderTimeRef = useRef<number>(0);
|
||
const isInitializedRef = useRef<boolean>(false);
|
||
|
||
// 当前显示的数据缓存
|
||
const [displayData, setDisplayData] = useState<ITrafficDataPoint[]>([]);
|
||
|
||
// 主题颜色配置
|
||
const colors = useMemo(
|
||
() => ({
|
||
up: theme.palette.secondary.main,
|
||
down: theme.palette.primary.main,
|
||
grid: theme.palette.divider,
|
||
text: theme.palette.text.secondary,
|
||
background: theme.palette.background.paper,
|
||
}),
|
||
[theme],
|
||
);
|
||
|
||
// 更新显示数据(防抖处理)
|
||
const updateDisplayDataDebounced = useMemo(() => {
|
||
let timeoutId: number;
|
||
return (newData: ITrafficDataPoint[]) => {
|
||
clearTimeout(timeoutId);
|
||
timeoutId = window.setTimeout(() => {
|
||
setDisplayData(newData);
|
||
}, 50); // 50ms防抖
|
||
};
|
||
}, []);
|
||
|
||
// 监听数据变化
|
||
useEffect(() => {
|
||
const timeRangeData = getDataForTimeRange(timeRange);
|
||
updateDisplayDataDebounced(timeRangeData);
|
||
}, [
|
||
dataPoints,
|
||
timeRange,
|
||
getDataForTimeRange,
|
||
updateDisplayDataDebounced,
|
||
]);
|
||
|
||
// Y轴坐标计算 - 基于刻度范围的线性映射
|
||
const calculateY = useCallback(
|
||
(value: number, height: number, data: ITrafficDataPoint[]): number => {
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const topY = padding.top + 10; // 与刻度系统保持一致
|
||
const bottomY = height - padding.bottom - 5;
|
||
|
||
if (data.length === 0) return bottomY;
|
||
|
||
// 获取当前的刻度范围
|
||
const allValues = [
|
||
...data.map((d) => d.up),
|
||
...data.map((d) => d.down),
|
||
];
|
||
const maxValue = Math.max(...allValues);
|
||
const minValue = Math.min(...allValues);
|
||
|
||
let topValue, bottomValue;
|
||
|
||
if (maxValue === 0) {
|
||
topValue = 1024;
|
||
bottomValue = 0;
|
||
} else {
|
||
const range = maxValue - minValue;
|
||
const padding_percent = range > 0 ? 0.1 : 0.5;
|
||
|
||
if (range === 0) {
|
||
bottomValue = 0;
|
||
topValue = maxValue * 1.2;
|
||
} else {
|
||
bottomValue = Math.max(0, minValue - range * padding_percent);
|
||
topValue = maxValue + range * padding_percent;
|
||
}
|
||
}
|
||
|
||
// 线性映射到Y坐标
|
||
if (topValue === bottomValue) return bottomY;
|
||
|
||
const ratio = (value - bottomValue) / (topValue - bottomValue);
|
||
return bottomY - ratio * (bottomY - topY);
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 鼠标悬浮处理 - 计算最近的数据点
|
||
const handleMouseMove = useCallback(
|
||
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas || displayData.length === 0) return;
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mouseX = event.clientX - rect.left;
|
||
const mouseY = event.clientY - rect.top;
|
||
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const effectiveWidth = rect.width - padding.left - padding.right;
|
||
|
||
// 计算最接近的数据点索引
|
||
const relativeMouseX = mouseX - padding.left;
|
||
const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth));
|
||
const dataIndex = Math.round(ratio * (displayData.length - 1));
|
||
|
||
if (dataIndex >= 0 && dataIndex < displayData.length) {
|
||
const dataPoint = displayData[dataIndex];
|
||
|
||
// 格式化流量数据
|
||
const [upValue, upUnit] = parseTraffic(dataPoint.up);
|
||
const [downValue, downUnit] = parseTraffic(dataPoint.down);
|
||
|
||
// 格式化时间戳
|
||
const timeStr = dataPoint.timestamp
|
||
? new Date(dataPoint.timestamp).toLocaleTimeString("zh-CN", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
})
|
||
: "未知时间";
|
||
|
||
// 计算数据点对应的Y坐标位置(用于高亮)
|
||
const upY = calculateY(dataPoint.up, rect.height, displayData);
|
||
const downY = calculateY(dataPoint.down, rect.height, displayData);
|
||
const highlightY =
|
||
Math.max(dataPoint.up, dataPoint.down) === dataPoint.up
|
||
? upY
|
||
: downY;
|
||
|
||
setTooltipData({
|
||
x: mouseX,
|
||
y: mouseY,
|
||
upSpeed: `${upValue}${upUnit}/s`,
|
||
downSpeed: `${downValue}${downUnit}/s`,
|
||
timestamp: timeStr,
|
||
visible: true,
|
||
dataIndex,
|
||
highlightY,
|
||
});
|
||
}
|
||
},
|
||
[displayData, calculateY],
|
||
);
|
||
|
||
// 鼠标离开处理
|
||
const handleMouseLeave = useCallback(() => {
|
||
setTooltipData((prev) => ({ ...prev, visible: false }));
|
||
}, []);
|
||
|
||
// 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值)
|
||
const getYAxisTicks = useCallback(
|
||
(data: ITrafficDataPoint[], height: number) => {
|
||
if (data.length === 0) return [];
|
||
|
||
// 找到数据的最大值和最小值
|
||
const allValues = [
|
||
...data.map((d) => d.up),
|
||
...data.map((d) => d.down),
|
||
];
|
||
const maxValue = Math.max(...allValues);
|
||
const minValue = Math.min(...allValues);
|
||
|
||
// 格式化流量数值
|
||
const formatTrafficValue = (bytes: number): string => {
|
||
if (bytes === 0) return "0";
|
||
if (bytes < 1024) return `${Math.round(bytes)}B`;
|
||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||
};
|
||
|
||
const padding = GRAPH_CONFIG.padding;
|
||
|
||
// 强制显示三个刻度:底部、中间、顶部
|
||
const topY = padding.top + 10; // 避免与顶部时间范围按钮重叠
|
||
const bottomY = height - padding.bottom - 5; // 避免与底部时间轴重叠
|
||
const middleY = (topY + bottomY) / 2;
|
||
|
||
// 计算对应的值
|
||
let topValue, middleValue, bottomValue;
|
||
|
||
if (maxValue === 0) {
|
||
// 如果没有流量,显示0到一个小值的范围
|
||
topValue = 1024; // 1KB
|
||
middleValue = 512; // 512B
|
||
bottomValue = 0;
|
||
} else {
|
||
// 根据数据范围计算合适的刻度值
|
||
const range = maxValue - minValue;
|
||
const padding_percent = range > 0 ? 0.1 : 0.5; // 如果范围为0,使用更大的边距
|
||
|
||
if (range === 0) {
|
||
// 所有值相同的情况
|
||
bottomValue = 0;
|
||
middleValue = maxValue * 0.5;
|
||
topValue = maxValue * 1.2;
|
||
} else {
|
||
// 正常情况
|
||
bottomValue = Math.max(0, minValue - range * padding_percent);
|
||
topValue = maxValue + range * padding_percent;
|
||
middleValue = (bottomValue + topValue) / 2;
|
||
}
|
||
}
|
||
|
||
// 创建三个固定位置的刻度
|
||
const ticks = [
|
||
{
|
||
value: bottomValue,
|
||
label: formatTrafficValue(bottomValue),
|
||
y: bottomY,
|
||
},
|
||
{
|
||
value: middleValue,
|
||
label: formatTrafficValue(middleValue),
|
||
y: middleY,
|
||
},
|
||
{
|
||
value: topValue,
|
||
label: formatTrafficValue(topValue),
|
||
y: topY,
|
||
},
|
||
];
|
||
|
||
return ticks;
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 绘制Y轴刻度线和标签
|
||
const drawYAxis = useCallback(
|
||
(
|
||
ctx: CanvasRenderingContext2D,
|
||
width: number,
|
||
height: number,
|
||
data: ITrafficDataPoint[],
|
||
) => {
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const ticks = getYAxisTicks(data, height);
|
||
|
||
if (ticks.length === 0) return;
|
||
|
||
ctx.save();
|
||
|
||
ticks.forEach((tick, index) => {
|
||
const isBottomTick = index === 0; // 最底部的刻度
|
||
const isTopTick = index === ticks.length - 1; // 最顶部的刻度
|
||
|
||
// 绘制水平刻度线,只绘制关键刻度线
|
||
if (isBottomTick || isTopTick) {
|
||
ctx.strokeStyle = colors.grid;
|
||
ctx.lineWidth = isBottomTick ? 0.8 : 0.4; // 底部刻度线稍粗
|
||
ctx.globalAlpha = isBottomTick ? 0.25 : 0.15;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, tick.y);
|
||
ctx.lineTo(width - padding.right, tick.y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 绘制Y轴标签
|
||
ctx.fillStyle = colors.text;
|
||
ctx.font =
|
||
"8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif";
|
||
ctx.globalAlpha = 0.9;
|
||
ctx.textAlign = "right";
|
||
ctx.textBaseline = "middle";
|
||
|
||
// 为标签添加更清晰的背景(仅在必要时)
|
||
if (tick.label !== "0") {
|
||
const labelWidth = ctx.measureText(tick.label).width;
|
||
ctx.globalAlpha = 0.15;
|
||
ctx.fillStyle = colors.background;
|
||
ctx.fillRect(
|
||
padding.left - labelWidth - 8,
|
||
tick.y - 5,
|
||
labelWidth + 4,
|
||
10,
|
||
);
|
||
}
|
||
|
||
// 绘制标签文字
|
||
ctx.globalAlpha = 0.9;
|
||
ctx.fillStyle = colors.text;
|
||
ctx.fillText(tick.label, padding.left - 4, tick.y);
|
||
});
|
||
|
||
ctx.restore();
|
||
},
|
||
[colors.grid, colors.text, colors.background, getYAxisTicks],
|
||
);
|
||
|
||
// 获取时间范围对应的最佳时间显示策略
|
||
const getTimeDisplayStrategy = useCallback(
|
||
(timeRangeMinutes: TimeRange) => {
|
||
switch (timeRangeMinutes) {
|
||
case 1: // 1分钟:更密集的时间标签,显示 MM:SS
|
||
return {
|
||
maxLabels: 6, // 减少到6个,更适合短时间
|
||
formatTime: (timestamp: number) => {
|
||
const date = new Date(timestamp);
|
||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||
const seconds = date.getSeconds().toString().padStart(2, "0");
|
||
return `${minutes}:${seconds}`; // 显示 MM:SS
|
||
},
|
||
intervalSeconds: 10, // 每10秒一个标签,更合理
|
||
minPixelDistance: 35, // 减少间距,允许更多标签
|
||
};
|
||
case 5: // 5分钟:中等密度,显示 HH:MM
|
||
return {
|
||
maxLabels: 6, // 6个标签比较合适
|
||
formatTime: (timestamp: number) => {
|
||
const date = new Date(timestamp);
|
||
return date.toLocaleTimeString("en-US", {
|
||
hour12: false,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}); // 显示 HH:MM
|
||
},
|
||
intervalSeconds: 30, // 约30秒间隔
|
||
minPixelDistance: 38, // 减少间距,允许更多标签
|
||
};
|
||
case 10: // 10分钟:标准密度,显示 HH:MM
|
||
default:
|
||
return {
|
||
maxLabels: 8, // 保持8个
|
||
formatTime: (timestamp: number) => {
|
||
const date = new Date(timestamp);
|
||
return date.toLocaleTimeString("en-US", {
|
||
hour12: false,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}); // 显示 HH:MM
|
||
},
|
||
intervalSeconds: 60, // 1分钟间隔
|
||
minPixelDistance: 40, // 减少间距,允许更多标签
|
||
};
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 绘制时间轴
|
||
const drawTimeAxis = useCallback(
|
||
(
|
||
ctx: CanvasRenderingContext2D,
|
||
width: number,
|
||
height: number,
|
||
data: ITrafficDataPoint[],
|
||
) => {
|
||
if (data.length === 0) return;
|
||
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const effectiveWidth = width - padding.left - padding.right;
|
||
const timeAxisY = height - padding.bottom + 14;
|
||
|
||
const strategy = getTimeDisplayStrategy(timeRange);
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = colors.text;
|
||
ctx.font =
|
||
"10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif";
|
||
ctx.globalAlpha = 0.7;
|
||
|
||
// 根据数据长度和时间范围智能选择显示间隔
|
||
const targetLabels = Math.min(strategy.maxLabels, data.length);
|
||
const step = Math.max(1, Math.floor(data.length / (targetLabels - 1)));
|
||
|
||
// 使用策略中定义的最小像素间距
|
||
const minPixelDistance = strategy.minPixelDistance || 45;
|
||
const actualStep = Math.max(
|
||
step,
|
||
Math.ceil((data.length * minPixelDistance) / effectiveWidth),
|
||
);
|
||
|
||
// 收集要显示的时间点
|
||
const timePoints: Array<{ index: number; x: number; label: string }> =
|
||
[];
|
||
|
||
// 添加第一个时间点
|
||
if (data.length > 0 && data[0].timestamp) {
|
||
timePoints.push({
|
||
index: 0,
|
||
x: padding.left,
|
||
label: strategy.formatTime(data[0].timestamp),
|
||
});
|
||
}
|
||
|
||
// 添加中间的时间点
|
||
for (
|
||
let i = actualStep;
|
||
i < data.length - actualStep;
|
||
i += actualStep
|
||
) {
|
||
const point = data[i];
|
||
if (!point.timestamp) continue;
|
||
|
||
const x = padding.left + (i / (data.length - 1)) * effectiveWidth;
|
||
timePoints.push({
|
||
index: i,
|
||
x,
|
||
label: strategy.formatTime(point.timestamp),
|
||
});
|
||
}
|
||
|
||
// 添加最后一个时间点(如果不会与前面的重叠)
|
||
if (data.length > 1 && data[data.length - 1].timestamp) {
|
||
const lastX = width - padding.right;
|
||
const lastPoint = timePoints[timePoints.length - 1];
|
||
|
||
// 确保最后一个标签与前一个标签有足够间距
|
||
if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) {
|
||
timePoints.push({
|
||
index: data.length - 1,
|
||
x: lastX,
|
||
label: strategy.formatTime(data[data.length - 1].timestamp),
|
||
});
|
||
}
|
||
}
|
||
|
||
// 绘制时间标签
|
||
timePoints.forEach((point, index) => {
|
||
if (index === 0) {
|
||
// 第一个标签左对齐
|
||
ctx.textAlign = "left";
|
||
} else if (index === timePoints.length - 1) {
|
||
// 最后一个标签右对齐
|
||
ctx.textAlign = "right";
|
||
} else {
|
||
// 中间标签居中对齐
|
||
ctx.textAlign = "center";
|
||
}
|
||
|
||
ctx.fillText(point.label, point.x, timeAxisY);
|
||
});
|
||
|
||
ctx.restore();
|
||
},
|
||
[colors.text, timeRange, getTimeDisplayStrategy],
|
||
);
|
||
|
||
// 绘制网格线
|
||
const drawGrid = useCallback(
|
||
(ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const effectiveWidth = width - padding.left - padding.right;
|
||
const effectiveHeight = height - padding.top - padding.bottom;
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = colors.grid;
|
||
ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid;
|
||
ctx.globalAlpha = 0.7;
|
||
|
||
// 水平网格线
|
||
const horizontalLines = 4;
|
||
for (let i = 1; i <= horizontalLines; i++) {
|
||
const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, y);
|
||
ctx.lineTo(width - padding.right, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 垂直网格线
|
||
const verticalLines = 6;
|
||
for (let i = 1; i <= verticalLines; i++) {
|
||
const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, padding.top);
|
||
ctx.lineTo(x, height - padding.bottom);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.restore();
|
||
},
|
||
[colors.grid],
|
||
);
|
||
|
||
// 绘制流量线条
|
||
const drawTrafficLine = useCallback(
|
||
(
|
||
ctx: CanvasRenderingContext2D,
|
||
values: number[],
|
||
width: number,
|
||
height: number,
|
||
color: string,
|
||
withGradient = false,
|
||
data: ITrafficDataPoint[],
|
||
) => {
|
||
if (values.length < 2) return;
|
||
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const effectiveWidth = width - padding.left - padding.right;
|
||
|
||
const points = values.map((value, index) => [
|
||
padding.left + (index / (values.length - 1)) * effectiveWidth,
|
||
calculateY(value, height, data),
|
||
]);
|
||
|
||
ctx.save();
|
||
|
||
// 绘制渐变填充
|
||
if (withGradient && chartStyle === "bezier") {
|
||
const gradient = ctx.createLinearGradient(
|
||
0,
|
||
padding.top,
|
||
0,
|
||
height - padding.bottom,
|
||
);
|
||
gradient.addColorStop(
|
||
0,
|
||
`${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255)
|
||
.toString(16)
|
||
.padStart(2, "0")}`,
|
||
);
|
||
gradient.addColorStop(1, `${color}00`);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0][0], points[0][1]);
|
||
|
||
if (chartStyle === "bezier") {
|
||
for (let i = 1; i < points.length; i++) {
|
||
const current = points[i];
|
||
const next = points[i + 1] || current;
|
||
const controlX = (current[0] + next[0]) / 2;
|
||
const controlY = (current[1] + next[1]) / 2;
|
||
ctx.quadraticCurveTo(current[0], current[1], controlX, controlY);
|
||
}
|
||
} else {
|
||
for (let i = 1; i < points.length; i++) {
|
||
ctx.lineTo(points[i][0], points[i][1]);
|
||
}
|
||
}
|
||
|
||
ctx.lineTo(points[points.length - 1][0], height - padding.bottom);
|
||
ctx.lineTo(points[0][0], height - padding.bottom);
|
||
ctx.closePath();
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
}
|
||
|
||
// 绘制主线条
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = GRAPH_CONFIG.lineWidth.up;
|
||
ctx.lineCap = "round";
|
||
ctx.lineJoin = "round";
|
||
ctx.globalAlpha = GRAPH_CONFIG.alpha.line;
|
||
|
||
ctx.moveTo(points[0][0], points[0][1]);
|
||
|
||
if (chartStyle === "bezier") {
|
||
for (let i = 1; i < points.length; i++) {
|
||
const current = points[i];
|
||
const next = points[i + 1] || current;
|
||
const controlX = (current[0] + next[0]) / 2;
|
||
const controlY = (current[1] + next[1]) / 2;
|
||
ctx.quadraticCurveTo(current[0], current[1], controlX, controlY);
|
||
}
|
||
} else {
|
||
for (let i = 1; i < points.length; i++) {
|
||
ctx.lineTo(points[i][0], points[i][1]);
|
||
}
|
||
}
|
||
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
},
|
||
[calculateY, chartStyle],
|
||
);
|
||
|
||
// 主绘制函数
|
||
const drawGraph = useCallback(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas || displayData.length === 0) return;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
// Canvas尺寸设置
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = rect.width;
|
||
const height = rect.height;
|
||
|
||
// 只在尺寸变化时重新设置Canvas
|
||
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
|
||
canvas.width = width * dpr;
|
||
canvas.height = height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
canvas.style.width = width + "px";
|
||
canvas.style.height = height + "px";
|
||
}
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
// 绘制Y轴刻度线(背景层)
|
||
drawYAxis(ctx, width, height, displayData);
|
||
|
||
// 绘制网格
|
||
drawGrid(ctx, width, height);
|
||
|
||
// 绘制时间轴
|
||
drawTimeAxis(ctx, width, height, displayData);
|
||
|
||
// 提取流量数据
|
||
const upValues = displayData.map((d) => d.up);
|
||
const downValues = displayData.map((d) => d.down);
|
||
|
||
// 绘制下载线(背景层)
|
||
drawTrafficLine(
|
||
ctx,
|
||
downValues,
|
||
width,
|
||
height,
|
||
colors.down,
|
||
true,
|
||
displayData,
|
||
);
|
||
|
||
// 绘制上传线(前景层)
|
||
drawTrafficLine(
|
||
ctx,
|
||
upValues,
|
||
width,
|
||
height,
|
||
colors.up,
|
||
true,
|
||
displayData,
|
||
);
|
||
|
||
// 绘制悬浮高亮线
|
||
if (tooltipData.visible && tooltipData.dataIndex >= 0) {
|
||
const padding = GRAPH_CONFIG.padding;
|
||
const effectiveWidth = width - padding.left - padding.right;
|
||
const dataX =
|
||
padding.left +
|
||
(tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth;
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = colors.text;
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.6;
|
||
ctx.setLineDash([4, 4]); // 虚线效果
|
||
|
||
// 绘制垂直指示线
|
||
ctx.beginPath();
|
||
ctx.moveTo(dataX, padding.top);
|
||
ctx.lineTo(dataX, height - padding.bottom);
|
||
ctx.stroke();
|
||
|
||
// 绘制水平指示线(高亮Y轴位置)
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, tooltipData.highlightY);
|
||
ctx.lineTo(width - padding.right, tooltipData.highlightY);
|
||
ctx.stroke();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
isInitializedRef.current = true;
|
||
}, [
|
||
displayData,
|
||
colors,
|
||
drawYAxis,
|
||
drawGrid,
|
||
drawTimeAxis,
|
||
drawTrafficLine,
|
||
tooltipData,
|
||
]);
|
||
|
||
// 受控的动画循环
|
||
useEffect(() => {
|
||
const animate = (currentTime: number) => {
|
||
// 控制帧率,减少不必要的重绘
|
||
if (
|
||
currentTime - lastRenderTimeRef.current >=
|
||
1000 / GRAPH_CONFIG.targetFPS
|
||
) {
|
||
drawGraph();
|
||
lastRenderTimeRef.current = currentTime;
|
||
}
|
||
animationFrameRef.current = requestAnimationFrame(animate);
|
||
};
|
||
|
||
// 只有在有数据时才开始动画
|
||
if (displayData.length > 0) {
|
||
animationFrameRef.current = requestAnimationFrame(animate);
|
||
}
|
||
|
||
return () => {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
};
|
||
}, [drawGraph, displayData.length]);
|
||
|
||
// 切换时间范围
|
||
const handleTimeRangeClick = useCallback((event: React.MouseEvent) => {
|
||
event.stopPropagation();
|
||
setTimeRange((prev) => {
|
||
return prev === 1 ? 5 : prev === 5 ? 10 : 1;
|
||
});
|
||
}, []);
|
||
|
||
// 切换图表样式
|
||
const toggleStyle = useCallback(() => {
|
||
setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier"));
|
||
}, []);
|
||
|
||
// 兼容性方法
|
||
const appendData = useCallback((data: ITrafficItem) => {
|
||
console.log(
|
||
"[EnhancedCanvasTrafficGraphV2] appendData called (using global data):",
|
||
data,
|
||
);
|
||
}, []);
|
||
|
||
// 暴露方法给父组件
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
appendData,
|
||
toggleStyle,
|
||
}),
|
||
[appendData, toggleStyle],
|
||
);
|
||
|
||
// 获取时间范围文本
|
||
const getTimeRangeText = useCallback(() => {
|
||
return t("{{time}} Minutes", { time: timeRange });
|
||
}, [timeRange, t]);
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
width: "100%",
|
||
height: "100%",
|
||
position: "relative",
|
||
bgcolor: "action.hover",
|
||
borderRadius: 1,
|
||
cursor: "pointer",
|
||
overflow: "hidden",
|
||
}}
|
||
onClick={toggleStyle}
|
||
>
|
||
<canvas
|
||
ref={canvasRef}
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
display: "block",
|
||
}}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseLeave={handleMouseLeave}
|
||
onClick={toggleStyle}
|
||
/>
|
||
|
||
{/* 控制层覆盖 */}
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{/* 时间范围按钮 */}
|
||
<Box
|
||
component="div"
|
||
onClick={handleTimeRangeClick}
|
||
sx={{
|
||
position: "absolute",
|
||
top: 6,
|
||
left: 40, // 向右移动,避免与Y轴最大值标签重叠
|
||
fontSize: "11px",
|
||
fontWeight: "bold",
|
||
color: "text.secondary",
|
||
cursor: "pointer",
|
||
pointerEvents: "all",
|
||
px: 1,
|
||
py: 0.5,
|
||
borderRadius: 0.5,
|
||
bgcolor: "rgba(0,0,0,0.05)",
|
||
"&:hover": {
|
||
bgcolor: "rgba(0,0,0,0.1)",
|
||
},
|
||
}}
|
||
>
|
||
{getTimeRangeText()}
|
||
</Box>
|
||
|
||
{/* 图例 */}
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
top: 6,
|
||
right: 8,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 0.5,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
fontSize: "11px",
|
||
fontWeight: "bold",
|
||
color: colors.up,
|
||
textAlign: "right",
|
||
}}
|
||
>
|
||
{t("Upload")}
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
fontSize: "11px",
|
||
fontWeight: "bold",
|
||
color: colors.down,
|
||
textAlign: "right",
|
||
}}
|
||
>
|
||
{t("Download")}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* 样式指示器 */}
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
bottom: 6,
|
||
right: 8,
|
||
fontSize: "10px",
|
||
color: "text.disabled",
|
||
opacity: 0.7,
|
||
}}
|
||
>
|
||
{chartStyle === "bezier" ? "Smooth" : "Linear"}
|
||
</Box>
|
||
|
||
{/* 数据统计指示器(左下角) */}
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
bottom: 6,
|
||
left: 8,
|
||
fontSize: "9px",
|
||
color: "text.disabled",
|
||
opacity: 0.6,
|
||
lineHeight: 1.2,
|
||
}}
|
||
>
|
||
Points: {displayData.length} | Compressed:{" "}
|
||
{samplerStats.compressedBufferSize}
|
||
</Box>
|
||
|
||
{/* 悬浮提示框 */}
|
||
{tooltipData.visible && (
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
left: tooltipData.x + 8,
|
||
top: tooltipData.y - 8,
|
||
bgcolor: theme.palette.background.paper,
|
||
border: 1,
|
||
borderColor: "divider",
|
||
borderRadius: 0.5,
|
||
px: 1,
|
||
py: 0.5,
|
||
fontSize: "10px",
|
||
lineHeight: 1.2,
|
||
zIndex: 1000,
|
||
pointerEvents: "none",
|
||
transform:
|
||
tooltipData.x > 200 ? "translateX(-100%)" : "translateX(0)",
|
||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||
backdropFilter: "none",
|
||
opacity: 1,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
<Box color="text.secondary" mb={0.2}>
|
||
{tooltipData.timestamp}
|
||
</Box>
|
||
<Box color="secondary.main" fontWeight="500">
|
||
↑ {tooltipData.upSpeed}
|
||
</Box>
|
||
<Box color="primary.main" fontWeight="500">
|
||
↓ {tooltipData.downSpeed}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
},
|
||
);
|
||
|
||
EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph";
|