mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-16 07:05:18 +08:00
* fix: replace DeleteOutline with DeleteOutlined icon in BackupHistoryViewer * chore(deps): update npm dependencies to v9 * refactor: bump @mui to v9 --------- Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1266 lines
38 KiB
TypeScript
1266 lines
38 KiB
TypeScript
import { Box, useTheme } from '@mui/material'
|
||
import type { Ref } from 'react'
|
||
import {
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useMemo,
|
||
useReducer,
|
||
useRef,
|
||
useState,
|
||
} from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
|
||
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
||
import { useVerge } from '@/hooks/use-verge'
|
||
import { debugLog } from '@/utils/debug'
|
||
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,
|
||
},
|
||
}
|
||
|
||
const MIN_FPS = 8
|
||
const MAX_FPS = 20
|
||
const FPS_ADJUST_INTERVAL = 3000 // ms
|
||
const FPS_SAMPLE_WINDOW = 12
|
||
const STALE_DATA_THRESHOLD = 2500 // ms without fresh data => drop FPS
|
||
const RESUME_FPS_TARGET = 12
|
||
const RESUME_COOLDOWN_MS = 2000
|
||
|
||
const getNow = () =>
|
||
typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||
|
||
interface EnhancedCanvasTrafficGraphProps {
|
||
ref?: Ref<EnhancedCanvasTrafficGraphRef>
|
||
}
|
||
|
||
const displayDataReducer = (
|
||
_: ITrafficDataPoint[],
|
||
payload: ITrafficDataPoint[],
|
||
): ITrafficDataPoint[] => payload
|
||
|
||
/**
|
||
* 稳定版Canvas流量图表组件
|
||
* 修复闪烁问题,添加时间轴显示
|
||
*/
|
||
export const EnhancedCanvasTrafficGraph = memo(
|
||
function EnhancedCanvasTrafficGraph({
|
||
ref,
|
||
}: EnhancedCanvasTrafficGraphProps) {
|
||
const theme = useTheme()
|
||
const { t } = useTranslation()
|
||
const verge = useVerge()
|
||
const pause_render_traffic_stats_on_blur =
|
||
verge.verge?.pause_render_traffic_stats_on_blur ?? true
|
||
|
||
// 使用增强版全局流量数据管理
|
||
const { dataPoints, requestRange, samplerStats } =
|
||
useTrafficGraphDataEnhanced()
|
||
|
||
// 基础状态
|
||
const [timeRange, setTimeRange] = useState<TimeRange>(10)
|
||
const [chartStyle, setChartStyle] = useState<'bezier' | 'line'>('bezier')
|
||
|
||
const initialFocusState =
|
||
typeof document !== 'undefined' ? !document.hidden : true
|
||
const [isWindowFocused, setIsWindowFocused] = useState(initialFocusState)
|
||
|
||
// 悬浮提示状态
|
||
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 isWindowFocusedRef = useRef<boolean>(initialFocusState)
|
||
const fpsControllerRef = useRef<{
|
||
target: number
|
||
samples: number[]
|
||
lastAdjustTime: number
|
||
}>({
|
||
target: GRAPH_CONFIG.targetFPS,
|
||
samples: [],
|
||
lastAdjustTime: 0,
|
||
})
|
||
const lastDataTimestampRef = useRef<number>(0)
|
||
const resumeCooldownRef = useRef<number>(0)
|
||
const dataStaleRef = useRef<boolean>(false)
|
||
|
||
// 当前显示的数据缓存
|
||
const [displayData, dispatchDisplayData] = useReducer(
|
||
displayDataReducer,
|
||
[],
|
||
)
|
||
const debounceTimeoutRef = useRef<number | null>(null)
|
||
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
|
||
|
||
// 主题颜色配置
|
||
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 updateDisplayData = useCallback((newData: ITrafficDataPoint[]) => {
|
||
if (debounceTimeoutRef.current !== null) {
|
||
window.clearTimeout(debounceTimeoutRef.current)
|
||
}
|
||
debounceTimeoutRef.current = window.setTimeout(() => {
|
||
dispatchDisplayData(newData)
|
||
}, 50) // 50ms防抖
|
||
}, [])
|
||
|
||
// 监听数据变化
|
||
useEffect(() => {
|
||
updateDisplayData(dataPoints)
|
||
|
||
return () => {
|
||
if (debounceTimeoutRef.current !== null) {
|
||
window.clearTimeout(debounceTimeoutRef.current)
|
||
debounceTimeoutRef.current = null
|
||
}
|
||
}
|
||
}, [dataPoints, updateDisplayData])
|
||
|
||
useEffect(() => {
|
||
requestRange(timeRange)
|
||
}, [requestRange, timeRange])
|
||
|
||
useEffect(() => {
|
||
if (displayData.length === 0) {
|
||
lastDataTimestampRef.current = 0
|
||
dataStaleRef.current = false
|
||
fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS
|
||
fpsControllerRef.current.samples = []
|
||
fpsControllerRef.current.lastAdjustTime = 0
|
||
// eslint-disable-next-line @eslint-react/set-state-in-effect
|
||
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||
return
|
||
}
|
||
|
||
const latestTimestamp =
|
||
displayData[displayData.length - 1]?.timestamp ?? null
|
||
if (latestTimestamp) {
|
||
lastDataTimestampRef.current = latestTimestamp
|
||
const age = Date.now() - latestTimestamp
|
||
const stale = age > STALE_DATA_THRESHOLD
|
||
dataStaleRef.current = stale
|
||
} else {
|
||
dataStaleRef.current = false
|
||
}
|
||
}, [displayData])
|
||
|
||
const handleFocusStateChange = useCallback(
|
||
(focused: boolean) => {
|
||
isWindowFocusedRef.current = focused
|
||
setIsWindowFocused(focused)
|
||
|
||
const highResNow = getNow()
|
||
lastRenderTimeRef.current = highResNow
|
||
|
||
if (focused || !pause_render_traffic_stats_on_blur) {
|
||
resumeCooldownRef.current = Date.now()
|
||
const controller = fpsControllerRef.current
|
||
const resumeTarget = Math.max(
|
||
MIN_FPS,
|
||
Math.min(controller.target, RESUME_FPS_TARGET),
|
||
)
|
||
controller.target = resumeTarget
|
||
controller.samples = []
|
||
controller.lastAdjustTime = 0
|
||
setCurrentFPS(resumeTarget)
|
||
} else {
|
||
resumeCooldownRef.current = 0
|
||
}
|
||
},
|
||
[setIsWindowFocused, setCurrentFPS, pause_render_traffic_stats_on_blur],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||
return
|
||
}
|
||
|
||
const handleFocus = () => handleFocusStateChange(true)
|
||
const handleBlur = () => handleFocusStateChange(false)
|
||
const handleVisibilityChange = () =>
|
||
handleFocusStateChange(!document.hidden)
|
||
|
||
window.addEventListener('focus', handleFocus)
|
||
window.addEventListener('blur', handleBlur)
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
|
||
return () => {
|
||
window.removeEventListener('focus', handleFocus)
|
||
window.removeEventListener('blur', handleBlur)
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
}
|
||
}, [handleFocusStateChange])
|
||
|
||
// 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
|
||
|
||
// Compute CSS size and pixel buffer size.
|
||
// Note: WebView2 on Windows may return fractional CSS sizes after maximize.
|
||
// We round pixel buffer to integers to avoid 1px gaps/cropping artifacts.
|
||
const rect = canvas.getBoundingClientRect()
|
||
const dpr = window.devicePixelRatio || 1
|
||
const cssWidth = rect.width
|
||
const cssHeight = rect.height
|
||
const pixelWidth = Math.max(1, Math.floor(cssWidth * dpr))
|
||
const pixelHeight = Math.max(1, Math.floor(cssHeight * dpr))
|
||
|
||
// Keep CSS-driven sizing so the canvas stretches with its container (e.g., on maximize).
|
||
if (canvas.style.width !== '100%') {
|
||
canvas.style.width = '100%'
|
||
}
|
||
if (canvas.style.height !== '100%') {
|
||
canvas.style.height = '100%'
|
||
}
|
||
|
||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||
canvas.width = pixelWidth
|
||
canvas.height = pixelHeight
|
||
// Reset transform before scaling to avoid cumulative scaling offsets.
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||
ctx.scale(dpr, dpr) // map CSS units to device pixels
|
||
}
|
||
|
||
// Clear using CSS dimensions; context is already scaled by DPR.
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight)
|
||
|
||
// 绘制Y轴刻度线(背景层)
|
||
drawYAxis(ctx, cssWidth, cssHeight, displayData)
|
||
|
||
// 绘制网格
|
||
drawGrid(ctx, cssWidth, cssHeight)
|
||
|
||
// 绘制时间轴
|
||
drawTimeAxis(ctx, cssWidth, cssHeight, displayData)
|
||
|
||
// 提取流量数据
|
||
const upValues = displayData.map((d) => d.up)
|
||
const downValues = displayData.map((d) => d.down)
|
||
|
||
// 绘制下载线(背景层)
|
||
drawTrafficLine(
|
||
ctx,
|
||
downValues,
|
||
cssWidth,
|
||
cssHeight,
|
||
colors.down,
|
||
true,
|
||
displayData,
|
||
)
|
||
|
||
// 绘制上传线(前景层)
|
||
drawTrafficLine(
|
||
ctx,
|
||
upValues,
|
||
cssWidth,
|
||
cssHeight,
|
||
colors.up,
|
||
true,
|
||
displayData,
|
||
)
|
||
|
||
// 绘制悬浮高亮线
|
||
if (tooltipData.visible && tooltipData.dataIndex >= 0) {
|
||
const padding = GRAPH_CONFIG.padding
|
||
const effectiveWidth = cssWidth - 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, cssHeight - padding.bottom)
|
||
ctx.stroke()
|
||
|
||
// 绘制水平指示线(高亮Y轴位置)
|
||
ctx.beginPath()
|
||
ctx.moveTo(padding.left, tooltipData.highlightY)
|
||
ctx.lineTo(cssWidth - padding.right, tooltipData.highlightY)
|
||
ctx.stroke()
|
||
|
||
ctx.restore()
|
||
}
|
||
|
||
isInitializedRef.current = true
|
||
}, [
|
||
displayData,
|
||
colors,
|
||
drawYAxis,
|
||
drawGrid,
|
||
drawTimeAxis,
|
||
drawTrafficLine,
|
||
tooltipData,
|
||
])
|
||
|
||
const collectFrameSample = useCallback(
|
||
(renderDuration: number, frameBudget: number) => {
|
||
const controller = fpsControllerRef.current
|
||
controller.samples.push(renderDuration)
|
||
if (controller.samples.length > FPS_SAMPLE_WINDOW) {
|
||
controller.samples.shift()
|
||
}
|
||
|
||
const perfNow = getNow()
|
||
const lastDataAge =
|
||
lastDataTimestampRef.current > 0
|
||
? Date.now() - lastDataTimestampRef.current
|
||
: null
|
||
const isDataStale =
|
||
typeof lastDataAge === 'number' && lastDataAge > STALE_DATA_THRESHOLD
|
||
|
||
let inResumeCooldown = false
|
||
if (resumeCooldownRef.current) {
|
||
const elapsedSinceResume = Date.now() - resumeCooldownRef.current
|
||
if (elapsedSinceResume < RESUME_COOLDOWN_MS) {
|
||
inResumeCooldown = true
|
||
} else {
|
||
resumeCooldownRef.current = 0
|
||
}
|
||
}
|
||
|
||
if (isDataStale && controller.target !== MIN_FPS) {
|
||
controller.target = MIN_FPS
|
||
controller.samples = []
|
||
controller.lastAdjustTime = perfNow
|
||
setCurrentFPS(controller.target)
|
||
return
|
||
}
|
||
|
||
if (
|
||
!isDataStale &&
|
||
!inResumeCooldown &&
|
||
controller.target < GRAPH_CONFIG.targetFPS
|
||
) {
|
||
controller.target = Math.min(
|
||
GRAPH_CONFIG.targetFPS,
|
||
controller.target + 2,
|
||
)
|
||
controller.samples = []
|
||
controller.lastAdjustTime = perfNow
|
||
setCurrentFPS(controller.target)
|
||
}
|
||
|
||
if (
|
||
controller.lastAdjustTime !== 0 &&
|
||
perfNow - controller.lastAdjustTime < FPS_ADJUST_INTERVAL
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (controller.samples.length === 0) return
|
||
|
||
const avgRender =
|
||
controller.samples.reduce((sum, value) => sum + value, 0) /
|
||
controller.samples.length
|
||
|
||
let nextTarget = controller.target
|
||
|
||
if (avgRender > frameBudget * 0.75 && controller.target > MIN_FPS) {
|
||
nextTarget = Math.max(MIN_FPS, controller.target - 2)
|
||
} else if (
|
||
avgRender < Math.max(4, frameBudget * 0.4) &&
|
||
controller.target < MAX_FPS &&
|
||
!inResumeCooldown
|
||
) {
|
||
nextTarget = Math.min(MAX_FPS, controller.target + 2)
|
||
}
|
||
|
||
controller.samples = []
|
||
controller.lastAdjustTime = perfNow
|
||
|
||
if (nextTarget !== controller.target) {
|
||
controller.target = nextTarget
|
||
setCurrentFPS(nextTarget)
|
||
}
|
||
},
|
||
[setCurrentFPS],
|
||
)
|
||
|
||
// 受控的动画循环
|
||
useEffect(() => {
|
||
if (
|
||
(!isWindowFocused && pause_render_traffic_stats_on_blur) ||
|
||
displayData.length === 0 ||
|
||
dataStaleRef.current
|
||
) {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current)
|
||
animationFrameRef.current = undefined
|
||
}
|
||
lastRenderTimeRef.current = getNow()
|
||
return
|
||
}
|
||
|
||
const animate = (currentTime: number) => {
|
||
if (!isWindowFocusedRef.current && pause_render_traffic_stats_on_blur) {
|
||
lastRenderTimeRef.current = getNow()
|
||
animationFrameRef.current = undefined
|
||
return
|
||
}
|
||
|
||
const lastDataAge =
|
||
lastDataTimestampRef.current > 0
|
||
? Date.now() - lastDataTimestampRef.current
|
||
: null
|
||
const targetFPS = fpsControllerRef.current.target
|
||
const frameBudget = 1000 / targetFPS
|
||
|
||
if (
|
||
typeof lastDataAge === 'number' &&
|
||
lastDataAge > STALE_DATA_THRESHOLD
|
||
) {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current)
|
||
animationFrameRef.current = undefined
|
||
}
|
||
dataStaleRef.current = true
|
||
return
|
||
}
|
||
|
||
if (
|
||
currentTime - lastRenderTimeRef.current >= frameBudget ||
|
||
!isInitializedRef.current
|
||
) {
|
||
const drawStart = getNow()
|
||
drawGraph()
|
||
const drawEnd = getNow()
|
||
|
||
lastRenderTimeRef.current = currentTime
|
||
collectFrameSample(drawEnd - drawStart, frameBudget)
|
||
}
|
||
|
||
animationFrameRef.current = requestAnimationFrame(animate)
|
||
}
|
||
|
||
animationFrameRef.current = requestAnimationFrame(animate)
|
||
|
||
return () => {
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current)
|
||
animationFrameRef.current = undefined
|
||
}
|
||
}
|
||
}, [
|
||
drawGraph,
|
||
displayData.length,
|
||
isWindowFocused,
|
||
pause_render_traffic_stats_on_blur,
|
||
collectFrameSample,
|
||
])
|
||
|
||
// 切换时间范围
|
||
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) => {
|
||
debugLog(
|
||
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
|
||
data,
|
||
)
|
||
}, [])
|
||
|
||
// 暴露方法给父组件
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
appendData,
|
||
toggleStyle,
|
||
}),
|
||
[appendData, toggleStyle],
|
||
)
|
||
|
||
// 获取时间范围文本
|
||
const getTimeRangeText = useCallback(() => {
|
||
return t('home.components.traffic.patterns.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('home.components.traffic.legends.upload')}
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
fontSize: '11px',
|
||
fontWeight: 'bold',
|
||
color: colors.down,
|
||
textAlign: 'right',
|
||
}}
|
||
>
|
||
{t('home.components.traffic.legends.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} | FPS: {currentFPS}
|
||
</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 sx={{ color: 'text.secondary', mb: 0.2 }}>
|
||
{tooltipData.timestamp}
|
||
</Box>
|
||
<Box sx={{ color: 'secondary.main', fontWeight: 500 }}>
|
||
↑ {tooltipData.upSpeed}
|
||
</Box>
|
||
<Box sx={{ color: 'primary.main', fontWeight: 500 }}>
|
||
↓ {tooltipData.downSpeed}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
},
|
||
)
|
||
|
||
EnhancedCanvasTrafficGraph.displayName = 'EnhancedCanvasTrafficGraph'
|