'use strict';
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000;
const TEMPORAL_CAP = 600_000;
const LTTB_MIN = 200; // never decimate below this many points
const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
];
/* ════════════════════════════════════════════════════════════════
Globals
════════════════════════════════════════════════════════════════ */
// 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];
}
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; } });
}
// 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';
/* ════════════════════════════════════════════════════════════════
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;
if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
else if (n === 1) { buffers[base] = makeBuffer(); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); }
});
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;
}
// 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
if (trigVals) checkTrigger(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 };
}
// 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 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.
function buildDataFromFetched(p, fetchedSignals, targetPts) {
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const sd = fetchedSignals[key];
if (!sd || !sd.t || !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;
}
}
const masterSd = fetchedSignals[masterKey];
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; }
const sd = fetchedSignals[key];
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
}
return [sharedT, ...yArrays];
}
/* ════════════════════════════════════════════════════════════════
uPlot helpers
════════════════════════════════════════════════════════════════ */
// Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks)
function fmtLiveTick(u, vals) {
return vals.map(v => {
if (v == null) return '';
const d = new Date(v * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return hh + ':' + mm + ':' + ss + '.' + ms;
});
}
// Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks)
function fmtTrigTick(u, vals) {
return vals.map(v => {
if (v == null) return '';
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
if (abs < 1) return sign + (abs * 1000).toFixed(1) + 'ms';
return sign + abs.toFixed(3) + 's';
});
}
// Draw 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)) {
const y = u.valToPos(trig.threshold, '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 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 DSZ = 5; // diamond half-size in px
p.traces.forEach((key, idx) => {
const v = interpAtTime(u, idx + 1, val);
if (v === null) return;
const cy = u.valToPos(v, 'y', true);
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
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
const str = Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString();
ctx.fillStyle = tc;
ctx.font = '11px monospace';
const currentAlign = ctx.textAlign;
ctx.textAlign = "left"; // horizontal alignment
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 seriesArr = [{}]; // time (index 0)
p.traces.forEach(key => {
const style = getSigStyle(key);
const pathsFn = makeSeriesPath(key);
seriesArr.push({
label: key,
stroke: style.color,
width: style.width,
points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color },
spanGaps: true,
...(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: true },
},
series: seriesArr,
axes: [
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
values: xVals, size: 36, space: 90
},
{ stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 },
],
legend: { show: false },
padding: [4, 4, 0, 0],
hooks: {
draw: [u => { 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
// 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) {
return buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
}
}
// Slice all traces; pick master by sampling rate then count.
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const buf = buffers[key];
if (!buf || buf.size === 0) continue;
const sl = getBufferSliceRange(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// In rolling mode, Go backend already LTTB-decimated temporal signals to
// maxPushPoints (2000) and scalar points per tick are naturally limited.
// Skip JS-side LTTB entirely — just use the raw buffer data as-is.
// In zoomed mode, run pixel-adaptive LTTB for display quality.
let sharedT, masterV;
if (isRolling) {
sharedT = masterRaw.t;
masterV = masterRaw.v;
} else {
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const 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, ...yArrays];
}
function buildTrigData(p) {
const trigT = trig.trigTime;
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
if (p.traces.length === 0) return [new Float64Array(0)];
const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
// Slice all traces; pick master by samplingRate first, then sample count
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const snap = trig.snapshot[key];
if (!snap) continue;
const sl = sliceTypedArrayRange(snap.t, snap.v, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
// Convert absolute → relative seconds
const sharedT = new Float64Array(dec.t.length);
for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT;
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
const sl = slices[key];
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
const relT = new Float64Array(sl.t.length);
for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT;
yArrays.push(resampleLinear(relT, sl.v, sharedT));
}
return [sharedT, ...yArrays];
}
/* ════════════════════════════════════════════════════════════════
Zoom sync
════════════════════════════════════════════════════════════════ */
let syncLocked = false;
function onZoom(sourcePlotId, min, max) {
// Push current range to history before applying new zoom
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
zoomHistory.push(prevRange);
if (zoomHistory.length > 30) zoomHistory.shift();
document.getElementById('btn-zoom-back').style.display = '';
// Store zoom on source plot
const src = plots.find(p => p.id === sourcePlotId);
if (src) src.xRange = [min, max];
// Show Auto button in live mode
if (!trig.enabled && !syncLocked) {
syncLocked = true;
document.getElementById('btn-sync-resume').style.display = '';
}
// Propagate to other plots
zoomGuard = true;
plots.forEach(p => {
if (p.id === sourcePlotId) return;
p.xRange = [min, max];
if (p.uplot) p.uplot.setScale('x', { min, max });
});
zoomGuard = false;
// Mark all plots dirty (re-slice data to the new range for full resolution)
plots.forEach(p => { p.needsRedraw = true; });
// 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;
});
function updateCursorReadout() {
const ro = document.getElementById('cursor-readout');
const active = cursors.mode === 'on';
ro.classList.toggle('visible', active);
if (!active) return;
// Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms
const fmt = v => {
if (v === null) return '—';
if (trig.enabled && trig.snapshot) {
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
return abs < 1 ? sign + (abs * 1000).toFixed(3) + 'ms' : sign + abs.toFixed(6) + 's';
}
const d = new Date(v * 1000);
return String(d.getHours()).padStart(2, '0') + ':'
+ String(d.getMinutes()).padStart(2, '0') + ':'
+ String(d.getSeconds()).padStart(2, '0') + '.'
+ String(d.getMilliseconds()).padStart(3, '0');
};
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
if (cursors.tA !== null && cursors.tB !== null) {
const dt = cursors.tB - cursors.tA, abs = Math.abs(dt);
const s = dt >= 0 ? '+' : '-';
const str = abs < 1 ? s + (abs * 1000).toFixed(3) + 'ms' : s + abs.toFixed(6) + 's';
document.getElementById('cur-dt').textContent = 'ΔT: ' + str;
} else {
document.getElementById('cur-dt').textContent = 'ΔT: —';
}
}
/* ════════════════════════════════════════════════════════════════
Trigger bar controls
════════════════════════════════════════════════════════════════ */
function openTrigBar(open) {
trig.enabled = open;
document.getElementById('trigbar').classList.toggle('open', open);
document.getElementById('btn-trigger').classList.toggle('trig-active', open);
document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px');
// Streaming-only controls are irrelevant while the trigger is active
const none = open ? 'none' : '';
document.getElementById('btn-pause-global').style.display = none;
document.getElementById('window-select').style.display = none;
document.getElementById('lbl-window').style.display = none;
if (!open) {
trigDisarm(); trig.snapshot = null;
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
} else {
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
resetZoom();
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
}
// Resize uPlot instances after trigbar height changes
setTimeout(() => {
plots.forEach(p => {
if (!p.uplot) return;
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
});
}, 220);
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
trig.signal = e.target.value; trig.prevVal = null;
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); });
document.getElementById('trig-pre').addEventListener('input', e => {
trig.prePercent = parseInt(e.target.value, 10);
document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
});
document.getElementById('trig-mode').addEventListener('change', e => {
trig.mode = e.target.value;
// 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'), cur = sel.value;
sel.innerHTML = '';
Object.values(sourcesMap).forEach(src => {
const prefix = src.id + ':';
const srcLabel = src.label || src.addr || src.id;
(src.signals || []).forEach(sig => {
const n = numElements(sig);
if (isTemporal(sig) || n === 1) {
const key = prefix + sig.name;
const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o);
} else {
for (let i = 0; i < n; i++) {
const key = prefix + sig.name + '[' + i + ']';
const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o);
}
}
});
});
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
trig.signal = sel.value;
}
/* ════════════════════════════════════════════════════════════════
Sidebar
════════════════════════════════════════════════════════════════ */
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
function buildSidebar() {
const list = document.getElementById('signal-list');
list.innerHTML = '';
const sources = Object.values(sourcesMap);
sources.forEach(src => {
const sigs = src.signals || [];
const prefix = src.id + ':';
// Source header
const grp = document.createElement('div');
grp.className = 'source-group';
const hdr = document.createElement('div');
hdr.className = 'source-group-header';
const dot = document.createElement('span');
dot.className = 'source-state-dot ' + (src.state || 'disconnected');
const nameEl = document.createElement('span');
nameEl.className = 'source-name';
nameEl.textContent = src.label || src.addr || src.id;
nameEl.title = src.addr || '';
const addrEl = document.createElement('span');
addrEl.className = 'source-addr';
if (src.label && src.addr) addrEl.textContent = src.addr;
const rmBtn = document.createElement('button');
rmBtn.className = 'source-remove-btn';
rmBtn.title = 'Remove source';
rmBtn.textContent = '×';
rmBtn.addEventListener('click', () => removeSource(src.id));
hdr.append(dot, nameEl, addrEl, rmBtn);
grp.appendChild(hdr);
sigs.forEach(sig => {
const n = numElements(sig), temporal = isTemporal(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
if (n === 1 || temporal) {
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
} else {
const group = document.createElement('div'); group.className = 'array-group';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '▶' + escHtml(sig.name) + ''
+ (sig.unit ? '' + escHtml(sig.unit) + '' : '')
+ '[' + n + '] ' + typeName + '';
header.addEventListener('click', () => header.classList.toggle('open'));
const children = document.createElement('div'); children.className = 'array-children';
for (let i = 0; i < n; i++) {
const key = globalKey + '[' + i + ']';
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
child.className = 'array-child'; children.appendChild(child);
}
group.appendChild(header); group.appendChild(children); grp.appendChild(group);
}
});
list.appendChild(grp);
});
if (!sources.length) {
const empty = document.createElement('div');
empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
empty.textContent = 'No sources configured';
list.appendChild(empty);
}
list.appendChild(makeAddSourceSection());
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
item.className = 'sig-item'; item.draggable = true;
item.innerHTML = '' + escHtml(label) + ''
+ (unit ? '' + escHtml(unit) + '' : '')
+ '' + escHtml(typeName) + '';
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
requestAnimationFrame(() => item.classList.add('dragging'));
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
return item;
}
/* ════════════════════════════════════════════════════════════════
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 += `