/** * Custom Canvas Chart Renderer * Lightweight, no-dependency chart for the network traffic area chart */ class AreaChart { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.data = { timestamps: [], rx: [], tx: [] }; this.animProgress = 0; this.animFrame = null; this.dpr = window.devicePixelRatio || 1; this.padding = { top: 20, right: 16, bottom: 32, left: 56 }; this._resize = this.resize.bind(this); window.addEventListener('resize', this._resize); this.resize(); } resize() { const rect = this.canvas.parentElement.getBoundingClientRect(); this.width = rect.width; this.height = rect.height; this.canvas.width = this.width * this.dpr; this.canvas.height = this.height * this.dpr; this.canvas.style.width = this.width + 'px'; this.canvas.style.height = this.height + 'px'; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.draw(); } setData(data) { this.data = data; this.animProgress = 0; this.animate(); } animate() { if (this.animFrame) cancelAnimationFrame(this.animFrame); const start = performance.now(); const duration = 800; const step = (now) => { const elapsed = now - start; this.animProgress = Math.min(elapsed / duration, 1); // Ease out cubic this.animProgress = 1 - Math.pow(1 - this.animProgress, 3); this.draw(); if (this.animProgress < 1) { this.animFrame = requestAnimationFrame(step); } }; this.animFrame = requestAnimationFrame(step); } draw() { const ctx = this.ctx; const w = this.width; const h = this.height; const p = this.padding; const chartW = w - p.left - p.right; const chartH = h - p.top - p.bottom; ctx.clearRect(0, 0, w, h); const { timestamps, rx, tx } = this.data; if (!timestamps || timestamps.length < 2) { ctx.fillStyle = '#5a6380'; ctx.font = '13px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('等待数据...', w / 2, h / 2); return; } // Find max let maxVal = 0; for (let i = 0; i < rx.length; i++) { maxVal = Math.max(maxVal, rx[i] || 0, tx[i] || 0); } maxVal = maxVal * 1.15 || 1; const len = timestamps.length; const xStep = chartW / (len - 1); // Helper to get point const getX = (i) => p.left + i * xStep; const getY = (val) => p.top + chartH - (val / maxVal) * chartH * this.animProgress; // Draw grid lines ctx.strokeStyle = 'rgba(99, 102, 241, 0.06)'; ctx.lineWidth = 1; const gridLines = 4; for (let i = 0; i <= gridLines; i++) { const y = p.top + (chartH / gridLines) * i; ctx.beginPath(); ctx.moveTo(p.left, y); ctx.lineTo(p.left + chartW, y); ctx.stroke(); // Y-axis labels const val = maxVal * (1 - i / gridLines); ctx.fillStyle = '#5a6380'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; ctx.fillText(formatBandwidth(val, 1), p.left - 8, y + 3); } // X-axis labels (every ~4 hours) ctx.fillStyle = '#5a6380'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; const labelInterval = Math.max(1, Math.floor(len / 6)); for (let i = 0; i < len; i += labelInterval) { const x = getX(i); ctx.fillText(formatTime(timestamps[i]), x, h - 8); } // Always show last label ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8); // Draw TX area this.drawArea(ctx, tx, getX, getY, chartH, p, 'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', '#6366f1', len); // Draw RX area (on top) this.drawArea(ctx, rx, getX, getY, chartH, p, 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', '#06b6d4', len); } drawArea(ctx, values, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) { if (!values || values.length === 0) return; // Fill ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0] || 0)); for (let i = 1; i < len; i++) { const prevX = getX(i - 1); const currX = getX(i); const prevY = getY(values[i - 1] || 0); const currY = getY(values[i] || 0); const midX = (prevX + currX) / 2; ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); } ctx.lineTo(getX(len - 1), p.top + chartH); ctx.lineTo(getX(0), p.top + chartH); ctx.closePath(); const gradient = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); gradient.addColorStop(0, fillColorTop); gradient.addColorStop(1, fillColorBottom); ctx.fillStyle = gradient; ctx.fill(); // Stroke ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0] || 0)); for (let i = 1; i < len; i++) { const prevX = getX(i - 1); const currX = getX(i); const prevY = getY(values[i - 1] || 0); const currY = getY(values[i] || 0); const midX = (prevX + currX) / 2; ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); } ctx.strokeStyle = strokeColor; ctx.lineWidth = 2; ctx.stroke(); } destroy() { window.removeEventListener('resize', this._resize); if (this.animFrame) cancelAnimationFrame(this.animFrame); } }