'use strict'; /* ════════════════════════════════════════════════════════════════ Constants ════════════════════════════════════════════════════════════════ */ // Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history. const MAX_WINDOW_SEC = 60; // Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v). const MAX_CAP = 600_000; // Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz. const DEFAULT_CAP = 100_000; // Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s. // TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz. const TEMPORAL_CAP = Math.min(MAX_CAP, 500_000); const LTTB_MIN = 200; // never decimate below this many points // Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz. function bufferCapForRate(rateHz) { if (!rateHz || rateHz <= 0) return DEFAULT_CAP; return Math.min(MAX_CAP, Math.ceil(rateHz * MAX_WINDOW_SEC * 1.5)); } // Return a new, larger circular buffer that preserves all existing samples. function growBuffer(buf, newCap) { if (newCap <= buf.cap) return buf; const nb = makeBuffer(newCap); const start = buf.size === buf.cap ? buf.head : 0; for (let i = 0; i < buf.size; i++) { const idx = (start + i) % buf.cap; pushBuffer(nb, buf.t[idx], buf.v[idx]); } return nb; } const TRACE_COLORS = [ '#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7', '#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7', ]; /* ════════════════════════════════════════════════════════════════ Globals ════════════════════════════════════════════════════════════════ */ // sourcesMap: id → {id, label, addr, state, signals:[]} const sourcesMap = {}; 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]; } // Per-signal style overrides (color, width, dash, marker, markerSize). const sigStyle = {}; function getSigStyle(key) { if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 }; return sigStyle[key]; } // Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset} const sigVScale = {}; // Active signal per plot: plotId → key const plotActiveSignal = {}; function setSigStyle(key, updates) { const s = getSigStyle(key); Object.assign(s, updates); if (updates.color) { traceColorMap[key] = updates.color; // Update badge dots for this key across all plots document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => { dot.style.background = updates.color; }); } // Recreate uPlot for all plots containing this key plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } }); } /* ─── VScale helpers ─────────────────────────────────────────────────────── */ // vsKey: compound key "plotId:signalKey" so same signal in different plots is independent. function getVScale(plotId, key) { const vsKey = plotId + ':' + key; if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false }; return sigVScale[vsKey]; } function findSignalMeta(key) { const colon = key.indexOf(':'); if (colon < 0) return null; const src = sourcesMap[key.slice(0, colon)]; if (!src) return null; return src.signals.find(s => s.name === key.slice(colon + 1)) || null; } // Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array. // y_norm = (y_raw - offset) / divValue + screenPos // divValue: units per division offset: raw value at screen center screenPos: divisions from center // Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use. function resolveVScale(plotId, key, rawY) { const vs = getVScale(plotId, key); const screenPos = vs.screenPos || 0; if (vs.mode === 'range') { const meta = findSignalMeta(key); if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) { const divValue = (meta.rangeMax - meta.rangeMin) / 8; const offset = (meta.rangeMin + meta.rangeMax) / 2; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; return { divValue, offset, screenPos }; } // Fall through to auto if no range } if (vs.mode === 'manual') { const divValue = Math.max(vs.divValue || 1, 1e-30); const offset = vs.offset != null ? vs.offset : 0; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; return { divValue, offset, screenPos }; } // Auto: fit data in central 6 of 8 divisions, centered at screenPos let min = Infinity, max = -Infinity; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } } if (!isFinite(min)) { min = -1; max = 1; } if (min === max) { min -= 1; max += 1; } const divValue = Math.max((max - min) / 6, 1e-30); const offset = (max + min) / 2; vs._resolvedDiv = divValue; vs._resolvedOffset = offset; return { divValue, offset, screenPos }; } // Mixed mode: each signal occupies a fixed band; within that band it is either // quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed. function applyMixedNorm(p, yArrays) { const n = p.traces.length; if (n === 0) return yArrays; const bandH = 8 / n; return yArrays.map((rawY, ki) => { const key = p.traces[ki]; const vs = getVScale(p.id, key); const centerY = 4 - (ki + 0.5) * bandH; const hi = centerY + bandH * 0.35; const lo = centerY - bandH * 0.35; let min = Infinity, max = -Infinity; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } } const out = new Float64Array(rawY.length); if (vs.digitalInMixed) { const threshold = isFinite(min) ? (min + max) / 2 : 0.5; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo); } } else { if (!isFinite(min)) { min = 0; max = 1; } if (min === max) { min -= 1; max += 1; } const range = max - min, bandRange = hi - lo; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; out[i] = (v == null || !isFinite(v)) ? NaN : lo + (v - min) / range * bandRange; } } return out; }); } // Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces). // Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos. function applyVScaleNorm(p, yArrays) { if (p.mode === 'digital') return applyDigitalNorm(p, yArrays); if (p.mode === 'mixed') return applyMixedNorm(p, yArrays); return yArrays.map((rawY, ki) => { const key = p.traces[ki]; const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY); const out = new Float64Array(rawY.length); for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos; } return out; }); } // Digital mode: quantize each signal to lo/hi within its own horizontal band. // Signals are arranged top-to-bottom matching badge order (index 0 = top). function applyDigitalNorm(p, yArrays) { const n = p.traces.length; if (n === 0) return yArrays; const bandH = 8 / n; // total Y span is 8 divisions (-4 to +4) return yArrays.map((rawY, ki) => { // Top-down: signal 0 is at top (highest Y value) const centerY = 4 - (ki + 0.5) * bandH; const hi = centerY + bandH * 0.35; const lo = centerY - bandH * 0.35; // Threshold: midpoint of min/max let min = Infinity, max = -Infinity; for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } } const threshold = isFinite(min) ? (min + max) / 2 : 0.5; const out = new Float64Array(rawY.length); for (let i = 0; i < rawY.length; i++) { const v = rawY[i]; out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo); } return out; }); } // Set the active (Y-axis-labelled) signal for a plot and update badge highlights. function setActiveSig(plotId, key) { if (key === null || key === undefined) { delete plotActiveSignal[plotId]; } else { plotActiveSignal[plotId] = key; } const c = document.getElementById('badges-' + plotId); if (c) c.querySelectorAll('.sig-badge').forEach(b => b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key)); const p = plots.find(q => q.id === plotId); if (p && p.uplot) p.uplot.redraw(false); updatePlotCursorReadouts(); } // Mark plots containing key dirty and refresh badge vscale text. function refreshPlotForKey(key) { plots.forEach(p => { if (p.traces.includes(key)) { p.needsRedraw = true; _updateBadgeVScaleInfo(p.id, key); } }); } // Format a numeric value concisely for badge/axis display. function _fmtVal(v) { if (v == null || !isFinite(v)) return '?'; const abs = Math.abs(v); if (abs === 0) return '0'; if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1); return parseFloat(v.toPrecision(3)).toString(); } // Refresh the vscale info text inside a badge. function _updateBadgeVScaleInfo(plotId, key) { const c = document.getElementById('badges-' + plotId); if (!c) return; const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return; const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return; const vs = sigVScale[plotId + ':' + key]; if (!vs) { infoEl.textContent = ''; return; } const divValue = vs._resolvedDiv || vs.divValue || 1; const sp = vs.screenPos || 0; let txt = _fmtVal(divValue) + '/div'; if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div'; infoEl.textContent = txt; } // 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 = []; // zoomData: hi-res data fetched from /api/zoom, keyed by plot id. // Each entry: { signals: { key: {t:Float64Array, v:Float64Array} }, t0, t1 } const zoomData = {}; let _zoomFetchTimer = null; // 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'; let colFrs = [1]; // fractional column sizes (sum = cols) let rowFrs = [1]; // fractional row sizes (sum = rows) let _gridCols = 1, _gridRows = 1; /* ════════════════════════════════════════════════════════════════ 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.binaryType = 'arraybuffer'; 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 => { if (evt.data instanceof ArrayBuffer) { onBinaryData(evt.data); return; } let msg; try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'sources') onSources(msg); else if (msg.type === 'config') onConfig(msg); else if (msg.type === 'data') onData(msg); else if (msg.type === 'stats') onStats(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 sid = msg.sourceId; if (!sid) return; // Ensure source exists (may arrive before 'sources' message in some edge cases). if (!sourcesMap[sid]) { sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] }; } const src = sourcesMap[sid]; const newSigs = msg.signals || []; const oldSigs = src.signals || []; const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0); const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i])); src.signals = newSigs; if (changed) { // Remove old buffers for this source only (prefix: "sid:"). const prefix = sid + ':'; Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; }); newSigs.forEach(sig => { const n = numElements(sig); const base = prefix + sig.name; const sigCap = bufferCapForRate(sig.samplingRate); if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); } else if (n === 1) { buffers[base] = makeBuffer(sigCap); } else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); } }); if (trig.signal && trig.signal.startsWith(prefix)) { 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]); }); // Increment data generation counter so render loop knows data changed _dataGen++; 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; }); } } /* ════════════════════════════════════════════════════════════════ Binary data handler — parses compact binary frames from Go backend. Wire format (little-endian): uint8 version (1) uint8 sourceIdLen UTF-8 sourceId uint32 numSignals for each signal: uint16 keyLen UTF-8 key (relative to source) uint32 pairCount N float64[N] t values float64[N] v values ════════════════════════════════════════════════════════════════ */ function onBinaryData(buf) { lastDataAt = performance.now(); const dv = new DataView(buf); let off = 0; if (dv.getUint8(off) !== 1) return; off += 1; const srcIdLen = dv.getUint8(off); off += 1; const srcId = new TextDecoder().decode(new Uint8Array(buf, off, srcIdLen)); off += srcIdLen; const prefix = srcId + ':'; const numSigs = dv.getUint32(off, true); off += 4; // Collect trigger-signal values for inline check let trigVals = null; for (let s = 0; s < numSigs; s++) { const keyLen = dv.getUint16(off, true); off += 2; const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen)); off += keyLen; const fullKey = prefix + key; const n = dv.getUint32(off, true); off += 4; let bufObj = buffers[fullKey]; if (!bufObj) { bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP); buffers[fullKey] = bufObj; } // If the buffer was created before the CONFIG message arrived (or with an // underestimated capacity), grow it now that we know the signal's rate. const detectedRate = getKeySamplingRate(fullKey); if (detectedRate > 0) { const needed = bufferCapForRate(detectedRate); if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; } } // Read t and v values in one pass (v array starts at off + n*8) const tOff = off, vOff = off + n * 8; for (let i = 0; i < n; i++) { pushBuffer(bufObj, dv.getFloat64(tOff + i * 8, true), dv.getFloat64(vOff + i * 8, true)); } off += n * 16; // skip both t and v arrays // Capture trigger signal values if (trig.enabled && trig.armed && fullKey === trig.signal) { trigVals = { t: new Float64Array(n), v: new Float64Array(n) }; for (let i = 0; i < n; i++) { trigVals.t[i] = dv.getFloat64(tOff + i * 8, true); trigVals.v[i] = dv.getFloat64(vOff + i * 8, true); } } } // Trigger check — pass as keyed object matching checkTrigger's expected format if (trigVals) checkTrigger({ [trig.signal]: trigVals }); if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec()) finaliseTriggerCapture(); if (!trig.enabled) { _dataGen++; plots.forEach(p => { if (globalPause) return; if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true; }); } } 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 && cur >= thr) || (e === 'falling' && prev > thr && cur <= thr) || (e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) { fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break; } } } function fireTrigger(t) { // Clear the previous snapshot here (not in trigArm) so the last waveform // and trigger marker stay visible while the trigger is rearmed and waiting. trig.snapshot = null; trig.armed = false; trig.collecting = true; trig.trigTime = t; 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'); // Show cursor button now that snapshot exists (positions preserved from before) 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); // Upgrade the snapshot in the background with full-resolution ring data. // The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs. upgradeTriggerSnapshot(t0, t1, trig.trigTime); } // Fetches full-resolution ring data for the trigger window and replaces the // push-buffer entries in trig.snapshot, then re-renders all plots. async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) { // Small delay so the ring receives the last post-trigger samples // (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin). await new Promise(r => setTimeout(r, 120)); // Abort if a new trigger has fired since we started. if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return; const keys = Object.keys(buffers); if (!keys.length) return; const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`; let ringSignals; try { const resp = await fetch(url); if (!resp.ok) return; const json = await resp.json(); ringSignals = json && json.signals; } catch (e) { console.warn('trigger snapshot ring upgrade failed:', e); return; } // Abort if the snapshot was replaced while we were fetching. if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return; let updated = false; Object.entries(ringSignals || {}).forEach(([key, sd]) => { if (!sd || !sd.t || !sd.t.length) return; trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) }; updated = true; }); if (updated) plots.forEach(p => { p.needsRedraw = true; }); } function trigArm() { // Do NOT clear trig.snapshot here — keep the last waveform and trigger marker // visible while waiting for the next event. fireTrigger() clears it when a new // trigger actually fires. trig.armed = true; trig.collecting = false; trig.prevVal = null; showRearmBtn(false); updateTrigStatusBadge('armed'); 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 }; } // Like getBufferSliceRange but also includes the nearest point just outside each // boundary so that a line is always drawn across the visible area even when the // zoom window contains only 0 or 1 samples. function getBufferSliceRangeWithBrackets(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; // Expand by one on each side for bracketing points. const kFrom = Math.max(0, kStart - 1); const kTo = Math.min(size, kEnd + 1); const len = kTo - kFrom; if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) }; const outT = new Float64Array(len), outV = new Float64Array(len); for (let i = 0; i < len; i++) { const idx = physAt(kFrom + i); outT[i] = buf.t[idx]; outV[i] = buf.v[idx]; } return { t: outT, v: outV }; } // Supplement sparse fetched signal data ({t, v} Float64Arrays) with the nearest // bracketing points from the local circular buffer, so lines are always drawn // across the zoom window even if the server returned 0 or 1 points. function supplementWithBrackets(sd, buf, t0, t1) { if (!buf || buf.size === 0) return sd; if (sd && sd.t.length >= 2) return sd; // already enough points const { size, head, cap } = 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; const leftK = kStart > 0 ? kStart - 1 : -1; const rightK = kEnd < size ? kEnd : -1; const tArr = [], vArr = []; if (leftK >= 0) { tArr.push(buf.t[physAt(leftK)]); vArr.push(buf.v[physAt(leftK)]); } if (sd) { for (let i = 0; i < sd.t.length; i++) { tArr.push(sd.t[i]); vArr.push(sd.v[i]); } } if (rightK >= 0) { tArr.push(buf.t[physAt(rightK)]); vArr.push(buf.v[physAt(rightK)]); } if (tArr.length === 0) return sd; return { t: Float64Array.from(tArr), v: Float64Array.from(vArr) }; } // 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) { // key format: "sourceId:signalName" or "sourceId:signalName[i]" for (const src of Object.values(sourcesMap)) { const prefix = src.id + ':'; if (!key.startsWith(prefix)) continue; const localKey = key.slice(prefix.length); const direct = (src.signals || []).find(s => s.name === localKey); if (direct) return direct.samplingRate || 0; const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '[')); if (sig) return sig.samplingRate || 0; } return 0; } // Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default). function makeSeriesPath(key) { const style = getSigStyle(key); if (style.dash === 'solid') return null; const dashPat = style.dash === 'dashed' ? [6, 4] : [2, 3]; return (u, si) => { const xd = u.data[0], yd = u.data[si]; if (!xd || !yd || !u.bbox) return { stroke: null, fill: null }; const { ctx, bbox } = u; ctx.save(); ctx.beginPath(); ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); ctx.clip(); ctx.strokeStyle = style.color; ctx.lineWidth = style.width; ctx.setLineDash(dashPat); ctx.lineJoin = 'round'; ctx.beginPath(); let moved = false; for (let i = 0; i < xd.length; i++) { if (yd[i] == null) { moved = false; continue; } const cx = u.valToPos(xd[i], 'x', true); const cy = u.valToPos(yd[i], 'y', true); if (!moved) { ctx.moveTo(cx, cy); moved = true; } else ctx.lineTo(cx, cy); } ctx.stroke(); ctx.restore(); return { stroke: null, fill: null }; // tell uPlot not to draw anything on top }; } /* ════════════════════════════════════════════════════════════════ LTTB Web Worker — offloads decimation off the main thread. Cache key: "::::" On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate). On cache-miss → render falls back to sync lttb once (first zoom render only), then worker takes over for subsequent updates. ════════════════════════════════════════════════════════════════ */ const lttbCache = new Map(); // key → {t, v} const lttbPending = new Set(); // keys currently in-flight let _lttbWorker = null; try { _lttbWorker = new Worker('lttb-worker.js'); _lttbWorker.onmessage = function({ data: { id, t, v } }) { lttbPending.delete(id); lttbCache.set(id, { t, v }); // Invalidate and redraw the owning plot. const plotId = parseInt(id.split(':')[0], 10); const p = plots.find(q => q.id === plotId); if (p) { p.needsRedraw = true; } }; _lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e); } catch(e) { console.warn('[lttb-worker] unavailable, using sync fallback:', e); } // Submit a LTTB job to the worker (or run sync if worker unavailable). // Returns cached {t, v} if fresh, null if a worker job was just submitted, // or a sync result if the worker is unavailable. function lttbAsync(cacheKey, t, v, threshold) { const cached = lttbCache.get(cacheKey); if (cached) return cached; // cache hit — use immediately if (!lttbPending.has(cacheKey)) { lttbPending.add(cacheKey); if (_lttbWorker) { // Send copies so the main thread retains the originals. const tCopy = new Float64Array(t); const vCopy = new Float64Array(v); _lttbWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold }, [tCopy.buffer, vCopy.buffer]); } else { // Synchronous fallback (worker unavailable). const result = lttb(t, v, threshold); lttbCache.set(cacheKey, result); lttbPending.delete(cacheKey); return result; } } return null; // worker job in-flight — caller should use fallback } // Evict stale LTTB cache entries for a plot (call when zoom range changes). function lttbCacheEvict(plotId) { const prefix = plotId + ':'; for (const k of [...lttbCache.keys()]) { if (k.startsWith(prefix)) lttbCache.delete(k); } for (const k of [...lttbPending]) { if (k.startsWith(prefix)) lttbPending.delete(k); } } // 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 }; } /* ════════════════════════════════════════════════════════════════ Hybrid zoom: hi-res data fetched from /api/zoom on demand ════════════════════════════════════════════════════════════════ */ function cancelZoomFetch() { if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; } } function scheduleZoomFetch(t0, t1) { cancelZoomFetch(); _zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150); } async function doZoomFetch(t0, t1) { // Collect all signal keys across all plots; each key encodes sourceId as prefix. const allKeys = new Set(); plots.forEach(p => p.traces.forEach(k => allKeys.add(k))); if (allKeys.size === 0) return; const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2); const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`; let fetched; try { const resp = await fetch(url); if (!resp.ok) return; fetched = await resp.json(); } catch (e) { console.warn('zoom fetch:', e); return; } const sigs = fetched && fetched.signals; if (!sigs) return; // Convert arrays from JSON to Float64Array and store per-plot. const merged = {}; Object.entries(sigs).forEach(([k, sd]) => { if (!sd || !sd.t || !sd.v) return; merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) }; }); plots.forEach(p => { // Only store if this plot's range still matches what we fetched. if (!p.xRange || Math.abs(p.xRange[0] - t0) > 1e-9 || Math.abs(p.xRange[1] - t1) > 1e-9) return; const plotSigs = {}; p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; }); if (Object.keys(plotSigs).length > 0) { zoomData[p.id] = { signals: plotSigs, t0, t1 }; p.needsRedraw = true; } }); } // Build uPlot data arrays from server-fetched hi-res signal data. // Sparse signals (0 or 1 pts in range) are supplemented with local buffer // bracket points so a line is always drawn across the visible area. function buildDataFromFetched(p, fetchedSignals, targetPts) { const t0 = p.xRange ? p.xRange[0] : -Infinity; const t1 = p.xRange ? p.xRange[1] : Infinity; let masterKey = p.traces[0], masterCount = -1, masterRate = -1; for (const key of p.traces) { let sd = fetchedSignals[key]; if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1); if (!sd || !sd.t.length) continue; const rate = getKeySamplingRate(key); if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) { masterRate = rate; masterCount = sd.t.length; masterKey = key; } } let masterSd = fetchedSignals[masterKey]; if (!masterSd || masterSd.t.length < 2) masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1); if (!masterSd || !masterSd.t.length) return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; const dec = lttb(masterSd.t, masterSd.v, targetPts); const sharedT = dec.t; const yArrays = []; for (const key of p.traces) { if (key === masterKey) { yArrays.push(dec.v); continue; } let sd = fetchedSignals[key]; if (!sd || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1); if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; } yArrays.push(resampleLinear(sd.t, sd.v, sharedT)); } return [sharedT, ...applyVScaleNorm(p, yArrays)]; } /* ════════════════════════════════════════════════════════════════ uPlot helpers ════════════════════════════════════════════════════════════════ */ // ─── Time formatting helpers ────────────────────────────────────────────────── // Returns the span (in seconds) of the currently visible x-axis across all plots. // Falls back to windowSec when no uPlot instances exist yet. function currentXSpan() { for (const p of plots) { if (p.uplot) { const s = p.uplot.scales.x; if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min); } } return windowSec; } // Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on // refSpan (e.g. the visible x-range or the value itself). // sign = '+' prefix only when showSign is true (default false for ΔT display). function fmtDuration(sec, refSpan, showSign) { const abs = Math.abs(sec); const sign = showSign ? (sec < 0 ? '−' : '+') : (sec < 0 ? '−' : ''); if (refSpan < 1e-6) { // nanosecond range return sign + (abs * 1e9).toFixed(1) + ' ns'; } else if (refSpan < 1e-3) { // microsecond range return sign + (abs * 1e6).toFixed(3) + ' µs'; } else if (refSpan < 1) { // millisecond range return sign + (abs * 1e3).toFixed(3) + ' ms'; } else { // second range return sign + abs.toFixed(6) + ' s'; } } // Format a Unix-seconds timestamp → HH:MM:SS.fraction // The number of sub-second digits adapts to the visible x-range span. function fmtLiveTime(v, span) { 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 frac = v - Math.floor(v); // sub-second part, full float64 precision if (span < 1e-6) { // show 9 decimal places (ns precision) const ns = Math.round(frac * 1e9); return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0'); } else if (span < 1e-3) { // show 6 decimal places (µs precision) const us = Math.round(frac * 1e6); return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0'); } else if (span < 1) { // show 3 decimal places (ms precision) — tick labels only show ms const ms = String(d.getMilliseconds()).padStart(3, '0'); return hh + ':' + mm + ':' + ss + '.' + ms; } else { const ms = String(d.getMilliseconds()).padStart(3, '0'); return hh + ':' + mm + ':' + ss + '.' + ms; } } // Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks) // Precision adapts to the visible x-range (via u.scales.x.{min,max}). function fmtLiveTick(u, vals) { const span = (u.scales.x && u.scales.x.max != null) ? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec; return vals.map(v => v == null ? '' : fmtLiveTime(v, span)); } // Format relative seconds → auto-scaled unit (used for trigger x-axis ticks) // Unit (s/ms/µs/ns) is determined by the visible x-range span. function fmtTrigTick(u, vals) { const span = (u.scales.x && u.scales.x.max != null) ? Math.abs(u.scales.x.max - u.scales.x.min) : 1; return vals.map(v => v == null ? '' : fmtDuration(v, span, true)); } // Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold // line on any plot that shows the trigger signal. function drawTriggerMarker(u, p) { if (!trig.enabled || !trig.snapshot) return; const { ctx, bbox } = u; if (!bbox) return; const x = u.valToPos(0, 'x', true); if (x < bbox.left || x > bbox.left + bbox.width) return; const px = Math.round(x); ctx.save(); // Thin dashed vertical line at t=0 ctx.strokeStyle = 'rgba(203,166,247,0.7)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(px, bbox.top); ctx.lineTo(px, bbox.top + bbox.height); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(203,166,247,0.7)'; ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top'; ctx.fillText('T', px + 3, bbox.top + 2); // Horizontal threshold line — only on plots that contain the trigger signal if (p && trig.signal && p.traces.includes(trig.signal)) { // Normalize the raw threshold to this plot's vscale for the trigger signal. const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null; let threshNorm = trig.threshold; if (tvs) { const dv = tvs._resolvedDiv || tvs.divValue || 1; const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0); const sp = tvs.screenPos || 0; threshNorm = (trig.threshold - ofs) / dv + sp; } const y = u.valToPos(threshNorm, 'y', true); if (y >= bbox.top && y <= bbox.top + bbox.height) { const py = Math.round(y); ctx.strokeStyle = 'rgba(203,166,247,0.45)'; ctx.lineWidth = 0.75; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(bbox.left, py); ctx.lineTo(bbox.left + bbox.width, py); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(203,166,247,0.45)'; ctx.font = '9px monospace'; ctx.textBaseline = 'bottom'; ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1); } } ctx.restore(); } // Linearly interpolate series value at time t from uPlot's current rendered data. function interpAtTime(u, si, t) { const td = u.data[0], vd = u.data[si]; if (!td || td.length === 0 || !vd) return null; let lo = 0, hi = td.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; } if (lo === 0) return vd[0] ?? null; const t0 = td[lo - 1], t1 = td[lo]; const v0 = vd[lo - 1], v1 = vd[lo]; if (v0 == null || v1 == null) return v0 ?? v1 ?? null; return v0 + (t - t0) / (t1 - t0) * (v1 - v0); } // Draw horizontal separator lines between signal bands in digital/mixed mode. function drawBandSeparators(u, p) { if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return; const n = p.traces.length; if (n < 2) return; const { ctx, bbox } = u; const dpr = window.devicePixelRatio || 1; const bandH = 8 / n; ctx.save(); ctx.strokeStyle = 'rgba(127,132,156,0.25)'; ctx.lineWidth = dpr; for (let i = 1; i < n; i++) { const yNorm = 4 - i * bandH; // boundary between band i-1 and band i const yPx = u.valToPos(yNorm, 'y', true); ctx.beginPath(); ctx.moveTo(bbox.left, yPx); ctx.lineTo(bbox.left + bbox.width, yPx); ctx.stroke(); } ctx.restore(); } // Redraw the active signal's line on top of all series with a wider stroke, so it // visually appears in the foreground regardless of series draw order. function drawActiveSeries(u, p) { if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return; const activeKey = plotActiveSignal[p.id]; if (!activeKey) return; const idx = p.traces.indexOf(activeKey); if (idx < 0) return; const xs = u.data[0]; const ys = u.data[idx + 1]; // +1 because index 0 is time if (!xs || !ys) return; const style = getSigStyle(activeKey); const dpr = window.devicePixelRatio || 1; const { ctx } = u; ctx.save(); ctx.strokeStyle = style.color; ctx.lineWidth = style.width * 2 * dpr; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); let started = false; for (let i = 0; i < xs.length; i++) { const yv = ys[i]; if (yv == null || !isFinite(yv)) { started = false; continue; } const xPx = u.valToPos(xs[i], 'x', true); const yPx = u.valToPos(yv, 'y', true); if (!started) { ctx.moveTo(xPx, yPx); started = true; } else ctx.lineTo(xPx, yPx); } ctx.stroke(); ctx.restore(); } // Draw offset position markers (right-pointing triangles) on the left edge of the plot // for each signal. Active signal marker is larger and outlined in white. function drawOffsetMarkers(u, p) { if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return; const { ctx, bbox } = u; const dpr = window.devicePixelRatio || 1; p.traces.forEach(key => { const vs = sigVScale[p.id + ':' + key]; const screenPos = vs ? (vs.screenPos || 0) : 0; const yCtr = u.valToPos(screenPos, 'y', true); const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr; const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr; if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return; const isActive = plotActiveSignal[p.id] === key; ctx.save(); ctx.fillStyle = getSigStyle(key).color; // Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area ctx.beginPath(); ctx.moveTo(bbox.left + dpr, yCtr); ctx.lineTo(bbox.left - mW + dpr, yCtr - mH); ctx.lineTo(bbox.left - mW + dpr, yCtr + mH); ctx.closePath(); ctx.fill(); if (isActive) { ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = dpr; ctx.stroke(); } ctx.restore(); }); } // Draw custom marker shapes (square, cross, diamond) for all series in a plot. // Circle markers are handled natively by uPlot's points option. function drawSeriesMarkers(u, p) { if (!u.bbox) return; const { ctx, bbox } = u; ctx.save(); ctx.beginPath(); ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); ctx.clip(); p.traces.forEach((key, idx) => { const style = getSigStyle(key); if (style.marker === 'none' || style.marker === 'circle') return; const si = idx + 1; const xd = u.data[0], yd = u.data[si]; if (!xd || !yd) return; const sz = style.markerSize; ctx.strokeStyle = style.color; ctx.fillStyle = style.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); for (let i = 0; i < xd.length; i++) { if (yd[i] == null) continue; const cx = u.valToPos(xd[i], 'x', true); const cy = u.valToPos(yd[i], 'y', true); if (cx < bbox.left || cx > bbox.left + bbox.width || cy < bbox.top || cy > bbox.top + bbox.height) continue; ctx.beginPath(); if (style.marker === 'square') { ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill(); } else if (style.marker === 'cross') { ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy); ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2); ctx.stroke(); } else if (style.marker === 'diamond') { ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy); ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy); ctx.closePath(); ctx.fill(); } } }); ctx.restore(); } // Draw cursor A/B vertical lines and signal value labels (called from draw hook). function drawCursorLines(u, p) { if (cursors.mode !== 'on') return; const { ctx, bbox } = u; if (!bbox) return; const drawLine = (val, color, label) => { if (val === null) return; const x = u.valToPos(val, 'x', true); if (x < bbox.left || x > bbox.left + bbox.width) return; const px = Math.round(x); ctx.save(); // Clip to plot area ctx.beginPath(); ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); ctx.clip(); // Vertical dashed cursor line ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.moveTo(px, bbox.top); ctx.lineTo(px, bbox.top + bbox.height); ctx.stroke(); ctx.setLineDash([]); // Cursor label at top ctx.fillStyle = color; ctx.font = 'bold 12px monospace'; ctx.textBaseline = 'top'; ctx.fillText(label, px + 14, bbox.top + 2); // Per-trace: diamond at crossing point + value label if (p) { const activeKey = plotActiveSignal[p.id]; p.traces.forEach((key, idx) => { const isActive = key === activeKey || (!activeKey && p.traces.length === 1); const DSZ = isActive ? 9 : 5; // diamond half-size in px const vNorm = interpAtTime(u, idx + 1, val); if (vNorm === null) return; const cy = u.valToPos(vNorm, 'y', true); if (cy < bbox.top || cy > bbox.top + bbox.height) return; // Un-transform normalized value back to real units for display // y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset const vs = sigVScale[p.id + ':' + key]; let vReal = vNorm; if (vs) { const dv = vs._resolvedDiv || vs.divValue || 1; const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); const sp = vs.screenPos || 0; vReal = (vNorm - sp) * dv + ofs; } const tc = getSigStyle(key).color; // Diamond marker at intersection ctx.fillStyle = tc; ctx.strokeStyle = tc; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, cy - DSZ); ctx.lineTo(px + DSZ, cy); ctx.lineTo(px, cy + DSZ); ctx.lineTo(px - DSZ, cy); ctx.closePath(); ctx.fill(); // Value text next to diamond (real units) const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString(); ctx.fillStyle = tc; ctx.font = '11px monospace'; const currentAlign = ctx.textAlign; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(str, px + DSZ + 4, cy); ctx.textAlign = currentAlign; }); } ctx.restore(); }; drawLine(cursors.tA, 'rgba(137,220,235,0.85)', 'A'); drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B'); } // Compute the rolling-window anchor ("newest common timestamp") for a plot. // Returns the min-of-max timestamp across ACTIVE sources contributing traces to p, // so no live source shows a blank right edge. // Sources whose newest timestamp lags the fastest source by more than windowSec are // considered stale (disconnected / from a previous session) and are excluded, so they // cannot anchor the rolling window far in the past. function computePlotNow(p) { const sourceNewest = {}; p.traces.forEach(key => { const colon = key.indexOf(':'); if (colon < 0) return; const srcId = key.slice(0, colon); const buf = buffers[key]; if (!buf || buf.size === 0) return; const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t; }); const srcVals = Object.values(sourceNewest); if (srcVals.length === 0) return Date.now() / 1000; const globalMax = Math.max(...srcVals); // Keep only sources that have received data within the last windowSec. const active = srcVals.filter(t => t >= globalMax - windowSec); let now = active.length > 0 ? Math.min(...active) : globalMax; if (!isFinite(now)) now = Date.now() / 1000; return now; } // Build uPlot opts for a given plot object function makeUPlotOpts(p, inTrigMode) { const isBanded = p.mode === 'digital' || p.mode === 'mixed'; const seriesArr = [{}]; // time (index 0) p.traces.forEach(key => { const style = getSigStyle(key); const pathsFn = isBanded ? null : makeSeriesPath(key); const vs = isBanded ? getVScale(p.id, key) : null; const isDigSig = p.mode === 'digital' || (p.mode === 'mixed' && vs && vs.digitalInMixed); seriesArr.push({ label: key, stroke: style.color, width: isBanded ? 1.5 : style.width, points: { show: false }, spanGaps: !isDigSig, ...(pathsFn ? { paths: pathsFn } : {}), }); }); 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: (() => { let xMin, xMax; if (p.xRange) { xMin = p.xRange[0]; xMax = p.xRange[1]; } else { const now = computePlotNow(p); xMin = now - windowSec; xMax = now; } return { time: false, auto: false, min: xMin, max: xMax }; })(), y: { auto: false, min: -4.5, max: 4.5 }, }, series: seriesArr, axes: [ { stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, values: xVals, size: 36, // Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines). splits: (u, _ai, sMin, sMax) => { const n = 10, span = sMax - sMin; if (span === 0) return [sMin]; return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n); }, }, { stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60, splits: () => { if (isBanded && p.traces.length > 0) { const n = p.traces.length, bandH = 8 / n; return p.traces.map((_, i) => 4 - (i + 0.5) * bandH); } return [-4, -3, -2, -1, 0, 1, 2, 3, 4]; }, values: (u, vals) => { if (isBanded && p.traces.length > 0) { const n = p.traces.length, bandH = 8 / n; return vals.map(v => { if (v == null) return ''; const i = Math.round((4 - v) / bandH - 0.5); if (i < 0 || i >= n) return ''; const k = p.traces[i]; return k.includes(':') ? k.split(':').slice(1).join(':') : k; }); } const activeKey = plotActiveSignal[p.id]; const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null; if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1)); const divValue = vs._resolvedDiv || vs.divValue || 1; const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); const screenPos = vs.screenPos || 0; return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset)); }, }, ], legend: { show: false }, padding: [4, 4, 0, 0], hooks: { draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }], // 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 = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null; p.uplot.over.style.cursor = snap ? 'ew-resize' : ''; }); p.uplot.over.addEventListener('mouseleave', () => { p.uplot.over.style.cursor = ''; }); // Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line). // If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior. p.uplot.over.addEventListener('mousedown', e => { if (e.button !== 0 || e.shiftKey) return; // shift is pan if (cursors.mode !== 'on') return; const target = _cursorAtClientX(e.clientX); if (!target) return; // not near a cursor — let uPlot handle zoom 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 // ── Offset marker drag ───────────────────────────────────────────────────── // Detect mousedown near the left edge of the plot area (marker triangle zone). // Dragging moves the marker AND the signal together by changing screenPos. p.div.addEventListener('mousedown', e => { if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return; const canvas = p.uplot.ctx.canvas; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const plotLeftCss = rect.left + p.uplot.bbox.left / dpr; const markerZone = 12; // CSS px hit area to left/right of plot edge if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return; // Find which marker was hit (closest to screenPos canvas position per signal). let hitKey = null, hitDist = Infinity; p.traces.forEach(key => { const vs = sigVScale[p.id + ':' + key]; const screenPos = vs ? (vs.screenPos || 0) : 0; const yDev = p.uplot.valToPos(screenPos, 'y', true); const yCss = rect.top + yDev / dpr; const dist = Math.abs(e.clientY - yCss); if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; } }); if (!hitKey) return; e.preventDefault(); e.stopPropagation(); setActiveSig(p.id, hitKey); const vs = getVScale(p.id, hitKey); const startY = e.clientY; const startScreenPos = vs.screenPos || 0; const overRect = p.uplot.over.getBoundingClientRect(); const onMove = ev => { const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm // Y scale spans 9 divisions over plot height; drag up → higher screenPos. const dNorm = -dy / overRect.height * 9; vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm)); // Keep "Position" input in sync if the vscale menu is open for this signal. if (_vsMenuKey === hitKey) { document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4)); } refreshPlotForKey(hitKey); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, true); // 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)]; const plotNow = computePlotNow(p); const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec; const t1 = p.xRange ? p.xRange[1] : plotNow; const isRolling = !p.xRange; // When zoomed, prefer server-fetched hi-res data if it covers this exact range. if (p.xRange) { const zd = zoomData[p.id]; if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) { const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2)); // Only use server data if it actually has samples; otherwise fall through to local buffer. if (fetched[0] && fetched[0].length > 0) return fetched; } } // Slice all traces; pick master by sampling rate then count. // In zoom mode, include one bracketing point on each side so lines are drawn // across the visible area even when the window contains 0 or 1 samples. const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets; 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 = sliceFn(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 to pixel-adaptive point count in both rolling and zoomed modes. // The server sends ≤2000 pts/tick but the buffer accumulates many ticks, so // the full window slice can easily reach 100k–300k pts — far more than uPlot // needs for a 1200px-wide canvas. Always run LTTB via the background worker // (stale-while-revalidate: use cached result; fall back to sync on first render). // Rolling-mode cache key uses _dataGen so the result refreshes on new data. const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2); const cacheKey = isRolling ? `${p.id}:${masterKey}:rolling:${_dataGen}` : `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`; let sharedT, masterV; if (masterRaw.t.length <= targetPts) { // Data already sparse enough — use directly (no decimation needed). sharedT = masterRaw.t; masterV = masterRaw.v; } else { const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); let dec; if (cached) { dec = cached; } else { // Worker job submitted — sync fallback this frame so the plot isn't blank. dec = lttb(masterRaw.t, masterRaw.v, targetPts); } sharedT = dec.t; masterV = dec.v; } const yArrays = []; for (const key of p.traces) { if (key === masterKey) { yArrays.push(masterV); 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, ...applyVScaleNorm(p, 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))]; // Trig snapshot is fixed (no new data arrives after capture), so the cache // key only needs range + data length. Worker result persists until re-arm. const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`; const cachedDec = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts); const dec = cachedDec || 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, ...applyVScaleNorm(p, 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; // Evict stale LTTB cache entries — new zoom range needs fresh decimation. plots.forEach(p => lttbCacheEvict(p.id)); // Mark all plots dirty (re-slice data to the new range for full resolution) plots.forEach(p => { p.needsRedraw = true; }); // Schedule hi-res fetch from ring buffers. scheduleZoomFetch(min, max); } // 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'; // Discard stale zoom data regardless of direction. Object.keys(zoomData).forEach(k => delete zoomData[k]); cancelZoomFetch(); 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; scheduleZoomFetch(prev[0], prev[1]); } } // Reset to auto/rolling window (clears all zoom) function resetZoom() { Object.keys(zoomData).forEach(k => delete zoomData[k]); cancelZoomFetch(); 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; scheduleZoomFetch(gMin, gMax); } // 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 ════════════════════════════════════════════════════════════════ */ // Show the cursor button only when paused or in trigger-snapshot mode. 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; // context changed, clear positions btn.textContent = 'Cursors'; btn.classList.remove('active'); document.getElementById('cursor-readout').classList.remove('visible'); cursorsDirty = true; } } document.getElementById('btn-cursor').addEventListener('click', () => { cursors.mode = cursors.mode === 'off' ? 'on' : 'off'; const btn = document.getElementById('btn-cursor'); btn.textContent = 'Cursors'; btn.classList.toggle('active', cursors.mode === 'on'); if (cursors.mode === 'on') { // Auto-place at 25%/75% of current x range on first use if (cursors.tA === null && cursors.tB === null) { const refPlot = plots.find(p => p.uplot); if (refPlot) { const { min, max } = refPlot.uplot.scales.x; const span = max - min; cursors.tA = min + span * 0.25; cursors.tB = min + span * 0.75; } } updateCursorReadout(); document.getElementById('cursor-readout').classList.add('visible'); } else { document.getElementById('cursor-readout').classList.remove('visible'); } cursorsDirty = true; }); // Format a signal value for the per-plot cursor readout. function fmtVal(v) { if (v === null || v === undefined) return '—'; return Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString(); } // Interpolate the real-unit value of the active/sole signal in plot p at time t. function getValueAtCursor(p, t) { if (!p.uplot || t === null) return null; const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); if (!key) return null; const idx = p.traces.indexOf(key); if (idx < 0) return null; const vNorm = interpAtTime(p.uplot, idx + 1, t); if (vNorm === null) return null; // Un-normalize: y_norm = (y_raw - offset) / divValue + screenPos const vs = sigVScale[p.id + ':' + key]; if (!vs) return vNorm; const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1); const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); const sp = vs.screenPos || 0; return (vNorm - sp) * dv + ofs; } // Update per-plot cursor value readouts (A, B, ΔV) for all plots. function updatePlotCursorReadouts() { plots.forEach(p => { const el = document.getElementById('pcur-' + p.id); if (!el) return; // Show only when cursors are on and the plot has an active or sole signal. const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); if (cursors.mode !== 'on' || !key || !p.uplot) { el.style.display = 'none'; return; } const vA = getValueAtCursor(p, cursors.tA); const vB = getValueAtCursor(p, cursors.tB); const dv = (vA !== null && vB !== null) ? vB - vA : null; document.getElementById('pcur-a-' + p.id).textContent = 'A: ' + fmtVal(vA); document.getElementById('pcur-b-' + p.id).textContent = 'B: ' + fmtVal(vB); document.getElementById('pcur-dv-' + p.id).textContent = 'ΔV: ' + fmtVal(dv); el.style.display = 'flex'; }); } function updateCursorReadout() { const ro = document.getElementById('cursor-readout'); const active = cursors.mode === 'on'; ro.classList.toggle('visible', active); updatePlotCursorReadouts(); if (!active) return; // Use the current visible x-range to pick the display unit. const span = currentXSpan(); // Format a cursor position: trigger mode → signed relative duration; // live mode → absolute wall time with span-appropriate precision. const fmt = v => { if (v === null) return '—'; if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true); return fmtLiveTime(v, span); }; 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; // ΔT auto-scales by its own magnitude for precision regardless of x-range. document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true); } 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 => { const val = e.target.value; if (!val) { trig.signal = ''; trigDisarm(); return; } // Array signal: ask for element index via picker dialog. const meta = findSignalMeta(val); const n = meta ? numElements(meta) : 1; if (meta && !isTemporal(meta) && n > 1) { showArrayIdxPicker(val, n, idx => { trig.signal = val + '[' + idx + ']'; trig.prevVal = null; if (trig.enabled) trigArm(); }, () => { // Cancelled: revert selection to current trig.signal base or empty. const sel = document.getElementById('trig-signal'); const base = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : ''; sel.value = base || ''; }); return; } trig.signal = val; 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; // If a snapshot already exists the trigger has already fired — update button // visibility immediately so it matches the newly selected mode. if (trig.snapshot) { showRearmBtn(trig.mode === 'single'); showStopBtn(trig.mode === 'normal'); updateStopBtn(); } }); 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'); // Preserve the currently active trig.signal (may include an array index like "[3]"). const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : ''; sel.innerHTML = ''; Object.values(sourcesMap).forEach(src => { const prefix = src.id + ':'; const srcLabel = src.label || src.addr || src.id; (src.signals || []).forEach(sig => { const n = numElements(sig); const key = prefix + sig.name; const o = document.createElement('option'); o.value = key; if (isTemporal(sig) || n === 1) { o.textContent = srcLabel + ': ' + sig.name; } else { // Array: single entry; user chooses index via dialog on selection. o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']'; } sel.appendChild(o); }); }); // Restore selection: match base key so array element "sig[3]" selects "sig" option. if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase; // Do NOT overwrite trig.signal here — an array element selection must be preserved. } /* ════════════════════════════════════════════════════════════════ Sidebar ════════════════════════════════════════════════════════════════ */ const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64']; function buildSidebar() { const list = document.getElementById('signal-list'); list.innerHTML = ''; const sources = Object.values(sourcesMap); sources.forEach(src => { const sigs = src.signals || []; const prefix = src.id + ':'; // Source header const grp = document.createElement('div'); grp.className = 'source-group'; const hdr = document.createElement('div'); hdr.className = 'source-group-header'; const dot = document.createElement('span'); dot.className = 'source-state-dot ' + (src.state || 'disconnected'); const nameEl = document.createElement('span'); nameEl.className = 'source-name'; nameEl.textContent = src.label || src.addr || src.id; nameEl.title = src.addr || ''; const addrEl = document.createElement('span'); addrEl.className = 'source-addr'; if (src.label && src.addr) addrEl.textContent = src.addr; const rmBtn = document.createElement('button'); rmBtn.className = 'source-remove-btn'; rmBtn.title = 'Remove source'; rmBtn.textContent = '×'; rmBtn.addEventListener('click', () => removeSource(src.id)); hdr.append(dot, nameEl, addrEl, rmBtn); grp.appendChild(hdr); sigs.forEach(sig => { const n = numElements(sig), temporal = isTemporal(sig); const typeName = _typeNames[sig.typeCode] || '?'; const globalKey = prefix + sig.name; if (n === 1 || temporal) { grp.appendChild(makeDraggable(globalKey, 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 = globalKey + '[' + i + ']'; const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || ''); child.className = 'array-child'; children.appendChild(child); } group.appendChild(header); group.appendChild(children); grp.appendChild(group); } }); list.appendChild(grp); }); if (!sources.length) { const empty = document.createElement('div'); empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;'; empty.textContent = 'No sources configured'; list.appendChild(empty); } list.appendChild(makeAddSourceSection()); } 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; } /* ════════════════════════════════════════════════════════════════ Grid resize handles ════════════════════════════════════════════════════════════════ */ function updateGridTemplate() { const grid = document.getElementById('plot-grid'); grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' '); grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' '); // Reposition all handles document.querySelectorAll('.resize-handle-v').forEach(h => { const i = parseInt(h.dataset.col); const tot = colFrs.reduce((a, b) => a + b, 0); h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; }); document.querySelectorAll('.resize-handle-h').forEach(h => { const i = parseInt(h.dataset.row); const tot = rowFrs.reduce((a, b) => a + b, 0); h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; }); // Notify uPlot of new sizes plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }); } function setupResizeHandles(cols, rows) { const grid = document.getElementById('plot-grid'); grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove()); const makeHandle = (cls, dataset, onDown) => { const h = document.createElement('div'); h.className = cls; Object.assign(h.dataset, dataset); h.addEventListener('mousedown', onDown); grid.appendChild(h); return h; }; for (let i = 0; i < cols - 1; i++) { const h = makeHandle('resize-handle-v', { col: i }, e => { e.preventDefault(); const rect = grid.getBoundingClientRect(); const tot = colFrs.reduce((a, b) => a + b, 0); const pairSum = colFrs[i] + colFrs[i + 1]; const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0); h.classList.add('dragging'); const onMove = ev => { const pct = (ev.clientX - rect.left) / rect.width; const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore)); colFrs[i] = newI; colFrs[i + 1] = pairSum - newI; updateGridTemplate(); }; const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); const tot = colFrs.reduce((a, b) => a + b, 0); h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; } for (let i = 0; i < rows - 1; i++) { const h = makeHandle('resize-handle-h', { row: i }, e => { e.preventDefault(); const rect = grid.getBoundingClientRect(); const tot = rowFrs.reduce((a, b) => a + b, 0); const pairSum = rowFrs[i] + rowFrs[i + 1]; const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0); h.classList.add('dragging'); const onMove = ev => { const pct = (ev.clientY - rect.top) / rect.height; const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore)); rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI; updateGridTemplate(); }; const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); const tot = rowFrs.reduce((a, b) => a + b, 0); h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%'; } } /* ════════════════════════════════════════════════════════════════ Plot config toolbar (click plot title to open) ════════════════════════════════════════════════════════════════ */ let _pcfgOpenId = null; function showPlotCfg(plotId) { if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg(); _pcfgOpenId = plotId; document.getElementById('pcfg-' + plotId).style.display = 'block'; } function hidePlotCfg() { if (_pcfgOpenId == null) return; const bar = document.getElementById('pcfg-' + _pcfgOpenId); if (bar) bar.style.display = 'none'; _pcfgOpenId = null; } function initPlotCfgBar(plotId, p) { const bar = document.getElementById('pcfg-' + plotId); if (!bar) return; const titleInput = bar.querySelector('.pcfg-title-input'); const titleEl = document.getElementById('ptitle-' + plotId); titleInput.addEventListener('input', () => { p.title = titleInput.value || ('Plot ' + plotId); if (titleEl) titleEl.textContent = p.title; }); bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => { btn.addEventListener('click', () => { const newMode = btn.dataset.mode; if (p.mode === newMode) return; p.mode = newMode; bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode)); createUPlot(p); p.needsRedraw = true; }); }); bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg); } /* ════════════════════════════════════════════════════════════════ 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 = 28, H = 20, GAP = 1.5, PAD = 1.5; 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; // Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied. if (cols !== _gridCols || rows !== _gridRows) { colFrs = Array(cols).fill(1); rowFrs = Array(rows).fill(1); _gridCols = cols; _gridRows = rows; } const grid = document.getElementById('plot-grid'); grid.className = cls; // keeps other grid CSS rules updateGridTemplate(); // override template with current fr sizes setupResizeHandles(cols, rows); // 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 (DOM only — uPlot created below after layout settles). while (plots.length < needed) addPlot(); // Recreate all uPlot instances once the CSS grid has sized the cells. requestAnimationFrame(() => { plots.forEach(p => { createUPlot(p); p.needsRedraw = true; }); setTimeout(() => plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); }), 60); }); } 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) — fetches full-resolution data from ring ════════════════════════════════════════════════════════════════ */ async function exportAllCSV() { const btn = document.getElementById('btn-csv-all'); if (btn.disabled) return; 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; // Determine time range to export. let t0, t1, relOffset = 0; if (inTrigMode) { // Export the full trigger window around the trigger event. t0 = trig.trigTime - trigPreSec(); t1 = trig.trigTime + trigPostSec(); relOffset = trig.trigTime; } else { // Use the current zoom range if active, else the rolling window. const refPlot = plots.find(p => p.xRange); if (refPlot) { [t0, t1] = refPlot.xRange; } else { let plotNow = -Infinity; keys.forEach(k => { const buf = buffers[k]; 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; t0 = plotNow - windowSec; t1 = plotNow; } } // Show loading state. const origLabel = btn.textContent; btn.textContent = '⏳ Downloading…'; btn.disabled = true; // Fetch full-resolution ring data (n=0 → no LTTB decimation). const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`; let ringSignals = null; try { const resp = await fetch(url); if (resp.ok) { const json = await resp.json(); ringSignals = json && json.signals; } } catch (e) { console.warn('CSV export: ring fetch failed, falling back to push buffer', e); } finally { btn.textContent = origLabel; btn.disabled = false; } // Build per-signal time/value arrays. // Priority: ring buffer (full res) → trigger snapshot → push buffer. const slices = keys.map(key => { const rd = ringSignals && ringSignals[key]; if (rd && rd.t && rd.t.length > 0) { const t = rd.t, v = rd.v; if (inTrigMode) { return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) }; } return { t: Array.from(t), v: Array.from(v) }; } // Fallback: push buffer or trigger snapshot. if (inTrigMode) { const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) }; return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) }; } const buf = buffers[key]; if (!buf) return { t: [], v: [] }; const sl = getBufferSliceRange(buf, t0, t1); return { t: Array.from(sl.t), v: Array.from(sl.v) }; }); // Merge all timestamps and build aligned rows. const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t))); const sortedT = Array.from(allT).sort((a, b) => a - b); if (!sortedT.length) return; const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t, i) => m.set(t, s.v[i])); return m; }); // Strip "sourceId:" prefix from column headers for readability. const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k)); const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].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, title: 'Plot ' + id, mode: 'normal', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 }; plots.push(p); // Wire config toolbar initPlotCfgBar(id, p); card.querySelector('#ptitle-' + id).addEventListener('click', () => { if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id); }); // uPlot creation is handled by applyLayout (batch, after DOM settles). 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; updatePlotCursorReadouts(); } 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); // If the removed trace was active, close toolbar and pick a new active signal. if (plotActiveSignal[plotId] === signalKey) { if (_vsMenuPlotId === plotId) hideVScaleMenu(); const newActive = p.traces[0] || null; if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId]; } createUPlot(p); p.needsRedraw = true; if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = ''; updatePlotCursorReadouts(); } function addBadge(plotId, key) { const c = document.getElementById('badges-' + plotId); if (!c) return; if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return; const color = getSigStyle(key).color; 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; // Show signal name without the "sourceId:" prefix. const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key; const nameSpan = document.createElement('span'); nameSpan.textContent = displayName; // Small vscale info text (V/div + offset when not in auto mode). const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info'; const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×'; x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); }); // Left-click: select + show vscale toolbar; click same signal again to deselect. badge.addEventListener('click', e => { if (e.target === x) return; const isActive = plotActiveSignal[plotId] === key; const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId; if (isActive && toolbarVisible) { setActiveSig(plotId, null); hideVScaleMenu(); } else { setActiveSig(plotId, key); showVScaleMenu(key, plotId); } }); // Right-click: open signal style (color/width/…) menu. badge.addEventListener('contextmenu', e => { e.preventDefault(); showSignalMenu(key, plotId, e.clientX, e.clientY); }); badge.appendChild(dot); badge.appendChild(nameSpan); badge.appendChild(infoSpan); badge.appendChild(x); c.appendChild(badge); // Auto-activate the first signal added to this plot. if (!plotActiveSignal[plotId]) setActiveSig(plotId, key); } 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(); lttbCacheEvict(plotId); plots.splice(idx, 1); document.querySelector('[data-plot-id="' + plotId + '"]').remove(); } /* ════════════════════════════════════════════════════════════════ Render loop ════════════════════════════════════════════════════════════════ */ let _dbgTick = 0; let _dataGen = 0; // incremented each time new data arrives function renderDirtyPlots() { // Compute global "now" once — shared by all rolling-window plots this frame. const globalPlotNow = getGlobalNow(); // Lightweight diagnostic: every ~10 s log buffer count if (++_dbgTick % 300 === 0) { let bufCount = 0, totalPts = 0; Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } }); if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`); } // 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. // When no new data arrived since the last render, only advance the viewport // via setScale instead of rebuilding all data arrays (much cheaper). if (!trig.enabled && !globalPause) { plots.forEach(p => { if (!p.uplot || p.xRange || p.traces.length === 0) return; p.needsRedraw = true; }); } plots.forEach(p => { if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return; const inTrigModeNow = trig.enabled && trig.snapshot !== null; const isRolling = !trig.enabled && !p.xRange; // Fast path: rolling-window plot with no new data — just shift viewport // anchored to buffer timestamps so the x-range only advances when // signal data actually moves forward. if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) { p.needsRedraw = false; zoomGuard = true; p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow }); zoomGuard = false; return; } p.needsRedraw = false; p.lastDataGen = _dataGen; const data = buildUPlotData(p, inTrigModeNow); // 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 && !inTrigModeNow) { // Armed / waiting for trigger: keep the current scale frozen. } else if (inTrigModeNow) { 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: use same anchor as buildLiveData (min-of-max per source). const plotNow = computePlotNow(p); p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow }); } zoomGuard = false; }); // Keep per-plot cursor value readouts in sync with live data. if (cursors.mode === 'on') updatePlotCursorReadouts(); 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); // Grow any buffers that can't hold the full new window for their signal's rate. Object.keys(buffers).forEach(key => { const rate = getKeySamplingRate(key); if (rate > 0) { const needed = Math.min(MAX_CAP, Math.ceil(rate * windowSec * 1.5)); if (needed > buffers[key].cap) buffers[key] = growBuffer(buffers[key], needed); } }); // Evict rolling-mode LTTB cache — window size change invalidates all cached results. plots.forEach(p => lttbCacheEvict(p.id)); // 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)); /* ════════════════════════════════════════════════════════════════ Multi-source management ════════════════════════════════════════════════════════════════ */ function onSources(msg) { const srcs = msg.sources || []; const newIds = new Set(srcs.map(s => s.id)); // Remove sources that disappeared. Object.keys(sourcesMap).forEach(id => { if (!newIds.has(id)) { const prefix = id + ':'; Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; }); delete sourcesMap[id]; } }); // Update or create entries. srcs.forEach(s => { if (!sourcesMap[s.id]) { sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] }; } else { Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state }); } }); buildSidebar(); if (statsOpen) _refreshStatsSelector(); } function addSourceWS(label, addr, multicastGroup, dataPort) { if (ws && ws.readyState === WebSocket.OPEN) { const msg = { type: 'addSource', label, addr }; if (multicastGroup) { msg.multicastGroup = multicastGroup; } if (dataPort) { msg.dataPort = dataPort; } ws.send(JSON.stringify(msg)); } } function removeSource(id) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'removeSource', id })); } } function saveSourcesWS() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'saveSources' })); } } function makeAddSourceSection() { const section = document.createElement('div'); section.className = 'add-source-section'; const title = document.createElement('div'); title.className = 'add-source-title'; title.innerHTML = ' Add Source'; const body = document.createElement('div'); body.className = 'add-source-body'; const addrInput = document.createElement('input'); addrInput.className = 'add-src-input'; addrInput.type = 'text'; addrInput.placeholder = 'host:port'; const labelInput = document.createElement('input'); labelInput.className = 'add-src-input'; labelInput.type = 'text'; labelInput.placeholder = 'label (optional)'; const mcastInput = document.createElement('input'); mcastInput.className = 'add-src-input'; mcastInput.type = 'text'; mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)'; const dataPortInput = document.createElement('input'); dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number'; dataPortInput.placeholder = 'data port (multicast only)'; dataPortInput.min = '1'; dataPortInput.max = '65535'; const addBtn = document.createElement('button'); addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect'; addBtn.addEventListener('click', () => { const addr = addrInput.value.trim(); if (!addr) return; const mcastGroup = mcastInput.value.trim(); const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0; addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort); addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = ''; }); addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); }); const saveBtn = document.createElement('button'); saveBtn.className = 'add-src-btn save-src-btn'; saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file'; saveBtn.addEventListener('click', saveSourcesWS); body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn); section.append(title, body); title.addEventListener('click', () => { const open = section.classList.toggle('open'); title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : ''; }); return section; } /* ════════════════════════════════════════════════════════════════ Utility ════════════════════════════════════════════════════════════════ */ function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /* ════════════════════════════════════════════════════════════════ VScale menu (left-click on badge) ════════════════════════════════════════════════════════════════ */ let _vsMenuKey = null, _vsMenuPlotId = null; function showVScaleMenu(key, plotId) { hideSignalMenu(); // If the toolbar was open for a different plot, hide that bar first. if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) { const oldBar = document.getElementById('vstb-' + _vsMenuPlotId); if (oldBar) oldBar.style.display = 'none'; } _vsMenuKey = key; _vsMenuPlotId = plotId; const menu = document.getElementById('vscale-menu'); const vs = getVScale(_vsMenuPlotId, key); const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key; document.getElementById('vscale-menu-key').textContent = displayName; document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === vs.mode)); // Disable Range button when signal has no defined range. const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]'); if (rangeBtn) { const meta = findSignalMeta(key); const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null; rangeBtn.disabled = !hasRange; rangeBtn.title = hasRange ? '' : 'No range defined for this signal'; } const isManual = vs.mode === 'manual'; document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; // Pre-fill V/div with resolved or stored value; Position always shows current screenPos. const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1); document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1; document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4)); // Type row (Analog/Digital) only shown in mixed mode. const p = plots.find(q => q.id === plotId); const isMixed = p && p.mode === 'mixed'; document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none'; if (isMixed) { document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog'))); } // Move the toolbar div into this plot's vscale bar. const bar = document.getElementById('vstb-' + plotId); if (bar) { bar.appendChild(menu); bar.style.display = 'block'; } menu.style.display = 'block'; } function hideVScaleMenu() { const menu = document.getElementById('vscale-menu'); // Return the menu element to body so it is detached from any plot card. menu.style.display = 'none'; document.body.appendChild(menu); // Hide the vscale bar of the previously active plot. if (_vsMenuPlotId != null) { const bar = document.getElementById('vstb-' + _vsMenuPlotId); if (bar) bar.style.display = 'none'; } _vsMenuKey = null; _vsMenuPlotId = null; } /* ─── Array index picker ─────────────────────────────────────────────────── */ let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0; function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) { _aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1; const menu = document.getElementById('array-idx-picker'); const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey; document.getElementById('aip-sig').textContent = displayName; document.getElementById('aip-range').textContent = '(0 – ' + (n - 1) + ')'; const idxInput = document.getElementById('aip-idx'); idxInput.max = n - 1; idxInput.value = 0; menu.style.display = 'block'; // Centre the picker on screen. const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120; menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px'; menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px'; idxInput.focus(); idxInput.select(); } function _aipConfirm() { const idxInput = document.getElementById('aip-idx'); const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0)); document.getElementById('array-idx-picker').style.display = 'none'; if (_aipOnConfirm) _aipOnConfirm(idx); _aipOnConfirm = _aipOnCancel = null; } function _aipCancel() { document.getElementById('array-idx-picker').style.display = 'none'; if (_aipOnCancel) _aipOnCancel(); _aipOnConfirm = _aipOnCancel = null; } function initArrayIdxPicker() { document.getElementById('aip-ok').addEventListener('click', _aipConfirm); document.getElementById('aip-cancel').addEventListener('click', _aipCancel); document.getElementById('aip-idx').addEventListener('keydown', e => { if (e.key === 'Enter') _aipConfirm(); if (e.key === 'Escape') _aipCancel(); }); } function initVScaleMenu() { document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => { btn.addEventListener('click', () => { if (!_vsMenuKey) return; const vs = getVScale(_vsMenuPlotId, _vsMenuKey); const newMode = btn.dataset.mode; if (newMode === 'manual' && vs.mode !== 'manual') { // Seed V/div from currently resolved value; screenPos stays as-is. vs.divValue = vs._resolvedDiv || 1; vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal) document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4)); document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4)); } vs.mode = newMode; document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const isManual = vs.mode === 'manual'; document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none'; document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none'; refreshPlotForKey(_vsMenuKey); }); }); document.getElementById('vscale-vdiv').addEventListener('input', e => { if (!_vsMenuKey) return; const vs = getVScale(_vsMenuPlotId, _vsMenuKey); vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30); refreshPlotForKey(_vsMenuKey); }); // "Position (div)" moves the marker and signal together on screen. document.getElementById('vscale-pos').addEventListener('input', e => { if (!_vsMenuKey) return; const vs = getVScale(_vsMenuPlotId, _vsMenuKey); vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0)); refreshPlotForKey(_vsMenuKey); }); // Type buttons (Analog / Digital) — only active in mixed mode. document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => { btn.addEventListener('click', () => { if (!_vsMenuKey || !_vsMenuPlotId) return; const vs = getVScale(_vsMenuPlotId, _vsMenuKey); vs.digitalInMixed = btn.dataset.type === 'digital'; document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b => b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog'))); // Rebuild needed because series rendering changes (digital = no spanGaps, etc.) const p = plots.find(q => q.id === _vsMenuPlotId); if (p) { createUPlot(p); p.needsRedraw = true; } }); }); document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu); } /* ════════════════════════════════════════════════════════════════ Signal style context menu ════════════════════════════════════════════════════════════════ */ let _ctxMenuKey = null; function showSignalMenu(key, plotId, x, y) { _ctxMenuKey = key; const menu = document.getElementById('sig-ctx-menu'); const style = getSigStyle(key); document.getElementById('ctx-menu-key').textContent = key; document.getElementById('ctx-color').value = style.color; document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width); }); document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.dash === style.dash); }); document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.marker === style.marker); }); document.getElementById('ctx-marker-size').value = style.markerSize; document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px'; menu.style.display = 'block'; const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200; menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px'; menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px'; } function hideSignalMenu() { document.getElementById('sig-ctx-menu').style.display = 'none'; _ctxMenuKey = null; } function initSignalMenu() { document.getElementById('ctx-color').addEventListener('input', e => { if (!_ctxMenuKey) return; setSigStyle(_ctxMenuKey, { color: e.target.value }); }); document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { btn.addEventListener('click', () => { if (!_ctxMenuKey) return; setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) }); document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { btn.addEventListener('click', () => { if (!_ctxMenuKey) return; setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash }); document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { btn.addEventListener('click', () => { if (!_ctxMenuKey) return; setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker }); document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); document.getElementById('ctx-marker-size').addEventListener('input', e => { const sz = parseInt(e.target.value, 10); document.getElementById('ctx-marker-size-val').textContent = sz + 'px'; if (!_ctxMenuKey) return; setSigStyle(_ctxMenuKey, { markerSize: sz }); }); document.addEventListener('click', e => { if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } }); } /* ════════════════════════════════════════════════════════════════ Source Statistics panel ════════════════════════════════════════════════════════════════ */ const sourceStats = {}; // sourceId → StatInfo (from backend 1 Hz message) let statsOpen = false; let statsSelectedSrc = null; // currently displayed source id function onStats(msg) { const incoming = msg.sources || {}; Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; }); if (statsOpen) renderStats(); } // Rebuild the source selector options; preserve selection when possible. function _refreshStatsSelector() { const sel = document.getElementById('stats-source-sel'); if (!sel) return; const prev = statsSelectedSrc; sel.innerHTML = ''; const srcs = Object.values(sourcesMap); srcs.forEach(src => { const opt = document.createElement('option'); opt.value = src.id; opt.textContent = src.label || src.id; sel.appendChild(opt); }); // Restore previous selection or default to first if (prev && sourcesMap[prev]) { sel.value = prev; } else if (srcs.length > 0) { sel.value = srcs[0].id; } statsSelectedSrc = sel.value || null; } // Frontend latency for one source: wallNow − newest calibrated buffer timestamp. function sourceLatencyMs(srcId) { const prefix = srcId + ':'; const wallNow = Date.now() / 1000; let best = null; Object.keys(buffers).forEach(key => { if (!key.startsWith(prefix)) return; const buf = buffers[key]; if (!buf || buf.size === 0) return; const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; const lag = (wallNow - newest) * 1000; if (best === null || lag < best) best = lag; }); return best; } function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; } function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; } function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; } function _statsKV(label, value, cls) { return `
${label}${value}
`; } function _histHTML(si) { if (!si.cycleHist || !si.cycleHist.length) return ''; const maxC = Math.max(...si.cycleHist, 1); const bars = si.cycleHist.map(c => { const pct = Math.max(Math.round((c / maxC) * 100), 1); return `
`; }).join(''); return `
${bars}
${si.cycleHistMin.toFixed(3)}${si.cycleHistMax.toFixed(3)} ms
`; } function renderStats() { const body = document.getElementById('stats-body'); if (!body) return; const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null; if (!src) { body.innerHTML = 'No source selected'; return; } const si = sourceStats[src.id]; const latMs = sourceLatencyMs(src.id); const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok'; const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—'; body.innerHTML = `
${_statsKV('Address', src.addr)} ${_statsKV('Latency', _fmtMs(latMs))} ${_statsKV('Lost / Rx', lossText, lossColor)} ${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')}

${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')} ${_statsKV('± σ', si ? _fmtHz(si.rateStdHz) : '—')} ${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')} ${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')}

${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')} ${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')} ${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')} ${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
${si ? _histHTML(si) : 'No data yet'}
`; } function toggleStats() { statsOpen = !statsOpen; document.getElementById('stats-panel').classList.toggle('open', statsOpen); document.getElementById('btn-stats').classList.toggle('active', statsOpen); if (statsOpen) { _refreshStatsSelector(); renderStats(); } } // Refresh latency and stats every second while panel is open. setInterval(() => { if (statsOpen) renderStats(); }, 1000); /* ════════════════════════════════════════════════════════════════ Init ════════════════════════════════════════════════════════════════ */ buildLayoutMenu(); applyLayout('l1x1'); buildSidebar(); // show "Add Source" section even before WS connection initArrayIdxPicker(); initVScaleMenu(); initSignalMenu(); document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); document.getElementById('btn-stats').addEventListener('click', toggleStats); document.getElementById('btn-stats-close').addEventListener('click', toggleStats); document.getElementById('stats-source-sel').addEventListener('change', e => { statsSelectedSrc = e.target.value || null; renderStats(); }); connectWS(); requestAnimationFrame(renderDirtyPlots); fetch('/version').then(r => r.text()).then(v => { document.getElementById('build-version').textContent = 'v' + v; }).catch(() => { });