Files
MARTe_IO_Components/Client/WebUI/static/app.js
T
2026-05-18 18:07:09 +02:00

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