Files
2026-05-27 16:46:06 +02:00

3457 lines
144 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
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 100k300k 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 = '<option value="">— none —</option>';
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 = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
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 = '<span class="sig-name">' + escHtml(label) + '</span>'
+ (unit ? '<span class="sig-unit">' + escHtml(unit) + '</span>' : '')
+ '<span class="type-badge">' + escHtml(typeName) + '</span>';
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 += `<rect x="${x}" y="${y}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
+ `<rect width="${W}" height="${H}" rx="2" fill="#11111b"/>`
+ `<g fill="#45475a">${rects}</g></svg>`;
}
// 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) + ' <span>' + label + '</span> ▾';
// 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) + '<span>' + label + '</span>';
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 = `
<div class="plot-card-header">
<span class="plot-title" id="ptitle-${id}" title="Click to configure">Plot ${id}</span>
<div class="sig-badges" id="badges-${id}"></div>
<div class="plot-cursor-ro" id="pcur-${id}" style="display:none">
<span class="pcur-a" id="pcur-a-${id}">A: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-b" id="pcur-b-${id}">B: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-dv" id="pcur-dv-${id}">ΔV: —</span>
</div>
</div>
<div class="plot-cfg-bar" id="pcfg-${id}" style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${id}" style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn active" data-mode="normal">Normal</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
</div>`;
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 = '<span class="add-src-arrow">&#x25B6;</span> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ════════════════════════════════════════════════════════════════
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 `<div class="stats-kv"><span class="stats-k">${label}</span><span class="stats-v${cls ? ' ' + cls : ''}">${value}</span></div>`;
}
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 `<div class="hist-bar" style="height:${pct}%" title="${c} samples"></div>`;
}).join('');
return `<div class="stats-hist">
<div class="hist-bars">${bars}</div>
<div class="hist-labels"><span>${si.cycleHistMin.toFixed(3)}</span><span>${si.cycleHistMax.toFixed(3)} ms</span></div>
</div>`;
}
function renderStats() {
const body = document.getElementById('stats-body');
if (!body) return;
const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null;
if (!src) {
body.innerHTML = '<span class="stats-empty">No source selected</span>';
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 = `
<div class="stats-section">
<div class="stats-section-label">Connection</div>
<div class="stats-row">
${_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')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section">
<div class="stats-section-label">Cycle rate</div>
<div class="stats-row">
${_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) : '—')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section stats-section-grow">
<div class="stats-section-label">Cycle time histogram</div>
<div class="stats-row" style="margin-bottom:6px">
${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')}
${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')}
${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')}
${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
</div>
${si ? _histHTML(si) : '<span class="stats-empty">No data yet</span>'}
</div>`;
}
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(() => { });