diff --git a/public/js/chart.js b/public/js/chart.js index b5a9f4e..9d473d1 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -23,9 +23,86 @@ class AreaChart { // Use debounced resize for performance and safety this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this); window.addEventListener('resize', this._resize); + + // Drag zoom support + this.isDraggingP95 = false; + this.customMaxVal = null; + + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + + this.canvas.addEventListener('pointerdown', this.onPointerDown); + window.addEventListener('pointermove', this.onPointerMove); + window.addEventListener('pointerup', this.onPointerUp); + this.resize(); } + onPointerDown(e) { + if (!this.showP95 || !this.p95) return; + const rect = this.canvas.getBoundingClientRect(); + const scaleY = this.height / rect.height; + const y = (e.clientY - rect.top) * scaleY; + + const p = this.padding; + const chartH = this.height - p.top - p.bottom; + + // Calculate current P95 Y position + const k = 1024; + const currentMaxVal = (this.customMaxVal !== null ? this.customMaxVal : (this.currentMaxVal || 1024)); + let unitIdx = Math.floor(Math.log(Math.max(1, currentMaxVal)) / Math.log(k)); + unitIdx = Math.max(0, Math.min(unitIdx, 4)); + const unitFactor = Math.pow(k, unitIdx); + const rawValInUnit = (currentMaxVal * 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 displayMaxVal = this.customMaxVal !== null ? this.customMaxVal : (niceMaxInUnit * unitFactor); + const p95Y = p.top + chartH - (this.p95 / (displayMaxVal || 1)) * chartH; + + if (Math.abs(y - p95Y) < 25) { + this.isDraggingP95 = true; + this.canvas.style.cursor = 'ns-resize'; + this.canvas.setPointerCapture(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + } + } + + onPointerMove(e) { + if (!this.isDraggingP95) return; + const rect = this.canvas.getBoundingClientRect(); + const scaleY = this.height / rect.height; + const y = (e.clientY - rect.top) * scaleY; + const p = this.padding; + const chartH = this.height - p.top - p.bottom; + + const dy = p.top + chartH - y; + if (dy > 10) { + this.customMaxVal = (this.p95 * chartH) / dy; + this.draw(); + } + } + + onPointerUp(e) { + if (this.isDraggingP95) { + this.isDraggingP95 = false; + this.canvas.style.cursor = ''; + this.canvas.releasePointerCapture(e.pointerId); + } + } + resize() { const rect = this.canvas.parentElement.getBoundingClientRect(); this.width = rect.width; @@ -164,13 +241,10 @@ class AreaChart { 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; @@ -183,7 +257,16 @@ class AreaChart { else if (rawValInUnit <= 1000) niceMaxInUnit = 1000; else niceMaxInUnit = Math.ceil(rawValInUnit / 100) * 100; - const maxVal = niceMaxInUnit * unitFactor; + let maxVal = niceMaxInUnit * unitFactor; + if (this.customMaxVal !== null) { + maxVal = this.customMaxVal; + } + + // Recalculate units based on final maxVal (could be zoomed) + let finalUnitIdx = Math.floor(Math.log(Math.max(1, maxVal)) / Math.log(k)); + finalUnitIdx = Math.max(0, Math.min(finalUnitIdx, sizes.length - 1)); + const finalFactor = Math.pow(k, finalUnitIdx); + const finalUnitLabel = sizes[finalUnitIdx]; const len = timestamps.length; const xStep = chartW / (len - 1); @@ -201,21 +284,21 @@ class AreaChart { 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(); + 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'; + // Y-axis labels + const v = maxVal * (1 - i / gridLines); + const valInUnit = v / finalFactor; + 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); + const label = (valInUnit % 1 === 0 ? valInUnit : valInUnit.toFixed(1)) + ' ' + finalUnitLabel; + ctx.fillText(label, p.left - 10, y + 3); } // X-axis labels (every ~4 hours) @@ -227,47 +310,42 @@ class AreaChart { 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); - const getPVal = (arr, i) => (arr && i < arr.length) ? arr[i] : 0; + // Draw data areas with clipping + ctx.save(); + ctx.beginPath(); + ctx.rect(p.left, p.top, chartW, chartH); + ctx.clip(); - // Draw TX area if (this.showTx) { this.drawArea(ctx, tx, this.prevData ? this.prevData.tx : null, getX, getY, chartH, p, - 'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', - '#6366f1', len); + 'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', '#6366f1', len); } - - // Draw RX area (on top) if (this.showRx) { this.drawArea(ctx, rx, this.prevData ? this.prevData.rx : null, getX, getY, chartH, p, - 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', - '#06b6d4', len); + 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', '#06b6d4', len); } + ctx.restore(); // Draw P95 line - if (this.showP95 && this.p95 && this.animProgress === 1) { + if (this.showP95 && this.p95 && (this.animProgress === 1 || this.isDraggingP95)) { 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.strokeStyle = 'rgba(244, 63, 94, 0.85)'; 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);