'use strict';
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
// Hard ceiling per live buffer (~32 MB per signal at Float64 t+v). This bounds
// memory, not the window: buffers start small and are grown only as far as the
// selected window needs (see growBufferForWindow).
const MAX_CAP = 2_000_000;
// Starting capacity for a scalar signal; grown from there if the window needs it.
const DEFAULT_CAP = 100_000;
// Starting capacity for a temporal (array) signal. The hub caps the live push at
// 50 points per signal per 30 Hz tick, so 500 k points is already ~5 minutes.
const TEMPORAL_CAP = 500_000;
const DECIM_MIN = 200; // never decimate below this many points
// Seconds of data currently held by a buffer.
function bufferSpanSec(buf) {
if (buf.size < 2) return 0;
const start = buf.size === buf.cap ? buf.head : 0;
return buf.t[(start + buf.size - 1) % buf.cap] - buf.t[start];
}
/* Grow buf until what it holds covers windowSec.
Capacity is in points but the window is in seconds, and the two are related
by the rate at which points *arrive* — which is not the signal's sampling
rate. The hub decimates the live push to 50 points per signal per tick, so a
1 MSps channel lands here at ~1.5 kpts/s. Sizing from the sampling rate
overshot by three orders of magnitude, saturated at the ceiling, and left the
longest windows showing only their tail. Measuring the buffer's own span
needs no rate model and is right for every signal type — scalars pushed once
per packet, decimated temporal streams, and snapshot waveforms alike. */
function growBufferForWindow(buf, windowSec) {
if (buf.size < buf.cap) return buf; // not full yet: it still reaches further back
const span = bufferSpanSec(buf);
const want = windowSec * 1.5; // headroom, so it is not re-grown every frame
if (span <= 0 || span >= want) return buf;
return growBuffer(buf, Math.min(MAX_CAP, Math.ceil(buf.cap * want / span)));
}
// Return a new, larger circular buffer that preserves all existing samples.
function growBuffer(buf, newCap) {
if (newCap <= buf.cap) return buf;
const nb = makeBuffer(newCap);
const start = buf.size === buf.cap ? buf.head : 0;
for (let i = 0; i < buf.size; i++) {
const idx = (start + i) % buf.cap;
pushBuffer(nb, buf.t[idx], buf.v[idx]);
}
return nb;
}
const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
];
/* ════════════════════════════════════════════════════════════════
Globals
════════════════════════════════════════════════════════════════ */
// sourcesMap: id → {id, label, addr, state, signals:[]}
const sourcesMap = {};
let buffers = {};
let plots = [];
let nextPlotId = 1;
let windowSec = 5;
let globalPause = false;
let lastDataAt = 0;
const traceColorMap = {};
let colorIdx = 0;
function getTraceColor(key) {
if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length];
return traceColorMap[key];
}
// Per-signal style overrides (color, width, dash, marker, markerSize).
const sigStyle = {};
function getSigStyle(key) {
if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
return sigStyle[key];
}
// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset}
const sigVScale = {};
// Active signal per plot: plotId → key
const plotActiveSignal = {};
function setSigStyle(key, updates) {
const s = getSigStyle(key);
Object.assign(s, updates);
if (updates.color) {
traceColorMap[key] = updates.color;
// Update badge dots for this key across all plots
document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => {
dot.style.background = updates.color;
});
}
// Recreate uPlot for all plots containing this key
plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
}
/* ─── VScale helpers ─────────────────────────────────────────────────────── */
// Key used to look a plot's vertical scale up. Normally compound,
// "plotId:signalKey", so the same signal in two plots scales independently. In
// unified mode every trace shares one scale, so they all collapse onto
// "plotId:*"; doing the redirect here rather than at each call site means the Y
// axis, cursors, rulers, offset markers and the scale panel follow for free.
const UNIFIED_VS_KEY = '*';
function vsKeyFor(plotId, key) {
const p = plots.find(q => q.id === plotId);
return plotId + ':' + ((p && p.mode === 'unified') ? UNIFIED_VS_KEY : key);
}
function getVScale(plotId, key) {
const vsKey = vsKeyFor(plotId, key);
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false };
return sigVScale[vsKey];
}
// Round a raw units-per-division up to the next 1/2/5×10ⁿ step so the Y-axis
// gridlines land on human-readable values.
function niceDiv(x) {
if (!isFinite(x) || x <= 0) return 1;
const p = Math.pow(10, Math.floor(Math.log10(x)));
const m = x / p;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;
}
function findSignalMeta(key) {
const colon = key.indexOf(':');
if (colon < 0) return null;
const src = sourcesMap[key.slice(0, colon)];
if (!src) return null;
const name = key.slice(colon + 1);
return src.signals.find(s => s.name === name)
|| src.signals.find(s => s.name === Calib.baseSignalName(name))
|| null;
}
/* ─── Calibration ────────────────────────────────────────────────────────── */
// Per-signal affine calibration, keyed by (source LABEL, base signal name).
// The label rather than the runtime id ('s1', 's2') is used because ids are
// assigned in add-order at startup, so an id-keyed entry would rebind to a
// different source whenever the source list order changed.
const CAL_LS_KEY = 'udpscope.calibration';
const calTable = new Calib.CalTable();
// Seeded from localStorage so calibration survives a reload against a hub that
// predates this feature (or one started without a config file). The first
// `calibration` frame from the hub overwrites it wholesale.
try {
const saved = localStorage.getItem(CAL_LS_KEY);
if (saved) calTable.replaceAll(JSON.parse(saved));
} catch { /* corrupt or unavailable storage: start empty */ }
function persistCalibration() {
try { localStorage.setItem(CAL_LS_KEY, JSON.stringify(calTable.list())); }
catch { /* quota or private mode: the hub copy is still authoritative */ }
}
// Signal key "s1:Adc[3]" → the source's label ("wave"), or '' if unknown.
function srcLabelForKey(key) {
const colon = key.indexOf(':');
if (colon < 0) return '';
const src = sourcesMap[key.slice(0, colon)];
return src ? (src.label || src.id) : '';
}
// Signal key "s1:Adc[3]" → base signal name ("Adc").
function baseSigForKey(key) {
const colon = key.indexOf(':');
return Calib.baseSignalName(colon < 0 ? key : key.slice(colon + 1));
}
// Never returns null — an uncalibrated signal yields Calib.IDENTITY.
function calForKey(key) {
return calTable.get(srcLabelForKey(key), baseSigForKey(key));
}
// The unit to show: the calibration override when set, else the streamer's.
function unitForKey(key) {
const cal = calForKey(key);
if (cal.unit) return cal.unit;
const meta = findSignalMeta(key);
return (meta && meta.unit) || '';
}
// Allocate a calibrated copy of a raw array. Returns the input untouched when
// the signal is uncalibrated, so the common case costs nothing.
function calibrateArray(key, rawY) {
const cal = calForKey(key);
if (cal.scale === 1 && cal.offset === 0) return 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 * cal.scale + cal.offset;
}
return out;
}
// Resolve the effective {divValue, offset} for a signal given its raw data array.
// y_norm = (y_raw - offset) / divValue
// divValue: units per division offset: raw value at screen centre
// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use.
function resolveVScale(plotId, key, rawY) {
const vs = getVScale(plotId, key);
if (vs.mode === 'range') {
const meta = findSignalMeta(key);
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
// rangeMin/rangeMax come from the streamer CONFIG in raw units and never
// pass through applyVScaleNorm, so calibrate them here. calRange re-orders
// the pair, which a negative scale would otherwise swap.
const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key));
const divValue = niceDiv((hi - lo) / 8);
const offset = Math.round((lo + hi) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset };
}
// 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 };
}
// Auto: fit data in central 6 of 8 divisions. Both the V/div and the centre
// offset are snapped so gridlines (and the zero line, when in view) fall on
// round values.
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 = niceDiv(Math.max((max - min) / 6, 1e-30));
const offset = Math.round((max + min) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset };
}
// 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;
});
}
// Unified mode: one vertical scale for the whole plot instead of one per
// signal, so traces can be compared against each other directly. Auto fits the
// union of every trace, range the union of their declared ranges; manual is a
// single per-plot setting. Calibration has already been applied to calArrays,
// so the shared scale is in calibrated units like the per-signal one.
function resolveUnifiedVScale(p, calArrays) {
const vs = getVScale(p.id, UNIFIED_VS_KEY);
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 };
}
let min = Infinity, max = -Infinity;
if (vs.mode === 'range') {
p.traces.forEach(key => {
const meta = findSignalMeta(key);
if (!meta || meta.rangeMin == null || meta.rangeMax == null ||
meta.rangeMax <= meta.rangeMin) return;
const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key));
if (lo < min) min = lo;
if (hi > max) max = hi;
});
if (isFinite(min) && max > min) {
const divValue = niceDiv((max - min) / 8);
const offset = Math.round((max + min) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset };
}
min = Infinity; max = -Infinity; // no trace declared a range: fall back to auto
}
for (const y of calArrays) {
for (let i = 0; i < y.length; i++) {
const v = y[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 = niceDiv(Math.max((max - min) / 6, 1e-30));
const offset = Math.round((max + min) / 2 / divValue) * divValue;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset };
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Calibration is applied first, so divValue/offset — and therefore the cursor,
// hover, ruler and Y-axis readouts derived from them — are all in calibrated
// units. Returns y_norm = (y_cal - offset) / divValue.
function applyVScaleNorm(p, yArrays) {
const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY));
if (p.mode === 'digital') return applyDigitalNorm(p, calArrays);
if (p.mode === 'mixed') return applyMixedNorm(p, calArrays);
if (p.mode === 'unified') {
const { divValue, offset } = resolveUnifiedVScale(p, calArrays);
return calArrays.map(y => {
const out = new Float64Array(y.length);
for (let i = 0; i < y.length; i++) {
const v = y[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue;
}
return out;
});
}
return calArrays.map((y, ki) => {
const key = p.traces[ki];
const { divValue, offset } = resolveVScale(p.id, key, y);
const out = new Float64Array(y.length);
for (let i = 0; i < y.length; i++) {
const v = y[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue;
}
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();
}
// Format a whole row/column of axis ticks. _fmtVal formats each value on its
// own merits, which collapses every tick to the same text once the values are
// large compared to the step between them (a Counter around 8.6e5 stepping by
// 5e3 printed "8.6e+5" nine times). Pick the precision from the tick step so
// neighbouring labels always differ.
function _fmtTickVals(vals) {
const nums = vals.filter(v => v != null && isFinite(v));
if (nums.length < 2) return vals.map(v => (v == null ? '' : _fmtVal(v)));
let step = Infinity;
for (let i = 1; i < nums.length; i++) {
const d = Math.abs(nums[i] - nums[i - 1]);
if (d > 0 && d < step) step = d;
}
const maxAbs = Math.max(...nums.map(Math.abs));
if (!isFinite(step) || step === 0 || maxAbs === 0) {
return vals.map(v => (v == null ? '' : _fmtVal(v)));
}
if (maxAbs >= 1e6 || maxAbs < 1e-3) {
const mant = Math.min(6, Math.max(0, Math.ceil(Math.log10(maxAbs / step))));
return vals.map(v => (v == null ? '' : v.toExponential(mant)));
}
const dec = Math.min(6, Math.max(0, Math.ceil(-Math.log10(step))));
return vals.map(v => (v == null ? '' : v.toFixed(dec)));
}
// 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[vsKeyFor(plotId, key)];
if (!vs) { infoEl.textContent = ''; return; }
const divValue = vs._resolvedDiv || vs.divValue || 1;
infoEl.textContent = _fmtVal(divValue) + '/div';
}
// 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
// Rolling-window anchor used to keep cursors visually fixed while live data scrolls.
let _cursorAnchorNow = null;
// Horizontal value rulers — stored in normalized division units (the shared
// y scale, -4.5…4.5) so one pair applies to every plot regardless of V/div.
const rulers = { mode: 'off', yA: null, yB: null };
// 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', holdoffSec: 0.2, stopped: false,
armed: false, collecting: false, trigTime: null, snapshot: null,
// Window latched by the hub at fire time (null until a trigger fires).
firedPreS: null, firedPostS: null,
};
// Edges of the window currently being filled. The hub reports what it latched
// at fire time; the config below is only a fallback for hubs that do not, and
// may have been edited since the trigger fired.
function trigPreSec() {
return trig.firedPreS !== null ? trig.firedPreS
: trig.windowSec * trig.prePercent / 100;
}
function trigPostSec() {
return trig.firedPostS !== null ? trig.firedPostS
: trig.windowSec * (100 - trig.prePercent) / 100;
}
/* Plots render in trigger-relative time in two situations: a finished capture
is on screen, or a trigger has fired and its post-window is still filling.
In the second case the hub has not sent the v2 frame yet (it waits for the
whole window plus a margin — several seconds for a long window), so the
trace is drawn live from this client's own buffers on the final x-axis. */
function inTrigWindow() {
return trig.enabled &&
(trig.snapshot !== null || (trig.collecting && trig.trigTime !== null));
}
// True while the window is filling and only live data is available.
function trigFilling() {
return trig.enabled && trig.snapshot === null && trig.collecting &&
trig.trigTime !== null;
}
// Window edges of whatever is on screen: a capture latches its own pre/post at
// fire time, so later UI edits must not move the axis of a finished capture.
function activePreSec() {
return (trig.snapshot && trig.snapshot._preS !== undefined)
? trig.snapshot._preS : trigPreSec();
}
function activePostSec() {
return (trig.snapshot && trig.snapshot._postS !== undefined)
? trig.snapshot._postS : trigPostSec();
}
/* ════════════════════════════════════════════════════════════════
WebSocket
════════════════════════════════════════════════════════════════ */
let ws = null, wsBackoff = 1000;
// Hub address resolution order:
// 1. ?hub=host:port query parameter (explicit override)
// 2. GET /hub served by the static webui server (points at the C++ StreamHub)
// 3. the serving host itself (Go hub mode: it implements /ws directly)
let HUB = new URLSearchParams(location.search).get('hub') || location.host;
async function resolveHub() {
if (new URLSearchParams(location.search).get('hub')) return;
try {
const r = await fetch('/hub', { cache: 'no-store' });
if (r.ok) {
const a = (await r.text()).trim();
if (a) HUB = a;
}
} catch { /* no /hub endpoint: Go hub mode, keep location.host */ }
}
function connectWS() {
ws = new WebSocket('ws://' + HUB + '/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
wsBackoff = 1000;
setStatus('orange', 'Connected – waiting for data');
// Restore monotonic TS preference from localStorage.
const monoPref = localStorage.getItem('udpscope.monotonic') === '1';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: monoPref }));
}
sendWindow();
};
ws.onclose = () => {
setStatus('red', 'Disconnected (reconnecting…)');
setTimeout(connectWS, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 30000);
};
ws.onerror = () => { };
ws.onmessage = evt => {
if (evt.data instanceof ArrayBuffer) { onBinaryData(evt.data); return; }
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'sources') onSources(msg);
else if (msg.type === 'config') onConfig(msg);
else if (msg.type === 'data') onData(msg);
else if (msg.type === 'stats') onStats(msg);
else if (msg.type === 'triggerState') onTriggerState(msg);
else if (msg.type === 'zoom') onZoomReply(msg);
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
else if (msg.type === 'monotonicState') onMonotonicState(msg);
else if (msg.type === 'calibration') onCalibration(msg);
else if (msg.type === 'configSaved' || msg.type === 'configReloaded') onConfigAck(msg);
};
}
/* Monotonic timestamp snapping — when enabled, the hub snaps small inter-frame
timestamp deviations (< 5 ms) to the ideal gap, eliminating overlaps/gaps
caused by software-dispatch jitter. */
function onMonotonicState(msg) {
const cb = document.getElementById('cb-monotonic');
if (!cb) return;
cb.checked = !!msg.enabled;
localStorage.setItem('udpscope.monotonic', msg.enabled ? '1' : '0');
}
document.getElementById('cb-monotonic').addEventListener('change', e => {
localStorage.setItem('udpscope.monotonic', e.target.checked ? '1' : '0');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: e.target.checked }));
}
});
/* WS zoom request/reply — replaces the Go hub's /api/zoom HTTP endpoint.
Resolves with the {key:{t,v}} signals map; rejects on timeout/closure. */
let _zoomReqId = 0;
const _zoomPending = new Map();
function wsZoomRequest(t0, t1, n, keys) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('ws not open')); return; }
const reqId = ++_zoomReqId;
const timer = setTimeout(() => {
_zoomPending.delete(reqId);
reject(new Error('zoom request timeout'));
}, 5000);
_zoomPending.set(reqId, { resolve, timer });
ws.send(JSON.stringify({ type: 'zoom', reqId, t0, t1, n, signals: keys.join(',') }));
});
}
function onZoomReply(msg) {
const p = _zoomPending.get(msg.reqId);
if (!p) return;
clearTimeout(p.timer);
_zoomPending.delete(msg.reqId);
p.resolve(msg.signals || {});
}
/* ════════════════════════════════════════════════════════════════
History (disk-backed data from the hub)
════════════════════════════════════════════════════════════════ */
// signals: key → {t0,t1,count,capacity,bucket}. bucket is how many source
// samples the hub folds into one archived min/max pair; 1 means verbatim.
let historyMeta = null; // {enabled, windowSec, decimation, maxMPts, signals}
const historyData = {}; // plotId → {signals:{key:{t,v}}, t0, t1}
const _histPending = new Map();
function wsHistoryZoomRequest(t0, t1, n, keys) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('ws not open')); return; }
const reqId = ++_zoomReqId;
const timer = setTimeout(() => {
_histPending.delete(reqId);
reject(new Error('historyZoom timeout'));
}, 10000);
_histPending.set(reqId, { resolve, timer });
ws.send(JSON.stringify({ type: 'historyZoom', reqId, t0, t1, n, signals: keys.join(',') }));
});
}
function onHistoryZoomReply(msg) {
const p = _histPending.get(msg.reqId);
if (!p) return;
clearTimeout(p.timer);
_histPending.delete(msg.reqId);
p.resolve(msg.signals || {});
}
function onHistoryInfo(msg) {
historyMeta = {
enabled: msg.enabled || false,
// The span the archive is sized to hold. The C++ StreamHub instead keeps a
// fixed retention period and reports it in hours.
windowSec: msg.windowSec || (msg.durationHours || 0) * 3600,
decimation: msg.decimation || 1,
maxMPts: msg.maxMPtsPerSignal || 0,
signals: msg.signals || {},
};
updateHistoryUI();
}
function updateHistoryUI() {
const badge = document.getElementById('history-badge');
if (!badge) return;
if (historyMeta && historyMeta.enabled) {
const nSigs = Object.keys(historyMeta.signals).length;
badge.textContent =
`History: ${fmtSpan(historyMeta.windowSec)}, ${nSigs} signals, ${fmtMPts(historyMeta.maxMPts)}/sig`;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
if (histPanelOpen) renderHistoryPanel();
}
// A timespan for the status bar: the archive covers seconds to minutes now that
// it is sized from the displayed window rather than from a retention period.
function fmtSpan(sec) {
if (!(sec > 0)) return '—';
if (sec < 1) return `${Math.round(sec * 1000)} ms`;
if (sec < 90) return `${+sec.toFixed(sec < 10 ? 1 : 0)} s`;
if (sec < 5400) return `${+(sec / 60).toFixed(1)} min`;
return `${+(sec / 3600).toFixed(1)} h`;
}
function fmtMPts(m) {
if (!(m > 0)) return '—';
return m >= 1 ? `${(+m.toFixed(3))} MPts` : `${Math.round(m * 1000)} kPts`;
}
/* ── Budget popup ───────────────────────────────────────────────── */
let histPanelOpen = false;
function toggleHistoryPanel() {
histPanelOpen = !histPanelOpen;
const panel = document.getElementById('history-panel');
if (!histPanelOpen) { panel.style.display = 'none'; return; }
document.getElementById('hist-budget').value =
historyMeta ? +historyMeta.maxMPts.toFixed(3) : '';
renderHistoryPanel();
panel.style.display = '';
// Anchored above the badge, since the badge sits in the status bar.
const r = document.getElementById('history-badge').getBoundingClientRect();
panel.style.left = `${Math.max(4, r.left)}px`;
panel.style.top = `${Math.max(4, r.top - panel.offsetHeight - 6)}px`;
}
function renderHistoryPanel() {
const box = document.getElementById('hist-signal-res');
const sigs = (historyMeta && historyMeta.signals) || {};
const keys = Object.keys(sigs).sort();
if (!keys.length) { box.innerHTML = '
no signals archived yet
'; return; }
box.innerHTML = keys.map(k => {
const s = sigs[k];
const bucket = s.bucket || 1;
// The bucket is what the budget actually controls, so it is what to show.
const res = bucket > 1 ? `min/max ÷${bucket}` : 'full res';
return `
${k}` +
`${res}
`;
}).join('');
}
function applyHistoryBudget() {
const v = parseFloat(document.getElementById('hist-budget').value);
if (!(v > 0)) return;
wsSend({ type: 'setHistoryBudget', maxMPtsPerSignal: v });
toggleHistoryPanel();
}
/* ════════════════════════════════════════════════════════════════
Status LED
════════════════════════════════════════════════════════════════ */
function setStatus(s, t) {
document.getElementById('status-led').className = s;
document.getElementById('status-text').textContent = t;
}
setInterval(() => {
const tsEl = document.getElementById('sb-tsage');
if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) {
const age = performance.now() - lastDataAt;
// Compute minimum lag: newest buffer timestamp vs browser wall clock.
let tsAge = null;
const wallNow = Date.now() / 1000;
Object.values(buffers).forEach(buf => {
if (buf.size === 0) return;
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
const a = wallNow - newest;
if (tsAge === null || a < tsAge) tsAge = a;
});
if (tsEl && tsAge !== null) {
const ms = tsAge * 1000;
tsEl.textContent = '| lag: ' + (ms < 1000 ? ms.toFixed(0) + 'ms' : tsAge.toFixed(2) + 's');
} else if (tsEl) {
tsEl.textContent = '';
}
if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's');
else setStatus('green', 'Streaming');
} else if (tsEl) {
tsEl.textContent = '';
}
}, 500);
/* ════════════════════════════════════════════════════════════════
Config handler
════════════════════════════════════════════════════════════════ */
function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }
function onConfig(msg) {
const sid = msg.sourceId;
if (!sid) return;
// Ensure source exists (may arrive before 'sources' message in some edge cases).
if (!sourcesMap[sid]) {
sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] };
}
const src = sourcesMap[sid];
const newSigs = msg.signals || [];
const oldSigs = src.signals || [];
const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0);
const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i]));
src.signals = newSigs;
if (changed) {
// Remove old buffers for this source only (prefix: "sid:").
const prefix = sid + ':';
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
newSigs.forEach(sig => {
const n = numElements(sig);
const base = prefix + sig.name;
// Both hubs flatten an array signal into a single series keyed by the
// signal name (element i of a packet is sample i in time), so a buffer
// per element could never be written to — for a 1000-element signal that
// reserved ~1.5 GB of dead memory.
buffers[base] = makeBuffer(n > 1 ? TEMPORAL_CAP : DEFAULT_CAP);
});
if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = 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) {
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;
const version = dv.getUint8(off); off += 1;
if (version === 2) { onTriggerCapture(dv, buf, off); return; }
if (version !== 1) return;
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;
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;
}
// Once full, grow towards the selected window. Only measuring can tell how
// many points a second of this signal costs, so this is where it happens.
const grown = growBufferForWindow(bufObj, windowSec);
if (grown !== bufObj) { bufObj = grown; 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
}
// Unconditionally: the generation is what tells decimateAsync its cached
// result is stale. Gating it on !trig.enabled froze the trigger-fill trace,
// because buildTrigFillData caches under a key that does not change as the
// window grows and relies on the generation alone to invalidate it. Only
// windows short enough to stay under targetPts escaped, since those skip the
// cache entirely — which is why just the long ones stopped sweeping.
_dataGen++;
// Marking plots dirty stays live-only: while a trigger is armed the plot is
// deliberately frozen on its last render, and the render loop marks a
// *filling* window dirty on its own.
if (!trig.enabled) {
plots.forEach(p => {
if (globalPause) return;
if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
});
}
}
/* Trigger logic runs hub-side (C++ StreamHub TriggerEngine). The SPA sends
the configuration + arm/disarm commands, tracks the FSM via "triggerState"
broadcasts, and receives the finished capture as a version-2 binary frame. */
function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
// Tell the hub how far back we are plotting. It sizes its per-signal buffers
// from the widest window any client reports: a window it does not know about is
// a window whose start may already have rolled out of the ring, which is what a
// zoom on a long timescale then has nothing to answer with.
function sendWindow() {
wsSend({ type: 'setWindow', seconds: windowSec });
}
// trig.threshold is held in calibrated units. The hub's comparator runs on raw
// samples, so invert on the way out: raw = (calibrated - offset) / scale.
function sendTrigConfig() {
const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY;
wsSend({
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec,
});
}
// Rewrite the threshold input and its unit hint from trig.threshold.
function refreshTrigThresholdField() {
const el = document.getElementById('trig-threshold');
if (document.activeElement !== el) el.value = trig.threshold;
const u = trig.signal ? unitForKey(trig.signal) : '';
el.title = u ? 'Threshold in ' + u : 'Threshold in the signal\u2019s raw units';
}
// Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]}
function onTriggerState(msg) {
const st = msg.state || 'idle';
trig.stopped = !!msg.stopped;
updateStopBtn();
// Window the hub latched at fire time. windowSec/prePercent below are the
// editable config and may have moved on since, so the fill axis must use
// this when the hub reports it.
if (msg.preSec !== undefined && msg.postSec !== undefined) {
trig.firedPreS = msg.preSec; trig.firedPostS = msg.postSec;
}
if (st === 'armed') {
trig.armed = true; trig.collecting = false;
// bufferFill is only sent while the hub is holding off: its buffers do not
// yet reach back far enough for a capture taken now to come back whole, so
// edges are ignored on purpose. Show it filling rather than leaving an
// armed trigger that looks broken.
const fill = msg.bufferFill;
setAllCardsCollecting(false); showRearmBtn(false);
updateTrigStatusBadge('armed', fill !== undefined ? `${Math.floor(fill * 100)}%` : '');
} else if (st === 'collecting') {
trig.armed = false; trig.collecting = true;
if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
// Clear the old snapshot now (mirrors the old fireTrigger) so the new
// capture replaces it when the v2 frame arrives.
trig.snapshot = null;
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
// Show the (still empty) trigger window immediately and let live data
// sweep into it: the hub only sends the capture once the whole window has
// elapsed, which is several seconds for a long window.
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
} else if (st === 'triggered') {
trig.armed = false; trig.collecting = false;
if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
} else { // idle
trig.armed = false; trig.collecting = false;
trig.firedPreS = null; trig.firedPostS = null;
setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('idle');
}
}
// Version-2 binary capture frame from the hub:
// [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]
// {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}
function onTriggerCapture(dv, buf, off) {
// The trigger lives in the hub and stays armed across client sessions, so a
// client that has not turned the trigger on locally still receives captures
// armed by someone else (or by its own previous page load). Applying them
// would silently drop this client's zoom and vertical scales while the UI is
// in plain live mode, so ignore captures we did not ask for.
if (!trig.enabled) return;
const trigTime = dv.getFloat64(off, true); off += 8;
const preS = dv.getFloat64(off, true); off += 8;
const postS = dv.getFloat64(off, true); off += 8;
const nSig = dv.getUint32(off, true); off += 4;
const snap = {};
for (let s = 0; s < nSig; s++) {
const keyLen = dv.getUint16(off, true); off += 2;
const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
off += keyLen;
const n = dv.getUint32(off, true); off += 4;
// slice() copies — guarantees the 8-byte alignment Float64Array requires.
const t = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
const v = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
snap[key] = { t, v };
}
// Window parameters latched at fire time (hub-side) so later UI edits do
// not affect how this capture is rendered.
snap._preS = preS;
snap._postS = postS;
trig.trigTime = trigTime;
trig.snapshot = snap;
trig.collecting = false;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
// Show cursor button now that snapshot exists (positions preserved from before)
updateCursorBtnVisibility();
// Drop any horizontal zoom so the whole new capture is visible, but leave the
// vertical scales alone: V/div and offset are settings the user dialled in and
// they must survive from shot to shot.
plots.forEach(p => {
p.xRange = null;
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. The "collecting" triggerState
// broadcast clears it when a new trigger actually fires.
trig.armed = true; trig.collecting = false;
showRearmBtn(false); updateTrigStatusBadge('armed');
updateCursorBtnVisibility();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
sendTrigConfig();
wsSend({ type: 'arm' });
}
function trigDisarm() {
trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false;
setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle');
wsSend({ type: 'disarm' });
}
function setAllCardsCollecting(on) {
document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on));
}
// note is appended to the label without changing the state colour, used for the
// pre-fill percentage of an armed-but-holding-off trigger.
function updateTrigStatusBadge(state, note) {
const el = document.getElementById('trig-status-badge');
el.className = state;
const label = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE';
el.textContent = note ? `${label} ${note}` : label;
el.title = note
? 'Waiting for the hub to buffer enough history to deliver a whole window'
: '';
}
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
};
}
/* ════════════════════════════════════════════════════════════════
Decimation Web Worker — offloads decimation off the main thread.
Cache key: "::::" when zoomed, or
"::rolling" in live mode, where the data
generation is passed alongside instead of being part of the key.
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
On cache-miss → render falls back to sync decimate once (first render only),
then worker takes over for subsequent updates.
════════════════════════════════════════════════════════════════ */
const decimCache = new Map(); // key → {t, v, gen}
const decimPending = new Map(); // key → generation currently in flight
// Hard ceiling on cached decimations. Rolling-mode keys are stable (one entry
// per plot), but zoom keys embed the range, so without a cap the map would grow
// for the whole session.
const DECIM_CACHE_MAX = 256;
// Store a decimation, re-inserting so Map iteration order stays oldest-first.
function decimCacheStore(key, entry) {
decimCache.delete(key);
decimCache.set(key, entry);
while (decimCache.size > DECIM_CACHE_MAX) {
decimCache.delete(decimCache.keys().next().value);
}
}
let _decimWorker = null;
try {
_decimWorker = new Worker('decimate-worker.js');
_decimWorker.onmessage = function({ data: { id, t, v } }) {
const gen = decimPending.get(id);
decimPending.delete(id);
decimCacheStore(id, { t, v, gen });
// Invalidate and redraw the owning plot. Clearing lastDataGen defeats the
// render loop's no-new-data fast path so this fresh result is actually drawn.
const plotId = parseInt(id.split(':')[0], 10);
const p = plots.find(q => q.id === plotId);
if (p) { p.needsRedraw = true; p.lastDataGen = -1; }
};
_decimWorker.onerror = e => console.warn('[decimate-worker] error:', e);
} catch(e) {
console.warn('[decimate-worker] unavailable, using sync fallback:', e);
}
// Submit a decimation job to the worker (or run sync if worker unavailable).
// `gen` identifies the input data behind a key that does not itself change with
// the data (rolling mode). A cached entry computed for an older generation is
// still returned — stale-while-revalidate — while a fresh job runs. Callers
// whose key already encodes the input (zoom ranges) pass no generation.
// Returns the cached {t, v} (fresh or stale), or null on the first render.
function decimateAsync(cacheKey, t, v, threshold, gen) {
const cached = decimCache.get(cacheKey);
if (cached && cached.gen === gen) return cached; // fresh — nothing to do
// At most one job per key in flight: submitting on every generation change
// would let the worker's message queue grow without bound whenever it cannot
// keep up with the push rate.
if (!decimPending.has(cacheKey)) {
decimPending.set(cacheKey, gen);
if (_decimWorker) {
// Send copies so the main thread retains the originals.
const tCopy = new Float64Array(t);
const vCopy = new Float64Array(v);
_decimWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold },
[tCopy.buffer, vCopy.buffer]);
} else {
// Synchronous fallback (worker unavailable).
const result = decimate(t, v, threshold);
result.gen = gen;
decimCacheStore(cacheKey, result);
decimPending.delete(cacheKey);
return result;
}
}
return cached || null; // stale entry, or nothing to draw yet
}
// Evict stale decimation cache entries for a plot (call when zoom range changes).
function decimCacheEvict(plotId) {
const prefix = plotId + ':';
for (const k of [...decimCache.keys()]) {
if (k.startsWith(prefix)) decimCache.delete(k);
}
for (const k of [...decimPending.keys()]) {
if (k.startsWith(prefix)) decimPending.delete(k);
}
}
/* Min/max (peak-envelope) decimation — the same algorithm as decimate-worker.js
and minMaxDecimate() in the Go hub, run inline when the worker result is not
ready yet. Splits the range into threshold/2 buckets and keeps each bucket's
smallest and largest sample in the order the two occurred, so a one-sample
spike survives being drawn at 1/1000 of its resolution. */
function decimate(t, v, threshold) {
const len = t.length;
if (len <= threshold || threshold < 4) return { t, v };
const buckets = threshold >> 1;
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
let n = 0;
for (let b = 0; b < buckets; b++) {
const lo = Math.floor(b * len / buckets);
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
if (lo >= hi) continue;
let iMin = lo, iMax = lo;
for (let j = lo + 1; j < hi; j++) {
if (v[j] < v[iMin]) iMin = j;
if (v[j] > v[iMax]) iMax = j;
}
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
}
return { t: outT.subarray(0, n), v: outV.subarray(0, n) };
}
/* ════════════════════════════════════════════════════════════════
Hybrid zoom: hi-res data fetched from /api/zoom on demand
════════════════════════════════════════════════════════════════ */
/* Plot x-ranges are stored relative to the trigger instant while a capture is on
screen, but the hub only knows absolute time — so every range that leaves this
client (and every comparison against a reply) goes through here. */
function absXRange(range) {
if (!range) return null;
if (!inTrigWindow() || trig.trigTime === null) return range;
return [trig.trigTime + range[0], trig.trigTime + range[1]];
}
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);
// Fire regular zoom + history zoom in parallel
const promises = [];
// Regular zoom (in-memory ring)
promises.push(wsZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
console.warn('zoom fetch:', e); return null;
}));
// History zoom (disk) — only if history is enabled
if (historyMeta && historyMeta.enabled) {
promises.push(wsHistoryZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
console.warn('history zoom fetch:', e); return null;
}));
}
const [sigs, histSigs] = await Promise.all(promises);
// Merge: history data first (older), regular zoom overlays (newer wins)
const merged = {};
if (histSigs) {
Object.entries(histSigs).forEach(([k, sd]) => {
if (!sd || !sd.t || !sd.v) return;
merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
});
}
if (sigs) {
Object.entries(sigs).forEach(([k, sd]) => {
if (!sd || !sd.t || !sd.v || sd.t.length === 0) return;
const zt = Float64Array.from(sd.t), zv = Float64Array.from(sd.v);
if (merged[k] && merged[k].t.length > 0) {
// Merge: use history for t < ring oldest, ring for the rest
const ht = merged[k].t, hv = merged[k].v;
const ringOldest = zt[0];
// Find cutoff in history data
let cutIdx = ht.length;
for (let i = 0; i < ht.length; i++) {
if (ht[i] >= ringOldest) { cutIdx = i; break; }
}
if (cutIdx > 0) {
// Prepend history data before ring data
const mt = new Float64Array(cutIdx + zt.length);
const mv = new Float64Array(cutIdx + zv.length);
mt.set(ht.subarray(0, cutIdx));
mv.set(hv.subarray(0, cutIdx));
mt.set(zt, cutIdx);
mv.set(zv, cutIdx);
merged[k] = { t: mt, v: mv };
} else {
merged[k] = { t: zt, v: zv };
}
} else {
merged[k] = { t: zt, v: zv };
}
});
}
plots.forEach(p => {
// Only store if this plot's range still matches what we fetched.
const abs = absXRange(p.xRange);
if (!abs || Math.abs(abs[0] - t0) > 1e-9 || Math.abs(abs[1] - t1) > 1e-9) return;
const plotSigs = {};
p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; });
if (Object.keys(plotSigs).length > 0) {
zoomData[p.id] = { signals: plotSigs, t0, t1 };
p.needsRedraw = true;
}
});
}
// Build uPlot data arrays from server-fetched hi-res signal data.
// Sparse signals (0 or 1 pts in range) are supplemented with local buffer
// bracket points so a line is always drawn across the visible area.
function buildDataFromFetched(p, fetchedSignals, targetPts) {
const t0 = p.xRange ? p.xRange[0] : -Infinity;
const t1 = p.xRange ? p.xRange[1] : Infinity;
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
let sd = fetchedSignals[key];
if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) continue;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
masterRate = rate; masterCount = sd.t.length; masterKey = key;
}
}
let masterSd = fetchedSignals[masterKey];
if (!masterSd || masterSd.t.length < 2)
masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1);
if (!masterSd || !masterSd.t.length)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
const dec = decimate(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 (!inTrigWindow()) 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.
// The trigger may target one element of an array ("sig[3]"), while traces are
// keyed by the base name, so compare on the base key.
const trigBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
if (p && trigBase && p.traces.includes(trigBase)) {
// Normalise the calibrated threshold to this plot's vscale for the trigger signal.
const tvs = sigVScale[p.id + ':' + trigBase];
let threshNorm = trig.threshold;
if (tvs) {
const dv = tvs._resolvedDiv || tvs.divValue || 1;
const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
threshNorm = (trig.threshold - ofs) / dv;
}
const y = u.valToPos(threshNorm, 'y', true);
if (y >= bbox.top && y <= bbox.top + bbox.height) {
const py = Math.round(y);
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
ctx.lineWidth = 0.75;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(bbox.left, py);
ctx.lineTo(bbox.left + bbox.width, py);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(203,166,247,0.45)';
ctx.font = '9px monospace';
ctx.textBaseline = 'bottom';
ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1);
}
}
ctx.restore();
}
// Linearly interpolate series value at time t from uPlot's current rendered data.
function interpAtTime(u, si, t) {
const td = u.data[0], vd = u.data[si];
if (!td || td.length === 0 || !vd) return null;
let lo = 0, hi = td.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; }
if (lo === 0) return vd[0] ?? null;
const t0 = td[lo - 1], t1 = td[lo];
const v0 = vd[lo - 1], v1 = vd[lo];
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
}
// Draw horizontal separator lines between signal bands in digital/mixed mode.
function drawBandSeparators(u, p) {
if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return;
const n = p.traces.length;
if (n < 2) return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
const bandH = 8 / n;
ctx.save();
ctx.strokeStyle = 'rgba(127,132,156,0.25)';
ctx.lineWidth = dpr;
for (let i = 1; i < n; i++) {
const yNorm = 4 - i * bandH; // boundary between band i-1 and band i
const yPx = u.valToPos(yNorm, 'y', true);
ctx.beginPath();
ctx.moveTo(bbox.left, yPx);
ctx.lineTo(bbox.left + bbox.width, yPx);
ctx.stroke();
}
ctx.restore();
}
// Redraw the active signal's line on top of all series with a wider stroke, so it
// visually appears in the foreground regardless of series draw order.
function drawActiveSeries(u, p) {
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const activeKey = plotActiveSignal[p.id];
if (!activeKey) return;
const idx = p.traces.indexOf(activeKey);
if (idx < 0) return;
const xs = u.data[0];
const ys = u.data[idx + 1]; // +1 because index 0 is time
if (!xs || !ys) return;
const style = getSigStyle(activeKey);
const dpr = window.devicePixelRatio || 1;
const { ctx } = u;
ctx.save();
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width * 2 * dpr;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
let started = false;
for (let i = 0; i < xs.length; i++) {
const yv = ys[i];
if (yv == null || !isFinite(yv)) { started = false; continue; }
const xPx = u.valToPos(xs[i], 'x', true);
const yPx = u.valToPos(yv, 'y', true);
if (!started) { ctx.moveTo(xPx, yPx); started = true; }
else ctx.lineTo(xPx, yPx);
}
ctx.stroke();
ctx.restore();
}
// Draw offset position markers (right-pointing triangles) on the left edge of
// the plot for each signal. The marker sits at the signal's zero level:
// y_norm = (0 - offset)/div, so signals with different offsets are visually
// separated and dragging the marker adjusts the offset directly.
// Active signal marker is larger and outlined in white.
//
// Unified mode has a single scale for the whole plot, so it gets one neutral
// marker instead of N identical triangles stacked on the same pixel.
function offsetMarkerEntries(p) {
if (p.mode === 'unified') {
const vs = sigVScale[p.id + ':' + UNIFIED_VS_KEY];
return vs ? [{ key: null, vs, color: '#9399b2' }] : [];
}
return p.traces
.map(key => ({ key, vs: sigVScale[p.id + ':' + key], color: getSigStyle(key).color }))
.filter(e => e.vs);
}
function drawOffsetMarkers(u, p) {
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
offsetMarkerEntries(p).forEach(({ key, vs, color }) => {
const dv = vs._resolvedDiv || vs.divValue || 1;
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const yCtr = u.valToPos(-ofs / dv, 'y', true);
const isActive = key !== null && plotActiveSignal[p.id] === key;
const mH = (isActive ? 7 : 5) * dpr;
const mW = (isActive ? 10 : 7) * dpr;
if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
ctx.save();
ctx.fillStyle = 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();
});
}
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 → y_raw = y_norm * divValue + offset
const vs = sigVScale[vsKeyFor(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);
vReal = vNorm * 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');
}
// Convert a normalized (division) y value to raw units — the active signal's in
// scope mode, the plot's shared ones in unified mode.
function rulerRawValue(p, yNorm) {
if (p.mode === 'unified') {
const uvs = sigVScale[p.id + ':' + UNIFIED_VS_KEY];
if (!uvs) return null;
const udv = uvs._resolvedDiv != null ? uvs._resolvedDiv : (uvs.divValue || 1);
const uofs = uvs._resolvedOffset != null ? uvs._resolvedOffset : (uvs.offset || 0);
return yNorm * udv + uofs;
}
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
const vs = key ? sigVScale[p.id + ':' + key] : null;
if (!vs) return null;
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
return yNorm * dv + ofs;
}
// Draw the horizontal value rulers (called from the draw hook).
function drawRulerLines(u, p) {
if (rulers.mode !== 'on') return;
const { ctx, bbox } = u;
if (!bbox) return;
const drawLine = (yNorm, color, label) => {
if (yNorm === null) return;
const y = Math.round(u.valToPos(yNorm, 'y', true));
if (y < bbox.top || y > bbox.top + bbox.height) return;
ctx.save();
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(bbox.left, y);
ctx.lineTo(bbox.left + bbox.width, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
const raw = p ? rulerRawValue(p, yNorm) : null;
ctx.fillText(label + (raw !== null ? ' ' + _fmtVal(raw) : ''), bbox.left + 4, y - 2);
ctx.restore();
};
drawLine(rulers.yA, 'rgba(166,227,161,0.85)', 'Y1');
drawLine(rulers.yB, 'rgba(243,139,168,0.85)', 'Y2');
}
// 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 } : {}),
});
});
// The x grid is pinned to 10 scope divisions (see splits below), so on narrow
// plots — a 2x2 layout, or any small tile — the 11 timestamps run into each
// other. Keep every grid line but label only as many of them as fit.
const xVals = (u, vals) => {
const labels = inTrigMode ? fmtTrigTick(u, vals) : fmtLiveTick(u, vals);
const plotW = u.bbox ? u.bbox.width / (devicePixelRatio || 1) : u.width;
const widest = labels.reduce((m, s) => Math.max(m, s.length), 0);
const need = widest * 7 + 12; // ~7 px per char at the axis font, plus a gap
const step = Math.max(1, Math.ceil((labels.length * need) / Math.max(plotW, 1)));
return step === 1 ? labels : labels.map((s, i) => (i % step === 0 ? s : ''));
};
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 },
// Measure the widest label instead of assuming one fits in 60 px: large
// offsets ("9.410e+8") and signal names in digital mode overflow a fixed
// gutter and get clipped at the canvas edge.
size: (u, values, axisIdx, cycleNum) => {
const axis = u.axes[axisIdx];
if (cycleNum > 1) return axis._size;
let sz = (axis.ticks && axis.ticks.size || 10) + (axis.gap || 5);
const longest = (values || []).reduce((a, v) => (String(v).length > a.length ? String(v) : a), '');
if (longest !== '') {
u.ctx.font = axis.font[0];
sz += u.ctx.measureText(longest).width / (devicePixelRatio || 1);
}
return Math.ceil(Math.max(40, Math.min(140, sz)));
},
splits: () => {
if (isBanded && p.traces.length > 0) {
const n = p.traces.length, bandH = 8 / n;
return p.traces.map((_, i) => 4 - (i + 0.5) * bandH);
}
return [-4, -3, -2, -1, 0, 1, 2, 3, 4];
},
values: (u, vals) => {
if (isBanded && p.traces.length > 0) {
const n = p.traces.length, bandH = 8 / n;
return vals.map(v => {
if (v == null) return '';
const i = Math.round((4 - v) / bandH - 0.5);
if (i < 0 || i >= n) return '';
const k = p.traces[i];
return k.includes(':') ? k.split(':').slice(1).join(':') : k;
});
}
// Unified mode labels the axis from the shared scale, so it needs no
// active signal — the numbers apply to every trace at once.
const activeKey = plotActiveSignal[p.id];
const vs = p.mode === 'unified'
? getVScale(p.id, UNIFIED_VS_KEY)
: (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);
return _fmtTickVals(vals.map(v => v == null ? null : v * divValue + offset));
},
},
],
legend: { show: false },
padding: [4, 4, 0, 0],
hooks: {
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawRulerLines(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 = inTrigWindow();
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);
// Which axis formatter / cursor-sync group this instance was built with, so
// the render loop can rebuild when the plot flips between live and trigger.
p.uplot._trigMode = inTrigMode;
// ── 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);
}
function _rulerAtClientY(clientY) {
const rect = p.uplot.over.getBoundingClientRect();
const { min, max } = p.uplot.scales.y;
const toY = val => rect.top + (1 - (val - min) / (max - min)) * rect.height;
if (rulers.yA !== null && Math.abs(clientY - toY(rulers.yA)) <= CURSOR_SNAP_PX) return 'A';
if (rulers.yB !== null && Math.abs(clientY - toY(rulers.yB)) <= CURSOR_SNAP_PX) return 'B';
return null;
}
function _rulerValFromEvent(e) {
const rect = p.uplot.over.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
const { min, max } = p.uplot.scales.y;
return max - pct * (max - min);
}
// Update pointer style based on what's under the mouse, and drive the
// time/value hover readout.
p.uplot.over.addEventListener('mousemove', e => {
const snapX = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
const snapY = !snapX && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
p.uplot.over.style.cursor = snapX ? 'ew-resize' : (snapY ? 'ns-resize' : '');
showHoverReadout(p, e);
});
p.uplot.over.addEventListener('mouseleave', () => {
p.uplot.over.style.cursor = '';
hideHoverReadout();
});
// 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
const target = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
const yTarget = !target && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
if (!target && !yTarget) return; // not near a cursor — let uPlot handle zoom
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
e.preventDefault();
// Set cursor position immediately on mousedown
if (yTarget) {
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(e);
else rulers.yB = _rulerValFromEvent(e);
} else if (target === 'A') cursors.tA = _cursorValFromEvent(e);
else cursors.tB = _cursorValFromEvent(e);
updateCursorReadout();
cursorsDirty = true;
const onMove = ev => {
if (yTarget) {
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(ev);
else rulers.yB = _rulerValFromEvent(ev);
} else 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 (the signal's zero level) AND the trace together
// by changing the vscale offset. Auto/range mode is switched to manual on
// drag start (seeded with the resolved V/div) so the drag sticks.
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 its zero-level canvas position).
// In unified mode there is exactly one marker and its key is null.
let hit = null, hitDist = Infinity;
offsetMarkerEntries(p).forEach(entry => {
const dv = entry.vs._resolvedDiv || entry.vs.divValue || 1;
const ofs = entry.vs._resolvedOffset != null ? entry.vs._resolvedOffset : (entry.vs.offset || 0);
const yDev = p.uplot.valToPos(-ofs / dv, 'y', true);
const yCss = rect.top + yDev / dpr;
const dist = Math.abs(e.clientY - yCss);
if (dist < 14 && dist < hitDist) { hitDist = dist; hit = entry; }
});
if (!hit) return;
const hitKey = hit.key;
e.preventDefault();
e.stopPropagation();
if (hitKey !== null) setActiveSig(p.id, hitKey);
const vs = hit.vs;
const startY = e.clientY;
const overRect = p.uplot.over.getBoundingClientRect();
// Seed manual mode from the resolved scale so the drag is visible immediately.
if (vs.mode !== 'manual') {
vs.divValue = vs._resolvedDiv || 1;
vs.offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
vs.mode = 'manual';
if (hitKey !== null && _vsMenuKey === hitKey) {
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
document.getElementById('vscale-manual-row').style.display = 'flex';
document.getElementById('vscale-offset-row').style.display = 'flex';
}
}
const startOffset = vs.offset != null ? vs.offset : 0;
const dv = vs.divValue || 1;
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 → trace up.
const dNorm = -dy / overRect.height * 9;
vs.offset = startOffset - dNorm * dv;
vs._resolvedOffset = vs.offset;
// Keep "Offset" input in sync if the vscale menu is open for this signal.
if (hitKey !== null && _vsMenuKey === hitKey) {
document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6));
}
if (hitKey !== null) refreshPlotForKey(hitKey);
else p.needsRedraw = true;
};
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)];
// Fired, window still filling: draw live data on the final trigger axis.
if (trigFilling()) return buildTrigFillData(p);
// Trigger enabled but not fired yet (armed): freeze on the last render.
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(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
// Only use server data if it actually has samples; otherwise fall through to local buffer.
if (fetched[0] && fetched[0].length > 0) return fetched;
}
}
// Slice all traces; pick master by sampling rate then count.
// In zoom mode, include one bracketing point on each side so lines are drawn
// across the visible area even when the window contains 0 or 1 samples.
const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets;
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const buf = buffers[key];
if (!buf || buf.size === 0) continue;
const sl = sliceFn(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// Decimate to pixel-adaptive point count in both rolling and zoomed modes.
// The server sends ≤2000 pts/tick but the buffer accumulates many ticks, so
// the full window slice can easily reach 100k–300k pts — far more than uPlot
// needs for a 1200px-wide canvas. Always decimate via the background worker
// (stale-while-revalidate: use cached result; fall back to sync on first render).
// The rolling-mode key is constant per (plot, signal) — the data generation is
// carried separately so the cache holds one entry per plot instead of one per
// push tick, which used to grow without bound for the whole session.
const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const cacheKey = isRolling
? `${p.id}:${masterKey}:rolling`
: `${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 = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts,
isRolling ? _dataGen : undefined);
let dec;
if (cached) {
dec = cached;
} else {
// Worker job submitted — sync fallback this frame so the plot isn't blank.
dec = decimate(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(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
/* The capture frame is the whole window in 20 000 points, so zooming into it
magnifies that decimation instead of resolving detail. When the hub has
answered a zoom for exactly this range — out of the ring while the window is
still in it, out of the disk history afterwards — that reply is the same
samples at the zoomed resolution, so it wins. It is only ever a supplement:
an empty or stale reply falls back to the snapshot, which is always there. */
const zd = p.xRange ? zoomData[p.id] : null;
const fetched = (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9)
? zd.signals : null;
/* Only when it spans the view. The window rolls out of the ring soon after the
capture (the ring is sized at ~1.2× the window), so a late reply can cover
just the newest sliver of the range — and half a trace at high resolution is
worse than a whole one at low resolution. */
const edgeTol = (t1 - t0) * 0.02;
const covers = (sd) => sd && sd.t.length > 1 &&
sd.t[0] <= t0 + edgeTol && sd.t[sd.t.length - 1] >= t1 - edgeTol;
// Slice all traces; pick master by samplingRate first, then sample count
const slices = {};
let usedFetched = false;
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const hi = fetched ? fetched[key] : null;
const src = covers(hi) ? hi : trig.snapshot[key];
if (!src) continue;
if (src === hi) usedFetched = true;
const sl = sliceTypedArrayRange(src.t, src.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. A fetched slice can replace a
// same-length snapshot slice for the same range, so it is tagged separately.
const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}:${usedFetched ? 'hi' : 'snap'}`;
const cachedDec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
const dec = cachedDec || decimate(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)];
}
/* Trace drawn while a fired trigger's window is still filling. Identical
framing to buildTrigData (relative seconds, fixed [-pre, +post] axis) but fed
from the live push buffers, so the waveform sweeps in from the left instead
of the plot sitting frozen until the hub finishes the capture. */
function buildTrigFillData(p) {
if (p.traces.length === 0) return [new Float64Array(0)];
const trigT = trig.trigTime;
const preS = trigPreSec(), postS = trigPostSec();
const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;
const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const buf = buffers[key];
if (!buf || buf.size === 0) continue;
const sl = getBufferSliceRange(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
masterRate = rate; masterCount = sl.t.length; masterKey = key;
}
}
const masterRaw = slices[masterKey];
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// The window keeps growing, so key the cache on the data generation the same
// way the rolling live path does rather than on the (fixed) range.
let sharedAbsT, masterV;
if (masterRaw.t.length <= targetPts) {
sharedAbsT = masterRaw.t;
masterV = masterRaw.v;
} else {
const cacheKey = `${p.id}:${masterKey}:trigfill`;
const dec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen) ||
decimate(masterRaw.t, masterRaw.v, targetPts);
sharedAbsT = dec.t;
masterV = dec.v;
}
const sharedT = new Float64Array(sharedAbsT.length);
for (let i = 0; i < sharedAbsT.length; i++) sharedT[i] = sharedAbsT[i] - trigT;
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; }
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 decimation cache entries — new zoom range needs fresh decimation.
plots.forEach(p => decimCacheEvict(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. A capture is a snapshot the hub
// already decimated to 20 000 pts for the whole window, so zooming into it
// needs the same round trip as live zooming does, not just a bigger axis.
const abs = absXRange([min, max]);
scheduleZoomFetch(abs[0], abs[1]);
}
// 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;
const abs = absXRange(prev);
scheduleZoomFetch(abs[0], abs[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 (inTrigWindow()) {
const preS = activePreSec();
const postS = activePostSec();
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 (inTrigWindow()) {
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
════════════════════════════════════════════════════════════════ */
// Cursors are always available — in live rolling mode they are pinned to the
// moving viewport by the render loop.
function updateCursorBtnVisibility() {
document.getElementById('btn-cursor').style.display = '';
}
/* Place cursors A/B at 25 %/75 % of what is currently on screen.
Cursor positions are absolute — Unix seconds live, seconds from the trigger
under one — so a zoom, a pan or a new capture can leave them outside the
viewport entirely, with no way to get them back: they are dragged by grabbing
their line, and an off-screen line cannot be grabbed. */
function resetCursors() {
const refPlot = plots.find(p => p.uplot);
if (!refPlot) return;
const { min, max } = refPlot.uplot.scales.x;
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return;
const span = max - min;
cursors.tA = min + span * 0.25;
cursors.tB = min + span * 0.75;
updateCursorReadout();
cursorsDirty = true;
}
document.getElementById('btn-cursor').addEventListener('click', () => {
cursors.mode = cursors.mode === 'off' ? 'on' : 'off';
const btn = document.getElementById('btn-cursor');
btn.textContent = 'Cursors';
btn.classList.toggle('active', cursors.mode === 'on');
// Reset is only meaningful while the cursors are drawn.
document.getElementById('btn-cursor-reset').style.display =
cursors.mode === 'on' ? '' : 'none';
if (cursors.mode === 'on') {
// Auto-place on first use; afterwards the positions are the user's.
if (cursors.tA === null && cursors.tB === null) resetCursors();
updateCursorReadout();
document.getElementById('cursor-readout').classList.add('visible');
} else {
document.getElementById('cursor-readout').classList.remove('visible');
}
cursorsDirty = true;
});
document.getElementById('btn-cursor-reset').addEventListener('click', resetCursors);
document.getElementById('btn-ruler').addEventListener('click', () => {
rulers.mode = rulers.mode === 'off' ? 'on' : 'off';
const btn = document.getElementById('btn-ruler');
btn.classList.toggle('active', rulers.mode === 'on');
if (rulers.mode === 'on' && rulers.yA === null && rulers.yB === null) {
// Auto-place at ±2 divisions from the centre on first use.
rulers.yA = -2; rulers.yB = 2;
}
updateCursorReadout();
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
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);
return vNorm * 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';
});
}
/* ─── Hover readout ──────────────────────────────────────────────────────── */
// Un-normalize a plotted value of trace `key` in plot `p` back to calibrated units.
function rawFromNorm(p, key, vNorm) {
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);
return vNorm * dv + ofs;
}
function hideHoverReadout() {
document.getElementById('hover-readout').style.display = 'none';
}
// Show the time under the mouse plus every trace's value at that time.
function showHoverReadout(p, e) {
const el = document.getElementById('hover-readout');
if (!p.uplot || p.traces.length === 0) { el.style.display = 'none'; return; }
const rect = p.uplot.over.getBoundingClientRect();
const { min, max } = p.uplot.scales.x;
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const t = min + pct * (max - min);
const span = Math.abs(max - min);
const tStr = inTrigWindow() ? fmtDuration(t, span, true) : fmtLiveTime(t, span);
let html = '
' + escHtml(tStr) + '
';
p.traces.forEach((key, idx) => {
const vNorm = interpAtTime(p.uplot, idx + 1, t);
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
// rawFromNorm inverts the vscale transform, which Task 7 made operate on
// calibrated values — so this is already in calibrated units.
const unit = unitForKey(key);
const val = vNorm === null ? '—'
: (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : ''));
html += '