'use strict'; /* ════════════════════════════════════════════════════════════════ Constants ════════════════════════════════════════════════════════════════ */ const DEFAULT_CAP = 10_000; const TEMPORAL_CAP = 600_000; const LTTB_MIN = 200; // never decimate below this many points const TRACE_COLORS = [ '#89b4fa','#a6e3a1','#f38ba8','#fab387','#cba6f7', '#94e2d5','#89dceb','#b4befe','#f9e2af','#f5c2e7', ]; /* ════════════════════════════════════════════════════════════════ Globals ════════════════════════════════════════════════════════════════ */ let signals = []; let buffers = {}; let plots = []; let nextPlotId = 1; let windowSec = 5; let globalPause = false; let lastDataAt = 0; const traceColorMap = {}; let colorIdx = 0; function getTraceColor(key) { if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length]; return traceColorMap[key]; } // Sync: shared uPlot cursor crosshair across all live plots const LIVE_SYNC = uPlot.sync('live'); const TRIG_SYNC = uPlot.sync('trig'); // Zoom guard: prevents echo on cross-plot sync calls inside onZoom. // zoomGuard prevents the setScale hook from calling onZoom when we programmatically // set the scale (rolling window, zoom-back, fit, resize, pan, cross-plot sync). // All programmatic setScale calls wrap with zoomGuard=true/false so that the hook // only fires for genuine user drag-zoom or scroll-wheel gestures. let zoomGuard = false; // Zoom history for Back button (global since plots are zoom-synced) const zoomHistory = []; // Cursors A/B — stored in x-axis units of the current mode: // live mode → Unix seconds // trig mode → relative seconds from trigger const cursors = { mode: 'off', tA: null, tB: null }; let cursorsDirty = false; // if true, redraw all plots to update cursor lines // Layout — [label, cssClass, cols, rows] const LAYOUTS = [ ['1×1','l1x1',1,1],['1×2','l1x2',1,2],['2×1','l2x1',2,1],['1×3','l1x3',1,3], ['3×1','l3x1',3,1],['2×2','l2x2',2,2],['1×4','l1x4',1,4],['4×1','l4x1',4,1], ]; let currentLayout = 'l1x1'; /* ════════════════════════════════════════════════════════════════ Trigger state ════════════════════════════════════════════════════════════════ */ const trig = { enabled:false, signal:'', edge:'rising', threshold:0, windowSec:1, prePercent:20, mode:'normal', stopped:false, armed:false, prevVal:null, collecting:false, trigTime:null, snapshot:null, }; function trigPreSec() { return trig.windowSec * trig.prePercent / 100; } function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; } /* ════════════════════════════════════════════════════════════════ WebSocket ════════════════════════════════════════════════════════════════ */ let ws = null, wsBackoff = 1000; function connectWS() { ws = new WebSocket('ws://' + location.host + '/ws'); ws.onopen = () => { wsBackoff = 1000; setStatus('orange','Connected – waiting for data'); }; ws.onclose = () => { setStatus('red','Disconnected (reconnecting…)'); setTimeout(connectWS, wsBackoff); wsBackoff = Math.min(wsBackoff * 2, 30000); }; ws.onerror = () => {}; ws.onmessage = evt => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'config') onConfig(msg); else if (msg.type === 'data') onData(msg); }; } /* ════════════════════════════════════════════════════════════════ Status LED ════════════════════════════════════════════════════════════════ */ function setStatus(s, t) { document.getElementById('status-led').className = s; document.getElementById('status-text').textContent = t; } setInterval(() => { const tsEl = document.getElementById('sb-tsage'); if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) { const age = performance.now() - lastDataAt; // Compute minimum lag: newest buffer timestamp vs browser wall clock. let tsAge = null; const wallNow = Date.now() / 1000; Object.values(buffers).forEach(buf => { if (buf.size === 0) return; const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; const a = wallNow - newest; if (tsAge === null || a < tsAge) tsAge = a; }); if (tsEl && tsAge !== null) { const ms = tsAge * 1000; tsEl.textContent = '| lag: ' + (ms < 1000 ? ms.toFixed(0) + 'ms' : tsAge.toFixed(2) + 's'); } else if (tsEl) { tsEl.textContent = ''; } if (age > 1000) setStatus('orange', 'No data for ' + (age/1000).toFixed(1) + 's'); else setStatus('green', 'Streaming'); } else if (tsEl) { tsEl.textContent = ''; } }, 500); /* ════════════════════════════════════════════════════════════════ Config handler ════════════════════════════════════════════════════════════════ */ function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); } function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; } function onConfig(msg) { const newSigs = msg.signals || []; const fp = s => s.name+':'+s.typeCode+':'+(s.numRows||1)+':'+(s.numCols||1)+':'+(s.timeMode||0); const changed = newSigs.length !== signals.length || newSigs.some((s,i) => fp(s) !== fp(signals[i])); signals = newSigs; if (changed) { buffers = {}; signals.forEach(sig => { const n = numElements(sig); if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); } else if (n === 1) { buffers[sig.name] = makeBuffer(); } else { for (let i = 0; i < n; i++) buffers[sig.name+'['+i+']'] = makeBuffer(); } }); trigDisarm(); trig.snapshot = null; trig.prevVal = null; zoomHistory.length = 0; document.getElementById('btn-zoom-back').style.display = 'none'; } buildSidebar(); buildTrigSignalSelect(); } /* ════════════════════════════════════════════════════════════════ Data handler ════════════════════════════════════════════════════════════════ */ function onData(msg) { lastDataAt = performance.now(); const sigs = msg.signals; if (!sigs) return; Object.keys(sigs).forEach(key => { const buf = buffers[key]; if (!buf) return; const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return; const len = Math.min(sd.t.length, sd.v.length); for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]); }); if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs); if (trig.enabled && trig.collecting && (Date.now()/1000) >= trig.trigTime + trigPostSec()) finaliseTriggerCapture(); if (!trig.enabled) { plots.forEach(p => { if (globalPause) return; if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true; }); } } /* ════════════════════════════════════════════════════════════════ Trigger logic ════════════════════════════════════════════════════════════════ */ function checkTrigger(sigs) { const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return; for (let i = 0; i < sd.v.length; i++) { const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur; if (prev === null) continue; const e = trig.edge, thr = trig.threshold; if ((e==='rising' &&prev=thr) || (e==='falling'&&prev>thr&&cur<=thr) || (e==='both'&&((prev=thr)||(prev>thr&&cur<=thr)))) { fireTrigger(sd.t ? sd.t[i] : Date.now()/1000); break; } } } function fireTrigger(t) { trig.armed=false; trig.collecting=true; trig.trigTime=t; trig.snapshot=null; updateTrigStatusBadge('waiting'); setAllCardsCollecting(true); } function finaliseTriggerCapture() { trig.collecting = false; const t0 = trig.trigTime - trigPreSec(), t1 = trig.trigTime + trigPostSec(); const snap = {}; Object.keys(buffers).forEach(key => { const sl = getBufferSliceRange(buffers[key], t0, t1); if (sl.t.length > 0) snap[key] = sl; }); // Tag snapshot with the window parameters captured at trigger time so that // the render loop uses the correct bounds even if UI controls are changed later. snap._preS = trigPreSec(); snap._postS = trigPostSec(); trig.snapshot = snap; setAllCardsCollecting(false); updateTrigStatusBadge('triggered'); showRearmBtn(trig.mode === 'single'); showStopBtn(trig.mode === 'normal'); // Reset cursor positions (mode changed) and show cursor button now that snapshot exists cursors.tA = null; cursors.tB = null; updateCursorReadout(); updateCursorBtnVisibility(); plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); if (trig.mode === 'normal' && !trig.stopped) setTimeout(() => { if (trig.enabled && trig.mode==='normal' && !trig.stopped) trigArm(); }, 200); } function trigArm() { trig.snapshot=null; trig.armed=true; trig.collecting=false; trig.prevVal=null; showRearmBtn(false); updateTrigStatusBadge('armed'); cursors.tA = null; cursors.tB = null; updateCursorReadout(); updateCursorBtnVisibility(); plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); } function trigDisarm() { trig.armed=false; trig.collecting=false; trig.trigTime=null; trig.stopped=false; setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle'); } function setAllCardsCollecting(on) { document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on)); } function updateTrigStatusBadge(state) { const el = document.getElementById('trig-status-badge'); el.className = state; el.textContent = {idle:'IDLE',armed:'ARMED',waiting:'COLLECTING',triggered:'TRIGGERED'}[state]||'IDLE'; } function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v?'inline-block':'none'; } function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v?'inline-block':'none'; } function updateStopBtn() { const btn = document.getElementById('btn-trig-stop'); btn.textContent = trig.stopped ? 'Resume' : 'Stop'; } /* ════════════════════════════════════════════════════════════════ Circular buffer ════════════════════════════════════════════════════════════════ */ function makeBuffer(cap) { cap = cap || DEFAULT_CAP; return { t: new Float64Array(cap), v: new Float64Array(cap), head:0, size:0, cap }; } function pushBuffer(buf, t, v) { buf.t[buf.head] = t; buf.v[buf.head] = v; buf.head = (buf.head + 1) % buf.cap; if (buf.size < buf.cap) buf.size++; } // Binary-search range slice of circular buffer — O(log n + window_size) function getBufferSliceRange(buf, t0, t1) { if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) }; const { cap, size, head } = buf; const start = (size === cap) ? head : 0; const physAt = k => (start + k) % cap; let lo = 0, hi = size; while (lo < hi) { const m = (lo+hi)>>>1; if (buf.t[physAt(m)] < t0) lo=m+1; else hi=m; } const kStart = lo; lo = kStart; hi = size; while (lo < hi) { const m = (lo+hi)>>>1; if (buf.t[physAt(m)] <= t1) lo=m+1; else hi=m; } const kEnd = lo, len = kEnd - kStart; if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) }; const outT = new Float64Array(len), outV = new Float64Array(len); const physStart = physAt(kStart), tail = cap - physStart; if (tail >= len) { outT.set(buf.t.subarray(physStart, physStart + len)); outV.set(buf.v.subarray(physStart, physStart + len)); } else { outT.set(buf.t.subarray(physStart, physStart + tail)); outT.set(buf.t.subarray(0, len - tail), tail); outV.set(buf.v.subarray(physStart, physStart + tail)); outV.set(buf.v.subarray(0, len - tail), tail); } return { t: outT, v: outV }; } // getGlobalNow returns the reference "now" for the rolling window. // Always anchors to the newest timestamp found in any buffer so the rolling // window tracks real data regardless of any clock skew between the Go server // and the browser. Falls back to Date.now()/1000 only when all buffers are // empty (no data yet received). function getGlobalNow() { let latest = -Infinity; Object.values(buffers).forEach(buf => { if (buf.size === 0) return; const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (newestT > latest) latest = newestT; }); return isFinite(latest) ? latest : Date.now() / 1000; } // getBufferNow returns the "now" anchor for a single buffer — the buffer's own // newest timestamp. This avoids cross-signal interference when signals have // different timescales or update rates. function getBufferNow(buf) { if (buf.size === 0) return Date.now() / 1000; return buf.t[(buf.head - 1 + buf.cap) % buf.cap]; } function getBufferSlice(buf) { const now = getBufferNow(buf); return getBufferSliceRange(buf, now - windowSec, now); } // Binary-search slice of a sorted contiguous Float64Array pair function sliceTypedArrayRange(t, v, t0, t1) { let lo = 0, hi = t.length; while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] < t0) lo=m+1; else hi=m; } const s = lo; lo = s; hi = t.length; while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] <= t1) lo=m+1; else hi=m; } return { t: t.subarray(s, lo), v: v.subarray(s, lo) }; } // Return the configured samplingRate for a buffer key. // Temporal array signals have a meaningful SamplingRate; scalars return 0. // Used to prefer high-freq signals as the master time grid regardless of trace order. function getKeySamplingRate(key) { const direct = signals.find(s => s.name === key); if (direct) return direct.samplingRate || 0; // Array element key like "Sig[3]" — strip the index const sig = signals.find(s => key.startsWith(s.name + '[')); return sig ? (sig.samplingRate || 0) : 0; } // LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold. function lttb(t, v, threshold) { const len = t.length; if (len <= threshold) return { t, v }; const outT = new Float64Array(threshold), outV = new Float64Array(threshold); outT[0] = t[0]; outV[0] = v[0]; outT[threshold-1] = t[len-1]; outV[threshold-1] = v[len-1]; const every = (len - 2) / (threshold - 2); let a = 0; for (let i = 0; i < threshold - 2; i++) { const avgS = Math.floor((i+1)*every)+1, avgE = Math.min(Math.floor((i+2)*every)+1, len); let avgT = 0, avgV = 0, n = 0; for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; } if (n) { avgT /= n; avgV /= n; } const rS = Math.floor(i*every)+1, rE = Math.min(Math.floor((i+1)*every)+1, len); let maxA = -1, next = rS; const aT = t[a], aV = v[a]; for (let j = rS; j < rE; j++) { const area = Math.abs((aT-avgT)*(v[j]-aV) - (aT-t[j])*(avgV-aV)); if (area > maxA) { maxA = area; next = j; } } outT[i+1] = t[next]; outV[i+1] = v[next]; a = next; } return { t: outT, v: outV }; } /* ════════════════════════════════════════════════════════════════ uPlot helpers ════════════════════════════════════════════════════════════════ */ // Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks) function fmtLiveTick(u, vals) { return vals.map(v => { if (v == null) return ''; const d = new Date(v * 1000); const hh = String(d.getHours()).padStart(2,'0'); const mm = String(d.getMinutes()).padStart(2,'0'); const ss = String(d.getSeconds()).padStart(2,'0'); const ms = String(d.getMilliseconds()).padStart(3,'0'); return hh+':'+mm+':'+ss+'.'+ms; }); } // Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks) function fmtTrigTick(u, vals) { return vals.map(v => { if (v == null) return ''; const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; if (abs < 1) return sign + (abs*1000).toFixed(1) + 'ms'; return sign + abs.toFixed(3) + 's'; }); } // Draw custom cursor A/B lines onto the canvas (called from draw hook) function drawCursorLines(u) { const { ctx, bbox } = u; if (!bbox) return; const drawLine = (val, color) => { if (val === null) return; const x = u.valToPos(val, 'x', true); // canvas pixels if (x < bbox.left || x > bbox.left + bbox.width) return; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.moveTo(Math.round(x), bbox.top); ctx.lineTo(Math.round(x), bbox.top + bbox.height); ctx.stroke(); ctx.restore(); }; drawLine(cursors.tA, 'rgba(137,220,235,0.85)'); drawLine(cursors.tB, 'rgba(249,226,175,0.85)'); } // Build uPlot opts for a given plot object function makeUPlotOpts(p, inTrigMode) { const seriesArr = [{}]; // time (index 0) p.traces.forEach(key => { seriesArr.push({ label: key, stroke: getTraceColor(key), width: 1.5, points: { show: false }, spanGaps: true, }); }); const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals) : (u, vals) => fmtLiveTick(u, vals); return { width: Math.max(p.div.clientWidth || 100, 50), height: Math.max(p.div.clientHeight || 100, 50), cursor: { sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false }, drag: { x: true, y: false, setScale: true, uni: 20 }, lock: false, }, select: { show: true }, scales: { x: { time: false, auto: false, min: p.xRange ? p.xRange[0] : 0, max: p.xRange ? p.xRange[1] : windowSec }, y: { auto: true }, }, series: seriesArr, axes: [ { stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, values: xVals, size: 40 }, { stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, size: 50 }, ], legend: { show: false }, padding: [4, 8, 0, 0], hooks: { draw: [ u => drawCursorLines(u) ], // Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated. // uPlot fires setSelect → then immediately setScale (when drag.setScale:true). // All programmatic setScale calls happen without a preceding setSelect, so the // flag is false and onZoom is never called unintentionally. setSelect: [(u) => { u._userZoom = (u.select.width > 0); }], setScale: [(u, key) => { if (key !== 'x' || !u._userZoom || !u._ready) return; u._userZoom = false; const { min, max } = u.scales.x; if (min == null || max == null || max <= min) return; onZoom(p.id, min, max); }], ready: [ u => { u._ready = true; } ], }, }; } // Create (or recreate) the uPlot instance for a plot, mounting into p.div function createUPlot(p) { // Destroy previous instance if (p.uplot) { p.uplot.destroy(); p.uplot = null; } const inTrigMode = trig.enabled && trig.snapshot !== null; const opts = makeUPlotOpts(p, inTrigMode); const data = buildUPlotData(p, inTrigMode); // Mount into the plot body div (clear previous uPlot DOM) p.div.querySelectorAll('.uplot').forEach(el => el.remove()); p.uplot = new uPlot(opts, data, p.div); // ── Cursor drag: place or drag A/B cursors ────────────────────────────── // - Near an existing cursor line (within CURSOR_SNAP_PX): drag to move it. // - cursor mode A or B active: click/drag places that cursor anywhere. // - Intercepts mousedown before uPlot zoom so the selection rect never shows. const CURSOR_SNAP_PX = 8; function _cursorAtClientX(clientX) { const rect = p.uplot.over.getBoundingClientRect(); const { min, max } = p.uplot.scales.x; const toX = val => rect.left + ((val - min) / (max - min)) * rect.width; if (cursors.tA !== null && Math.abs(clientX - toX(cursors.tA)) <= CURSOR_SNAP_PX) return 'A'; if (cursors.tB !== null && Math.abs(clientX - toX(cursors.tB)) <= CURSOR_SNAP_PX) return 'B'; return null; } function _cursorValFromEvent(e) { const rect = p.uplot.over.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const { min, max } = p.uplot.scales.x; return min + pct * (max - min); } // Update pointer style based on what's under the mouse p.uplot.over.addEventListener('mousemove', e => { const snap = _cursorAtClientX(e.clientX); p.uplot.over.style.cursor = snap ? 'ew-resize' : ''; }); p.uplot.over.addEventListener('mouseleave', () => { p.uplot.over.style.cursor = ''; }); // Mousedown: start drag (intercept before uPlot to suppress zoom selection) p.uplot.over.addEventListener('mousedown', e => { if (e.button !== 0 || e.shiftKey) return; // shift is pan const snap = _cursorAtClientX(e.clientX); const target = snap || (cursors.mode !== 'off' ? cursors.mode : null); if (!target) return; e.stopImmediatePropagation(); // prevent uPlot drag-zoom e.preventDefault(); // Set cursor position immediately on mousedown if (target === 'A') cursors.tA = _cursorValFromEvent(e); else cursors.tB = _cursorValFromEvent(e); updateCursorReadout(); cursorsDirty = true; const onMove = ev => { if (target === 'A') cursors.tA = _cursorValFromEvent(ev); else cursors.tB = _cursorValFromEvent(ev); updateCursorReadout(); cursorsDirty = true; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, true); // capture:true so we fire before uPlot's own handlers // Pan support: Shift+left-drag pans the current view (synced across all plots). // Works in both zoomed mode (xRange set) and rolling mode (freezes the window first). let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0; p.uplot.over.addEventListener('mousedown', e => { if (e.button !== 0 || !e.shiftKey) return; e.stopImmediatePropagation(); e.preventDefault(); _panActive = true; _panAnchorX = e.clientX; const xr = p.xRange; if (xr) { _panAnchorMin = xr[0]; _panAnchorMax = xr[1]; } else { // Rolling mode: capture the current window position and freeze it so we // have a stable anchor to pan from. let now = -Infinity; p.traces.forEach(key => { const buf = buffers[key]; if (buf && buf.size > 0) { const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (t > now) now = t; } }); if (!isFinite(now)) now = Date.now() / 1000; _panAnchorMin = now - windowSec; _panAnchorMax = now; // Freeze all plots at this position immediately. const pMin = _panAnchorMin, pMax = _panAnchorMax; zoomGuard = true; plots.forEach(q => { q.xRange = [pMin, pMax]; if (q.uplot) q.uplot.setScale('x', { min: pMin, max: pMax }); }); zoomGuard = false; if (!syncLocked) { syncLocked = true; const btnR = document.getElementById('btn-sync-resume'); if (btnR) btnR.style.display = ''; } } }, true); const _onPanMove = e => { if (!_panActive || !p.uplot) return; const w = p.uplot.over.getBoundingClientRect().width; const span = _panAnchorMax - _panAnchorMin; const dt = -((e.clientX - _panAnchorX) / w) * span; const newMin = _panAnchorMin + dt; const newMax = _panAnchorMax + dt; zoomGuard = true; plots.forEach(q => { q.xRange = [newMin, newMax]; if (q.uplot) q.uplot.setScale('x', { min: newMin, max: newMax }); q.needsRedraw = true; }); zoomGuard = false; }; const _onPanEnd = () => { _panActive = false; }; document.addEventListener('mousemove', _onPanMove); document.addEventListener('mouseup', _onPanEnd); // Resize observer so the uPlot fills its container. // zoomGuard prevents setSize → setScale → hook from calling onZoom. if (p.ro) { p.ro.disconnect(); } p.ro = new ResizeObserver(() => { if (!p.uplot) return; const w = Math.max(p.div.clientWidth || 50, 50); const h = Math.max(p.div.clientHeight || 50, 50); zoomGuard = true; p.uplot.setSize({ width: w, height: h }); zoomGuard = false; }); p.ro.observe(p.div); } // Build the uPlot data array from buffers / trigger snapshot function buildUPlotData(p, inTrigMode) { if (p.traces.length === 0) return [new Float64Array(0)]; // When trigger is enabled but no snapshot yet (armed/waiting), return // the last-rendered data so the plot stays frozen. if (trig.enabled && !inTrigMode) { if (!p.uplot || !p.uplot.data || !p.uplot.data[0]) return [new Float64Array(0)]; return p.uplot.data; } if (inTrigMode && trig.snapshot) return buildTrigData(p); return buildLiveData(p); } // Resample (vSrc) from times (tSrc) onto target times (tDst) using linear interpolation. // tSrc must be sorted ascending. Values outside tSrc range are clamped to the nearest // endpoint (extrapolation is not safe for streaming data). function resampleLinear(tSrc, vSrc, tDst) { const n = tDst.length; const out = new Float64Array(n); if (tSrc.length === 0) return out; // all zeros if (tSrc.length === 1) { out.fill(vSrc[0]); return out; } let j = 0; for (let i = 0; i < n; i++) { const td = tDst[i]; // Advance j so that tSrc[j] <= td < tSrc[j+1] (or j at last index) while (j < tSrc.length - 2 && tSrc[j + 1] < td) j++; if (td <= tSrc[0]) { out[i] = vSrc[0]; } else if (td >= tSrc[tSrc.length - 1]) { out[i] = vSrc[vSrc.length - 1]; } else { const t0 = tSrc[j], t1 = tSrc[j + 1]; const frac = (td - t0) / (t1 - t0); out[i] = vSrc[j] + frac * (vSrc[j + 1] - vSrc[j]); } } return out; } function buildLiveData(p) { if (p.traces.length === 0) return [new Float64Array(0)]; // plotNow = newest timestamp across ALL traces let plotNow = -Infinity; p.traces.forEach(key => { const buf = buffers[key]; if (buf && buf.size > 0) { const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (t > plotNow) plotNow = t; } }); if (!isFinite(plotNow)) plotNow = Date.now() / 1000; const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec; const t1 = p.xRange ? p.xRange[1] : plotNow; // Pixel-adaptive LTTB target: 2× plot width so zooming in automatically // raises the effective sample cap and reveals full resolution. const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); // Slice all traces once; pick the master time grid using configured samplingRate // as the primary criterion (unambiguous, independent of buffer fill / trace order). // Fall back to raw sample count for signals without a configured rate. const slices = {}; let masterKey = p.traces[0], masterCount = -1, masterRate = -1; for (const key of p.traces) { const buf = buffers[key]; if (!buf || buf.size === 0) continue; const sl = getBufferSliceRange(buf, t0, t1); slices[key] = sl; const rate = getKeySamplingRate(key); if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { masterRate = rate; masterCount = sl.t.length; masterKey = key; } } const masterRaw = slices[masterKey]; if (!masterRaw || masterRaw.t.length === 0) return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; // Decimate master with pixel-adaptive LTTB, use resulting grid for all others const dec = lttb(masterRaw.t, masterRaw.v, targetPts); const sharedT = dec.t; const yArrays = []; for (const key of p.traces) { if (key === masterKey) { yArrays.push(dec.v); continue; } const sl = slices[key]; if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; } yArrays.push(resampleLinear(sl.t, sl.v, sharedT)); } return [sharedT, ...yArrays]; } function buildTrigData(p) { const trigT = trig.trigTime; const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); if (p.traces.length === 0) return [new Float64Array(0)]; const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS; const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS; const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); // Slice all traces; pick master by samplingRate first, then sample count const slices = {}; let masterKey = p.traces[0], masterCount = -1, masterRate = -1; for (const key of p.traces) { const snap = trig.snapshot[key]; if (!snap) continue; const sl = sliceTypedArrayRange(snap.t, snap.v, t0, t1); slices[key] = sl; const rate = getKeySamplingRate(key); if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { masterRate = rate; masterCount = sl.t.length; masterKey = key; } } const masterRaw = slices[masterKey]; if (!masterRaw || masterRaw.t.length === 0) return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; const dec = lttb(masterRaw.t, masterRaw.v, targetPts); // Convert absolute → relative seconds const sharedT = new Float64Array(dec.t.length); for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT; const yArrays = []; for (const key of p.traces) { if (key === masterKey) { yArrays.push(dec.v); continue; } const sl = slices[key]; if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; } const relT = new Float64Array(sl.t.length); for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT; yArrays.push(resampleLinear(relT, sl.v, sharedT)); } return [sharedT, ...yArrays]; } /* ════════════════════════════════════════════════════════════════ Zoom sync ════════════════════════════════════════════════════════════════ */ let syncLocked = false; function onZoom(sourcePlotId, min, max) { // Push current range to history before applying new zoom const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null; zoomHistory.push(prevRange); if (zoomHistory.length > 30) zoomHistory.shift(); document.getElementById('btn-zoom-back').style.display = ''; // Store zoom on source plot const src = plots.find(p => p.id === sourcePlotId); if (src) src.xRange = [min, max]; // Show Auto button in live mode if (!trig.enabled && !syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; } // Propagate to other plots zoomGuard = true; plots.forEach(p => { if (p.id === sourcePlotId) return; p.xRange = [min, max]; if (p.uplot) p.uplot.setScale('x', { min, max }); }); zoomGuard = false; // Mark all plots dirty (re-slice data to the new range for full resolution) plots.forEach(p => { p.needsRedraw = true; }); } // Undo last zoom/pan action function zoomBack() { if (!zoomHistory.length) return; const prev = zoomHistory.pop(); if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none'; if (prev === null) { // Was at auto/rolling state before the zoom resetZoom(); } else { zoomGuard = true; plots.forEach(p => { p.xRange = [...prev]; if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] }); p.needsRedraw = true; }); zoomGuard = false; } } // Reset to auto/rolling window (clears all zoom) function resetZoom() { syncLocked = false; document.getElementById('btn-sync-resume').style.display = 'none'; if (trig.enabled && trig.snapshot) { const preS = trig.snapshot._preS || trigPreSec(); const postS = trig.snapshot._postS || trigPostSec(); zoomGuard = true; plots.forEach(p => { p.xRange = null; if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS }); p.needsRedraw = true; }); zoomGuard = false; } else { // Back to rolling window — setScale to current window, render loop keeps it moving plots.forEach(p => { p.xRange = null; if (!globalPause) p.needsRedraw = true; }); } } // Fit x-axis to all data currently in buffers (or full trigger snapshot) function zoomFit() { if (trig.enabled && trig.snapshot) { resetZoom(); // "Fit" in trigger mode = show full trigger window return; } // Find oldest/newest timestamps across all visible signals let gMin = Infinity, gMax = -Infinity; plots.forEach(p => { p.traces.forEach(key => { const buf = buffers[key]; if (!buf || buf.size === 0) return; const startIdx = (buf.size === buf.cap) ? buf.head : 0; const oldestT = buf.t[startIdx]; const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (oldestT < gMin) gMin = oldestT; if (newestT > gMax) gMax = newestT; }); }); if (!isFinite(gMin) || gMin >= gMax) return; // Push to history const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null; zoomHistory.push(prevRange); document.getElementById('btn-zoom-back').style.display = ''; if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; } zoomGuard = true; plots.forEach(p => { p.xRange = [gMin, gMax]; if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax }); p.needsRedraw = true; }); zoomGuard = false; } // Auto = return to rolling window / full trigger window function exitSyncLock() { resetZoom(); } document.getElementById('btn-sync-resume').addEventListener('click', resetZoom); document.getElementById('btn-zoom-back').addEventListener('click', zoomBack); document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit); /* ════════════════════════════════════════════════════════════════ Cursor controls ════════════════════════════════════════════════════════════════ */ const CURSOR_LABELS = { off:'Cursor', A:'Cursor A', B:'Cursor B' }; // Show the cursor button only when paused or in trigger-snapshot mode. // Hides and clears cursors whenever neither condition holds. function updateCursorBtnVisibility() { const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null); const btn = document.getElementById('btn-cursor'); btn.style.display = canUseCursors ? '' : 'none'; if (!canUseCursors && cursors.mode !== 'off') { cursors.mode = 'off'; cursors.tA = null; cursors.tB = null; btn.textContent = CURSOR_LABELS.off; btn.className = 'ctrl-btn'; document.getElementById('cursor-readout').classList.remove('visible'); cursorsDirty = true; } } const CURSOR_MODES = ['off','A','B']; document.getElementById('btn-cursor').addEventListener('click', () => { cursors.mode = CURSOR_MODES[(CURSOR_MODES.indexOf(cursors.mode)+1) % 3]; const btn = document.getElementById('btn-cursor'); btn.textContent = CURSOR_LABELS[cursors.mode]; btn.className = 'ctrl-btn' + (cursors.mode==='A' ? ' cursor-a' : cursors.mode==='B' ? ' cursor-b' : ''); if (cursors.mode === 'off') { cursors.tA = null; cursors.tB = null; document.getElementById('cursor-readout').classList.remove('visible'); cursorsDirty = true; } else { document.getElementById('cursor-readout').classList.add('visible'); } }); function updateCursorReadout() { const ro = document.getElementById('cursor-readout'); const active = cursors.mode !== 'off' || cursors.tA !== null || cursors.tB !== null; ro.classList.toggle('visible', active); if (!active) return; // Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms const fmt = v => { if (v === null) return '—'; if (trig.enabled && trig.snapshot) { const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; return abs < 1 ? sign+(abs*1000).toFixed(3)+'ms' : sign+abs.toFixed(6)+'s'; } const d = new Date(v * 1000); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0') + ':' + String(d.getSeconds()).padStart(2,'0') + '.' + String(d.getMilliseconds()).padStart(3,'0'); }; document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA); document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB); if (cursors.tA !== null && cursors.tB !== null) { const dt = cursors.tB - cursors.tA, abs = Math.abs(dt); const s = dt >= 0 ? '+' : '-'; const str = abs < 1 ? s+(abs*1000).toFixed(3)+'ms' : s+abs.toFixed(6)+'s'; document.getElementById('cur-dt').textContent = 'ΔT: ' + str; } else { document.getElementById('cur-dt').textContent = 'ΔT: —'; } } /* ════════════════════════════════════════════════════════════════ Trigger bar controls ════════════════════════════════════════════════════════════════ */ function openTrigBar(open) { trig.enabled = open; document.getElementById('trigbar').classList.toggle('open', open); document.getElementById('btn-trigger').classList.toggle('trig-active', open); document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px'); // Streaming-only controls are irrelevant while the trigger is active const none = open ? 'none' : ''; document.getElementById('btn-pause-global').style.display = none; document.getElementById('window-select').style.display = none; document.getElementById('lbl-window').style.display = none; if (!open) { trigDisarm(); trig.snapshot = null; cursors.tA = null; cursors.tB = null; updateCursorReadout(); updateCursorBtnVisibility(); zoomHistory.length = 0; document.getElementById('btn-zoom-back').style.display = 'none'; plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); } else { cursors.tA = null; cursors.tB = null; updateCursorReadout(); updateCursorBtnVisibility(); zoomHistory.length = 0; document.getElementById('btn-zoom-back').style.display = 'none'; resetZoom(); if (trig.signal) trigArm(); else updateTrigStatusBadge('idle'); } // Resize uPlot instances after trigbar height changes setTimeout(() => { plots.forEach(p => { if (!p.uplot) return; p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }); }, 220); } document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled)); document.getElementById('trig-signal').addEventListener('change', e => { trig.signal = e.target.value; trig.prevVal = null; if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm(); }); document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; }); document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value)||0; if (trig.armed) trig.prevVal = null; }); document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); }); document.getElementById('trig-pre').addEventListener('input', e => { trig.prePercent = parseInt(e.target.value, 10); document.getElementById('trig-pre-val').textContent = trig.prePercent + '%'; }); document.getElementById('trig-mode').addEventListener('change', e => { trig.mode = e.target.value; }); document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); }); document.getElementById('btn-trig-stop').addEventListener('click', () => { if (!trig.enabled || trig.mode !== 'normal') return; trig.stopped = !trig.stopped; updateStopBtn(); if (!trig.stopped) trigArm(); // resume: re-arm immediately }); /* ════════════════════════════════════════════════════════════════ Trigger signal selector ════════════════════════════════════════════════════════════════ */ function buildTrigSignalSelect() { const sel = document.getElementById('trig-signal'), cur = sel.value; sel.innerHTML = ''; signals.forEach(sig => { const n = numElements(sig); if (isTemporal(sig) || n === 1) { const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o); } else { for (let i = 0; i < n; i++) { const key = sig.name+'['+i+']', o = document.createElement('option'); o.value = key; o.textContent = key; sel.appendChild(o); } } }); if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur; trig.signal = sel.value; } /* ════════════════════════════════════════════════════════════════ Sidebar ════════════════════════════════════════════════════════════════ */ function buildSidebar() { const list = document.getElementById('signal-list'); list.innerHTML = ''; if (!signals.length) { list.innerHTML = '
No signals
'; return; } const typeNames = ['u8','i8','u16','i16','u32','i32','u64','i64','f32','f64']; signals.forEach(sig => { const n = numElements(sig), temporal = isTemporal(sig); const typeName = typeNames[sig.typeCode] || '?'; if (n === 1 || temporal) { list.appendChild(makeDraggable(sig.name, sig.name, temporal?'['+n+'] '+typeName:typeName, sig.unit||'')); } else { const group = document.createElement('div'); group.className = 'array-group'; const header = document.createElement('div'); header.className = 'array-header'; header.innerHTML = ''+escHtml(sig.name)+'' + (sig.unit?''+escHtml(sig.unit)+'':'') + '['+n+'] '+typeName+''; header.addEventListener('click', () => header.classList.toggle('open')); const children = document.createElement('div'); children.className = 'array-children'; for (let i = 0; i < n; i++) { const key = sig.name+'['+i+']', child = makeDraggable(key, key, typeName, sig.unit||''); child.className = 'array-child'; children.appendChild(child); } group.appendChild(header); group.appendChild(children); list.appendChild(group); } }); } function makeDraggable(key, label, typeName, unit) { const item = document.createElement('div'); item.className = 'sig-item'; item.draggable = true; item.innerHTML = ''+escHtml(label)+'' + (unit?''+escHtml(unit)+'':'') + ''+escHtml(typeName)+''; item.addEventListener('dragstart', e => { e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy'; requestAnimationFrame(() => item.classList.add('dragging')); }); item.addEventListener('dragend', () => item.classList.remove('dragging')); return item; } /* ════════════════════════════════════════════════════════════════ Layout management ════════════════════════════════════════════════════════════════ */ // Returns the number of plot cells in a layout (cols × rows). function layoutPlotCount(cls) { const m = cls.match(/^l(\d+)x(\d+)$/); return m ? parseInt(m[1]) * parseInt(m[2]) : 1; } // Build a small SVG grid thumbnail for a given cols×rows layout. function layoutSVG(cols, rows) { const W = 40, H = 28, GAP = 2, PAD = 2; const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols; const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows; let rects = ''; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const x = (PAD + c * (cw + GAP)).toFixed(1); const y = (PAD + r * (ch + GAP)).toFixed(1); rects += ``; } } return `` + `${rects}`; } // Apply a new layout: switch the grid class and auto-add/remove plots. // Plots with traces are preserved; empty plots are removed first when shrinking. function applyLayout(cls) { const entry = LAYOUTS.find(l => l[1] === cls); if (!entry) return; const [label,, cols, rows] = entry; currentLayout = cls; document.getElementById('plot-grid').className = cls; // Update button label const btn = document.getElementById('btn-layout'); if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' ' + label + ' ▾'; // Update active state in menu document.querySelectorAll('.layout-menu-item') .forEach(el => el.classList.toggle('active', el.dataset.layout === cls)); const needed = layoutPlotCount(cls); // Remove excess plots — prefer empty ones to preserve trace assignments. while (plots.length > needed) { let removeId = plots[plots.length - 1].id; for (let i = plots.length - 1; i >= 0; i--) { if (plots[i].traces.length === 0) { removeId = plots[i].id; break; } } deletePlot(removeId); } // Add missing plots. while (plots.length < needed) addPlot(); setTimeout(() => plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }), 120); } function buildLayoutMenu() { const menu = document.getElementById('layout-menu'); LAYOUTS.forEach(([label, cls, cols, rows]) => { const item = document.createElement('button'); item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : ''); item.dataset.layout = cls; item.innerHTML = layoutSVG(cols, rows) + '' + label + ''; item.addEventListener('click', () => { applyLayout(cls); menu.classList.remove('open'); }); menu.appendChild(item); }); // Toggle menu open/close document.getElementById('btn-layout').addEventListener('click', e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); menu.style.left = r.left + 'px'; menu.style.top = (r.bottom + 4) + 'px'; menu.classList.toggle('open'); }); // Close when clicking outside document.addEventListener('click', e => { if (!e.target.closest('#layout-menu') && !e.target.closest('#btn-layout')) menu.classList.remove('open'); }); } /* ════════════════════════════════════════════════════════════════ Export CSV (all plots) ════════════════════════════════════════════════════════════════ */ function exportAllCSV() { const inTrigMode = trig.enabled && trig.snapshot !== null; // Collect unique signal keys across all plots (preserving order) const keys = []; plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); })); if (!keys.length) return; const slices = keys.map(key => { if (inTrigMode) { const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) }; return { t: Array.from(raw.t).map(ts => ts - trig.trigTime), v: Array.from(raw.v) }; } const buf = buffers[key]; if (!buf) return { t: [], v: [] }; const sl = getBufferSlice(buf); return { t: Array.from(sl.t), v: Array.from(sl.v) }; }); const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t))); const sortedT = Array.from(allT).sort((a, b) => a - b); const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t, i) => m.set(t, s.v[i])); return m; }); const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...keys].join(','); const rows = sortedT.map(t => [t.toFixed(9), ...lookups.map(lk => lk.has(t) ? lk.get(t) : '')].join(',')); const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'signals_' + Date.now() + '.csv'; a.click(); URL.revokeObjectURL(a.href); } /* ════════════════════════════════════════════════════════════════ Plot management ════════════════════════════════════════════════════════════════ */ function addPlot() { const id = nextPlotId++; const card = document.createElement('div'); card.className = 'plot-card'; card.dataset.plotId = id; card.innerHTML = `
Plot ${id}
Drop signals here
⚡ Collecting…
`; card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='copy'; card.classList.add('drag-over'); }); card.addEventListener('dragleave', () => card.classList.remove('drag-over')); card.addEventListener('drop', e => { e.preventDefault(); card.classList.remove('drag-over'); const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key); }); document.getElementById('plot-grid').appendChild(card); const plotBody = card.querySelector('#pbody-'+id); const p = { id, traces:[], div: plotBody, needsRedraw:false, xRange:null, uplot:null, ro:null }; plots.push(p); // Create the uPlot instance (empty) — deferred so DOM has settled requestAnimationFrame(() => { createUPlot(p); }); return id; } function addTraceTo(plotId, signalKey) { const p = plots.find(p => p.id === plotId); if (!p) return; if (p.traces.includes(signalKey)) return; p.traces.push(signalKey); document.querySelector('#hint-'+plotId).style.display = 'none'; addBadge(plotId, signalKey); // Recreate uPlot with new series list createUPlot(p); p.needsRedraw = true; } function removeTraceFrom(plotId, signalKey) { const p = plots.find(p => p.id === plotId); if (!p) return; p.traces = p.traces.filter(t => t !== signalKey); removeBadge(plotId, signalKey); createUPlot(p); p.needsRedraw = true; if (!p.traces.length) document.querySelector('#hint-'+plotId).style.display = ''; } function addBadge(plotId, key) { const c = document.getElementById('badges-'+plotId); if (!c) return; if (c.querySelector('[data-key="'+CSS.escape(key)+'"]')) return; const color = getTraceColor(key); const badge = document.createElement('span'); badge.className = 'sig-badge'; badge.dataset.key = key; const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color; const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×'; x.addEventListener('click', () => removeTraceFrom(plotId, key)); badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x); c.appendChild(badge); } function removeBadge(plotId, key) { const c = document.getElementById('badges-'+plotId); if (!c) return; const b = c.querySelector('[data-key="'+CSS.escape(key)+'"]'); if (b) b.remove(); } function deletePlot(plotId) { const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return; const p = plots[idx]; if (p.ro) p.ro.disconnect(); if (p.uplot) p.uplot.destroy(); plots.splice(idx, 1); document.querySelector('[data-plot-id="'+plotId+'"]').remove(); } /* ════════════════════════════════════════════════════════════════ Render loop ════════════════════════════════════════════════════════════════ */ let _dbgTick = 0; function renderDirtyPlots() { const inTrigMode = trig.enabled && trig.snapshot !== null; // Diagnostic: every ~5 s print buffer state to the browser console. // Open DevTools → Console to see timestamps and sizes. if (++_dbgTick % 300 === 0) { const wallNow = Date.now() / 1000; let anyData = false; Object.entries(buffers).forEach(([k, b]) => { if (b.size === 0) return; anyData = true; const newest = b.t[(b.head - 1 + b.cap) % b.cap]; const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap]; const sliceLen = getBufferSlice(b).t.length; console.log(`[buf] ${k}: size=${b.size} oldest=${oldest.toFixed(3)} newest=${newest.toFixed(3)} wallNow=${wallNow.toFixed(3)} ts-age=${(wallNow-newest).toFixed(3)}s slice(${windowSec}s)=${sliceLen}pts`); }); if (!anyData) console.log('[buf] all buffers empty — no data received yet'); plots.forEach(p => console.log(`[plot${p.id}] traces=${JSON.stringify(p.traces)} xRange=${JSON.stringify(p.xRange)} needsRedraw=${p.needsRedraw}`)); } // Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen // zoom window) and clear it back to rolling. Run unconditionally before the // data-rebuild loop so the stale plot is correctly handled this frame. if (!trig.enabled && !globalPause) { plots.forEach(p => { if (!p.uplot || !p.xRange) return; const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0); if (!hasData) return; const stale = p.traces.every(key => { const buf = buffers[key]; if (!buf || buf.size === 0) return true; const oldest = buf.t[buf.size === buf.cap ? buf.head : 0]; return p.xRange[1] < oldest; }); if (stale) { p.xRange = null; syncLocked = false; const btnR = document.getElementById('btn-sync-resume'); if (btnR) btnR.style.display = 'none'; } }); } // Fast path: cursor-only redraw (no data rebuild needed) if (cursorsDirty) { cursorsDirty = false; plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); }); } // Rolling-window plots: mark dirty every frame for smooth continuous scrolling. // setScale is called AFTER setData inside the rebuild loop so the viewport and // data slice are always computed with the same plotNow anchor. if (!trig.enabled && !globalPause) { plots.forEach(p => { if (!p.uplot || p.xRange) return; p.needsRedraw = true; }); } plots.forEach(p => { if (!p.needsRedraw || !p.uplot) return; p.needsRedraw = false; const data = buildUPlotData(p, inTrigMode); // setData internally triggers the setScale hook in uPlot (it reaffirms the // current scale even with auto:false). Keep zoomGuard raised across the // entire setData + setScale block so the hook never calls onZoom and freezes // p.xRange unintentionally. The guard is safe here: JS is single-threaded so // no genuine user drag-zoom event can fire during this synchronous block. zoomGuard = true; p.uplot.setData(data); // Re-apply the x-scale after setData so the viewport stays correct. if (trig.enabled && !inTrigMode) { // Armed / waiting for trigger: keep the current scale frozen. } else if (inTrigMode) { const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); p.uplot.setScale('x', { min: p.xRange ? p.xRange[0] : -preS, max: p.xRange ? p.xRange[1] : postS }); } else if (p.xRange) { // Zoomed: re-apply so scale is correct after setData p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] }); } else { // Rolling window: compute plotNow fresh each frame (same anchor as buildLiveData) // and set the scale immediately after setData for correct alignment. let plotNow = -Infinity; p.traces.forEach(key => { const buf = buffers[key]; if (!buf || buf.size === 0) return; const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (t > plotNow) plotNow = t; }); if (!isFinite(plotNow)) plotNow = Date.now() / 1000; p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow }); } zoomGuard = false; }); requestAnimationFrame(renderDirtyPlots); } /* ════════════════════════════════════════════════════════════════ Global controls ════════════════════════════════════════════════════════════════ */ document.getElementById('btn-pause-global').addEventListener('click', () => { globalPause = !globalPause; const btn = document.getElementById('btn-pause-global'); btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause'; btn.classList.toggle('active', globalPause); if (!globalPause) plots.forEach(p => { p.needsRedraw = true; }); updateCursorBtnVisibility(); }); document.getElementById('window-select').addEventListener('change', e => { windowSec = parseFloat(e.target.value); // Don't trigger redraws while in trigger mode without a snapshot if (trig.enabled && !trig.snapshot) return; plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; }); }); /* ════════════════════════════════════════════════════════════════ Sidebar toggle ════════════════════════════════════════════════════════════════ */ let sidebarOpen = true; function setSidebar(open) { sidebarOpen = open; document.getElementById('sidebar').classList.toggle('collapsed', !open); document.getElementById('btn-sidebar').classList.toggle('active', open); setTimeout(() => { plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }); }, 200); } document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen)); /* ════════════════════════════════════════════════════════════════ Utility ════════════════════════════════════════════════════════════════ */ function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /* ════════════════════════════════════════════════════════════════ Init ════════════════════════════════════════════════════════════════ */ buildLayoutMenu(); applyLayout('l1x1'); document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); connectWS(); requestAnimationFrame(renderDirtyPlots); fetch('/version').then(r => r.text()).then(v => { document.getElementById('build-version').textContent = 'v' + v; }).catch(() => {});