1433 lines
62 KiB
JavaScript
1433 lines
62 KiB
JavaScript
'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">▶</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Init
|
||
════════════════════════════════════════════════════════════════ */
|
||
buildLayoutMenu();
|
||
applyLayout('l1x1');
|
||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||
connectWS();
|
||
requestAnimationFrame(renderDirtyPlots);
|
||
fetch('/version').then(r => r.text()).then(v => {
|
||
document.getElementById('build-version').textContent = 'v' + v;
|
||
}).catch(() => {});
|