'use strict'; /* ══════════════════════════════════════════════════════════════ GraphPanel — reusable stacked time-series widget Shows up to 3 traces (x/v/a or custom) in stacked sub-plots. Usage: const gp = new GraphPanel(canvas, { traces: ['x', 'v', 'a'], labels: ['x', 'v', 'a'], units: ['м', 'м/с', 'м/с²'], colors: ['#06D6E0', '#FFD166', '#EF476F'], maxPoints: 600 }); gp.push(t, [xVal, vVal, aVal]); // call every frame gp.draw(); // call every frame gp.clear(); // reset ══════════════════════════════════════════════════════════════ */ class GraphPanel { constructor(canvas, opts) { opts = opts || {}; this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.traces = opts.traces || ['x', 'v', 'a']; this.colors = opts.colors || ['#06D6E0', '#FFD166', '#EF476F']; this.labels = opts.labels || ['x', 'v', 'a']; this.units = opts.units || ['м', 'м/с', 'м/с²']; this.maxPoints = opts.maxPoints || 600; this.bgColor = opts.bgColor || 'rgba(10,10,22,0.97)'; this.gridColor = opts.gridColor || 'rgba(255,255,255,0.06)'; this.axisColor = opts.axisColor || 'rgba(255,255,255,0.18)'; this.frozen = false; this._frozenSnap = null; this.t = []; this.data = this.traces.map(() => []); } /* ─── Data feed ─────────────────────────────────────────────── */ push(t, values) { if (this.frozen) return; this.t.push(t); values.forEach((v, i) => { if (this.data[i]) this.data[i].push(isFinite(v) ? v : 0); }); while (this.t.length > this.maxPoints) { this.t.shift(); this.data.forEach(arr => arr.shift()); } } clear() { this.t = []; this.data.forEach(arr => { arr.length = 0; }); this.frozen = false; this._frozenSnap = null; this.draw(); } freeze() { this.frozen = !this.frozen; if (this.frozen) { this._frozenSnap = { t: [...this.t], data: this.data.map(arr => [...arr]), traces: this.traces, labels: this.labels }; } else { this._frozenSnap = null; } return this._frozenSnap; } exportPNG() { return this.canvas.toDataURL('image/png'); } /* ─── Draw ───────────────────────────────────────────────────── */ draw() { const { canvas, ctx } = this; const W = canvas.width; const H = canvas.height; const n = this.traces.length; if (!n) return; ctx.clearRect(0, 0, W, H); /* background */ ctx.fillStyle = this.bgColor; ctx.fillRect(0, 0, W, H); /* frozen overlay badge */ if (this.frozen) { ctx.save(); ctx.fillStyle = 'rgba(255,209,102,0.12)'; ctx.fillRect(0, 0, W, H); ctx.restore(); } const PAD_L = 42; const PAD_R = 8; const PAD_TOP = 6; const PAD_BOT = 6; const plotH = Math.floor((H - PAD_TOP - PAD_BOT) / n); for (let ti = 0; ti < n; ti++) { const y0 = PAD_TOP + ti * plotH; const y1 = y0 + plotH; const pw = W - PAD_L - PAD_R; const ph = plotH - 2; /* sub-plot frame */ if (ti > 0) { ctx.save(); ctx.strokeStyle = this.gridColor; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(PAD_L, y0); ctx.lineTo(W - PAD_R, y0); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } /* label */ ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.fillStyle = this.colors[ti] || '#fff'; ctx.textAlign = 'left'; ctx.fillText(this.labels[ti] || this.traces[ti], 2, y0 + 13); ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '8px Manrope,sans-serif'; ctx.fillText(this.units[ti] || '', 2, y0 + 23); ctx.restore(); const arr = this.data[ti]; if (!arr || arr.length < 2) continue; /* auto-scale */ let mn = arr[0], mx = arr[0]; for (let k = 1; k < arr.length; k++) { if (arr[k] < mn) mn = arr[k]; if (arr[k] > mx) mx = arr[k]; } const span = Math.max(mx - mn, 1e-9); const pad = span * 0.12; const yMin = mn - pad; const yMax = mx + pad; const mapX = (idx) => PAD_L + (idx / (this.maxPoints - 1)) * pw; const mapY = (v) => y0 + ph - ((v - yMin) / (yMax - yMin)) * ph; /* grid lines (3 horizontal) */ ctx.save(); ctx.strokeStyle = this.gridColor; ctx.lineWidth = 0.5; for (let g = 0; g <= 2; g++) { const gy = y0 + (g / 2) * ph; ctx.beginPath(); ctx.moveTo(PAD_L, gy); ctx.lineTo(W - PAD_R, gy); ctx.stroke(); } ctx.restore(); /* zero line */ if (yMin < 0 && yMax > 0) { const zy = mapY(0); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 0.8; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.moveTo(PAD_L, zy); ctx.lineTo(W - PAD_R, zy); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } /* y-axis min/max labels */ ctx.save(); ctx.font = '8px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.textAlign = 'right'; ctx.fillText(_gpFmt(yMax), PAD_L - 2, y0 + 9); ctx.fillText(_gpFmt(yMin), PAD_L - 2, y0 + ph + 1); ctx.restore(); /* trace */ ctx.save(); ctx.strokeStyle = this.colors[ti] || '#fff'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.beginPath(); const startIdx = Math.max(0, this.maxPoints - arr.length); let first = true; for (let k = 0; k < arr.length; k++) { const px = mapX(startIdx + k); const py = mapY(arr[k]); if (first) { ctx.moveTo(px, py); first = false; } else { ctx.lineTo(px, py); } } ctx.stroke(); ctx.restore(); /* current-value dot + readout */ const lastV = arr[arr.length - 1]; const dotX = mapX(startIdx + arr.length - 1); const dotY = mapY(lastV); ctx.save(); ctx.fillStyle = this.colors[ti] || '#fff'; ctx.shadowColor = this.colors[ti] || '#fff'; ctx.shadowBlur = 6; ctx.beginPath(); ctx.arc(dotX, dotY, 3, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.font = 'bold 9px Manrope,sans-serif'; ctx.fillStyle = this.colors[ti] || '#fff'; ctx.textAlign = 'right'; ctx.fillText(_gpFmt(lastV), W - PAD_R - 1, y0 + 12); ctx.restore(); } /* frozen badge */ if (this.frozen) { ctx.save(); ctx.font = 'bold 9px Manrope,sans-serif'; ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; ctx.fillText('ЗАМОРОЖЕНО', W / 2, H - 3); ctx.restore(); } } } /* compact number format helper */ function _gpFmt(v) { const av = Math.abs(v); if (av === 0) return '0'; if (av >= 1000) return v.toFixed(0); if (av >= 100) return v.toFixed(1); if (av >= 10) return v.toFixed(2); if (av >= 0.1) return v.toFixed(3); return v.toExponential(1); } window.LSGraphPanel = GraphPanel; /* ══════════════════════════════════════════════════════════════ GraphPanelUI — self-contained overlay widget for a sim panel Creates a bottom-overlay