/** * 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.showP95 = false; 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; } // Calculate P95 (95th percentile) // Common standard: 95th percentile of the peak (max of rx/tx or sum) // We'll use max(rx, tx) at each point which is common for billing const combined = data.rx.map((r, i) => Math.max(r || 0, data.tx[i] || 0)); if (combined.length > 0) { const sorted = [...combined].sort((a, b) => a - b); const p95Idx = Math.floor(sorted.length * 0.95); this.p95 = sorted[p95Idx]; } else { this.p95 = null; } 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); // Draw P95 line if (this.showP95 && this.p95 && this.animProgress === 1) { const p95Y = getY(this.p95); // Only draw if within visible range if (p95Y >= p.top && p95Y <= p.top + chartH) { ctx.save(); ctx.beginPath(); ctx.setLineDash([6, 4]); ctx.strokeStyle = 'rgba(244, 63, 94, 0.85)'; // --accent-rose ctx.lineWidth = 1.5; ctx.moveTo(p.left, p95Y); ctx.lineTo(p.left + chartW, p95Y); ctx.stroke(); // P95 label background const label = '95计费: ' + (window.formatBandwidth ? window.formatBandwidth(this.p95) : this.p95.toFixed(2)); ctx.font = 'bold 11px "JetBrains Mono", monospace'; const metrics = ctx.measureText(label); ctx.fillStyle = 'rgba(244, 63, 94, 0.15)'; ctx.fillRect(p.left + 8, p95Y - 20, metrics.width + 12, 18); // P95 label text ctx.fillStyle = '#f43f5e'; ctx.textAlign = 'left'; ctx.fillText(label, p.left + 14, p95Y - 7); ctx.restore(); } } } 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); } } class MetricChart { constructor(canvas, unit = '') { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.data = { timestamps: [], values: [], series: null }; this.unit = unit; // '%', 'B/s', etc. this.dpr = window.devicePixelRatio || 1; this.padding = { top: 10, right: 10, bottom: 20, left: 60 }; this.animProgress = 0; this._resize = this.resize.bind(this); window.addEventListener('resize', this._resize); this.resize(); } resize() { const parent = this.canvas.parentElement; if (!parent) return; const rect = parent.getBoundingClientRect(); if (rect.width === 0) return; 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 || { timestamps: [], values: [], series: null }; this.animate(); } animate() { if (this.animFrame) cancelAnimationFrame(this.animFrame); const start = performance.now(); const duration = 500; const step = (now) => { const elapsed = now - start; this.animProgress = Math.min(elapsed / duration, 1); this.animProgress = 1 - Math.pow(1 - this.animProgress, 3); this.draw(); if (elapsed < duration) 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, values, series } = this.data; if (!timestamps || timestamps.length < 2) { ctx.fillStyle = '#5a6380'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('正在加载或暂无数据...', w / 2, h / 2); return; } // Determine Y max let maxVal = 0; if (series) { // For stacked CPU, max sum should be 100 maxVal = 100; } else { maxVal = Math.max(...values, 0.1); if (this.unit === '%' && maxVal <= 100) { if (maxVal > 80) maxVal = 100; else if (maxVal > 40) maxVal = 80; else if (maxVal > 20) maxVal = 50; else maxVal = 25; } else { maxVal = maxVal * 1.25; } } const len = timestamps.length; const xStep = chartW / (len - 1); const getX = (i) => p.left + i * xStep; const getY = (val) => p.top + chartH - (val / (maxVal || 1)) * chartH * this.animProgress; // Grid ctx.strokeStyle = 'rgba(99, 102, 241, 0.05)'; ctx.lineWidth = 1; for (let i = 0; i <= 3; i++) { const y = p.top + (chartH / 3) * i; ctx.beginPath(); ctx.moveTo(p.left, y); ctx.lineTo(p.left + chartW, y); ctx.stroke(); const v = (maxVal * (1 - i / 3)); ctx.fillStyle = '#5a6380'; ctx.font = '9px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; let label = ''; if (this.unit === 'B/s' || this.unit === 'B') { const isRate = this.unit === 'B/s'; if (window.formatBandwidth && isRate) { label = window.formatBandwidth(v); } else if (window.formatBytes) { label = window.formatBytes(v) + (isRate ? '/s' : ''); } else { label = v.toFixed(0) + this.unit; } } else { label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit; } ctx.fillText(label, p.left - 8, y + 3); } if (series) { // Draw Stacked Area const modes = [ { name: 'idle', color: 'rgba(34, 197, 94, 0.4)', stroke: '#22c55e' }, // Green { name: 'other', color: 'rgba(168, 85, 247, 0.4)', stroke: '#a855f7' }, // Purple { name: 'irq', color: 'rgba(249, 115, 22, 0.4)', stroke: '#f97316' }, // Orange { name: 'iowait', color: 'rgba(239, 68, 68, 0.4)', stroke: '#ef4444' }, // Red { name: 'system', color: 'rgba(234, 179, 8, 0.4)', stroke: '#eab308' }, // Yellow { name: 'user', color: 'rgba(99, 102, 241, 0.4)', stroke: '#6366f1' } // Indigo ]; let currentBase = new Array(len).fill(0); modes.forEach(mode => { const vals = series[mode.name]; if (!vals) return; ctx.beginPath(); ctx.moveTo(getX(0), getY(currentBase[0] + vals[0])); for (let i = 1; i < len; i++) { ctx.lineTo(getX(i), getY(currentBase[i] + vals[i])); } ctx.lineTo(getX(len - 1), getY(currentBase[len - 1])); for (let i = len - 1; i >= 0; i--) { ctx.lineTo(getX(i), getY(currentBase[i])); } ctx.closePath(); ctx.fillStyle = mode.color; ctx.fill(); // Stroke ctx.beginPath(); ctx.moveTo(getX(0), getY(currentBase[0] + vals[0])); for (let i = 1; i < len; i++) { ctx.lineTo(getX(i), getY(currentBase[i] + vals[i])); } ctx.strokeStyle = mode.stroke; ctx.lineWidth = 1; ctx.stroke(); // Update base for (let i = 0; i < len; i++) { currentBase[i] += vals[i]; } }); // Add Legend at bottom right (moved up slightly) ctx.font = '9px sans-serif'; ctx.textAlign = 'right'; let lx = w - 10; let ly = h - 20; // Increased padding from bottom [...modes].reverse().forEach(m => { ctx.fillStyle = m.stroke; ctx.fillRect(lx - 10, ly - 8, 8, 8); ctx.fillStyle = '#5a6380'; ctx.fillText(m.name.charAt(0).toUpperCase() + m.name.slice(1), lx - 15, ly - 1); lx -= 70; // Increased gap for safety }); } else { // Draw Single Line Path ctx.beginPath(); ctx.moveTo(getX(0), getY(values[0])); for (let i = 1; i < len; i++) { const prevX = getX(i - 1); const currX = getX(i); const prevY = getY(values[i - 1]); const currY = getY(values[i]); const midX = (prevX + currX) / 2; ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); } // Stroke ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); // Fill ctx.lineTo(getX(len - 1), p.top + chartH); ctx.lineTo(getX(0), p.top + chartH); ctx.closePath(); const grad = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); grad.addColorStop(0, 'rgba(99, 102, 241, 0.15)'); grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); ctx.fillStyle = grad; ctx.fill(); // Last point pulse const lastX = getX(len - 1); const lastY = getY(values[len - 1]); ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fillStyle = '#6366f1'; ctx.fill(); } } destroy() { window.removeEventListener('resize', this._resize); if (this.animFrame) cancelAnimationFrame(this.animFrame); } }