Files
MARTe-Integrated-Components/Client/debugger/static/app.js
T
2026-07-01 16:39:34 +02:00

4645 lines
194 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
════════════════════════════════════════════════════════════════ */
// Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history.
const MAX_WINDOW_SEC = 60;
// Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v).
const MAX_CAP = 600_000;
// Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz.
const DEFAULT_CAP = 100_000;
// Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s.
// TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz.
const TEMPORAL_CAP = Math.min(MAX_CAP, 500_000);
const LTTB_MIN = 200; // never decimate below this many points
// Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz.
function bufferCapForRate(rateHz) {
if (!rateHz || rateHz <= 0) return DEFAULT_CAP;
return Math.min(MAX_CAP, Math.ceil(rateHz * MAX_WINDOW_SEC * 1.5));
}
// Return a new, larger circular buffer that preserves all existing samples.
function growBuffer(buf, newCap) {
if (newCap <= buf.cap) return buf;
const nb = makeBuffer(newCap);
const start = buf.size === buf.cap ? buf.head : 0;
for (let i = 0; i < buf.size; i++) {
const idx = (start + i) % buf.cap;
pushBuffer(nb, buf.t[idx], buf.v[idx]);
}
return nb;
}
const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
];
/* ════════════════════════════════════════════════════════════════
Globals
════════════════════════════════════════════════════════════════ */
// sourcesMap: id → {id, label, addr, state, signals:[]}
const sourcesMap = {};
let buffers = {};
let plots = [];
let nextPlotId = 1;
let windowSec = 5;
let globalPause = false;
let lastDataAt = 0;
/* ── Debug-specific globals ─────────────────────────────────────── */
let marteConnected = false;
let signalsById = {}; // id → {id, name, type, elements, dimensions, names:[]}
let signalsByName = {}; // name → meta (multiple entries per signal)
let tracedSet = new Set();
let forcedSignals = {}; // name → value
const forcedGroups = new Map(); // baseName → {isAll, indices, elements, value, expanded}
let breakpoints = {}; // name → {op, threshold}
const tracedGroups = new Map(); // baseName → {indices, elements, expanded}
let threads = [];
let allObjects = [];
let logs = [];
const MAX_LOGS = 2000;
let logDirty = false;
let stepStatus = null; // {paused, gam, remaining, thread}
let stepPollTimer = null;
let unforceTarget = '';
const _cmdSent = new Set();
let _treeReady = false;
let _arrAction = 'trace', _arrPath = '', _arrElem = 0, _arrSel = 'all';
const traceColorMap = {};
let colorIdx = 0;
function getTraceColor(key) {
if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length];
return traceColorMap[key];
}
// Per-signal style overrides (color, width, dash, marker, markerSize).
const sigStyle = {};
function getSigStyle(key) {
if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
return sigStyle[key];
}
// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset}
const sigVScale = {};
// Active signal per plot: plotId → key
const plotActiveSignal = {};
function setSigStyle(key, updates) {
const s = getSigStyle(key);
Object.assign(s, updates);
if (updates.color) {
traceColorMap[key] = updates.color;
// Update badge dots for this key across all plots
document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => {
dot.style.background = updates.color;
});
}
// Recreate uPlot for all plots containing this key
plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
}
/* ─── VScale helpers ─────────────────────────────────────────────────────── */
// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
function getVScale(plotId, key) {
const vsKey = plotId + ':' + key;
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false };
return sigVScale[vsKey];
}
function findSignalMeta(key) {
const colon = key.indexOf(':');
if (colon < 0) return null;
const src = sourcesMap[key.slice(0, colon)];
if (!src) return null;
return src.signals.find(s => s.name === key.slice(colon + 1)) || null;
}
// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array.
// y_norm = (y_raw - offset) / divValue + screenPos
// divValue: units per division offset: raw value at screen center screenPos: divisions from center
// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use.
function resolveVScale(plotId, key, rawY) {
const vs = getVScale(plotId, key);
const screenPos = vs.screenPos || 0;
if (vs.mode === 'range') {
const meta = findSignalMeta(key);
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
const divValue = (meta.rangeMax - meta.rangeMin) / 8;
const offset = (meta.rangeMin + meta.rangeMax) / 2;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Fall through to auto if no range
}
if (vs.mode === 'manual') {
const divValue = Math.max(vs.divValue || 1, 1e-30);
const offset = vs.offset != null ? vs.offset : 0;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Auto: fit data in central 6 of 8 divisions, centered at screenPos
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
if (!isFinite(min)) { min = -1; max = 1; }
if (min === max) { min -= 1; max += 1; }
const divValue = Math.max((max - min) / 6, 1e-30);
const offset = (max + min) / 2;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Mixed mode: each signal occupies a fixed band; within that band it is either
// quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed.
function applyMixedNorm(p, yArrays) {
const n = p.traces.length;
if (n === 0) return yArrays;
const bandH = 8 / n;
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const vs = getVScale(p.id, key);
const centerY = 4 - (ki + 0.5) * bandH;
const hi = centerY + bandH * 0.35;
const lo = centerY - bandH * 0.35;
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
const out = new Float64Array(rawY.length);
if (vs.digitalInMixed) {
const threshold = isFinite(min) ? (min + max) / 2 : 0.5;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo);
}
} else {
if (!isFinite(min)) { min = 0; max = 1; }
if (min === max) { min -= 1; max += 1; }
const range = max - min, bandRange = hi - lo;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : lo + (v - min) / range * bandRange;
}
}
return out;
});
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) {
if (p.mode === 'digital') return applyDigitalNorm(p, yArrays);
if (p.mode === 'mixed') return applyMixedNorm(p, yArrays);
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
const out = new Float64Array(rawY.length);
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
}
return out;
});
}
// Digital mode: quantize each signal to lo/hi within its own horizontal band.
// Signals are arranged top-to-bottom matching badge order (index 0 = top).
function applyDigitalNorm(p, yArrays) {
const n = p.traces.length;
if (n === 0) return yArrays;
const bandH = 8 / n; // total Y span is 8 divisions (-4 to +4)
return yArrays.map((rawY, ki) => {
// Top-down: signal 0 is at top (highest Y value)
const centerY = 4 - (ki + 0.5) * bandH;
const hi = centerY + bandH * 0.35;
const lo = centerY - bandH * 0.35;
// Threshold: midpoint of min/max
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
const threshold = isFinite(min) ? (min + max) / 2 : 0.5;
const out = new Float64Array(rawY.length);
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo);
}
return out;
});
}
// Set the active (Y-axis-labelled) signal for a plot and update badge highlights.
function setActiveSig(plotId, key) {
if (key === null || key === undefined) {
delete plotActiveSignal[plotId];
} else {
plotActiveSignal[plotId] = key;
}
const c = document.getElementById('badges-' + plotId);
if (c) c.querySelectorAll('.sig-badge').forEach(b =>
b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key));
const p = plots.find(q => q.id === plotId);
if (p && p.uplot) p.uplot.redraw(false);
updatePlotCursorReadouts();
}
// Mark plots containing key dirty and refresh badge vscale text.
function refreshPlotForKey(key) {
plots.forEach(p => {
if (p.traces.includes(key)) {
p.needsRedraw = true;
_updateBadgeVScaleInfo(p.id, key);
}
});
}
// Format a numeric value concisely for badge/axis display.
function _fmtVal(v) {
if (v == null || !isFinite(v)) return '?';
const abs = Math.abs(v);
if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1);
return parseFloat(v.toPrecision(3)).toString();
}
// Refresh the vscale info text inside a badge.
function _updateBadgeVScaleInfo(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return;
const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return;
const vs = sigVScale[plotId + ':' + key];
if (!vs) { infoEl.textContent = ''; return; }
const divValue = vs._resolvedDiv || vs.divValue || 1;
const sp = vs.screenPos || 0;
let txt = _fmtVal(divValue) + '/div';
if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div';
infoEl.textContent = txt;
}
// Sync: shared uPlot cursor crosshair across all live plots
const LIVE_SYNC = uPlot.sync('live');
const TRIG_SYNC = uPlot.sync('trig');
// Zoom guard: prevents echo on cross-plot sync calls inside onZoom.
// zoomGuard prevents the setScale hook from calling onZoom when we programmatically
// set the scale (rolling window, zoom-back, fit, resize, pan, cross-plot sync).
// All programmatic setScale calls wrap with zoomGuard=true/false so that the hook
// only fires for genuine user drag-zoom or scroll-wheel gestures.
let zoomGuard = false;
// Zoom history for Back button (global since plots are zoom-synced)
const zoomHistory = [];
// zoomData: hi-res data fetched from /api/zoom, keyed by plot id.
// Each entry: { signals: { key: {t:Float64Array, v:Float64Array} }, t0, t1 }
const zoomData = {};
let _zoomFetchTimer = null;
// Cursors A/B — stored in x-axis units of the current mode:
// live mode → Unix seconds
// trig mode → relative seconds from trigger
const cursors = { mode: 'off', tA: null, tB: null };
let cursorsDirty = false; // if true, redraw all plots to update cursor lines
// Layout — [label, cssClass, cols, rows]
const LAYOUTS = [
['1×1', 'l1x1', 1, 1], ['1×2', 'l1x2', 1, 2], ['2×1', 'l2x1', 2, 1], ['1×3', 'l1x3', 1, 3],
['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1],
];
let currentLayout = 'l1x1';
let colFrs = [1]; // fractional column sizes (sum = cols)
let rowFrs = [1]; // fractional row sizes (sum = rows)
let _gridCols = 1, _gridRows = 1;
/* ════════════════════════════════════════════════════════════════
Trigger state
════════════════════════════════════════════════════════════════ */
const trig = {
enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1,
prePercent: 20, mode: 'normal', stopped: false,
armed: false, prevVal: null, collecting: false, trigTime: null, snapshot: null,
};
function trigPreSec() { return trig.windowSec * trig.prePercent / 100; }
function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; }
/* ════════════════════════════════════════════════════════════════
WebSocket
════════════════════════════════════════════════════════════════ */
let ws = null, wsBackoff = 1000;
function connectWS() {
ws = new WebSocket('ws://' + location.host + '/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected waiting for data'); };
ws.onclose = () => {
setStatus('red', 'Disconnected (reconnecting…)');
// Reset MARTe connection state so that when the WS reconnects the hub can
// replay the 'connected' message and trigger a fresh DISCOVER/TREE sequence.
// Without this, the marteConnected guard in onMarteConnected blocks the
// sequence from re-running after a hub-WS reconnect (even if the MARTe2 TCP
// connection stayed alive and no 'disconnected' message was sent).
marteConnected = false;
setTimeout(connectWS, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 30000);
};
ws.onerror = () => { };
ws.onmessage = evt => {
if (evt.data instanceof ArrayBuffer) { onBinaryData(evt.data); return; }
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'sources') onSources(msg);
else if (msg.type === 'config') onConfig(msg);
else if (msg.type === 'data') onData(msg);
else if (msg.type === 'stats') onStats(msg);
// Debug-specific messages
else if (msg.type === 'connected') onMarteConnected();
else if (msg.type === 'disconnected') onMarteDisconnected();
else if (msg.type === 'log') onLog(msg);
else if (msg.type === 'response') { onResponse(msg); }
else if (msg.type === 'tree_node') { try { onTreeNode(JSON.parse(msg.data)); } catch(e) { console.error('tree_node error:', e, msg.data); appendLog('sys','ERROR','Tree render error: '+e.message); } }
else if (msg.type === 'text_line') onTextLine(msg.data);
else if (msg.type === 'service_config') onServiceConfig(msg);
else if (msg.type === 'forced_state') onForcedState(msg);
else if (msg.type === 'traced_state') onTracedState(msg);
};
}
/* ════════════════════════════════════════════════════════════════
Status LED
════════════════════════════════════════════════════════════════ */
function setStatus(s, t) {
document.getElementById('status-led').className = s;
document.getElementById('status-text').textContent = t;
}
setInterval(() => {
const tsEl = document.getElementById('sb-tsage');
if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) {
const age = performance.now() - lastDataAt;
// Compute minimum lag: newest buffer timestamp vs browser wall clock.
let tsAge = null;
const wallNow = Date.now() / 1000;
Object.values(buffers).forEach(buf => {
if (buf.size === 0) return;
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
const a = wallNow - newest;
if (tsAge === null || a < tsAge) tsAge = a;
});
if (tsEl && tsAge !== null) {
const ms = tsAge * 1000;
tsEl.textContent = '| lag: ' + (ms < 1000 ? ms.toFixed(0) + 'ms' : tsAge.toFixed(2) + 's');
} else if (tsEl) {
tsEl.textContent = '';
}
if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's');
else setStatus('green', 'Streaming');
} else if (tsEl) {
tsEl.textContent = '';
}
}, 500);
/* ════════════════════════════════════════════════════════════════
Config handler
════════════════════════════════════════════════════════════════ */
function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }
function onConfig(msg) {
const sid = msg.sourceId;
if (!sid) return;
// Ensure source exists (may arrive before 'sources' message in some edge cases).
if (!sourcesMap[sid]) {
sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] };
}
const src = sourcesMap[sid];
const newSigs = msg.signals || [];
const oldSigs = src.signals || [];
// Fingerprint only covers fields that change the binary layout of the data.
// timeMode and samplingRate are intentionally excluded: synthesizeHubConfig
// always sets timeMode=TimeModePacket, so including it would cause a buffer
// reset on every real UDP CONFIG arrival (synthesized→real transition), wiping
// the fast-signal buffer and limiting visible history to ~0.5 s.
const fp = s => s.name + ':' + s.typeCode + ':' + numElements(s);
const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i]));
src.signals = newSigs;
if (changed) {
const prefix = sid + ':';
// Build the complete set of buffer keys the new config requires.
const newKeys = new Set();
const oldFp = {};
oldSigs.forEach(s => { oldFp[s.name] = fp(s); });
newSigs.forEach(sig => {
// All signals (scalar, temporal, or snapshot-waveform arrays) use the base key.
// Non-temporal arrays (n>1, timeMode=Packet=0) receive a snapshot waveform
// from the hub under the base signal name, not element-indexed keys.
newKeys.add(prefix + sig.name);
});
// Build set of base signal names currently traced so their buffers survive
// config-cleanup even if the synthesised config uses different canonical names.
const tracedBaseNames = new Set();
for (const n of tracedSet) tracedBaseNames.add(n.replace(/\[\d+\]$/, ''));
// Remove buffers for signals that are no longer present and not currently traced.
Object.keys(buffers).forEach(k => {
if (k.startsWith(prefix) && !newKeys.has(k)) {
const sigName = k.slice(prefix.length);
if (!tracedBaseNames.has(sigName)) delete buffers[k];
}
});
// Create buffers only for signals that are new or whose metadata changed.
newSigs.forEach(sig => {
const n = numElements(sig);
const base = prefix + sig.name;
const sigCap = bufferCapForRate(sig.samplingRate);
const metaChanged = oldFp[sig.name] !== fp(sig);
if (isTemporal(sig)) {
if (metaChanged || !buffers[base]) buffers[base] = makeBuffer(TEMPORAL_CAP);
} else {
// Both scalar (n==1) and snapshot-waveform (n>1, timeMode=Packet) arrays
// use a single buffer under the base key. The snapshot case stores
// n entries per hub tick (t=element index, v=value) — TEMPORAL_CAP keeps
// the last ~600 s worth of sweeps at 30 Hz × 1000 elements.
const cap = n > 1 ? TEMPORAL_CAP : sigCap;
if (metaChanged || !buffers[base]) buffers[base] = makeBuffer(cap);
}
});
if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
}
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
}
buildSidebar();
buildTrigSignalSelect();
}
/* ════════════════════════════════════════════════════════════════
Data handler
════════════════════════════════════════════════════════════════ */
function onData(msg) {
lastDataAt = performance.now();
const sigs = msg.signals; if (!sigs) return;
Object.keys(sigs).forEach(key => {
const buf = buffers[key]; if (!buf) return;
const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return;
const len = Math.min(sd.t.length, sd.v.length);
for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]);
});
// Increment data generation counter so render loop knows data changed
_dataGen++;
if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs);
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.enabled) {
plots.forEach(p => {
if (globalPause) return;
if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
});
}
}
/* ════════════════════════════════════════════════════════════════
Binary data handler — parses compact binary frames from Go backend.
Wire format (little-endian):
uint8 version (1)
uint8 sourceIdLen
UTF-8 sourceId
uint32 numSignals
for each signal:
uint16 keyLen
UTF-8 key (relative to source)
uint32 pairCount N
float64[N] t values
float64[N] v values
════════════════════════════════════════════════════════════════ */
function onBinaryData(buf) {
lastDataAt = performance.now();
const dv = new DataView(buf);
let off = 0;
if (dv.getUint8(off) !== 1) return;
off += 1;
const srcIdLen = dv.getUint8(off); off += 1;
const srcId = new TextDecoder().decode(new Uint8Array(buf, off, srcIdLen));
off += srcIdLen;
const prefix = srcId + ':';
const numSigs = dv.getUint32(off, true); off += 4;
// Collect trigger-signal values for inline check
let trigVals = null;
for (let s = 0; s < numSigs; s++) {
const keyLen = dv.getUint16(off, true); off += 2;
const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
off += keyLen;
const fullKey = prefix + key;
const n = dv.getUint32(off, true); off += 4;
let bufObj = buffers[fullKey];
if (!bufObj) {
bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP);
buffers[fullKey] = bufObj;
}
// If the buffer was created before the CONFIG message arrived (or with an
// underestimated capacity), grow it now that we know the signal's rate.
const detectedRate = getKeySamplingRate(fullKey);
if (detectedRate > 0) {
const needed = bufferCapForRate(detectedRate);
if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; }
}
// Read t and v values in one pass (v array starts at off + n*8)
const tOff = off, vOff = off + n * 8;
for (let i = 0; i < n; i++) {
pushBuffer(bufObj, dv.getFloat64(tOff + i * 8, true), dv.getFloat64(vOff + i * 8, true));
}
off += n * 16; // skip both t and v arrays
// Capture trigger signal values
if (trig.enabled && trig.armed && fullKey === trig.signal) {
trigVals = { t: new Float64Array(n), v: new Float64Array(n) };
for (let i = 0; i < n; i++) {
trigVals.t[i] = dv.getFloat64(tOff + i * 8, true);
trigVals.v[i] = dv.getFloat64(vOff + i * 8, true);
}
}
}
// Trigger check — pass as keyed object matching checkTrigger's expected format
if (trigVals) checkTrigger({ [trig.signal]: trigVals });
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.enabled) {
_dataGen++;
plots.forEach(p => {
if (globalPause) return;
if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
});
}
}
function checkTrigger(sigs) {
const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return;
for (let i = 0; i < sd.v.length; i++) {
const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur;
if (prev === null) continue;
const e = trig.edge, thr = trig.threshold;
if ((e === 'rising' && prev < thr && cur >= thr) ||
(e === 'falling' && prev > thr && cur <= thr) ||
(e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) {
fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break;
}
}
}
function fireTrigger(t) {
// Clear the previous snapshot here (not in trigArm) so the last waveform
// and trigger marker stay visible while the trigger is rearmed and waiting.
trig.snapshot = null; trig.armed = false; trig.collecting = true; trig.trigTime = t;
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
}
function finaliseTriggerCapture() {
trig.collecting = false;
const t0 = trig.trigTime - trigPreSec(), t1 = trig.trigTime + trigPostSec();
const snap = {};
Object.keys(buffers).forEach(key => {
const sl = getBufferSliceRange(buffers[key], t0, t1);
if (sl.t.length > 0) snap[key] = sl;
});
// Tag snapshot with the window parameters captured at trigger time so that
// the render loop uses the correct bounds even if UI controls are changed later.
snap._preS = trigPreSec();
snap._postS = trigPostSec();
trig.snapshot = snap;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
// Show cursor button now that snapshot exists (positions preserved from before)
updateCursorBtnVisibility();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
if (trig.mode === 'normal' && !trig.stopped)
setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200);
// Upgrade the snapshot in the background with full-resolution ring data.
// The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs.
upgradeTriggerSnapshot(t0, t1, trig.trigTime);
}
// Fetches full-resolution ring data for the trigger window and replaces the
// push-buffer entries in trig.snapshot, then re-renders all plots.
async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) {
// Small delay so the ring receives the last post-trigger samples
// (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin).
await new Promise(r => setTimeout(r, 120));
// Abort if a new trigger has fired since we started.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
const keys = Object.keys(buffers);
if (!keys.length) return;
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const json = await resp.json();
ringSignals = json && json.signals;
} catch (e) {
console.warn('trigger snapshot ring upgrade failed:', e);
return;
}
// Abort if the snapshot was replaced while we were fetching.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
let updated = false;
Object.entries(ringSignals || {}).forEach(([key, sd]) => {
if (!sd || !sd.t || !sd.t.length) return;
trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
updated = true;
});
if (updated) plots.forEach(p => { p.needsRedraw = true; });
}
function trigArm() {
// Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
// visible while waiting for the next event. fireTrigger() clears it when a new
// trigger actually fires.
trig.armed = true; trig.collecting = false; trig.prevVal = null;
showRearmBtn(false); updateTrigStatusBadge('armed');
updateCursorBtnVisibility();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
}
function trigDisarm() {
trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false;
setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle');
}
function setAllCardsCollecting(on) {
document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on));
}
function updateTrigStatusBadge(state) {
const el = document.getElementById('trig-status-badge');
el.className = state;
el.textContent = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE';
}
function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v ? 'inline-block' : 'none'; }
function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v ? 'inline-block' : 'none'; }
function updateStopBtn() {
const btn = document.getElementById('btn-trig-stop');
btn.textContent = trig.stopped ? 'Resume' : 'Stop';
}
/* ════════════════════════════════════════════════════════════════
Circular buffer
════════════════════════════════════════════════════════════════ */
function makeBuffer(cap) {
cap = cap || DEFAULT_CAP;
return { t: new Float64Array(cap), v: new Float64Array(cap), head: 0, size: 0, cap };
}
function pushBuffer(buf, t, v) {
buf.t[buf.head] = t; buf.v[buf.head] = v;
buf.head = (buf.head + 1) % buf.cap;
if (buf.size < buf.cap) buf.size++;
}
// Binary-search range slice of circular buffer — O(log n + window_size)
function getBufferSliceRange(buf, t0, t1) {
if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const { cap, size, head } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; }
const kEnd = lo, len = kEnd - kStart;
if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const outT = new Float64Array(len), outV = new Float64Array(len);
const physStart = physAt(kStart), tail = cap - physStart;
if (tail >= len) {
outT.set(buf.t.subarray(physStart, physStart + len));
outV.set(buf.v.subarray(physStart, physStart + len));
} else {
outT.set(buf.t.subarray(physStart, physStart + tail));
outT.set(buf.t.subarray(0, len - tail), tail);
outV.set(buf.v.subarray(physStart, physStart + tail));
outV.set(buf.v.subarray(0, len - tail), tail);
}
return { t: outT, v: outV };
}
// Like getBufferSliceRange but also includes the nearest point just outside each
// boundary so that a line is always drawn across the visible area even when the
// zoom window contains only 0 or 1 samples.
function getBufferSliceRangeWithBrackets(buf, t0, t1) {
if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const { cap, size, head } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; }
const kEnd = lo;
// Expand by one on each side for bracketing points.
const kFrom = Math.max(0, kStart - 1);
const kTo = Math.min(size, kEnd + 1);
const len = kTo - kFrom;
if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const outT = new Float64Array(len), outV = new Float64Array(len);
for (let i = 0; i < len; i++) {
const idx = physAt(kFrom + i);
outT[i] = buf.t[idx]; outV[i] = buf.v[idx];
}
return { t: outT, v: outV };
}
// Supplement sparse fetched signal data ({t, v} Float64Arrays) with the nearest
// bracketing points from the local circular buffer, so lines are always drawn
// across the zoom window even if the server returned 0 or 1 points.
function supplementWithBrackets(sd, buf, t0, t1) {
if (!buf || buf.size === 0) return sd;
if (sd && sd.t.length >= 2) return sd; // already enough points
const { size, head, cap } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; }
const kEnd = lo;
const leftK = kStart > 0 ? kStart - 1 : -1;
const rightK = kEnd < size ? kEnd : -1;
const tArr = [], vArr = [];
if (leftK >= 0) { tArr.push(buf.t[physAt(leftK)]); vArr.push(buf.v[physAt(leftK)]); }
if (sd) { for (let i = 0; i < sd.t.length; i++) { tArr.push(sd.t[i]); vArr.push(sd.v[i]); } }
if (rightK >= 0) { tArr.push(buf.t[physAt(rightK)]); vArr.push(buf.v[physAt(rightK)]); }
if (tArr.length === 0) return sd;
return { t: Float64Array.from(tArr), v: Float64Array.from(vArr) };
}
// getGlobalNow returns the reference "now" for the rolling window.
// Always anchors to the newest timestamp found in any buffer so the rolling
// window tracks real data regardless of any clock skew between the Go server
// and the browser. Falls back to Date.now()/1000 only when all buffers are
// empty (no data yet received).
function getGlobalNow() {
let latest = -Infinity;
Object.values(buffers).forEach(buf => {
if (buf.size === 0) return;
const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (newestT > latest) latest = newestT;
});
return isFinite(latest) ? latest : Date.now() / 1000;
}
// getBufferNow returns the "now" anchor for a single buffer — the buffer's own
// newest timestamp. This avoids cross-signal interference when signals have
// different timescales or update rates.
function getBufferNow(buf) {
if (buf.size === 0) return Date.now() / 1000;
return buf.t[(buf.head - 1 + buf.cap) % buf.cap];
}
function getBufferSlice(buf) {
const now = getBufferNow(buf);
return getBufferSliceRange(buf, now - windowSec, now);
}
// Binary-search slice of a sorted contiguous Float64Array pair
function sliceTypedArrayRange(t, v, t0, t1) {
let lo = 0, hi = t.length;
while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] < t0) lo = m + 1; else hi = m; }
const s = lo; lo = s; hi = t.length;
while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] <= t1) lo = m + 1; else hi = m; }
return { t: t.subarray(s, lo), v: v.subarray(s, lo) };
}
// Return the configured samplingRate for a buffer key.
// Temporal array signals have a meaningful SamplingRate; scalars return 0.
// Used to prefer high-freq signals as the master time grid regardless of trace order.
function getKeySamplingRate(key) {
// key format: "sourceId:signalName" or "sourceId:signalName[i]"
for (const src of Object.values(sourcesMap)) {
const prefix = src.id + ':';
if (!key.startsWith(prefix)) continue;
const localKey = key.slice(prefix.length);
const direct = (src.signals || []).find(s => s.name === localKey);
if (direct) return direct.samplingRate || 0;
const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '['));
if (sig) return sig.samplingRate || 0;
}
return 0;
}
// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default).
function makeSeriesPath(key) {
const style = getSigStyle(key);
if (style.dash === 'solid') return null;
const dashPat = style.dash === 'dashed' ? [6, 4] : [2, 3];
return (u, si) => {
const xd = u.data[0], yd = u.data[si];
if (!xd || !yd || !u.bbox) return { stroke: null, fill: null };
const { ctx, bbox } = u;
ctx.save();
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width;
ctx.setLineDash(dashPat);
ctx.lineJoin = 'round';
ctx.beginPath();
let moved = false;
for (let i = 0; i < xd.length; i++) {
if (yd[i] == null) { moved = false; continue; }
const cx = u.valToPos(xd[i], 'x', true);
const cy = u.valToPos(yd[i], 'y', true);
if (!moved) { ctx.moveTo(cx, cy); moved = true; }
else ctx.lineTo(cx, cy);
}
ctx.stroke();
ctx.restore();
return { stroke: null, fill: null }; // tell uPlot not to draw anything on top
};
}
/* ════════════════════════════════════════════════════════════════
LTTB Web Worker — offloads decimation off the main thread.
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
On cache-miss → render falls back to sync lttb once (first zoom render only),
then worker takes over for subsequent updates.
════════════════════════════════════════════════════════════════ */
const lttbCache = new Map(); // key → {t, v}
const lttbPending = new Set(); // keys currently in-flight
let _lttbWorker = null;
try {
_lttbWorker = new Worker('lttb-worker.js');
_lttbWorker.onmessage = function({ data: { id, t, v } }) {
lttbPending.delete(id);
lttbCache.set(id, { t, v });
// Invalidate and redraw the owning plot.
const plotId = parseInt(id.split(':')[0], 10);
const p = plots.find(q => q.id === plotId);
if (p) { p.needsRedraw = true; }
};
_lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
} catch(e) {
console.warn('[lttb-worker] unavailable, using sync fallback:', e);
}
// Submit a LTTB job to the worker (or run sync if worker unavailable).
// Returns cached {t, v} if fresh, null if a worker job was just submitted,
// or a sync result if the worker is unavailable.
function lttbAsync(cacheKey, t, v, threshold) {
const cached = lttbCache.get(cacheKey);
if (cached) return cached; // cache hit — use immediately
if (!lttbPending.has(cacheKey)) {
lttbPending.add(cacheKey);
if (_lttbWorker) {
// Send copies so the main thread retains the originals.
const tCopy = new Float64Array(t);
const vCopy = new Float64Array(v);
_lttbWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold },
[tCopy.buffer, vCopy.buffer]);
} else {
// Synchronous fallback (worker unavailable).
const result = lttb(t, v, threshold);
lttbCache.set(cacheKey, result);
lttbPending.delete(cacheKey);
return result;
}
}
return null; // worker job in-flight — caller should use fallback
}
// Evict stale LTTB cache entries for a plot (call when zoom range changes).
function lttbCacheEvict(plotId) {
const prefix = plotId + ':';
for (const k of [...lttbCache.keys()]) {
if (k.startsWith(prefix)) lttbCache.delete(k);
}
for (const k of [...lttbPending]) {
if (k.startsWith(prefix)) lttbPending.delete(k);
}
}
// LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold.
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold) return { t, v };
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
outT[0] = t[0]; outV[0] = v[0];
outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
const every = (len - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
const avgS = Math.floor((i + 1) * every) + 1, avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
let avgT = 0, avgV = 0, n = 0;
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
if (n) { avgT /= n; avgV /= n; }
const rS = Math.floor(i * every) + 1, rE = Math.min(Math.floor((i + 1) * every) + 1, len);
let maxA = -1, next = rS;
const aT = t[a], aV = v[a];
for (let j = rS; j < rE; j++) {
const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
if (area > maxA) { maxA = area; next = j; }
}
outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
}
return { t: outT, v: outV };
}
/* ════════════════════════════════════════════════════════════════
Hybrid zoom: hi-res data fetched from /api/zoom on demand
════════════════════════════════════════════════════════════════ */
function cancelZoomFetch() {
if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; }
}
function scheduleZoomFetch(t0, t1) {
cancelZoomFetch();
_zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150);
}
async function doZoomFetch(t0, t1) {
// Collect all signal keys across all plots; each key encodes sourceId as prefix.
const allKeys = new Set();
plots.forEach(p => p.traces.forEach(k => allKeys.add(k)));
if (allKeys.size === 0) return;
const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2);
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`;
let fetched;
try {
const resp = await fetch(url);
if (!resp.ok) return;
fetched = await resp.json();
} catch (e) { console.warn('zoom fetch:', e); return; }
const sigs = fetched && fetched.signals;
if (!sigs) return;
// Convert arrays from JSON to Float64Array and store per-plot.
const merged = {};
Object.entries(sigs).forEach(([k, sd]) => {
if (!sd || !sd.t || !sd.v) return;
merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
});
plots.forEach(p => {
// Only store if this plot's range still matches what we fetched.
if (!p.xRange || Math.abs(p.xRange[0] - t0) > 1e-9 || Math.abs(p.xRange[1] - t1) > 1e-9) return;
const plotSigs = {};
p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; });
if (Object.keys(plotSigs).length > 0) {
zoomData[p.id] = { signals: plotSigs, t0, t1 };
p.needsRedraw = true;
}
});
}
// Build uPlot data arrays from server-fetched hi-res signal data.
// Sparse signals (0 or 1 pts in range) are supplemented with local buffer
// bracket points so a line is always drawn across the visible area.
function buildDataFromFetched(p, fetchedSignals, targetPts) {
const t0 = p.xRange ? p.xRange[0] : -Infinity;
const t1 = p.xRange ? p.xRange[1] : Infinity;
let masterKey = p.traces[0], masterCount = -1, masterRate = -1, masterSpan = -1;
for (const key of p.traces) {
let sd = fetchedSignals[key];
if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) continue;
const rate = getKeySamplingRate(key);
const span = sd.t.length > 1 ? sd.t[sd.t.length - 1] - sd.t[0] : 0;
if (span > masterSpan ||
(span === masterSpan && rate > masterRate) ||
(span === masterSpan && rate === masterRate && sd.t.length > masterCount)) {
masterRate = rate; masterSpan = span; masterCount = sd.t.length; masterKey = key;
}
}
let masterSd = fetchedSignals[masterKey];
if (!masterSd || masterSd.t.length < 2)
masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1);
if (!masterSd || !masterSd.t.length)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
const dec = lttb(masterSd.t, masterSd.v, targetPts);
const sharedT = dec.t;
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
let sd = fetchedSignals[key];
if (!sd || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
}
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
/* ════════════════════════════════════════════════════════════════
uPlot helpers
════════════════════════════════════════════════════════════════ */
// ─── Time formatting helpers ──────────────────────────────────────────────────
// Returns the span (in seconds) of the currently visible x-axis across all plots.
// Falls back to windowSec when no uPlot instances exist yet.
function currentXSpan() {
for (const p of plots) {
if (p.uplot) {
const s = p.uplot.scales.x;
if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min);
}
}
return windowSec;
}
// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on
// refSpan (e.g. the visible x-range or the value itself).
// sign = '+' prefix only when showSign is true (default false for ΔT display).
function fmtDuration(sec, refSpan, showSign) {
const abs = Math.abs(sec);
const sign = showSign ? (sec < 0 ? '' : '+') : (sec < 0 ? '' : '');
if (refSpan < 1e-6) { // nanosecond range
return sign + (abs * 1e9).toFixed(1) + ' ns';
} else if (refSpan < 1e-3) { // microsecond range
return sign + (abs * 1e6).toFixed(3) + ' µs';
} else if (refSpan < 1) { // millisecond range
return sign + (abs * 1e3).toFixed(3) + ' ms';
} else { // second range
return sign + abs.toFixed(6) + ' s';
}
}
// Format a Unix-seconds timestamp → HH:MM:SS.fraction
// The number of sub-second digits adapts to the visible x-range span.
function fmtLiveTime(v, span) {
const d = new Date(v * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const frac = v - Math.floor(v); // sub-second part, full float64 precision
if (span < 1e-6) {
// show 9 decimal places (ns precision)
const ns = Math.round(frac * 1e9);
return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0');
} else if (span < 1e-3) {
// show 6 decimal places (µs precision)
const us = Math.round(frac * 1e6);
return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0');
} else if (span < 1) {
// show 3 decimal places (ms precision) — tick labels only show ms
const ms = String(d.getMilliseconds()).padStart(3, '0');
return hh + ':' + mm + ':' + ss + '.' + ms;
} else {
const ms = String(d.getMilliseconds()).padStart(3, '0');
return hh + ':' + mm + ':' + ss + '.' + ms;
}
}
// Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks)
// Precision adapts to the visible x-range (via u.scales.x.{min,max}).
function fmtLiveTick(u, vals) {
const span = (u.scales.x && u.scales.x.max != null)
? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec;
return vals.map(v => v == null ? '' : fmtLiveTime(v, span));
}
// Format relative seconds → auto-scaled unit (used for trigger x-axis ticks)
// Unit (s/ms/µs/ns) is determined by the visible x-range span.
function fmtTrigTick(u, vals) {
const span = (u.scales.x && u.scales.x.max != null)
? Math.abs(u.scales.x.max - u.scales.x.min) : 1;
return vals.map(v => v == null ? '' : fmtDuration(v, span, true));
}
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
// line on any plot that shows the trigger signal.
function drawTriggerMarker(u, p) {
if (!trig.enabled || !trig.snapshot) return;
const { ctx, bbox } = u;
if (!bbox) return;
const x = u.valToPos(0, 'x', true);
if (x < bbox.left || x > bbox.left + bbox.width) return;
const px = Math.round(x);
ctx.save();
// Thin dashed vertical line at t=0
ctx.strokeStyle = 'rgba(203,166,247,0.7)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(px, bbox.top);
ctx.lineTo(px, bbox.top + bbox.height);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(203,166,247,0.7)';
ctx.font = 'bold 9px monospace';
ctx.textBaseline = 'top';
ctx.fillText('T', px + 3, bbox.top + 2);
// Horizontal threshold line — only on plots that contain the trigger signal
if (p && trig.signal && p.traces.includes(trig.signal)) {
// Normalize the raw threshold to this plot's vscale for the trigger signal.
const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null;
let threshNorm = trig.threshold;
if (tvs) {
const dv = tvs._resolvedDiv || tvs.divValue || 1;
const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
const sp = tvs.screenPos || 0;
threshNorm = (trig.threshold - ofs) / dv + sp;
}
const y = u.valToPos(threshNorm, 'y', true);
if (y >= bbox.top && y <= bbox.top + bbox.height) {
const py = Math.round(y);
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
ctx.lineWidth = 0.75;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(bbox.left, py);
ctx.lineTo(bbox.left + bbox.width, py);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(203,166,247,0.45)';
ctx.font = '9px monospace';
ctx.textBaseline = 'bottom';
ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1);
}
}
ctx.restore();
}
// Linearly interpolate series value at time t from uPlot's current rendered data.
function interpAtTime(u, si, t) {
const td = u.data[0], vd = u.data[si];
if (!td || td.length === 0 || !vd) return null;
let lo = 0, hi = td.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; }
if (lo === 0) return vd[0] ?? null;
const t0 = td[lo - 1], t1 = td[lo];
const v0 = vd[lo - 1], v1 = vd[lo];
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
}
// Draw signal names rotated -90° in the Y-axis strip for digital/mixed mode.
// Each name is centred vertically within its band and clipped so it never bleeds
// into adjacent bands. The Y-axis size must be reduced accordingly (see createUPlotOpts).
function drawBandLabels(u, p) {
if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return;
const n = p.traces.length;
if (n === 0) return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
const bandH = 8 / n;
// The Y-axis strip is 20 CSS-px wide → 20*dpr physical px; its centre sits at
// bbox.left - 10*dpr. (Must match the axis `size` value below.)
const axisW = 20 * dpr;
const xCenter = bbox.left - axisW / 2;
ctx.save();
ctx.font = `${9 * dpr}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < n; i++) {
const key = p.traces[i];
const color = getSigStyle(key).color;
// Band vertical extent in physical (canvas) pixels topPx < botPx.
const topPx = u.valToPos(4 - i * bandH, 'y', true);
const botPx = u.valToPos(4 - (i + 1) * bandH, 'y', true);
const bandPx = botPx - topPx; // positive, increases downward
const centerPx = (topPx + botPx) / 2;
// Use the last two dot-separated components of the signal name (no source prefix).
const rawName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
const parts = rawName.split('.');
const label = parts.length > 2 ? parts.slice(-2).join('.') : rawName;
ctx.save();
ctx.fillStyle = color;
// Clip to this band's column in the axis strip so long labels don't bleed.
ctx.beginPath();
ctx.rect(bbox.left - axisW, topPx + 1, axisW, bandPx - 2);
ctx.clip();
// Rotate around the label's centre point.
ctx.translate(xCenter, centerPx);
ctx.rotate(-Math.PI / 2); // reads bottom-to-top
ctx.fillText(label, 0, 0);
ctx.restore();
}
ctx.restore();
}
// Draw horizontal separator lines between signal bands in digital/mixed mode.
function drawBandSeparators(u, p) {
if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return;
const n = p.traces.length;
if (n < 2) return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
const bandH = 8 / n;
ctx.save();
ctx.strokeStyle = 'rgba(127,132,156,0.55)';
ctx.lineWidth = dpr;
for (let i = 1; i < n; i++) {
const yNorm = 4 - i * bandH; // boundary between band i-1 and band i
const yPx = u.valToPos(yNorm, 'y', true);
ctx.beginPath();
ctx.moveTo(bbox.left, yPx);
ctx.lineTo(bbox.left + bbox.width, yPx);
ctx.stroke();
}
ctx.restore();
}
// Redraw the active signal's line on top of all series with a wider stroke, so it
// visually appears in the foreground regardless of series draw order.
function drawActiveSeries(u, p) {
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const activeKey = plotActiveSignal[p.id];
if (!activeKey) return;
const idx = p.traces.indexOf(activeKey);
if (idx < 0) return;
const xs = u.data[0];
const ys = u.data[idx + 1]; // +1 because index 0 is time
if (!xs || !ys) return;
const style = getSigStyle(activeKey);
const dpr = window.devicePixelRatio || 1;
const { ctx } = u;
ctx.save();
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width * 2 * dpr;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
let started = false;
for (let i = 0; i < xs.length; i++) {
const yv = ys[i];
if (yv == null || !isFinite(yv)) { started = false; continue; }
const xPx = u.valToPos(xs[i], 'x', true);
const yPx = u.valToPos(yv, 'y', true);
if (!started) { ctx.moveTo(xPx, yPx); started = true; }
else ctx.lineTo(xPx, yPx);
}
ctx.stroke();
ctx.restore();
}
// Draw offset position markers (right-pointing triangles) on the left edge of the plot
// for each signal. Active signal marker is larger and outlined in white.
function drawOffsetMarkers(u, p) {
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
p.traces.forEach(key => {
const vs = sigVScale[p.id + ':' + key];
const screenPos = vs ? (vs.screenPos || 0) : 0;
const yCtr = u.valToPos(screenPos, 'y', true);
const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr;
const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr;
if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
const isActive = plotActiveSignal[p.id] === key;
ctx.save();
ctx.fillStyle = getSigStyle(key).color;
// Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area
ctx.beginPath();
ctx.moveTo(bbox.left + dpr, yCtr);
ctx.lineTo(bbox.left - mW + dpr, yCtr - mH);
ctx.lineTo(bbox.left - mW + dpr, yCtr + mH);
ctx.closePath();
ctx.fill();
if (isActive) {
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
ctx.lineWidth = dpr;
ctx.stroke();
}
ctx.restore();
});
}
// Draw custom marker shapes (square, cross, diamond) for all series in a plot.
// Circle markers are handled natively by uPlot's points option.
function drawSeriesMarkers(u, p) {
if (!u.bbox) return;
const { ctx, bbox } = u;
ctx.save();
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
p.traces.forEach((key, idx) => {
const style = getSigStyle(key);
if (style.marker === 'none' || style.marker === 'circle') return;
const si = idx + 1;
const xd = u.data[0], yd = u.data[si];
if (!xd || !yd) return;
const sz = style.markerSize;
ctx.strokeStyle = style.color;
ctx.fillStyle = style.color;
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
for (let i = 0; i < xd.length; i++) {
if (yd[i] == null) continue;
const cx = u.valToPos(xd[i], 'x', true);
const cy = u.valToPos(yd[i], 'y', true);
if (cx < bbox.left || cx > bbox.left + bbox.width ||
cy < bbox.top || cy > bbox.top + bbox.height) continue;
ctx.beginPath();
if (style.marker === 'square') {
ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill();
} else if (style.marker === 'cross') {
ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy);
ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2);
ctx.stroke();
} else if (style.marker === 'diamond') {
ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy);
ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy);
ctx.closePath(); ctx.fill();
}
}
});
ctx.restore();
}
// Draw cursor A/B vertical lines and signal value labels (called from draw hook).
function drawCursorLines(u, p) {
if (cursors.mode !== 'on') return;
const { ctx, bbox } = u;
if (!bbox) return;
const drawLine = (val, color, label) => {
if (val === null) return;
const x = u.valToPos(val, 'x', true);
if (x < bbox.left || x > bbox.left + bbox.width) return;
const px = Math.round(x);
ctx.save();
// Clip to plot area
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
// Vertical dashed cursor line
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(px, bbox.top);
ctx.lineTo(px, bbox.top + bbox.height);
ctx.stroke();
ctx.setLineDash([]);
// Cursor label at top
ctx.fillStyle = color;
ctx.font = 'bold 12px monospace';
ctx.textBaseline = 'top';
ctx.fillText(label, px + 14, bbox.top + 2);
// Per-trace: diamond at crossing point + value label
if (p) {
const activeKey = plotActiveSignal[p.id];
p.traces.forEach((key, idx) => {
const isActive = key === activeKey || (!activeKey && p.traces.length === 1);
const DSZ = isActive ? 9 : 5; // diamond half-size in px
const vNorm = interpAtTime(u, idx + 1, val);
if (vNorm === null) return;
const cy = u.valToPos(vNorm, 'y', true);
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
// Un-transform normalized value back to real units for display
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
const vs = sigVScale[p.id + ':' + key];
let vReal = vNorm;
if (vs) {
const dv = vs._resolvedDiv || vs.divValue || 1;
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const sp = vs.screenPos || 0;
vReal = (vNorm - sp) * dv + ofs;
}
const tc = getSigStyle(key).color;
// Diamond marker at intersection
ctx.fillStyle = tc;
ctx.strokeStyle = tc;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px, cy - DSZ);
ctx.lineTo(px + DSZ, cy);
ctx.lineTo(px, cy + DSZ);
ctx.lineTo(px - DSZ, cy);
ctx.closePath();
ctx.fill();
// Value text next to diamond (real units)
const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString();
ctx.fillStyle = tc;
ctx.font = '11px monospace';
const currentAlign = ctx.textAlign;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(str, px + DSZ + 4, cy);
ctx.textAlign = currentAlign;
});
}
ctx.restore();
};
drawLine(cursors.tA, 'rgba(137,220,235,0.85)', 'A');
drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
}
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p,
// so no live source shows a blank right edge.
// Sources whose newest timestamp lags the fastest source by more than windowSec are
// considered stale (disconnected / from a previous session) and are excluded, so they
// cannot anchor the rolling window far in the past.
function computePlotNow(p) {
const sourceNewest = {};
p.traces.forEach(key => {
const colon = key.indexOf(':');
if (colon < 0) return;
const srcId = key.slice(0, colon);
const buf = buffers[key];
if (!buf || buf.size === 0) return;
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t;
});
const srcVals = Object.values(sourceNewest);
if (srcVals.length === 0) return Date.now() / 1000;
const globalMax = Math.max(...srcVals);
// Keep only sources that have received data within the last windowSec.
const active = srcVals.filter(t => t >= globalMax - windowSec);
let now = active.length > 0 ? Math.min(...active) : globalMax;
if (!isFinite(now)) now = Date.now() / 1000;
return now;
}
// Build uPlot opts for a given plot object
function makeUPlotOpts(p, inTrigMode) {
const isBanded = p.mode === 'digital' || p.mode === 'mixed';
const seriesArr = [{}]; // time (index 0)
p.traces.forEach(key => {
const style = getSigStyle(key);
const pathsFn = isBanded ? null : makeSeriesPath(key);
const vs = isBanded ? getVScale(p.id, key) : null;
const isDigSig = p.mode === 'digital' || (p.mode === 'mixed' && vs && vs.digitalInMixed);
seriesArr.push({
label: key,
stroke: style.color,
width: isBanded ? 1.5 : style.width,
points: { show: false },
spanGaps: !isDigSig,
...(pathsFn ? { paths: pathsFn } : {}),
});
});
const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals)
: (u, vals) => fmtLiveTick(u, vals);
return {
width: Math.max(p.div.clientWidth || 100, 50),
height: Math.max(p.div.clientHeight || 100, 50),
cursor: {
sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false },
drag: { x: true, y: false, setScale: true, uni: 20 },
lock: false,
},
select: { show: true },
scales: {
x: (() => {
let xMin, xMax;
if (p.xRange) {
xMin = p.xRange[0]; xMax = p.xRange[1];
} else {
const now = computePlotNow(p);
xMin = now - windowSec; xMax = now;
}
return { time: false, auto: false, min: xMin, max: xMax };
})(),
y: { auto: false, min: -4.5, max: 4.5 },
},
series: seriesArr,
axes: [
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
values: xVals, size: 36,
// Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines).
splits: (u, _ai, sMin, sMax) => {
const n = 10, span = sMax - sMin;
if (span === 0) return [sMin];
return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n);
},
},
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
// In digital/mixed mode signal names are drawn as rotated text by drawBandLabels,
// so we only need a narrow strip (20 px) instead of the wide label column (60 px).
size: isBanded ? 20 : 60,
splits: () => {
if (isBanded && p.traces.length > 0) {
const n = p.traces.length, bandH = 8 / n;
return p.traces.map((_, i) => 4 - (i + 0.5) * bandH);
}
return [-4, -3, -2, -1, 0, 1, 2, 3, 4];
},
values: (u, vals) => {
if (isBanded && p.traces.length > 0) {
// Labels are drawn manually as rotated text; suppress uPlot's horizontal rendering.
return vals.map(() => '');
}
const activeKey = plotActiveSignal[p.id];
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
const divValue = vs._resolvedDiv || vs.divValue || 1;
const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const screenPos = vs.screenPos || 0;
return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset));
},
},
],
legend: { show: false },
padding: [4, 4, 0, 0],
hooks: {
draw: [u => { drawBandLabels(u, p); drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
// All programmatic setScale calls happen without a preceding setSelect, so the
// flag is false and onZoom is never called unintentionally.
setSelect: [(u) => { u._userZoom = (u.select.width > 0); }],
setScale: [(u, key) => {
if (key !== 'x' || !u._userZoom || !u._ready) return;
u._userZoom = false;
const { min, max } = u.scales.x;
if (min == null || max == null || max <= min) return;
onZoom(p.id, min, max);
}],
ready: [u => { u._ready = true; }],
},
};
}
// Create (or recreate) the uPlot instance for a plot, mounting into p.div
function createUPlot(p) {
// Destroy previous instance
if (p.uplot) { p.uplot.destroy(); p.uplot = null; }
const inTrigMode = trig.enabled && trig.snapshot !== null;
const opts = makeUPlotOpts(p, inTrigMode);
const data = buildUPlotData(p, inTrigMode);
// Mount into the plot body div (clear previous uPlot DOM)
p.div.querySelectorAll('.uplot').forEach(el => el.remove());
p.uplot = new uPlot(opts, data, p.div);
// ── Cursor drag: place or drag A/B cursors ──────────────────────────────
// - Near an existing cursor line (within CURSOR_SNAP_PX): drag to move it.
// - cursor mode A or B active: click/drag places that cursor anywhere.
// - Intercepts mousedown before uPlot zoom so the selection rect never shows.
const CURSOR_SNAP_PX = 8;
function _cursorAtClientX(clientX) {
const rect = p.uplot.over.getBoundingClientRect();
const { min, max } = p.uplot.scales.x;
const toX = val => rect.left + ((val - min) / (max - min)) * rect.width;
if (cursors.tA !== null && Math.abs(clientX - toX(cursors.tA)) <= CURSOR_SNAP_PX) return 'A';
if (cursors.tB !== null && Math.abs(clientX - toX(cursors.tB)) <= CURSOR_SNAP_PX) return 'B';
return null;
}
function _cursorValFromEvent(e) {
const rect = p.uplot.over.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const { min, max } = p.uplot.scales.x;
return min + pct * (max - min);
}
// Update pointer style based on what's under the mouse
p.uplot.over.addEventListener('mousemove', e => {
const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
p.uplot.over.style.cursor = snap ? 'ew-resize' : '';
});
p.uplot.over.addEventListener('mouseleave', () => {
p.uplot.over.style.cursor = '';
});
// Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line).
// If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior.
p.uplot.over.addEventListener('mousedown', e => {
if (e.button !== 0 || e.shiftKey) return; // shift is pan
if (cursors.mode !== 'on') return;
const target = _cursorAtClientX(e.clientX);
if (!target) return; // not near a cursor — let uPlot handle zoom
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
e.preventDefault();
// Set cursor position immediately on mousedown
if (target === 'A') cursors.tA = _cursorValFromEvent(e);
else cursors.tB = _cursorValFromEvent(e);
updateCursorReadout();
cursorsDirty = true;
const onMove = ev => {
if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
else cursors.tB = _cursorValFromEvent(ev);
updateCursorReadout();
cursorsDirty = true;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, true); // capture:true so we fire before uPlot's own handlers
// ── Offset marker drag ─────────────────────────────────────────────────────
// Detect mousedown near the left edge of the plot area (marker triangle zone).
// Dragging moves the marker AND the signal together by changing screenPos.
p.div.addEventListener('mousedown', e => {
if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return;
const canvas = p.uplot.ctx.canvas;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const plotLeftCss = rect.left + p.uplot.bbox.left / dpr;
const markerZone = 12; // CSS px hit area to left/right of plot edge
if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return;
// Find which marker was hit (closest to screenPos canvas position per signal).
let hitKey = null, hitDist = Infinity;
p.traces.forEach(key => {
const vs = sigVScale[p.id + ':' + key];
const screenPos = vs ? (vs.screenPos || 0) : 0;
const yDev = p.uplot.valToPos(screenPos, 'y', true);
const yCss = rect.top + yDev / dpr;
const dist = Math.abs(e.clientY - yCss);
if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; }
});
if (!hitKey) return;
e.preventDefault();
e.stopPropagation();
setActiveSig(p.id, hitKey);
const vs = getVScale(p.id, hitKey);
const startY = e.clientY;
const startScreenPos = vs.screenPos || 0;
const overRect = p.uplot.over.getBoundingClientRect();
const onMove = ev => {
const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm
// Y scale spans 9 divisions over plot height; drag up → higher screenPos.
const dNorm = -dy / overRect.height * 9;
vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm));
// Keep "Position" input in sync if the vscale menu is open for this signal.
if (_vsMenuKey === hitKey) {
document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4));
}
refreshPlotForKey(hitKey);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, true);
// Pan support: Shift+left-drag pans the current view (synced across all plots).
// Works in both zoomed mode (xRange set) and rolling mode (freezes the window first).
let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0;
p.uplot.over.addEventListener('mousedown', e => {
if (e.button !== 0 || !e.shiftKey) return;
e.stopImmediatePropagation();
e.preventDefault();
_panActive = true;
_panAnchorX = e.clientX;
const xr = p.xRange;
if (xr) {
_panAnchorMin = xr[0];
_panAnchorMax = xr[1];
} else {
// Rolling mode: capture the current window position and freeze it so we
// have a stable anchor to pan from.
let now = -Infinity;
p.traces.forEach(key => {
const buf = buffers[key];
if (buf && buf.size > 0) {
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > now) now = t;
}
});
if (!isFinite(now)) now = Date.now() / 1000;
_panAnchorMin = now - windowSec;
_panAnchorMax = now;
// Freeze all plots at this position immediately.
const pMin = _panAnchorMin, pMax = _panAnchorMax;
zoomGuard = true;
plots.forEach(q => {
q.xRange = [pMin, pMax];
if (q.uplot) q.uplot.setScale('x', { min: pMin, max: pMax });
});
zoomGuard = false;
if (!syncLocked) {
syncLocked = true;
const btnR = document.getElementById('btn-sync-resume');
if (btnR) btnR.style.display = '';
}
}
}, true);
const _onPanMove = e => {
if (!_panActive || !p.uplot) return;
const w = p.uplot.over.getBoundingClientRect().width;
const span = _panAnchorMax - _panAnchorMin;
const dt = -((e.clientX - _panAnchorX) / w) * span;
const newMin = _panAnchorMin + dt;
const newMax = _panAnchorMax + dt;
zoomGuard = true;
plots.forEach(q => {
q.xRange = [newMin, newMax];
if (q.uplot) q.uplot.setScale('x', { min: newMin, max: newMax });
q.needsRedraw = true;
});
zoomGuard = false;
};
const _onPanEnd = () => { _panActive = false; };
document.addEventListener('mousemove', _onPanMove);
document.addEventListener('mouseup', _onPanEnd);
// Resize observer so the uPlot fills its container.
// zoomGuard prevents setSize → setScale → hook from calling onZoom.
if (p.ro) { p.ro.disconnect(); }
p.ro = new ResizeObserver(() => {
if (!p.uplot) return;
const w = Math.max(p.div.clientWidth || 50, 50);
const h = Math.max(p.div.clientHeight || 50, 50);
zoomGuard = true;
p.uplot.setSize({ width: w, height: h });
zoomGuard = false;
});
p.ro.observe(p.div);
}
// Build the uPlot data array from buffers / trigger snapshot
function buildUPlotData(p, inTrigMode) {
if (p.traces.length === 0) return [new Float64Array(0)];
// When trigger is enabled but no snapshot yet (armed/waiting), return
// the last-rendered data so the plot stays frozen.
if (trig.enabled && !inTrigMode) {
if (!p.uplot || !p.uplot.data || !p.uplot.data[0]) return [new Float64Array(0)];
return p.uplot.data;
}
if (inTrigMode && trig.snapshot) return buildTrigData(p);
return buildLiveData(p);
}
// Resample (vSrc) from times (tSrc) onto target times (tDst) using linear interpolation.
// tSrc must be sorted ascending. Values outside tSrc range are clamped to the nearest
// endpoint (extrapolation is not safe for streaming data).
function resampleLinear(tSrc, vSrc, tDst) {
const n = tDst.length;
const out = new Float64Array(n);
if (tSrc.length === 0) return out; // all zeros
if (tSrc.length === 1) { out.fill(vSrc[0]); return out; }
let j = 0;
for (let i = 0; i < n; i++) {
const td = tDst[i];
// Advance j so that tSrc[j] <= td < tSrc[j+1] (or j at last index)
while (j < tSrc.length - 2 && tSrc[j + 1] < td) j++;
if (td <= tSrc[0]) {
out[i] = vSrc[0];
} else if (td >= tSrc[tSrc.length - 1]) {
out[i] = vSrc[vSrc.length - 1];
} else {
const t0 = tSrc[j], t1 = tSrc[j + 1];
const frac = (td - t0) / (t1 - t0);
out[i] = vSrc[j] + frac * (vSrc[j + 1] - vSrc[j]);
}
}
return out;
}
function buildLiveData(p) {
if (p.traces.length === 0) return [new Float64Array(0)];
const plotNow = computePlotNow(p);
const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec;
const t1 = p.xRange ? p.xRange[1] : plotNow;
const isRolling = !p.xRange;
// When zoomed, prefer server-fetched hi-res data if it covers this exact range.
if (p.xRange) {
const zd = zoomData[p.id];
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
// Only use server data if it actually has samples; otherwise fall through to local buffer.
if (fetched[0] && fetched[0].length > 0) return fetched;
}
}
// Slice all traces; pick master by time span (widest window wins), then
// by sampling rate, then by point count. A fast signal that has only 0.5s
// of history must NOT beat a slow signal that has 5s of history — otherwise
// sharedT covers only 0.5s and all other signals appear truncated.
// In zoom mode, include one bracketing point on each side so lines are drawn
// across the visible area even when the window contains 0 or 1 samples.
const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets;
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1, masterSpan = -1;
for (const key of p.traces) {
const buf = buffers[key];
if (!buf || buf.size === 0) continue;
const sl = sliceFn(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
const span = sl.t.length > 1 ? sl.t[sl.t.length - 1] - sl.t[0] : 0;
if (span > masterSpan ||
(span === masterSpan && rate > masterRate) ||
(span === masterSpan && rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterSpan = span; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// Decimate to pixel-adaptive point count in both rolling and zoomed modes.
// The server sends ≤2000 pts/tick but the buffer accumulates many ticks, so
// the full window slice can easily reach 100k300k pts — far more than uPlot
// needs for a 1200px-wide canvas. Always run LTTB via the background worker
// (stale-while-revalidate: use cached result; fall back to sync on first render).
// Rolling-mode cache key uses _dataGen so the result refreshes on new data.
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const cacheKey = isRolling
? `${p.id}:${masterKey}:rolling:${_dataGen}`
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
let sharedT, masterV;
if (masterRaw.t.length <= targetPts) {
// Data already sparse enough — use directly (no decimation needed).
sharedT = masterRaw.t;
masterV = masterRaw.v;
} else {
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
let dec;
if (cached) {
dec = cached;
} else {
// Worker job submitted — sync fallback this frame so the plot isn't blank.
dec = lttb(masterRaw.t, masterRaw.v, targetPts);
}
sharedT = dec.t;
masterV = dec.v;
}
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(masterV); continue; }
const sl = slices[key];
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
}
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
function buildTrigData(p) {
const trigT = trig.trigTime;
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
if (p.traces.length === 0) return [new Float64Array(0)];
const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
// Slice all traces; pick master by samplingRate first, then sample count
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const snap = trig.snapshot[key];
if (!snap) continue;
const sl = sliceTypedArrayRange(snap.t, snap.v, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// Trig snapshot is fixed (no new data arrives after capture), so the cache
// key only needs range + data length. Worker result persists until re-arm.
const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
const cachedDec = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
const dec = cachedDec || lttb(masterRaw.t, masterRaw.v, targetPts);
// Convert absolute → relative seconds
const sharedT = new Float64Array(dec.t.length);
for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT;
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
const sl = slices[key];
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
const relT = new Float64Array(sl.t.length);
for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT;
yArrays.push(resampleLinear(relT, sl.v, sharedT));
}
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
/* ════════════════════════════════════════════════════════════════
Zoom sync
════════════════════════════════════════════════════════════════ */
let syncLocked = false;
function onZoom(sourcePlotId, min, max) {
// Push current range to history before applying new zoom
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
zoomHistory.push(prevRange);
if (zoomHistory.length > 30) zoomHistory.shift();
document.getElementById('btn-zoom-back').style.display = '';
// Store zoom on source plot
const src = plots.find(p => p.id === sourcePlotId);
if (src) src.xRange = [min, max];
// Show Auto button in live mode
if (!trig.enabled && !syncLocked) {
syncLocked = true;
document.getElementById('btn-sync-resume').style.display = '';
}
// Propagate to other plots
zoomGuard = true;
plots.forEach(p => {
if (p.id === sourcePlotId) return;
p.xRange = [min, max];
if (p.uplot) p.uplot.setScale('x', { min, max });
});
zoomGuard = false;
// Evict stale LTTB cache entries — new zoom range needs fresh decimation.
plots.forEach(p => lttbCacheEvict(p.id));
// Mark all plots dirty (re-slice data to the new range for full resolution)
plots.forEach(p => { p.needsRedraw = true; });
// Schedule hi-res fetch from ring buffers.
scheduleZoomFetch(min, max);
}
// Undo last zoom/pan action
function zoomBack() {
if (!zoomHistory.length) return;
const prev = zoomHistory.pop();
if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none';
// Discard stale zoom data regardless of direction.
Object.keys(zoomData).forEach(k => delete zoomData[k]);
cancelZoomFetch();
if (prev === null) {
// Was at auto/rolling state before the zoom
resetZoom();
} else {
zoomGuard = true;
plots.forEach(p => {
p.xRange = [...prev];
if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] });
p.needsRedraw = true;
});
zoomGuard = false;
scheduleZoomFetch(prev[0], prev[1]);
}
}
// Reset to auto/rolling window (clears all zoom)
function resetZoom() {
Object.keys(zoomData).forEach(k => delete zoomData[k]);
cancelZoomFetch();
syncLocked = false;
document.getElementById('btn-sync-resume').style.display = 'none';
if (trig.enabled && trig.snapshot) {
const preS = trig.snapshot._preS || trigPreSec();
const postS = trig.snapshot._postS || trigPostSec();
zoomGuard = true;
plots.forEach(p => {
p.xRange = null;
if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS });
p.needsRedraw = true;
});
zoomGuard = false;
} else {
// Back to rolling window — setScale to current window, render loop keeps it moving
plots.forEach(p => {
p.xRange = null;
if (!globalPause) p.needsRedraw = true;
});
}
}
// Fit x-axis to all data currently in buffers (or full trigger snapshot)
function zoomFit() {
if (trig.enabled && trig.snapshot) {
resetZoom(); // "Fit" in trigger mode = show full trigger window
return;
}
// Find oldest/newest timestamps across all visible signals
let gMin = Infinity, gMax = -Infinity;
plots.forEach(p => {
p.traces.forEach(key => {
const buf = buffers[key]; if (!buf || buf.size === 0) return;
const startIdx = (buf.size === buf.cap) ? buf.head : 0;
const oldestT = buf.t[startIdx];
const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (oldestT < gMin) gMin = oldestT;
if (newestT > gMax) gMax = newestT;
});
});
if (!isFinite(gMin) || gMin >= gMax) return;
// Push to history
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
zoomHistory.push(prevRange);
document.getElementById('btn-zoom-back').style.display = '';
if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; }
zoomGuard = true;
plots.forEach(p => {
p.xRange = [gMin, gMax];
if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax });
p.needsRedraw = true;
});
zoomGuard = false;
scheduleZoomFetch(gMin, gMax);
}
// Auto = return to rolling window / full trigger window
function exitSyncLock() { resetZoom(); }
document.getElementById('btn-sync-resume').addEventListener('click', resetZoom);
document.getElementById('btn-zoom-back').addEventListener('click', zoomBack);
document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
/* ════════════════════════════════════════════════════════════════
Cursor controls
════════════════════════════════════════════════════════════════ */
// Cursor button is always visible; readout visibility follows cursors.mode.
function updateCursorBtnVisibility() {
document.getElementById('btn-cursor').style.display = '';
}
document.getElementById('btn-cursor').addEventListener('click', () => {
cursors.mode = cursors.mode === 'off' ? 'on' : 'off';
const btn = document.getElementById('btn-cursor');
btn.textContent = 'Cursors';
btn.classList.toggle('active', cursors.mode === 'on');
if (cursors.mode === 'on') {
// Auto-place at 25%/75% of current x range on first use
if (cursors.tA === null && cursors.tB === null) {
const refPlot = plots.find(p => p.uplot);
if (refPlot) {
const { min, max } = refPlot.uplot.scales.x;
const span = max - min;
cursors.tA = min + span * 0.25;
cursors.tB = min + span * 0.75;
}
}
updateCursorReadout();
document.getElementById('cursor-readout').classList.add('visible');
} else {
document.getElementById('cursor-readout').classList.remove('visible');
}
cursorsDirty = true;
});
// Format a signal value for the per-plot cursor readout.
function fmtVal(v) {
if (v === null || v === undefined) return '—';
return Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString();
}
// Interpolate the real-unit value of the active/sole signal in plot p at time t.
function getValueAtCursor(p, t) {
if (!p.uplot || t === null) return null;
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
if (!key) return null;
const idx = p.traces.indexOf(key);
if (idx < 0) return null;
const vNorm = interpAtTime(p.uplot, idx + 1, t);
if (vNorm === null) return null;
// Un-normalize: y_norm = (y_raw - offset) / divValue + screenPos
const vs = sigVScale[p.id + ':' + key];
if (!vs) return vNorm;
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const sp = vs.screenPos || 0;
return (vNorm - sp) * dv + ofs;
}
// Update per-plot cursor value readouts (A, B, ΔV) for all plots.
function updatePlotCursorReadouts() {
plots.forEach(p => {
const el = document.getElementById('pcur-' + p.id);
if (!el) return;
// Show only when cursors are on and the plot has an active or sole signal.
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
if (cursors.mode !== 'on' || !key || !p.uplot) {
el.style.display = 'none';
return;
}
const vA = getValueAtCursor(p, cursors.tA);
const vB = getValueAtCursor(p, cursors.tB);
const dv = (vA !== null && vB !== null) ? vB - vA : null;
document.getElementById('pcur-a-' + p.id).textContent = 'A: ' + fmtVal(vA);
document.getElementById('pcur-b-' + p.id).textContent = 'B: ' + fmtVal(vB);
document.getElementById('pcur-dv-' + p.id).textContent = 'ΔV: ' + fmtVal(dv);
el.style.display = 'flex';
});
}
function updateCursorReadout() {
const ro = document.getElementById('cursor-readout');
const active = cursors.mode === 'on';
ro.classList.toggle('visible', active);
updatePlotCursorReadouts();
if (!active) return;
// Use the current visible x-range to pick the display unit.
const span = currentXSpan();
// Format a cursor position: trigger mode → signed relative duration;
// live mode → absolute wall time with span-appropriate precision.
const fmt = v => {
if (v === null) return '—';
if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true);
return fmtLiveTime(v, span);
};
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
if (cursors.tA !== null && cursors.tB !== null) {
const dt = cursors.tB - cursors.tA;
// ΔT auto-scales by its own magnitude for precision regardless of x-range.
document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true);
} else {
document.getElementById('cur-dt').textContent = 'ΔT: —';
}
}
/* ════════════════════════════════════════════════════════════════
Trigger bar controls
════════════════════════════════════════════════════════════════ */
function openTrigBar(open) {
trig.enabled = open;
document.getElementById('trigbar').classList.toggle('open', open);
document.getElementById('btn-trigger').classList.toggle('trig-active', open);
document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px');
// Streaming-only controls are irrelevant while the trigger is active
const none = open ? 'none' : '';
document.getElementById('btn-pause-global').style.display = none;
document.getElementById('window-select').style.display = none;
document.getElementById('lbl-window').style.display = none;
if (!open) {
trigDisarm(); trig.snapshot = null;
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
} else {
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
resetZoom();
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
}
// Resize uPlot instances after trigbar height changes
setTimeout(() => {
plots.forEach(p => {
if (!p.uplot) return;
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
});
}, 220);
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
const val = e.target.value;
if (!val) { trig.signal = ''; trigDisarm(); return; }
// All signals (including non-temporal arrays) now use the base key.
// Array signals get a snapshot waveform buffer; the trigger fires on
// the last-pushed element value.
trig.signal = val; trig.prevVal = null;
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); });
document.getElementById('trig-pre').addEventListener('input', e => {
trig.prePercent = parseInt(e.target.value, 10);
document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
});
document.getElementById('trig-mode').addEventListener('change', e => {
trig.mode = e.target.value;
// If a snapshot already exists the trigger has already fired — update button
// visibility immediately so it matches the newly selected mode.
if (trig.snapshot) {
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
updateStopBtn();
}
});
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
document.getElementById('btn-trig-stop').addEventListener('click', () => {
if (!trig.enabled || trig.mode !== 'normal') return;
trig.stopped = !trig.stopped;
updateStopBtn();
if (!trig.stopped) trigArm(); // resume: re-arm immediately
});
/* ════════════════════════════════════════════════════════════════
Trigger signal selector
════════════════════════════════════════════════════════════════ */
function buildTrigSignalSelect() {
const sel = document.getElementById('trig-signal');
// Preserve the currently active trig.signal (may include an array index like "[3]").
const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
sel.innerHTML = '<option value="">— none —</option>';
Object.values(sourcesMap).forEach(src => {
const prefix = src.id + ':';
const srcLabel = src.label || src.addr || src.id;
(src.signals || []).forEach(sig => {
const n = numElements(sig);
const key = prefix + sig.name;
const o = document.createElement('option');
o.value = key;
o.textContent = srcLabel + ': ' + sig.name + (n > 1 ? ' [0…' + (n - 1) + ']' : '');
sel.appendChild(o);
});
});
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
}
/* ════════════════════════════════════════════════════════════════
Sidebar
════════════════════════════════════════════════════════════════ */
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
function buildSidebar() {
const list = document.getElementById('signal-list');
list.innerHTML = '';
const sources = Object.values(sourcesMap);
sources.forEach(src => {
const sigs = src.signals || [];
const prefix = src.id + ':';
// Source header
const grp = document.createElement('div');
grp.className = 'source-group';
const hdr = document.createElement('div');
hdr.className = 'source-group-header';
const dot = document.createElement('span');
dot.className = 'source-state-dot ' + (src.state || 'disconnected');
const nameEl = document.createElement('span');
nameEl.className = 'source-name';
nameEl.textContent = src.label || src.addr || src.id;
nameEl.title = src.addr || '';
const addrEl = document.createElement('span');
addrEl.className = 'source-addr';
if (src.label && src.addr) addrEl.textContent = src.addr;
const rmBtn = document.createElement('button');
rmBtn.className = 'source-remove-btn';
rmBtn.title = 'Remove source';
rmBtn.textContent = '×';
rmBtn.addEventListener('click', () => removeSource(src.id));
hdr.append(dot, nameEl, addrEl, rmBtn);
grp.appendChild(hdr);
sigs.forEach(sig => {
const n = numElements(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
if (n === 1) {
grp.appendChild(makeDraggable(globalKey, sig.name, typeName, sig.unit || ''));
} else {
// Both temporal and snapshot-waveform (non-temporal) arrays are dragged
// as a whole signal (base key). Temporal: plot vs time; snapshot: plot vs element index.
grp.appendChild(makeDraggable(globalKey, sig.name, '[' + n + '] ' + typeName, sig.unit || ''));
}
});
list.appendChild(grp);
});
if (!sources.length) {
const empty = document.createElement('div');
empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
empty.textContent = 'No sources configured';
list.appendChild(empty);
}
list.appendChild(makeAddSourceSection());
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
item.className = 'sig-item'; item.draggable = true;
item.innerHTML = '<span class="sig-name">' + escHtml(label) + '</span>'
+ (unit ? '<span class="sig-unit">' + escHtml(unit) + '</span>' : '')
+ '<span class="type-badge">' + escHtml(typeName) + '</span>';
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
requestAnimationFrame(() => item.classList.add('dragging'));
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
return item;
}
/* ════════════════════════════════════════════════════════════════
Grid resize handles
════════════════════════════════════════════════════════════════ */
function updateGridTemplate() {
const grid = document.getElementById('plot-grid');
grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' ');
grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' ');
// Reposition all handles
document.querySelectorAll('.resize-handle-v').forEach(h => {
const i = parseInt(h.dataset.col);
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
document.querySelectorAll('.resize-handle-h').forEach(h => {
const i = parseInt(h.dataset.row);
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
// Notify uPlot of new sizes
plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); });
}
function setupResizeHandles(cols, rows) {
const grid = document.getElementById('plot-grid');
grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove());
const makeHandle = (cls, dataset, onDown) => {
const h = document.createElement('div');
h.className = cls;
Object.assign(h.dataset, dataset);
h.addEventListener('mousedown', onDown);
grid.appendChild(h);
return h;
};
for (let i = 0; i < cols - 1; i++) {
const h = makeHandle('resize-handle-v', { col: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = colFrs.reduce((a, b) => a + b, 0);
const pairSum = colFrs[i] + colFrs[i + 1];
const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientX - rect.left) / rect.width;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore));
colFrs[i] = newI; colFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
for (let i = 0; i < rows - 1; i++) {
const h = makeHandle('resize-handle-h', { row: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = rowFrs.reduce((a, b) => a + b, 0);
const pairSum = rowFrs[i] + rowFrs[i + 1];
const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientY - rect.top) / rect.height;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore));
rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
}
/* ════════════════════════════════════════════════════════════════
Plot config toolbar (click plot title to open)
════════════════════════════════════════════════════════════════ */
let _pcfgOpenId = null;
function showPlotCfg(plotId) {
if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg();
_pcfgOpenId = plotId;
document.getElementById('pcfg-' + plotId).style.display = 'block';
}
function hidePlotCfg() {
if (_pcfgOpenId == null) return;
const bar = document.getElementById('pcfg-' + _pcfgOpenId);
if (bar) bar.style.display = 'none';
_pcfgOpenId = null;
}
function initPlotCfgBar(plotId, p) {
const bar = document.getElementById('pcfg-' + plotId);
if (!bar) return;
const titleInput = bar.querySelector('.pcfg-title-input');
const titleEl = document.getElementById('ptitle-' + plotId);
titleInput.addEventListener('input', () => {
p.title = titleInput.value || ('Plot ' + plotId);
if (titleEl) titleEl.textContent = p.title;
});
bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const newMode = btn.dataset.mode;
if (p.mode === newMode) return;
p.mode = newMode;
bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode));
createUPlot(p);
p.needsRedraw = true;
});
});
bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg);
}
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
// Returns the number of plot cells in a layout (cols × rows).
function layoutPlotCount(cls) {
const m = cls.match(/^l(\d+)x(\d+)$/);
return m ? parseInt(m[1]) * parseInt(m[2]) : 1;
}
// Build a small SVG grid thumbnail for a given cols×rows layout.
function layoutSVG(cols, rows) {
const W = 28, H = 20, GAP = 1.5, PAD = 1.5;
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols;
const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows;
let rects = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = (PAD + c * (cw + GAP)).toFixed(1);
const y = (PAD + r * (ch + GAP)).toFixed(1);
rects += `<rect x="${x}" y="${y}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
+ `<rect width="${W}" height="${H}" rx="2" fill="#11111b"/>`
+ `<g fill="#45475a">${rects}</g></svg>`;
}
// Apply a new layout: switch the grid class and auto-add/remove plots.
// Plots with traces are preserved; empty plots are removed first when shrinking.
function applyLayout(cls) {
const entry = LAYOUTS.find(l => l[1] === cls);
if (!entry) return;
const [label, , cols, rows] = entry;
currentLayout = cls;
// Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied.
if (cols !== _gridCols || rows !== _gridRows) {
colFrs = Array(cols).fill(1);
rowFrs = Array(rows).fill(1);
_gridCols = cols; _gridRows = rows;
}
const grid = document.getElementById('plot-grid');
grid.className = cls; // keeps other grid CSS rules
updateGridTemplate(); // override template with current fr sizes
setupResizeHandles(cols, rows);
// Update button label
const btn = document.getElementById('btn-layout');
if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' <span>' + label + '</span> ▾';
// Update active state in menu
document.querySelectorAll('.layout-menu-item')
.forEach(el => el.classList.toggle('active', el.dataset.layout === cls));
const needed = layoutPlotCount(cls);
// Remove excess plots — prefer empty ones to preserve trace assignments.
while (plots.length > needed) {
let removeId = plots[plots.length - 1].id;
for (let i = plots.length - 1; i >= 0; i--) {
if (plots[i].traces.length === 0) { removeId = plots[i].id; break; }
}
deletePlot(removeId);
}
// Add missing plots (DOM only — uPlot created below after layout settles).
while (plots.length < needed) addPlot();
// Recreate all uPlot instances once the CSS grid has sized the cells.
requestAnimationFrame(() => {
plots.forEach(p => {
createUPlot(p);
p.needsRedraw = true;
});
setTimeout(() => plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
}), 60);
});
}
function buildLayoutMenu() {
const menu = document.getElementById('layout-menu');
LAYOUTS.forEach(([label, cls, cols, rows]) => {
const item = document.createElement('button');
item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : '');
item.dataset.layout = cls;
item.innerHTML = layoutSVG(cols, rows) + '<span>' + label + '</span>';
item.addEventListener('click', () => {
applyLayout(cls);
menu.classList.remove('open');
});
menu.appendChild(item);
});
// Toggle menu open/close
document.getElementById('btn-layout').addEventListener('click', e => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.top = (r.bottom + 4) + 'px';
menu.classList.toggle('open');
});
// Close when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('#layout-menu') && !e.target.closest('#btn-layout'))
menu.classList.remove('open');
});
}
/* ════════════════════════════════════════════════════════════════
Export CSV (all plots) — fetches full-resolution data from ring
════════════════════════════════════════════════════════════════ */
async function exportAllCSV() {
const btn = document.getElementById('btn-csv-all');
if (btn.disabled) return;
const inTrigMode = trig.enabled && trig.snapshot !== null;
// Collect unique signal keys across all plots (preserving order).
const keys = [];
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
if (!keys.length) return;
// Determine time range to export.
let t0, t1, relOffset = 0;
if (inTrigMode) {
// Export the full trigger window around the trigger event.
t0 = trig.trigTime - trigPreSec();
t1 = trig.trigTime + trigPostSec();
relOffset = trig.trigTime;
} else {
// Use the current zoom range if active, else the rolling window.
const refPlot = plots.find(p => p.xRange);
if (refPlot) {
[t0, t1] = refPlot.xRange;
} else {
let plotNow = -Infinity;
keys.forEach(k => {
const buf = buffers[k];
if (buf && buf.size > 0) {
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > plotNow) plotNow = t;
}
});
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
t0 = plotNow - windowSec;
t1 = plotNow;
}
}
// Show loading state.
const origLabel = btn.textContent;
btn.textContent = '⏳ Downloading…';
btn.disabled = true;
// Fetch full-resolution ring data (n=0 → no LTTB decimation).
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals = null;
try {
const resp = await fetch(url);
if (resp.ok) {
const json = await resp.json();
ringSignals = json && json.signals;
}
} catch (e) {
console.warn('CSV export: ring fetch failed, falling back to push buffer', e);
} finally {
btn.textContent = origLabel;
btn.disabled = false;
}
// Build per-signal time/value arrays.
// Priority: ring buffer (full res) → trigger snapshot → push buffer.
const slices = keys.map(key => {
const rd = ringSignals && ringSignals[key];
if (rd && rd.t && rd.t.length > 0) {
const t = rd.t, v = rd.v;
if (inTrigMode) {
return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) };
}
return { t: Array.from(t), v: Array.from(v) };
}
// Fallback: push buffer or trigger snapshot.
if (inTrigMode) {
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) };
}
const buf = buffers[key]; if (!buf) return { t: [], v: [] };
const sl = getBufferSliceRange(buf, t0, t1);
return { t: Array.from(sl.t), v: Array.from(sl.v) };
});
// Merge all timestamps and build aligned rows.
const allT = new Set();
slices.forEach(s => s.t.forEach(t => allT.add(t)));
const sortedT = Array.from(allT).sort((a, b) => a - b);
if (!sortedT.length) return;
const lookups = slices.map(s => {
const m = new Map();
s.t.forEach((t, i) => m.set(t, s.v[i]));
return m;
});
// Strip "sourceId:" prefix from column headers for readability.
const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
const rows = sortedT.map(t =>
[t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',')
);
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'signals_' + Date.now() + '.csv';
a.click();
URL.revokeObjectURL(a.href);
}
/* ════════════════════════════════════════════════════════════════
Plot management
════════════════════════════════════════════════════════════════ */
function addPlot() {
const id = nextPlotId++;
const card = document.createElement('div');
card.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = `
<div class="plot-card-header">
<span class="plot-title" id="ptitle-${id}" title="Click to configure">Plot ${id}</span>
<div class="sig-badges" id="badges-${id}"></div>
<div class="plot-cursor-ro" id="pcur-${id}" style="display:none">
<span class="pcur-a" id="pcur-a-${id}">A: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-b" id="pcur-b-${id}">B: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-dv" id="pcur-dv-${id}">ΔV: —</span>
</div>
</div>
<div class="plot-cfg-bar" id="pcfg-${id}" style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${id}" style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn active" data-mode="normal">Normal</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
</div>`;
card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', e => {
e.preventDefault(); card.classList.remove('drag-over');
const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key);
});
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id);
const p = { id, title: 'Plot ' + id, mode: 'normal', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
plots.push(p);
// Wire config toolbar
initPlotCfgBar(id, p);
card.querySelector('#ptitle-' + id).addEventListener('click', () => {
if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id);
});
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id;
}
function addTraceTo(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
if (p.traces.includes(signalKey)) return;
p.traces.push(signalKey);
document.querySelector('#hint-' + plotId).style.display = 'none';
addBadge(plotId, signalKey);
// Recreate uPlot with new series list
createUPlot(p);
p.needsRedraw = true;
updatePlotCursorReadouts();
}
function removeTraceFrom(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
p.traces = p.traces.filter(t => t !== signalKey);
removeBadge(plotId, signalKey);
// If the removed trace was active, close toolbar and pick a new active signal.
if (plotActiveSignal[plotId] === signalKey) {
if (_vsMenuPlotId === plotId) hideVScaleMenu();
const newActive = p.traces[0] || null;
if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId];
}
createUPlot(p);
p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
updatePlotCursorReadouts();
}
function addBadge(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return;
const color = getSigStyle(key).color;
const badge = document.createElement('span');
badge.className = 'sig-badge'; badge.dataset.key = key;
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
// Show signal name without the "sourceId:" prefix.
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
const nameSpan = document.createElement('span'); nameSpan.textContent = displayName;
// Small vscale info text (V/div + offset when not in auto mode).
const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info';
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); });
// Left-click: select + show vscale toolbar; click same signal again to deselect.
badge.addEventListener('click', e => {
if (e.target === x) return;
const isActive = plotActiveSignal[plotId] === key;
const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId;
if (isActive && toolbarVisible) {
setActiveSig(plotId, null);
hideVScaleMenu();
} else {
setActiveSig(plotId, key);
showVScaleMenu(key, plotId);
}
});
// Right-click: open signal style (color/width/…) menu.
badge.addEventListener('contextmenu', e => {
e.preventDefault();
showSignalMenu(key, plotId, e.clientX, e.clientY);
});
badge.appendChild(dot);
badge.appendChild(nameSpan);
badge.appendChild(infoSpan);
badge.appendChild(x);
c.appendChild(badge);
// Auto-activate the first signal added to this plot.
if (!plotActiveSignal[plotId]) setActiveSig(plotId, key);
}
function removeBadge(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (b) b.remove();
}
function deletePlot(plotId) {
const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return;
const p = plots[idx];
if (p.ro) p.ro.disconnect();
if (p.uplot) p.uplot.destroy();
lttbCacheEvict(plotId);
plots.splice(idx, 1);
document.querySelector('[data-plot-id="' + plotId + '"]').remove();
}
/* ════════════════════════════════════════════════════════════════
Render loop
════════════════════════════════════════════════════════════════ */
let _dbgTick = 0;
let _dataGen = 0; // incremented each time new data arrives
function renderDirtyPlots() {
// Compute global "now" once — shared by all rolling-window plots this frame.
const globalPlotNow = getGlobalNow();
// Lightweight diagnostic: every ~10 s log buffer count
if (++_dbgTick % 300 === 0) {
let bufCount = 0, totalPts = 0;
Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } });
if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`);
}
// Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen
// zoom window) and clear it back to rolling. Run unconditionally before the
// data-rebuild loop so the stale plot is correctly handled this frame.
if (!trig.enabled && !globalPause) {
plots.forEach(p => {
if (!p.uplot || !p.xRange) return;
const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0);
if (!hasData) return;
const stale = p.traces.every(key => {
const buf = buffers[key];
if (!buf || buf.size === 0) return true;
const oldest = buf.t[buf.size === buf.cap ? buf.head : 0];
return p.xRange[1] < oldest;
});
if (stale) {
p.xRange = null;
syncLocked = false;
const btnR = document.getElementById('btn-sync-resume');
if (btnR) btnR.style.display = 'none';
}
});
}
// Fast path: cursor-only redraw (no data rebuild needed)
if (cursorsDirty) {
cursorsDirty = false;
plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); });
}
// Rolling-window plots: mark dirty every frame for smooth continuous scrolling.
// When no new data arrived since the last render, only advance the viewport
// via setScale instead of rebuilding all data arrays (much cheaper).
if (!trig.enabled && !globalPause) {
plots.forEach(p => {
if (!p.uplot || p.xRange || p.traces.length === 0) return;
p.needsRedraw = true;
});
}
plots.forEach(p => {
if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return;
const inTrigModeNow = trig.enabled && trig.snapshot !== null;
const isRolling = !trig.enabled && !p.xRange;
// Fast path: rolling-window plot with no new data — just shift viewport
// anchored to buffer timestamps so the x-range only advances when
// signal data actually moves forward.
if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) {
p.needsRedraw = false;
zoomGuard = true;
p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow });
zoomGuard = false;
return;
}
p.needsRedraw = false;
p.lastDataGen = _dataGen;
const data = buildUPlotData(p, inTrigModeNow);
// setData internally triggers the setScale hook in uPlot (it reaffirms the
// current scale even with auto:false). Keep zoomGuard raised across the
// entire setData + setScale block so the hook never calls onZoom and freezes
// p.xRange unintentionally. The guard is safe here: JS is single-threaded so
// no genuine user drag-zoom event can fire during this synchronous block.
zoomGuard = true;
p.uplot.setData(data);
// Re-apply the x-scale after setData so the viewport stays correct.
if (trig.enabled && !inTrigModeNow) {
// Armed / waiting for trigger: keep the current scale frozen.
} else if (inTrigModeNow) {
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
p.uplot.setScale('x', {
min: p.xRange ? p.xRange[0] : -preS,
max: p.xRange ? p.xRange[1] : postS
});
} else if (p.xRange) {
// Zoomed: re-apply so scale is correct after setData
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
} else {
// Rolling window: use same anchor as buildLiveData (min-of-max per source).
const plotNow = computePlotNow(p);
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
}
zoomGuard = false;
});
// Keep per-plot cursor value readouts in sync with live data.
if (cursors.mode === 'on') updatePlotCursorReadouts();
requestAnimationFrame(renderDirtyPlots);
}
/* ════════════════════════════════════════════════════════════════
Global controls
════════════════════════════════════════════════════════════════ */
document.getElementById('btn-pause-global').addEventListener('click', () => {
globalPause = !globalPause;
const btn = document.getElementById('btn-pause-global');
btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause';
btn.classList.toggle('active', globalPause);
if (!globalPause) plots.forEach(p => { p.needsRedraw = true; });
updateCursorBtnVisibility();
});
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
// Grow any buffers that can't hold the full new window for their signal's rate.
Object.keys(buffers).forEach(key => {
const rate = getKeySamplingRate(key);
if (rate > 0) {
const needed = Math.min(MAX_CAP, Math.ceil(rate * windowSec * 1.5));
if (needed > buffers[key].cap) buffers[key] = growBuffer(buffers[key], needed);
}
});
// Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots.forEach(p => lttbCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot
if (trig.enabled && !trig.snapshot) return;
plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; });
});
/* ════════════════════════════════════════════════════════════════
Sidebar / right-panel strip — click to collapse, drag to resize
════════════════════════════════════════════════════════════════ */
let sidebarOpen = true;
function setSidebar(open) {
sidebarOpen = open;
document.getElementById('sidebar').classList.toggle('collapsed', !open);
const btn = document.getElementById('btn-sidebar');
if (btn) btn.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));
(function() {
const DRAG_THRESHOLD = 4; // px before we treat as drag not click
function resizePlots() {
plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); });
}
function setupStrip(stripId, panelId, growDir) {
// growDir: 'right' → dragging right increases panel (left strip),
// 'left' → dragging left increases panel (right strip).
const strip = document.getElementById(stripId);
const panel = document.getElementById(panelId);
if (!strip || !panel) return;
strip.addEventListener('mousedown', e => {
e.preventDefault();
const startX = e.clientX;
const startW = panel.getBoundingClientRect().width;
let dragged = false;
function onMove(ev) {
const dx = ev.clientX - startX;
if (!dragged && Math.abs(dx) < DRAG_THRESHOLD) return;
dragged = true;
const newW = growDir === 'right' ? startW + dx : startW - dx;
if (newW < 40) {
panel.classList.add('collapsed');
panel.style.width = '';
panel.style.minWidth = '';
} else {
panel.classList.remove('collapsed');
panel.style.width = newW + 'px';
panel.style.minWidth = newW + 'px';
}
resizePlots();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (!dragged) {
// Pure click — toggle collapse
const nowCollapsed = panel.classList.toggle('collapsed');
if (!nowCollapsed && parseInt(panel.style.width || '0') < 40) {
panel.style.width = '';
panel.style.minWidth = '';
}
setTimeout(resizePlots, 220);
}
if (panelId === 'sidebar') sidebarOpen = !panel.classList.contains('collapsed');
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
setupStrip('left-strip', 'sidebar', 'right');
setupStrip('right-strip', 'right-panel', 'left');
})();
/* ════════════════════════════════════════════════════════════════
Multi-source management
════════════════════════════════════════════════════════════════ */
function onSources(msg) {
const srcs = msg.sources || [];
const newIds = new Set(srcs.map(s => s.id));
// Remove sources that disappeared.
Object.keys(sourcesMap).forEach(id => {
if (!newIds.has(id)) {
const prefix = id + ':';
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
delete sourcesMap[id];
}
});
// Update or create entries.
srcs.forEach(s => {
if (!sourcesMap[s.id]) {
sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] };
} else {
Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state });
}
});
buildSidebar();
if (statsOpen) _refreshStatsSelector();
// If the server is already connected to MARTe2 (state replayed on WS reconnect),
// restore the connected UI without sending another connect command.
const debugSrc = srcs.find(s => s.id === 'debug');
if (debugSrc && debugSrc.state === 'connected' && !marteConnected) {
onMarteConnected();
}
}
function addSourceWS(label, addr, multicastGroup, dataPort) {
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = { type: 'addSource', label, addr };
if (multicastGroup) { msg.multicastGroup = multicastGroup; }
if (dataPort) { msg.dataPort = dataPort; }
ws.send(JSON.stringify(msg));
}
}
function removeSource(id) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'removeSource', id }));
}
}
function saveSourcesWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'saveSources' }));
}
}
function makeAddSourceSection() {
const section = document.createElement('div');
section.className = 'add-source-section';
const title = document.createElement('div');
title.className = 'add-source-title';
title.innerHTML = '<span class="add-src-arrow">&#x25B6;</span> Add Source';
const body = document.createElement('div');
body.className = 'add-source-body';
const addrInput = document.createElement('input');
addrInput.className = 'add-src-input'; addrInput.type = 'text';
addrInput.placeholder = 'host:port';
const labelInput = document.createElement('input');
labelInput.className = 'add-src-input'; labelInput.type = 'text';
labelInput.placeholder = 'label (optional)';
const mcastInput = document.createElement('input');
mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';
const dataPortInput = document.createElement('input');
dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
dataPortInput.placeholder = 'data port (multicast only)';
dataPortInput.min = '1'; dataPortInput.max = '65535';
const addBtn = document.createElement('button');
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
addBtn.addEventListener('click', () => {
const addr = addrInput.value.trim(); if (!addr) return;
const mcastGroup = mcastInput.value.trim();
const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
const saveBtn = document.createElement('button');
saveBtn.className = 'add-src-btn save-src-btn';
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
saveBtn.addEventListener('click', saveSourcesWS);
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
section.append(title, body);
title.addEventListener('click', () => {
const open = section.classList.toggle('open');
title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
});
return section;
}
/* ════════════════════════════════════════════════════════════════
Utility
════════════════════════════════════════════════════════════════ */
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ════════════════════════════════════════════════════════════════
VScale menu (left-click on badge)
════════════════════════════════════════════════════════════════ */
let _vsMenuKey = null, _vsMenuPlotId = null;
function showVScaleMenu(key, plotId) {
hideSignalMenu();
// If the toolbar was open for a different plot, hide that bar first.
if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) {
const oldBar = document.getElementById('vstb-' + _vsMenuPlotId);
if (oldBar) oldBar.style.display = 'none';
}
_vsMenuKey = key; _vsMenuPlotId = plotId;
const menu = document.getElementById('vscale-menu');
const vs = getVScale(_vsMenuPlotId, key);
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
document.getElementById('vscale-menu-key').textContent = displayName;
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.mode === vs.mode));
// Disable Range button when signal has no defined range.
const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]');
if (rangeBtn) {
const meta = findSignalMeta(key);
const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null;
rangeBtn.disabled = !hasRange;
rangeBtn.title = hasRange ? '' : 'No range defined for this signal';
}
const isManual = vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
// Pre-fill V/div with resolved or stored value; Position always shows current screenPos.
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
// Type row (Analog/Digital) only shown in mixed mode.
const p = plots.find(q => q.id === plotId);
const isMixed = p && p.mode === 'mixed';
document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none';
if (isMixed) {
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
}
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
bar.appendChild(menu);
bar.style.display = 'block';
}
menu.style.display = 'block';
}
function hideVScaleMenu() {
const menu = document.getElementById('vscale-menu');
// Return the menu element to body so it is detached from any plot card.
menu.style.display = 'none';
document.body.appendChild(menu);
// Hide the vscale bar of the previously active plot.
if (_vsMenuPlotId != null) {
const bar = document.getElementById('vstb-' + _vsMenuPlotId);
if (bar) bar.style.display = 'none';
}
_vsMenuKey = null; _vsMenuPlotId = null;
}
/* ─── Array index picker ─────────────────────────────────────────────────── */
let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0;
function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) {
_aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1;
const menu = document.getElementById('array-idx-picker');
const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey;
document.getElementById('aip-sig').textContent = displayName;
document.getElementById('aip-range').textContent = '(0 ' + (n - 1) + ')';
const idxInput = document.getElementById('aip-idx');
idxInput.max = n - 1; idxInput.value = 0;
menu.style.display = 'block';
// Centre the picker on screen.
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120;
menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px';
menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px';
idxInput.focus(); idxInput.select();
}
function _aipConfirm() {
const idxInput = document.getElementById('aip-idx');
const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0));
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnConfirm) _aipOnConfirm(idx);
_aipOnConfirm = _aipOnCancel = null;
}
function _aipCancel() {
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnCancel) _aipOnCancel();
_aipOnConfirm = _aipOnCancel = null;
}
function initArrayIdxPicker() {
document.getElementById('aip-ok').addEventListener('click', _aipConfirm);
document.getElementById('aip-cancel').addEventListener('click', _aipCancel);
document.getElementById('aip-idx').addEventListener('keydown', e => {
if (e.key === 'Enter') _aipConfirm();
if (e.key === 'Escape') _aipCancel();
});
}
function initVScaleMenu() {
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
const newMode = btn.dataset.mode;
if (newMode === 'manual' && vs.mode !== 'manual') {
// Seed V/div from currently resolved value; screenPos stays as-is.
vs.divValue = vs._resolvedDiv || 1;
vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal)
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
}
vs.mode = newMode;
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const isManual = vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
refreshPlotForKey(_vsMenuKey);
});
});
document.getElementById('vscale-vdiv').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
refreshPlotForKey(_vsMenuKey);
});
// "Position (div)" moves the marker and signal together on screen.
document.getElementById('vscale-pos').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
refreshPlotForKey(_vsMenuKey);
});
// Type buttons (Analog / Digital) — only active in mixed mode.
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey || !_vsMenuPlotId) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.digitalInMixed = btn.dataset.type === 'digital';
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b =>
b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
// Rebuild needed because series rendering changes (digital = no spanGaps, etc.)
const p = plots.find(q => q.id === _vsMenuPlotId);
if (p) { createUPlot(p); p.needsRedraw = true; }
});
});
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}
/* ════════════════════════════════════════════════════════════════
Signal style context menu
════════════════════════════════════════════════════════════════ */
let _ctxMenuKey = null;
function showSignalMenu(key, plotId, x, y) {
_ctxMenuKey = key;
const menu = document.getElementById('sig-ctx-menu');
const style = getSigStyle(key);
document.getElementById('ctx-menu-key').textContent = key;
document.getElementById('ctx-color').value = style.color;
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width);
});
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.dash === style.dash);
});
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.marker === style.marker);
});
document.getElementById('ctx-marker-size').value = style.markerSize;
document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px';
menu.style.display = 'block';
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200;
menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px';
menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px';
}
function hideSignalMenu() {
document.getElementById('sig-ctx-menu').style.display = 'none';
_ctxMenuKey = null;
}
function initSignalMenu() {
document.getElementById('ctx-color').addEventListener('input', e => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { color: e.target.value });
});
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) });
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash });
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker });
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.getElementById('ctx-marker-size').addEventListener('input', e => {
const sz = parseInt(e.target.value, 10);
document.getElementById('ctx-marker-size-val').textContent = sz + 'px';
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { markerSize: sz });
});
document.addEventListener('click', e => {
if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } });
}
/* ════════════════════════════════════════════════════════════════
Source Statistics panel
════════════════════════════════════════════════════════════════ */
const sourceStats = {}; // sourceId → StatInfo (from backend 1 Hz message)
let statsOpen = false;
let statsSelectedSrc = null; // currently displayed source id
function onStats(msg) {
const incoming = msg.sources || {};
Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; });
if (statsOpen) renderStats();
}
// Rebuild the source selector options; preserve selection when possible.
function _refreshStatsSelector() {
const sel = document.getElementById('stats-source-sel');
if (!sel) return;
const prev = statsSelectedSrc;
sel.innerHTML = '';
const srcs = Object.values(sourcesMap);
srcs.forEach(src => {
const opt = document.createElement('option');
opt.value = src.id;
opt.textContent = src.label || src.id;
sel.appendChild(opt);
});
// Restore previous selection or default to first
if (prev && sourcesMap[prev]) {
sel.value = prev;
} else if (srcs.length > 0) {
sel.value = srcs[0].id;
}
statsSelectedSrc = sel.value || null;
}
// Frontend latency for one source: wallNow newest calibrated buffer timestamp.
function sourceLatencyMs(srcId) {
const prefix = srcId + ':';
const wallNow = Date.now() / 1000;
let best = null;
Object.keys(buffers).forEach(key => {
if (!key.startsWith(prefix)) return;
const buf = buffers[key];
if (!buf || buf.size === 0) return;
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
const lag = (wallNow - newest) * 1000;
if (best === null || lag < best) best = lag;
});
return best;
}
function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; }
function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; }
function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; }
function _statsKV(label, value, cls) {
return `<div class="stats-kv"><span class="stats-k">${escHtml(label)}</span><span class="stats-v${cls ? ' ' + cls : ''}">${escHtml(value)}</span></div>`;
}
function _histHTML(si) {
if (!si.cycleHist || !si.cycleHist.length) return '';
const maxC = Math.max(...si.cycleHist, 1);
const bars = si.cycleHist.map(c => {
const pct = Math.max(Math.round((c / maxC) * 100), 1);
return `<div class="hist-bar" style="height:${pct}%" title="${c} samples"></div>`;
}).join('');
return `<div class="stats-hist">
<div class="hist-bars">${bars}</div>
<div class="hist-labels"><span>${si.cycleHistMin.toFixed(3)}</span><span>${si.cycleHistMax.toFixed(3)} ms</span></div>
</div>`;
}
function renderStats() {
const body = document.getElementById('stats-body');
if (!body) return;
const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null;
if (!src) {
body.innerHTML = '<span class="stats-empty">No source selected</span>';
return;
}
const si = sourceStats[src.id];
const latMs = sourceLatencyMs(src.id);
const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok';
const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—';
body.innerHTML = `
<div class="stats-section">
<div class="stats-section-label">Connection</div>
<div class="stats-row">
${_statsKV('Address', src.addr)}
${_statsKV('Latency', _fmtMs(latMs))}
${_statsKV('Lost / Rx', lossText, lossColor)}
${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section">
<div class="stats-section-label">Cycle rate</div>
<div class="stats-row">
${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')}
${_statsKV(σ', si ? _fmtHz(si.rateStdHz) : '—')}
${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')}
${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section stats-section-grow">
<div class="stats-section-label">Cycle time histogram</div>
<div class="stats-row" style="margin-bottom:6px">
${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')}
${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')}
${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')}
${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
</div>
${si ? _histHTML(si) : '<span class="stats-empty">No data yet</span>'}
</div>`;
}
function toggleStats() {
statsOpen = !statsOpen;
document.getElementById('stats-panel').classList.toggle('open', statsOpen);
document.getElementById('btn-stats').classList.toggle('active', statsOpen);
if (statsOpen) {
_refreshStatsSelector();
renderStats();
}
}
// Refresh latency and stats every second while panel is open.
setInterval(() => { if (statsOpen) renderStats(); }, 1000);
/* ════════════════════════════════════════════════════════════════
Init
════════════════════════════════════════════════════════════════ */
buildLayoutMenu();
applyLayout('l1x1');
buildSidebar(); // show "Add Source" section even before WS connection
initArrayIdxPicker();
initVScaleMenu();
initSignalMenu();
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
document.getElementById('btn-stats').addEventListener('click', toggleStats);
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
document.getElementById('stats-source-sel').addEventListener('change', e => {
statsSelectedSrc = e.target.value || null;
renderStats();
});
connectWS();
requestAnimationFrame(renderDirtyPlots);
fetch('/version').then(r => r.text()).then(v => {
document.getElementById('build-version').textContent = 'v' + v;
}).catch(() => { });
/* ════════════════════════════════════════════════════════════════
══ DEBUG EXTENSION — MARTe2 control + signal trace/force/break
════════════════════════════════════════════════════════════════ */
/* ── Small helpers ───────────────────────────────────────────────────── */
// HTML escape (alias for IO_components escHtml)
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Current time string for log entries
function now() {
return new Date().toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
}
// Shorten a signal path for compact display
function shortName(name) {
if (!name) return '';
const parts = name.split('.');
return parts.length > 2 ? '…' + parts.slice(-2).join('.') : name;
}
// Format a numeric value for debug display (avoids conflict with IO's fmtVal)
function _dbgFmtVal(v) {
if (v === undefined || v === null || !isFinite(v)) return '—';
if (Math.abs(v) < 1e-3 && v !== 0) return v.toExponential(3);
if (Math.abs(v) >= 1e6) return v.toExponential(3);
return parseFloat(v.toPrecision(5)).toString();
}
// Get the last value of a signal from the IO_components buffer
function _debugLastVal(name) {
const key = 'debug:' + name;
const buf = buffers[key];
if (!buf || buf.size === 0) return '—';
const idx = (buf.head - 1 + buf.cap) % buf.cap;
return _dbgFmtVal(buf.v[idx]);
}
// Make a small button (used in trace/break/forced lists)
function mkBtn(label, cls, fn) {
const b = document.createElement('button');
b.className = 'tree-btn' + (cls ? ' ' + cls : '');
b.textContent = label;
b.addEventListener('click', e => { e.stopPropagation(); fn(); });
return b;
}
// Debugger-specific color picker (separate from oscilloscope colors)
const _DBG_COLORS = [
'#89b4fa','#f38ba8','#a6e3a1','#fab387','#f9e2af',
'#cba6f7','#89dceb','#b4befe','#eba0ac','#94e2d5'
];
let _dbgColorIdx = 0;
function nextDebugColor() { return _DBG_COLORS[_dbgColorIdx++ % _DBG_COLORS.length]; }
/* ── WebSocket send helpers ─────────────────────────────────────────── */
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); }
/* ── Connection state handlers ──────────────────────────────────────── */
// Restore forced-signal state replayed by the server when a browser reconnects.
// Populates forcedSignals without re-sending FORCE commands to MARTe2.
function onForcedState(msg) {
const sigs = msg.signals || {};
Object.assign(forcedSignals, sigs);
renderForcedTab();
}
// Restore traced-signal state replayed by the server when a browser reconnects.
// Populates tracedSet without re-sending TRACE commands to MARTe2.
function onTracedState(msg) {
const names = msg.signals || [];
names.forEach(n => tracedSet.add(n));
renderTracedTab();
}
function onMarteConnected() {
// Guard against double-invocation: onSources() may call us when it detects
// debug.state==='connected', and replayStateToClient also sends a 'connected'
// message. A second call would wipe _cmdSent mid-flight (via _clearStartupState),
// cancelling the in-progress DISCOVER/TREE sequence.
if (marteConnected) return;
marteConnected = true;
_clearStartupState();
const el = document.getElementById('conn-status');
if (el) el.classList.add('ok');
const btn = document.getElementById('btn-connect');
if (btn) btn.textContent = 'Disconnect';
appendLog('sys', 'INFO', 'Connected to MARTe2');
sendOnce('DISCOVER');
}
function onMarteDisconnected() {
marteConnected = false;
_clearStartupState();
const el = document.getElementById('conn-status');
if (el) el.classList.remove('ok');
const btn = document.getElementById('btn-connect');
if (btn) btn.textContent = 'Connect';
appendLog('sys', 'WARNING', 'Disconnected from MARTe2');
stopStepPoll();
}
/* ── Startup sequencing ─────────────────────────────────────────────── */
function sendOnce(cmd) {
if (_cmdSent.has(cmd)) return;
_cmdSent.add(cmd);
sendCmd(cmd);
}
function _clearStartupState() {
_cmdSent.clear();
_treeReady = false;
threads = [];
allObjects = [];
stopStepPoll();
const tb = document.getElementById('tree-body');
if (tb) tb.innerHTML = '<div class="empty-hint">Connect to MARTe2 then click Tree</div>';
}
/* ── Message handlers ───────────────────────────────────────────────── */
function onResponse(msg) {
try {
const data = msg.data ? JSON.parse(msg.data) : null;
switch (msg.tag) {
case 'DISCOVER':
if (data) {
processDiscovery(data.Signals || []);
sendOnce('TREE');
}
break;
case 'TREE':
if (data) onTreeNode(data);
break;
case 'INFO':
case 'CONFIG':
case 'SERVICE_INFO':
showInfo(msg.tag, msg.data);
break;
case 'STEP_STATUS':
processStepStatus(data);
break;
case 'VALUE':
processValue(data);
break;
}
} catch(e) { /* non-JSON responses silently ignored */ }
}
function onTextLine(line) {
if (!line) return;
// Show TRACE/FORCE/BREAK responses so user can see match counts
if (line.startsWith('OK TRACE ') || line.startsWith('OK FORCE ') ||
line.startsWith('OK BREAK ') || line.startsWith('OK UNFORCE ')) {
const parts = line.split(' ');
const count = parseInt(parts[2], 10);
const level = (count === 0) ? 'WARNING' : 'INFO';
appendLog('sys', level, line + (count === 0 ? ' — no signals matched!' : ''));
return;
}
if (line.startsWith('OK ')) return;
appendLog('sys', 'DEBUG', line);
}
function onLog(msg) {
let lv = msg.level || 'INFO';
if (lv === 'Information') lv = 'INFO';
else if (lv === 'Warning') lv = 'WARNING';
else if (lv === 'Debug') lv = 'DEBUG';
else if (lv === 'FatalError' || lv === 'ParametersError' || lv === 'OSError') lv = 'ERROR';
appendLog(msg.time || now(), lv, msg.message || '');
}
function onServiceConfig(msg) {
const auto = document.getElementById('auto-ports');
if (auto && auto.checked) {
if (msg.udp_port) { const el = document.getElementById('udp-port'); if (el) el.value = msg.udp_port; }
if (msg.log_port) { const el = document.getElementById('log-port'); if (el) el.value = msg.log_port; }
}
appendLog('sys', 'INFO', `ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}`);
}
/* ── Discovery ──────────────────────────────────────────────────────── */
function processDiscovery(sigs) {
signalsById = {};
signalsByName = {};
for (const s of sigs) {
const el = s.elements || 1;
const allNames = (s.Names && s.Names.length > 0) ? s.Names : [s.name];
const meta = {id: s.id, name: s.name, type: s.type || '', elements: el,
dimensions: s.dimensions || 0, names: allNames};
signalsById[s.id] = meta;
for (const n of allNames) {
signalsByName[n] = meta;
if (el > 1) {
for (let i = 0; i < el; i++) signalsByName[`${n}[${i}]`] = meta;
}
}
}
updateSignalAutocomplete();
appendLog('sys', 'INFO', `Discovered ${sigs.length} signals`);
}
function updateSignalAutocomplete() {
const names = Object.keys(signalsByName).filter(n => !n.includes('['));
['force-sig-list', 'break-sig-list'].forEach(id => {
const dl = document.getElementById(id);
if (dl) dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
});
}
/* ── Object Tree (lazy loading) ─────────────────────────────────────── */
function onTreeNode(data) {
const path = data.Path || '';
const children = data.Children || [];
if (path === '') {
const body = document.getElementById('tree-body');
if (!body) return;
body.innerHTML = '';
const frag = document.createDocumentFragment();
for (const child of children) frag.appendChild(_buildLazyNode(child, ''));
body.appendChild(frag);
_treeReady = true;
switchSidebarTab('tree');
appendLog('sys', 'INFO', `Tree root loaded — ${children.length} top-level nodes`);
startStepPoll();
} else {
const details = document.querySelector(`[data-tree-path="${CSS.escape(path)}"]`);
if (details) {
const ld = details.querySelector('.tree-loading');
if (ld) ld.remove();
const childDiv = document.createElement('div');
childDiv.className = 'children';
const infoBtn = _makeLeafButtons(path);
if (infoBtn) childDiv.appendChild(infoBtn);
const frag = document.createDocumentFragment();
for (const child of children) frag.appendChild(_buildLazyNode(child, path));
childDiv.appendChild(frag);
details.appendChild(childDiv);
}
}
for (const child of children) {
const fullPath = path ? path + '.' + child.Name : child.Name;
if (child.Class === 'RealTimeThread' && !threads.includes(child.Name)) threads.push(child.Name);
if (child.HasChildren) allObjects.push(fullPath);
}
_updateTreeMeta();
}
function _buildLazyNode(child, parentPath) {
const fullPath = parentPath ? parentPath + '.' + child.Name : child.Name;
if (child.HasChildren) {
const details = document.createElement('details');
details.setAttribute('data-tree-path', fullPath);
const summary = document.createElement('summary');
summary.innerHTML = `<span class="tree-name">${esc(child.Name)}</span><span class="tree-class">[${esc(child.Class)}]</span>`;
details.appendChild(summary);
details.addEventListener('toggle', function() {
if (!details.open || details._childrenBuilt) return;
details._childrenBuilt = true;
const ld = document.createElement('div');
ld.className = 'tree-loading'; ld.textContent = '⏳ Loading…';
details.appendChild(ld);
sendCmd('TREE ' + fullPath);
});
return details;
}
const isSig = child.Class === 'Signal' || child.Class === 'InputSignal' ||
child.Class === 'OutputSignal' || child.IsTraceable;
const row = document.createElement('div');
row.className = 'tree-node';
row.appendChild(_makeLeafRow(fullPath, child, isSig));
return row;
}
function _makeLeafRow(path, item, isSig) {
const div = document.createElement('div');
div.className = 'tree-leaf';
div.title = path;
const nm = document.createElement('span');
nm.className = 'tree-name'; nm.textContent = item.Name || path;
div.appendChild(nm);
if (isSig) {
const el = item.Elements || 1;
const actions = document.createElement('div');
actions.className = 'tree-actions';
if (item.IsTraceable !== false) {
const btnT = mkBtn('T', '', () => { traceSignal(path, true); switchTab('traced'); });
btnT.title = 'Trace this signal';
actions.appendChild(btnT);
}
if (item.IsForcable !== false && el === 1) {
const btnF = mkBtn('F', '', () => openForceDialogFor(path));
btnF.title = 'Force value';
actions.appendChild(btnF);
}
const btnB = mkBtn('B', '', () => openBreakDialogFor(path));
btnB.title = 'Set breakpoint';
actions.appendChild(btnB);
div.appendChild(actions);
}
const btnI = mkBtn('', '', () => showNodeInfo(path));
btnI.title = 'Info';
div.appendChild(btnI);
return div;
}
function _makeLeafButtons(path) {
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:4px;padding:2px 4px 2px 0';
div.appendChild(mkBtn(' info', '', () => showNodeInfo(path)));
return div;
}
function _updateTreeMeta() {
['step-thread', 'step-thread-list', 'step-thread-inp'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'SELECT') {
el.innerHTML = '<option value="">— all threads —</option>' +
threads.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
} else if (el.tagName === 'DATALIST') {
el.innerHTML = threads.map(t => `<option value="${esc(t)}">`).join('');
}
});
const dl = document.getElementById('msg-dest-list');
if (dl) dl.innerHTML = allObjects.map(n => `<option value="${esc(n)}">`).join('');
}
function filterTree(q) {
const body = document.getElementById('tree-body');
if (!body) return;
q = (q || '').toLowerCase();
if (q) body.querySelectorAll('details').forEach(d => { if (d._childrenBuilt) d.open = true; });
body.querySelectorAll('.tree-leaf').forEach(el => {
if (!q) { el.style.display = ''; return; }
const t = (el.querySelector('.tree-name')?.textContent || '').toLowerCase() +
(el.title || '').toLowerCase();
el.style.display = t.includes(q) ? '' : 'none';
});
}
/* ── Tracing ────────────────────────────────────────────────────────── */
function traceSignal(name, enable) {
const bracketMatch = name.match(/^(.+)\[\d+\]$/);
const baseName = bracketMatch ? bracketMatch[1] : name;
if (enable) {
tracedSet.add(name);
sendCmd(`TRACE ${baseName} 1`);
} else {
tracedSet.delete(name);
if (bracketMatch) {
const anyLeft = tracedSet.has(baseName) || [...tracedSet].some(n => n.startsWith(baseName + '['));
if (!anyLeft) sendCmd(`TRACE ${baseName} 0`);
} else {
sendCmd(`TRACE ${name} 0`);
}
}
renderTracedTab();
}
function untraceGroup(baseName) {
const grp = tracedGroups.get(baseName);
if (grp) {
for (const i of grp.indices) tracedSet.delete(`${baseName}[${i}]`);
tracedGroups.delete(baseName);
}
const anyLeft = tracedSet.has(baseName) || [...tracedSet].some(n => n.startsWith(baseName + '['));
if (!anyLeft) sendCmd(`TRACE ${baseName} 0`);
renderTracedTab();
}
function renderTracedTab() {
const tab = document.getElementById('tab-traced');
if (!tab) return;
const hint = document.getElementById('no-traced-hint');
if (hint) hint.style.display = (tracedSet.size === 0 && tracedGroups.size === 0) ? 'block' : 'none';
tab.querySelectorAll('.sig-item,.sig-group').forEach(e => e.remove());
const grouped = new Set();
// Grouped array signals
for (const [baseName, grp] of tracedGroups) {
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
for (const i of sortedIdx) grouped.add(`${baseName}[${i}]`);
const hdr = document.createElement('div');
hdr.className = 'sig-group'; hdr.draggable = true;
hdr.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', 'debug:' + baseName);
e.dataTransfer.effectAllowed = 'copy';
});
const expBtn = document.createElement('span');
expBtn.className = 'sig-group-exp';
expBtn.textContent = grp.expanded ? '▾' : '▸';
expBtn.addEventListener('click', e => {
e.stopPropagation(); grp.expanded = !grp.expanded; renderTracedTab();
});
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = baseName; nm.textContent = shortName(baseName);
const badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;color:#89b4fa;border:1px solid #45475a;border-radius:3px;padding:0 3px;flex-shrink:0;margin-left:2px';
badge.textContent = `×${grp.indices.size}`;
const val = document.createElement('span');
val.className = 'sig-val'; val.id = `tval-grp-${CSS.escape(baseName)}`;
val.textContent = _debugLastVal(`${baseName}[${sortedIdx[0]}]`);
const btnR = mkBtn('×', 'danger', () => untraceGroup(baseName));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
hdr.append(expBtn, nm, badge, val, btnR);
tab.appendChild(hdr);
if (grp.expanded) {
for (const i of sortedIdx) {
const n = `${baseName}[${i}]`;
const sub = document.createElement('div');
sub.className = 'sig-item sig-item-sub'; sub.draggable = true;
sub.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', 'debug:' + n); e.dataTransfer.effectAllowed = 'copy';
});
const snm = document.createElement('span');
snm.className = 'sig-name'; snm.textContent = `[${i}]`;
const sval = document.createElement('span');
sval.className = 'sig-val'; sval.id = `tval-${CSS.escape(n)}`;
sval.textContent = _debugLastVal(n);
const sbtnR = mkBtn('×', 'danger', () => {
tracedSet.delete(n); grp.indices.delete(i);
if (grp.indices.size === 0) { tracedGroups.delete(baseName); sendCmd(`TRACE ${baseName} 0`); }
renderTracedTab();
});
sbtnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
sub.append(snm, sval, sbtnR);
tab.appendChild(sub);
}
}
}
// Individual signals
for (const name of tracedSet) {
if (grouped.has(name)) continue;
const color = getTraceColor('debug:' + name);
const div = document.createElement('div');
div.className = 'sig-item'; div.draggable = true;
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', 'debug:' + name); e.dataTransfer.effectAllowed = 'copy';
});
const dot = document.createElement('span');
dot.className = 'sig-dot'; dot.style.background = color;
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = name; nm.textContent = shortName(name);
const val = document.createElement('span');
val.className = 'sig-val'; val.id = `tval-${CSS.escape(name)}`;
val.textContent = _debugLastVal(name);
const btnR = mkBtn('×', 'danger', () => traceSignal(name, false));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(dot, nm, val, btnR);
tab.appendChild(div);
}
}
function updateTracedValues() {
for (const name of tracedSet) {
const el = document.getElementById(`tval-${CSS.escape(name)}`);
if (el) el.textContent = _debugLastVal(name);
}
for (const [baseName, grp] of tracedGroups) {
const el = document.getElementById(`tval-grp-${CSS.escape(baseName)}`);
if (!el) continue;
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
if (sortedIdx.length > 0) el.textContent = _debugLastVal(`${baseName}[${sortedIdx[0]}]`);
}
}
setInterval(updateTracedValues, 500);
/* ── Force ──────────────────────────────────────────────────────────── */
function openForceDialog() { openDlg('dlg-force'); }
function openForceDialogFor(name) {
const el = document.getElementById('force-sig');
if (el) el.value = name;
openDlg('dlg-force');
}
function doForce() {
const sig = (document.getElementById('force-sig')?.value || '').trim();
const val = (document.getElementById('force-val')?.value || '').trim();
if (!sig || !val) return;
sendCmd(`FORCE ${sig} ${val}`);
forcedSignals[sig] = val;
closeDlg('dlg-force');
renderForcedTab();
}
function doUnforce() {
if (unforceTarget) {
sendCmd(`UNFORCE ${unforceTarget}`);
delete forcedSignals[unforceTarget];
unforceTarget = '';
renderForcedTab();
}
closeDlg('dlg-unforce');
}
function confirmUnforce(name) {
unforceTarget = name;
const el = document.getElementById('unforce-msg');
if (el) el.textContent = `Remove force on "${name}"?`;
openDlg('dlg-unforce');
}
function unforceGroup(baseName) {
const grp = forcedGroups.get(baseName);
if (!grp) return;
if (grp.isAll) { sendCmd(`UNFORCE ${baseName}`); }
else { for (const i of grp.indices) sendCmd(`UNFORCE ${baseName}[${i}]`); }
forcedGroups.delete(baseName);
renderForcedTab();
}
function renderForcedTab() {
const tab = document.getElementById('tab-forced');
if (!tab) return;
tab.querySelectorAll('.sig-item,.sig-group').forEach(e => e.remove());
const hint = document.getElementById('no-forced-hint');
if (hint) hint.style.display = (Object.keys(forcedSignals).length === 0 && forcedGroups.size === 0) ? 'block' : 'none';
for (const [baseName, grp] of forcedGroups) {
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
const hdr = document.createElement('div'); hdr.className = 'sig-group';
const expBtn = document.createElement('span'); expBtn.className = 'sig-group-exp';
expBtn.textContent = grp.expanded ? '▾' : '▸';
expBtn.addEventListener('click', e => { e.stopPropagation(); grp.expanded = !grp.expanded; renderForcedTab(); });
const nm = document.createElement('span'); nm.className = 'sig-name'; nm.textContent = shortName(baseName);
const badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;color:#89b4fa;border:1px solid #45475a;border-radius:3px;padding:0 3px;flex-shrink:0;margin-left:2px';
badge.textContent = grp.isAll ? '×all' : `×${grp.indices.size}`;
const val = document.createElement('span'); val.className = 'sig-val'; val.style.color = '#f9e2af';
val.textContent = grp.value;
const btnR = mkBtn('×', 'danger', () => unforceGroup(baseName));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
hdr.append(expBtn, nm, badge, val, btnR);
tab.appendChild(hdr);
if (grp.expanded) {
const dispIdx = grp.isAll ? Array.from({length: grp.elements}, (_, i) => i) : sortedIdx;
for (const i of dispIdx) {
const sub = document.createElement('div'); sub.className = 'sig-item sig-item-sub';
const snm = document.createElement('span'); snm.className = 'sig-name'; snm.textContent = `[${i}]`;
const sval = document.createElement('span'); sval.className = 'sig-val'; sval.style.color = '#f9e2af';
sval.textContent = grp.value;
const sbtnR = mkBtn('×', 'danger', () => {
if (grp.isAll) {
forcedGroups.delete(baseName); sendCmd(`UNFORCE ${baseName}`);
for (const j of dispIdx) { if (j !== i) { sendCmd(`FORCE ${baseName}[${j}] ${grp.value}`); forcedSignals[`${baseName}[${j}]`] = grp.value; } }
} else { sendCmd(`UNFORCE ${baseName}[${i}]`); grp.indices.delete(i); if (grp.indices.size === 0) forcedGroups.delete(baseName); }
renderForcedTab();
});
sbtnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
sub.append(snm, sval, sbtnR);
tab.appendChild(sub);
}
}
}
for (const name of Object.keys(forcedSignals)) {
const div = document.createElement('div'); div.className = 'sig-item';
const nm = document.createElement('span'); nm.className = 'sig-name'; nm.title = name; nm.textContent = shortName(name);
const val = document.createElement('span'); val.className = 'sig-val'; val.style.color = '#f9e2af'; val.textContent = forcedSignals[name];
const btnR = mkBtn('×', '', () => confirmUnforce(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(nm, val, btnR);
tab.appendChild(div);
}
}
/* ── Breakpoints ────────────────────────────────────────────────────── */
function openBreakDialog() { openDlg('dlg-break'); }
function openBreakDialogFor(name) {
const el = document.getElementById('break-sig');
if (el) el.value = name;
openDlg('dlg-break');
}
function doBreak() {
const sig = (document.getElementById('break-sig')?.value || '').trim();
const op = document.getElementById('break-op')?.value || '>';
const thresh = (document.getElementById('break-thresh')?.value || '').trim();
if (!sig || !thresh) return;
sendCmd(`BREAK ${sig} ${op} ${thresh}`);
breakpoints[sig] = {op, threshold: thresh};
closeDlg('dlg-break');
renderBreaksTab();
}
function clearBreak(name) {
sendCmd(`BREAK ${name} OFF`);
delete breakpoints[name];
renderBreaksTab();
}
function renderBreaksTab() {
const tab = document.getElementById('tab-breaks');
if (!tab) return;
tab.querySelectorAll('.break-item').forEach(e => e.remove());
const hint = document.getElementById('no-breaks-hint');
const keys = Object.keys(breakpoints);
if (hint) hint.style.display = keys.length ? 'none' : 'block';
for (const name of keys) {
const bp = breakpoints[name];
const div = document.createElement('div'); div.className = 'break-item';
const sig = document.createElement('span'); sig.className = 'break-sig'; sig.title = name; sig.textContent = shortName(name);
const cond = document.createElement('span'); cond.style.color = '#fab387';
cond.textContent = `${bp.op} ${bp.threshold}`;
const btnR = mkBtn('×', '', () => clearBreak(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(sig, cond, btnR);
tab.appendChild(div);
}
}
/* ── Messages ───────────────────────────────────────────────────────── */
let msgHistory = [];
function openMsgDialog() { openDlg('dlg-msg'); }
function doSendMsg() {
const dest = (document.getElementById('msg-dest')?.value || '').trim();
const func = (document.getElementById('msg-func')?.value || '').trim();
const payload = (document.getElementById('msg-payload')?.value || '').trim();
const wait = document.getElementById('msg-wait')?.checked ? 1 : 0;
if (!dest || !func) return;
const kvLine = payload.split('\n').map(l => l.trim()).filter(Boolean).map(l => l.replace(/\s*=\s*/, '=')).join(' ');
const cmd = `MSG ${dest} ${func} ${wait}${kvLine ? ' ' + kvLine : ''}`;
sendCmd(cmd);
const entry = {time: now(), dest, func, payload, cmd, status: 'pending'};
msgHistory.unshift(entry);
closeDlg('dlg-msg');
renderMsgsTab();
}
function resendMsg(idx) { const m = msgHistory[idx]; if (m) sendCmd(m.cmd); }
function renderMsgsTab() {
const tab = document.getElementById('tab-msgs');
if (!tab) return;
tab.querySelectorAll('.msg-item').forEach(e => e.remove());
const hint = document.getElementById('no-msgs-hint');
if (hint) hint.style.display = msgHistory.length ? 'none' : 'block';
for (const m of msgHistory.slice(0, 50)) {
const div = document.createElement('div'); div.className = 'msg-item';
const icon = m.status === 'ok' ? '✔' : m.status === 'err' ? '✘' : '…';
div.innerHTML = `<div style="display:flex;gap:6px;align-items:center">
<span style="color:${m.status==='ok'?'#a6e3a1':m.status==='err'?'#f38ba8':'#585b70'}">${icon}</span>
<span style="color:#585b70;flex-shrink:0">${esc(m.time)}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(m.cmd)}">${esc(m.dest)}.${esc(m.func)}</span>
<button class="tree-btn" onclick="resendMsg(${msgHistory.indexOf(m)})">↺</button>
</div>`;
tab.appendChild(div);
}
}
/* ── Step / Pause ───────────────────────────────────────────────────── */
let isPaused = false;
function togglePause() {
if (isPaused) {
sendCmd('RESUME');
isPaused = false;
const bar = document.getElementById('step-bar');
if (bar) bar.classList.remove('visible');
} else {
sendCmd('PAUSE');
isPaused = true;
}
const btn = document.getElementById('btn-pause');
if (btn) btn.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
function openStepDialog() { openDlg('dlg-step'); }
function doStep() {
const count = document.getElementById('step-count')?.value || 1;
const thread = (document.getElementById('step-thread-inp')?.value || '').trim();
sendCmd(thread ? `STEP ${count} ${thread}` : `STEP ${count}`);
closeDlg('dlg-step');
}
function step(n) {
const thread = document.getElementById('step-thread')?.value || '';
sendCmd(thread ? `STEP ${n} ${thread}` : `STEP ${n}`);
}
function startStepPoll() {
if (!_treeReady) return;
stopStepPoll();
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
}
function stopStepPoll() {
if (stepPollTimer) { clearInterval(stepPollTimer); stepPollTimer = null; }
}
function processStepStatus(data) {
if (!data) return;
stepStatus = data;
const bar = document.getElementById('step-bar');
const btn = document.getElementById('btn-pause');
if (data.Paused) {
isPaused = true;
if (bar) bar.classList.add('visible');
const pg = document.getElementById('paused-gam');
if (pg) pg.textContent = data.PausedAtGam || '—';
const sr = document.getElementById('step-remaining');
if (sr) sr.textContent = data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
if (btn) btn.textContent = '▶ Resume';
if (_treeReady) { stopStepPoll(); stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500); }
} else {
if (isPaused) {
isPaused = false;
if (bar) bar.classList.remove('visible');
if (btn) btn.textContent = '⏸ Pause';
startStepPoll();
}
}
}
/* ── Value / Info ───────────────────────────────────────────────────── */
function processValue(data) {
if (!data || data.Error) return;
showInfo(`Value: ${data.Name}`, `${data.Value}\n(${data.Elements} element${data.Elements > 1 ? 's' : ''})`);
}
function showNodeInfo(path) {
const it = document.getElementById('info-title');
const ib = document.getElementById('info-body');
if (it) it.textContent = path;
if (ib) ib.textContent = 'Loading…';
openDlg('dlg-info');
sendCmd(`INFO ${path}`);
}
function showInfo(title, data) {
let pretty = data;
try { pretty = JSON.stringify(JSON.parse(data), null, 2); } catch(e){}
const it = document.getElementById('info-title');
const ib = document.getElementById('info-body');
if (it) it.textContent = title;
if (ib) ib.textContent = pretty;
if (!document.getElementById('dlg-info') || document.getElementById('dlg-info').style.display === 'none')
openDlg('dlg-info');
}
/* ── Connect / Disconnect ───────────────────────────────────────────── */
function requireConnected() {
if (!marteConnected) { appendLog('sys', 'WARNING', 'Not connected to MARTe2'); return false; }
return true;
}
function discoverCmd() { if (requireConnected()) { sendCmd('DISCOVER'); closeAllMenus(); } }
function treeCmd() { if (requireConnected()) { sendCmd('TREE'); closeAllMenus(); } }
function serviceInfoCmd() { if (requireConnected()) { sendCmd('SERVICE_INFO'); closeAllMenus(); } }
function toggleAutoPorts(auto) {
const row = document.getElementById('port-manual-row');
if (row) row.style.display = auto ? 'none' : 'flex';
}
function toggleConnect() {
if (marteConnected) {
sendWS({type: 'disconnect'});
} else {
const host = (document.getElementById('host')?.value || '').trim() || '127.0.0.1';
const port = parseInt(document.getElementById('port')?.value) || 8080;
const auto = document.getElementById('auto-ports')?.checked ?? true;
const udpPort = auto ? port + 1 : (parseInt(document.getElementById('udp-port')?.value) || port + 1);
const logPort = auto ? port + 2 : (parseInt(document.getElementById('log-port')?.value) || port + 2);
sendWS({type: 'connect', data: {host, port, udp_port: udpPort, log_port: logPort}});
}
closeAllMenus();
}
/* ── Log panel ──────────────────────────────────────────────────────── */
function appendLog(time, level, message) {
logs.push({time, level, message});
if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS);
logDirty = true;
}
function renderLogs() {
const body = document.getElementById('log-body');
if (!body) return;
const levelFilters = {
CMD: document.getElementById('lf-service')?.checked,
RESP: document.getElementById('lf-service')?.checked,
DEBUG: document.getElementById('lf-debug')?.checked,
INFO: document.getElementById('lf-info')?.checked,
WARNING: document.getElementById('lf-warn')?.checked,
ERROR: document.getElementById('lf-error')?.checked,
sys: true,
};
const filter = (document.getElementById('log-filter')?.value || '').toLowerCase();
const visible = logs.filter(e => {
const lv = e.level || 'INFO';
if (!levelFilters[lv] && !levelFilters[lv.toUpperCase()]) return false;
if (filter && !((e.message || '').toLowerCase().includes(filter))) return false;
return true;
});
const lvColor = {CMD:'#89b4fa',RESP:'#94e2d5',DEBUG:'#585b70',INFO:'#a6e3a1',WARNING:'#f9e2af',ERROR:'#f38ba8'};
body.innerHTML = visible.slice(-500).map(e => {
const c = lvColor[e.level] || '#cdd6f4';
return `<div class="log-line"><span class="log-time">${esc(e.time)}</span>` +
`<span class="log-level" style="color:${c}">${esc(e.level)}</span>` +
`<span class="log-msg">${esc(e.message)}</span></div>`;
}).join('');
if (document.getElementById('log-autoscroll')?.checked) body.scrollTop = body.scrollHeight;
logDirty = false;
}
setInterval(() => { if (logDirty) renderLogs(); }, 200);
/* ── Dialogs ────────────────────────────────────────────────────────── */
function openDlg(id) {
const el = document.getElementById(id);
if (el) { el.style.display = 'flex'; el.style.zIndex = '1000'; }
}
function closeDlg(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.dialog-overlay').forEach(d => d.style.display = 'none'); });
/* ── Panel switching ────────────────────────────────────────────────── */
function switchSidebarTab(name) {
document.querySelectorAll('#sidebar .panel-tab').forEach(t => t.classList.toggle('active', t.textContent.toLowerCase() === name.toLowerCase() || t.onclick?.toString().includes(`'${name}'`)));
document.querySelectorAll('#sidebar .sidebar-tab-body').forEach(b => b.classList.toggle('active', b.id === 'sidebar-' + name));
}
function switchTab(name) {
document.querySelectorAll('#right-panel .tab').forEach(t => t.classList.toggle('active', t.onclick?.toString().includes(`'${name}'`)));
document.querySelectorAll('#right-panel .tab-content').forEach(b => b.classList.toggle('active', b.id === 'tab-' + name));
}
/* ── Menu / Panel helpers ───────────────────────────────────────────── */
function toggleMenu(id) {
const el = document.getElementById(id);
if (!el) return;
const isOpen = el.classList.contains('open');
closeAllMenus();
if (!isOpen) el.classList.add('open');
}
function closeAllMenus() {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
}
document.addEventListener('click', e => {
if (!e.target.closest('.menu-wrap')) closeAllMenus();
});
function togglePanel(panelId, collapseIcon, expandIcon, btn) {
const panel = document.getElementById(panelId);
if (!panel) return;
const collapsed = panel.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? expandIcon : collapseIcon;
}
/* ── Pause global (oscilloscope) — single handler defined above at btn-pause-global ── */
/* ── Array operations (debug signals) ──────────────────────────────── */
function setArrSel(mode) {
_arrSel = mode;
['all','idx','rng'].forEach(m => {
document.getElementById(`arr-sel-${m}`)?.classList.toggle('active', m === mode);
});
document.getElementById('arr-idx-sect').style.display = mode === 'idx' ? '' : 'none';
document.getElementById('arr-rng-sect').style.display = mode === 'rng' ? '' : 'none';
}
function openArrayDialog(path, elements, action) {
_arrAction = action; _arrPath = path; _arrElem = elements;
const t = document.getElementById('arr-dlg-title');
const s = document.getElementById('arr-dlg-sig');
const n = document.getElementById('arr-dlg-n');
if (t) t.textContent = action === 'trace' ? '📊 Trace Array' : action === 'force' ? '⚡ Force Array' : '🔴 Break Array';
if (s) s.textContent = path;
if (n) n.textContent = elements;
const im = document.getElementById('arr-idx-max'); if (im) im.textContent = elements - 1;
const ai = document.getElementById('arr-idx'); if (ai) { ai.max = elements - 1; }
const r0 = document.getElementById('arr-r0'); if (r0) r0.max = elements - 1;
const r1 = document.getElementById('arr-r1'); if (r1) { r1.max = elements - 1; r1.value = elements - 1; }
const fs = document.getElementById('arr-force-sect'); if (fs) fs.style.display = action === 'force' ? '' : 'none';
const bs = document.getElementById('arr-break-sect'); if (bs) bs.style.display = action === 'break' ? '' : 'none';
const ok = document.getElementById('arr-ok-btn'); if (ok) ok.textContent = action === 'trace' ? 'Trace' : action === 'force' ? 'Force' : 'Set Break';
setArrSel('all');
openDlg('dlg-array');
}
function _arrIndices() {
if (_arrSel === 'idx') {
const v = Math.min(Math.max(0, parseInt(document.getElementById('arr-idx')?.value || '0')), _arrElem - 1);
return [v];
}
if (_arrSel === 'rng') {
const s = Math.min(Math.max(0, parseInt(document.getElementById('arr-r0')?.value || '0')), _arrElem - 1);
const e = Math.min(Math.max(s, parseInt(document.getElementById('arr-r1')?.value || '0')), _arrElem - 1);
return Array.from({length: e - s + 1}, (_, i) => s + i);
}
return Array.from({length: _arrElem}, (_, i) => i);
}
function doArrayOp() {
const indices = _arrIndices();
const isAll = indices.length === _arrElem;
if (_arrAction === 'trace') {
sendCmd(`TRACE ${_arrPath} 1`);
for (const i of indices) {
const n = `${_arrPath}[${i}]`;
tracedSet.add(n);
}
if (_arrSel !== 'idx') {
const nEl = _arrElem;
const grp = tracedGroups.get(_arrPath);
if (grp) { for (const i of indices) grp.indices.add(i); }
else { tracedGroups.set(_arrPath, {indices: new Set(indices), elements: nEl, expanded: false}); }
}
renderTracedTab();
} else if (_arrAction === 'force') {
const val = (document.getElementById('arr-force-val')?.value || '').trim();
if (!val) { closeDlg('dlg-array'); return; }
if (_arrSel === 'idx') {
const i = indices[0];
sendCmd(`FORCE ${_arrPath}[${i}] ${val}`);
forcedSignals[`${_arrPath}[${i}]`] = val;
} else {
if (isAll) { sendCmd(`FORCE ${_arrPath} ${val}`); }
else { for (const i of indices) sendCmd(`FORCE ${_arrPath}[${i}] ${val}`); }
const grp = forcedGroups.get(_arrPath);
if (grp) { for (const i of indices) grp.indices.add(i); grp.value = val; grp.isAll = isAll || (grp.isAll && grp.indices.size === _arrElem); }
else { forcedGroups.set(_arrPath, {isAll, indices: new Set(indices), elements: _arrElem, value: val, expanded: false}); }
}
renderForcedTab();
} else { // break
const op = document.getElementById('arr-break-op')?.value || '>';
const thr = (document.getElementById('arr-break-thr')?.value || '').trim();
if (!thr) { closeDlg('dlg-array'); return; }
if (isAll) { sendCmd(`BREAK ${_arrPath} ${op} ${thr}`); breakpoints[_arrPath] = {op, threshold: thr}; }
else { for (const i of indices) { const n = `${_arrPath}[${i}]`; sendCmd(`BREAK ${n} ${op} ${thr}`); breakpoints[n] = {op, threshold: thr}; } }
renderBreaksTab();
}
closeDlg('dlg-array');
}
/* ── Array → plot mode (Sequential / Waterfall) ─────────────────────── */
// These dialogs exist in the IO_components system for non-debug sources.
// For debug source, we adapt: sequential adds the temporal signal to a plot,
// waterfall opens the existing IO_components waterfall dialog if available.
let _arrpMode = 'sequential';
function setArrpMode(mode) {
_arrpMode = mode;
['sequential', 'waterfall'].forEach(m => {
document.getElementById(`arrp-sel-${m}`)?.classList.toggle('active', m === mode);
});
const desc = document.getElementById('arrp-desc');
if (desc) {
desc.textContent = mode === 'sequential'
? 'Unroll all elements as a dense time series interpolating timestamps.'
: 'Render as a 2D heatmap scrolling over time (one row per cycle).';
}
}
function doArrayPlotMode() {
closeDlg('dlg-arr-plot');
// Both modes: send TRACE command and add signal to the current plot
sendCmd(`TRACE ${_arrPath} 1`);
tracedSet.add(_arrPath);
renderTracedTab();
}
/* ── Sidebar tab init ───────────────────────────────────────────────── */
// Wire up sidebar tab click events (complement to HTML onclick)
// Sidebar tabs use onclick="switchSidebarTab('name')" in HTML — no duplicate listener needed.
// Wire up right panel tab click events
document.querySelectorAll('#right-panel .tab').forEach(tab => {
const fn = tab.getAttribute('onclick') || '';
const m = fn.match(/'(\w+)'/);
if (m) { const name = m[1]; tab.addEventListener('click', () => switchTab(name)); }
});
// Window select
document.getElementById('window-select')?.addEventListener('change', e => {
windowSec = parseInt(e.target.value, 10);
});
// Sync resume button
document.getElementById('btn-sync-resume')?.addEventListener('click', () => {
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
document.getElementById('btn-sync-resume').style.display = 'none';
});
// Log panel filter listeners
['lf-service','lf-debug','lf-info','lf-warn','lf-error'].forEach(id => {
document.getElementById(id)?.addEventListener('change', renderLogs);
});
document.getElementById('log-filter')?.addEventListener('input', renderLogs);
document.getElementById('log-autoscroll')?.addEventListener('change', () => {});
// Step bar thread select
document.getElementById('step-thread')?.addEventListener('change', () => {});
appendLog('sys', 'INFO', 'MARTe2 Integrated Client ready');
/* ── END DEBUG EXTENSION ── */