/** * 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) { if (!data || !data.timestamps) return; // Downsample if data is too dense (target ~1500 points for performance) const MAX_POINTS = 1500; if (data.timestamps.length > MAX_POINTS) { const skip = Math.ceil(data.timestamps.length / MAX_POINTS); const downsampled = { timestamps: [], rx: [], tx: [] }; for (let i = 0; i < data.timestamps.length; i += skip) { downsampled.timestamps.push(data.timestamps[i]); downsampled.rx.push(data.rx[i]); downsampled.tx.push(data.tx[i]); } this.data = downsampled; } else { 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; this.padding = { top: 20, right: 16, bottom: 32, left: 64 }; 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 raw value let maxDataVal = 0; for (let i = 0; i < rx.length; i++) { maxDataVal = Math.max(maxDataVal, rx[i] || 0, tx[i] || 0); } // Determine consistent unit based on max data value const k = 1024; const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s']; let unitIdx = Math.floor(Math.log(Math.max(1, maxDataVal)) / Math.log(k)); unitIdx = Math.max(0, Math.min(unitIdx, sizes.length - 1)); const unitFactor = Math.pow(k, unitIdx); const unitLabel = sizes[unitIdx]; // Get value in current units and find a "nice" round max // Use 1.15 cushion const rawValInUnit = (maxDataVal * 1.15) / unitFactor; let niceMaxInUnit; if (rawValInUnit <= 1) niceMaxInUnit = 1; else if (rawValInUnit <= 2) niceMaxInUnit = 2; else if (rawValInUnit <= 5) niceMaxInUnit = 5; else if (rawValInUnit <= 10) niceMaxInUnit = 10; else if (rawValInUnit <= 20) niceMaxInUnit = 20; else if (rawValInUnit <= 50) niceMaxInUnit = 50; else if (rawValInUnit <= 100) niceMaxInUnit = 100; else if (rawValInUnit <= 200) niceMaxInUnit = 200; else if (rawValInUnit <= 500) niceMaxInUnit = 500; else if (rawValInUnit <= 1000) niceMaxInUnit = 1000; else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100; const maxVal = niceMaxInUnit * unitFactor; 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 || 1)) * chartH * this.animProgress; // Draw grid lines ctx.strokeStyle = 'rgba(99, 102, 241, 0.08)'; 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 - share the same unit for readability const valInUnit = niceMaxInUnit * (1 - i / gridLines); ctx.fillStyle = '#5a6380'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; // Format: "X.X MB/s" or "X MB/s" const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + unitLabel; ctx.fillText(label, p.left - 10, 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; const useSimple = len > 500; // Fill ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0] || 0)); for (let i = 1; i < len; i++) { if (useSimple) { ctx.lineTo(getX(i), getY(values[i] || 0)); } else { 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++) { if (useSimple) { ctx.lineTo(getX(i), getY(values[i] || 0)); } else { 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.lineJoin = 'round'; ctx.stroke(); } destroy() { window.removeEventListener('resize', this._resize); if (this.animFrame) cancelAnimationFrame(this.animFrame); } }