Files
MARTe-Integrated-Components/Client/udpstreamer/static/app.js
T
Martino FerrariandClaude Opus 4.6 f334995865 feat(webui): sporadic-trigger capture, CSV export and UI rework
Brings the Go hub and web SPA work developed on feature/udpscope onto
main, without the udpscope client itself.

The trigger engine could not capture a sporadic event: it armed on the
live tail only, so a burst shorter than one push window was already past
by the time the FSM looked for it. It now searches the ring history for
the crossing, which also makes a capture reproducible from the same data
rather than dependent on push timing (wshub/trigger.go, ringbuf.go,
history.go).

Adds CSV/JSON export of the visible window (wshub/export.go) and reworks
the SPA: per-signal axis controls, a readable trigger panel, and a fix
for the flicker caused by repainting on every push instead of on a frame
tick (static/app.js, index.html, style.css).

BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction,
which is otherwise only inferable from the three files that implement it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 01:18:46 +02:00

5070 lines
214 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
// 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. The on/off toggle is global, but each plot keeps its
// own pair of normalized-division positions (rulerState), so dragging Y1 in
// one plot does not move it in the others.
const rulers = { mode: 'off', plotId: null };
const rulerState = {}; // plotId → { yA, yB }
function getRulerState(plotId) {
if (!rulerState[plotId]) rulerState[plotId] = { yA: null, yB: null };
return rulerState[plotId];
}
// Layout — [label, cssClass, cols, rows, (optional) plotCount].
// Custom (non-uniform) layouts carry an explicit plotCount; the grid template
// and the spanning cells are defined in style.css under #plot-grid.<class>.
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],
['1+2', 'l1p2', 2, 2, 3], // one plot spanning the top row, two below
];
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 = '<div class="hist-res-row">no signals archived yet</div>'; 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 `<div class="hist-res-row"><span class="hist-res-key" title="${k}">${k}</span>` +
`<span class="hist-res-val">${res}</span></div>`;
}).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();
maybeRestoreViewLate();
}
/* ════════════════════════════════════════════════════════════════
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;
// A negative calibration gain flips the signal on screen (v_cal = v_raw·scale
// + offset with scale < 0), so a calibrated rising edge is a raw FALLING
// edge. The hub compares raw samples, so send the raw direction that matches
// the edge the user picked on the calibrated trace.
let edge = trig.edge;
if (cal.scale < 0) {
if (edge === 'rising') edge = 'falling';
else if (edge === 'falling') edge = 'rising';
}
wsSend({
type: 'setTrigger', signal: trig.signal, edge: 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: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>" when zoomed, or
"<plotId>:<masterKey>: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;
}
}
// Never hand out a stale decimation: drawing it (at its old timestamps) and
// then the fresh one a frame later is what makes the trace jump/shimmer on
// every push. Return null instead — the caller holds the previous render
// until the worker's fresh result lands (it flags the plot for redraw).
return null;
}
// 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;
// Calibrated value at the cursor time, from the raw source (matches
// the hover and the cursor readouts in every display mode).
const vReal = calibratedValueAt(key, val);
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 = vReal === null ? '—' : (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 rs = rulerState[p.id];
if (!rs) 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(rs.yA, 'rgba(166,227,161,0.85)', 'Y1');
drawLine(rs.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;
const rs = rulerState[p.id];
if (rs && rs.yA !== null && Math.abs(clientY - toY(rs.yA)) <= CURSOR_SNAP_PX) return 'A';
if (rs && rs.yB !== null && Math.abs(clientY - toY(rs.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) {
rulers.plotId = p.id; // the readout follows the plot whose rulers moved
const rs = getRulerState(p.id);
if (yTarget === 'A') rs.yA = _rulerValFromEvent(e);
else rs.yB = _rulerValFromEvent(e);
} else if (target === 'A') cursors.tA = _cursorValFromEvent(e);
else cursors.tB = _cursorValFromEvent(e);
updateCursorReadout();
cursorsDirty = true;
const onMove = ev => {
if (yTarget) {
const rs = getRulerState(p.id);
if (yTarget === 'A') rs.yA = _rulerValFromEvent(ev);
else rs.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 100k300k 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 if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) {
// Fresh decimation not ready yet — hold the previous render so the trace
// does not flicker between a stale decimation and the fresh one.
return p.uplot.data;
} else {
// First render: worker job submitted, nothing on screen yet — sync.
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);
let dec;
if (cachedDec) {
dec = cachedDec;
} else if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) {
return p.uplot.data; // hold the previous render until the fresh decimation lands
} else {
dec = 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 decd = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen);
let dec;
if (decd) {
dec = decd;
} else if (p.uplot && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length) {
return p.uplot.data; // hold until the fresh decimation is ready
} else {
dec = 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 resetRulers() {
// Re-place every plot's rulers at the default ±2 divisions, like
// resetCursors re-places the vertical cursors.
plots.forEach(p => {
const rs = getRulerState(p.id);
rs.yA = -2; rs.yB = 2;
});
updateCursorReadout();
cursorsDirty = true;
}
function resetCursors() {
resetRulers();
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') {
// Auto-place every plot at ±2 divisions from the centre on first use;
// afterwards each plot keeps its own positions.
plots.forEach(pl => {
const rs = getRulerState(pl.id);
if (rs.yA === null && rs.yB === null) { rs.yA = -2; rs.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;
// Interpolate the raw wire value and apply the calibration explicitly, so
// cursor readouts match the hover in every display mode.
return calibratedValueAt(key, t);
}
// 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;
}
// Linear interpolation of a sorted (t, v) pair at absolute time tAbs. Returns
// null outside the data's range — never fabricated, so an export or readout
// cannot invent samples the signal never had.
function interpSortedRaw(t, v, tAbs) {
if (!t || t.length === 0) return null;
if (tAbs < t[0] || tAbs > t[t.length - 1]) return null;
let lo = 0, hi = t.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (t[m] < tAbs) lo = m + 1; else hi = m; }
if (lo === 0) return v[0] ?? null;
const t0 = t[lo - 1], t1 = t[lo];
const v0 = v[lo - 1], v1 = v[lo];
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
return v0 + (tAbs - t0) / (t1 - t0) * (v1 - v0);
}
// Binary-search linear interpolation of a circular buffer at time t.
function interpCircular(buf, t) {
if (!buf || buf.size === 0) return null;
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)] < t) lo = m + 1; else hi = m; }
if (lo === 0) return buf.v[physAt(0)] ?? null;
if (lo >= size) return buf.v[physAt(size - 1)] ?? null;
const t0 = buf.t[physAt(lo - 1)], t1 = buf.t[physAt(lo)];
const v0 = buf.v[physAt(lo - 1)], v1 = buf.v[physAt(lo)];
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
}
// Raw (uncalibrated) value of `key` at absolute time tAbs, from the best
// available raw source: trigger snapshot → fetched zoom data → live push
// buffer. All three store wire values, so calibration is applied here, at the
// point of display, exactly once.
function rawAtAbsTime(key, tAbs) {
if (trig.snapshot) {
const s = trig.snapshot[key];
if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; }
}
for (const p of plots) {
const zd = zoomData[p.id];
if (!zd) continue;
const s = zd.signals[key];
if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; }
}
const buf = buffers[key];
if (buf && buf.size) { const v = interpCircular(buf, tAbs); if (v != null) return v; }
return null;
}
// Calibrated value of `key` at axis time t. Under a trigger the axis is
// relative to the trigger instant, so convert to absolute first.
function calibratedValueAt(key, t) {
const tAbs = (inTrigWindow() && trig.trigTime != null) ? trig.trigTime + t : t;
const raw = rawAtAbsTime(key, tAbs);
return raw === null ? null : Calib.applyCal(raw, calForKey(key));
}
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 = '<div class="hov-time">' + escHtml(tStr) + '</div>';
p.traces.forEach((key, idx) => {
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
const unit = unitForKey(key);
// Interpolate the raw wire value and apply the calibration explicitly,
// so the hover is correct in every display mode (analog, digital,
// mixed) and independent of the vscale state.
const vCal = calibratedValueAt(key, t);
const val = vCal === null ? '—'
: (_fmtVal(vCal) + (unit ? ' ' + unit : ''));
html += '<div class="hov-row"><span class="hov-dot" style="background:' +
escHtml(getSigStyle(key).color) + '"></span>' +
'<span class="hov-name">' + escHtml(name) + '</span>' +
'<span class="hov-val">' + escHtml(val) + '</span></div>';
});
el.innerHTML = html;
el.style.display = 'block';
// Keep the tooltip inside the viewport.
const w = el.offsetWidth, h = el.offsetHeight;
let x = e.clientX + 14, y = e.clientY + 14;
if (x + w > window.innerWidth - 4) x = e.clientX - w - 14;
if (y + h > window.innerHeight - 4) y = e.clientY - h - 14;
el.style.left = Math.max(4, x) + 'px';
el.style.top = Math.max(4, y) + 'px';
}
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the plot
// whose rulers were last moved, falling back to the first plot with a signal.
function updateRulerReadout() {
const box = document.getElementById('ruler-readout');
const on = rulers.mode === 'on';
box.style.display = on ? '' : 'none';
if (!on) return;
let ref = null;
if (rulers.plotId !== null) {
const pl = plots.find(p => p.id === rulers.plotId);
if (pl && pl.uplot && pl.traces.length > 0) ref = pl;
}
if (!ref) {
ref = plots.find(p => p.uplot && p.traces.length > 0 &&
rulerRawValue(p, 0) !== null) || null;
}
const rs = ref ? rulerState[ref.id] : null;
const conv = y => (y === null || !ref || !rs) ? null : rulerRawValue(ref, y);
const vA = conv(rs ? rs.yA : null), vB = conv(rs ? rs.yB : null);
document.getElementById('cur-y1').textContent = 'Y1: ' + fmtVal(vA);
document.getElementById('cur-y2').textContent = 'Y2: ' + fmtVal(vB);
document.getElementById('cur-dy').textContent =
'ΔY: ' + fmtVal(vA !== null && vB !== null ? vB - vA : null);
}
function updateCursorReadout() {
const ro = document.getElementById('cursor-readout');
const active = cursors.mode === 'on';
ro.classList.toggle('visible', active || rulers.mode === 'on');
updatePlotCursorReadouts();
updateRulerReadout();
['cur-ta', 'cur-tb', 'cur-dt'].forEach(id => {
document.getElementById(id).style.display = active ? '' : 'none';
});
if (!active) return;
// Use the current visible x-range to pick the display unit.
const span = currentXSpan();
// Format a cursor position: trigger mode → signed relative duration;
// live mode → absolute wall time with span-appropriate precision.
const fmt = v => {
if (v === null) return '—';
if (inTrigWindow()) return fmtDuration(v, span, true);
return fmtLiveTime(v, span);
};
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
if (cursors.tA !== null && cursors.tB !== null) {
const dt = cursors.tB - cursors.tA;
// ΔT auto-scales by its own magnitude for precision regardless of x-range.
document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true);
} else {
document.getElementById('cur-dt').textContent = 'ΔT: —';
}
}
/* ════════════════════════════════════════════════════════════════
Trigger bar controls
════════════════════════════════════════════════════════════════ */
function openTrigBar(open) {
trig.enabled = open;
document.getElementById('trigbar').classList.toggle('open', open);
document.getElementById('btn-trigger').classList.toggle('trig-active', open);
document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px');
// Streaming-only controls are irrelevant while the trigger is active
const none = open ? 'none' : '';
document.getElementById('btn-pause-global').style.display = none;
document.getElementById('window-select').style.display = none;
document.getElementById('lbl-window').style.display = none;
if (!open) {
trigDisarm(); trig.snapshot = null;
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
} else {
cursors.tA = null; cursors.tB = null; updateCursorReadout();
updateCursorBtnVisibility();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
resetZoom();
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
}
// Resize uPlot instances after trigbar height changes
setTimeout(() => {
plots.forEach(p => {
if (!p.uplot) return;
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
});
}, 220);
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
const val = e.target.value;
if (!val) { trig.signal = ''; trigDisarm(); return; }
// Array signal: ask for element index via picker dialog.
const meta = findSignalMeta(val);
const n = meta ? numElements(meta) : 1;
if (meta && !isTemporal(meta) && n > 1) {
showArrayIdxPicker(val, n, idx => {
trig.signal = val + '[' + idx + ']'; refreshTrigThresholdField(); sendTrigConfig();
if (trig.enabled) trigArm();
}, () => {
// Cancelled: revert selection to current trig.signal base or empty.
const sel = document.getElementById('trig-signal');
const base = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
sel.value = base || '';
});
return;
}
trig.signal = val; refreshTrigThresholdField(); sendTrigConfig();
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; sendTrigConfig(); });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; sendTrigConfig(); });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); sendTrigConfig(); });
document.getElementById('trig-pre').addEventListener('input', e => {
trig.prePercent = parseInt(e.target.value, 10);
document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
sendTrigConfig();
});
document.getElementById('trig-holdoff').addEventListener('change', e => {
let v = parseFloat(e.target.value);
if (!isFinite(v)) v = 0.2;
trig.holdoffSec = Math.max(0, Math.min(60, v));
e.target.value = trig.holdoffSec;
sendTrigConfig();
});
document.getElementById('trig-mode').addEventListener('change', e => {
trig.mode = e.target.value;
sendTrigConfig();
// If a snapshot already exists the trigger has already fired — update button
// visibility immediately so it matches the newly selected mode.
if (trig.snapshot) {
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
updateStopBtn();
}
});
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
// Force: capture the window around the newest sample regardless of the threshold.
document.getElementById('btn-trig-force').addEventListener('click', () => {
if (!trig.enabled || !trig.signal) return;
sendTrigConfig();
wsSend({ type: 'forceTrigger' });
});
document.getElementById('btn-trig-stop').addEventListener('click', () => {
if (!trig.enabled || trig.mode !== 'normal') return;
trig.stopped = !trig.stopped;
updateStopBtn();
wsSend({ type: 'trigStop', stopped: trig.stopped });
if (!trig.stopped) trigArm(); // resume: re-arm immediately
});
/* ════════════════════════════════════════════════════════════════
Trigger signal selector
════════════════════════════════════════════════════════════════ */
function buildTrigSignalSelect() {
const sel = document.getElementById('trig-signal');
// Preserve the currently active trig.signal (may include an array index like "[3]").
const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
sel.innerHTML = '<option value="">— none —</option>';
Object.values(sourcesMap).forEach(src => {
const prefix = src.id + ':';
const srcLabel = src.label || src.addr || src.id;
(src.signals || []).forEach(sig => {
const n = numElements(sig);
const key = prefix + sig.name;
const o = document.createElement('option');
o.value = key;
if (isTemporal(sig) || n === 1) {
o.textContent = srcLabel + ': ' + sig.name;
} else {
// Array: single entry; user chooses index via dialog on selection.
o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']';
}
sel.appendChild(o);
});
});
// Restore selection: match base key so array element "sig[3]" selects "sig" option.
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
// Do NOT overwrite trig.signal here — an array element selection must be preserved.
refreshTrigThresholdField();
}
/* ════════════════════════════════════════════════════════════════
Sidebar
════════════════════════════════════════════════════════════════ */
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
function buildSidebar() {
const list = document.getElementById('signal-list');
list.innerHTML = '';
const sources = Object.values(sourcesMap);
sources.forEach(src => {
const sigs = src.signals || [];
const prefix = src.id + ':';
// Source header
const grp = document.createElement('div');
grp.className = 'source-group';
const hdr = document.createElement('div');
hdr.className = 'source-group-header';
const dot = document.createElement('span');
dot.className = 'source-state-dot ' + (src.state || 'disconnected');
const nameEl = document.createElement('span');
nameEl.className = 'source-name';
nameEl.textContent = src.label || src.addr || src.id;
nameEl.title = src.addr || '';
const addrEl = document.createElement('span');
addrEl.className = 'source-addr';
if (src.label && src.addr) addrEl.textContent = src.addr;
const rmBtn = document.createElement('button');
rmBtn.className = 'source-remove-btn';
rmBtn.title = 'Remove source';
rmBtn.textContent = '×';
rmBtn.addEventListener('click', () => removeSource(src.id));
hdr.append(dot, nameEl, addrEl, rmBtn);
grp.appendChild(hdr);
sigs.forEach(sig => {
const n = numElements(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
// One row per signal, array or not: the hubs stream every array under the
// base name, so per-element rows only ever produced empty traces.
grp.appendChild(makeDraggable(globalKey, sig.name,
n > 1 ? '[' + n + '] ' + typeName : typeName, unitForKey(globalKey)));
});
list.appendChild(grp);
});
if (!sources.length) {
const empty = document.createElement('div');
empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
empty.textContent = 'No sources configured';
list.appendChild(empty);
}
list.appendChild(makeSourcesConfigSection());
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
item.className = 'sig-item'; item.draggable = true;
item.innerHTML = '<span class="sig-name">' + escHtml(label) + '</span>'
+ (unit ? '<span class="sig-unit">' + escHtml(unit) + '</span>' : '')
+ '<span class="type-badge">' + escHtml(typeName) + '</span>';
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
requestAnimationFrame(() => item.classList.add('dragging'));
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
return item;
}
/* ════════════════════════════════════════════════════════════════
Grid resize handles
════════════════════════════════════════════════════════════════ */
function updateGridTemplate() {
const grid = document.getElementById('plot-grid');
grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' ');
grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' ');
// Reposition all handles
document.querySelectorAll('.resize-handle-v').forEach(h => {
const i = parseInt(h.dataset.col);
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
document.querySelectorAll('.resize-handle-h').forEach(h => {
const i = parseInt(h.dataset.row);
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
// Notify uPlot of new sizes
plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); });
}
function setupResizeHandles(cols, rows) {
const grid = document.getElementById('plot-grid');
grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove());
const makeHandle = (cls, dataset, onDown) => {
const h = document.createElement('div');
h.className = cls;
Object.assign(h.dataset, dataset);
h.addEventListener('mousedown', onDown);
grid.appendChild(h);
return h;
};
for (let i = 0; i < cols - 1; i++) {
const h = makeHandle('resize-handle-v', { col: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = colFrs.reduce((a, b) => a + b, 0);
const pairSum = colFrs[i] + colFrs[i + 1];
const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientX - rect.left) / rect.width;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore));
colFrs[i] = newI; colFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
for (let i = 0; i < rows - 1; i++) {
const h = makeHandle('resize-handle-h', { row: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = rowFrs.reduce((a, b) => a + b, 0);
const pairSum = rowFrs[i] + rowFrs[i + 1];
const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientY - rect.top) / rect.height;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore));
rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
}
/* ════════════════════════════════════════════════════════════════
Plot config toolbar (click plot title to open)
════════════════════════════════════════════════════════════════ */
let _pcfgOpenId = null;
function showPlotCfg(plotId) {
if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg();
_pcfgOpenId = plotId;
document.getElementById('pcfg-' + plotId).style.display = 'block';
syncPlotCfgVScale(plotId);
}
function hidePlotCfg() {
if (_pcfgOpenId == null) return;
const bar = document.getElementById('pcfg-' + _pcfgOpenId);
if (bar) bar.style.display = 'none';
_pcfgOpenId = null;
}
// Repaint the plot-level Y-scale controls from the plot's shared scale. Only
// meaningful in unified mode; the group stays hidden otherwise.
function syncPlotCfgVScale(plotId) {
const bar = document.getElementById('pcfg-' + plotId);
const p = plots.find(q => q.id === plotId);
if (!bar || !p) return;
const group = bar.querySelector('.pcfg-vs-group');
if (!group) return;
const isUnified = p.mode === 'unified';
group.style.display = isUnified ? 'flex' : 'none';
if (!isUnified) return;
const vs = getVScale(plotId, UNIFIED_VS_KEY);
group.querySelectorAll('.pcfg-vs-mode .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.mode === vs.mode));
// Range is only meaningful if at least one trace declares one.
const rangeBtn = group.querySelector('.pcfg-vs-mode [data-mode="range"]');
if (rangeBtn) {
const hasRange = p.traces.some(k => {
const meta = findSignalMeta(k);
return meta && meta.rangeMin != null && meta.rangeMax != null;
});
rangeBtn.disabled = !hasRange;
rangeBtn.title = hasRange ? '' : 'No range defined for any signal in this plot';
}
const isManual = vs.mode === 'manual';
group.querySelector('.pcfg-vs-manual').style.display = isManual ? 'flex' : 'none';
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0);
const dvEl = group.querySelector('.pcfg-vs-vdiv');
const ofsEl = group.querySelector('.pcfg-vs-offset');
const focused = document.activeElement;
if (focused !== dvEl) dvEl.value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
if (focused !== ofsEl) ofsEl.value = parseFloat(ofs.toPrecision(6));
}
function initPlotCfgBar(plotId, p) {
const bar = document.getElementById('pcfg-' + plotId);
if (!bar) return;
const titleInput = bar.querySelector('.pcfg-title-input');
const titleEl = document.getElementById('ptitle-' + plotId);
titleInput.addEventListener('input', () => {
p.title = titleInput.value || ('Plot ' + plotId);
if (titleEl) titleEl.textContent = p.title;
});
bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const newMode = btn.dataset.mode;
if (p.mode === newMode) return;
p.mode = newMode;
bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode));
// Entering or leaving unified mode swaps which vscale each badge reads,
// and moves the scale controls between this bar and the signal panel.
p.traces.forEach(key => _updateBadgeVScaleInfo(plotId, key));
syncPlotCfgVScale(plotId);
if (_vsMenuPlotId === plotId && _vsMenuKey) showVScaleMenu(_vsMenuKey, plotId);
createUPlot(p);
p.needsRedraw = true;
});
});
const group = bar.querySelector('.pcfg-vs-group');
if (group) {
group.querySelectorAll('.pcfg-vs-mode .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
const vs = getVScale(plotId, UNIFIED_VS_KEY);
// Seed manual from whatever is on screen, so switching to Manual does
// not jump the trace before the user has typed anything.
if (btn.dataset.mode === 'manual' && vs.mode !== 'manual') {
vs.divValue = vs._resolvedDiv != null ? vs._resolvedDiv : 1;
vs.offset = vs._resolvedOffset != null ? vs._resolvedOffset : 0;
}
vs.mode = btn.dataset.mode;
syncPlotCfgVScale(plotId);
p.needsRedraw = true;
});
});
group.querySelector('.pcfg-vs-vdiv').addEventListener('input', e => {
const v = parseFloat(e.target.value);
if (!isFinite(v) || v === 0) return;
const vs = getVScale(plotId, UNIFIED_VS_KEY);
vs.divValue = Math.abs(v); vs.mode = 'manual';
p.traces.forEach(key => _updateBadgeVScaleInfo(plotId, key));
p.needsRedraw = true;
});
group.querySelector('.pcfg-vs-offset').addEventListener('input', e => {
const v = parseFloat(e.target.value);
if (!isFinite(v)) return;
const vs = getVScale(plotId, UNIFIED_VS_KEY);
vs.offset = v; vs.mode = 'manual';
p.needsRedraw = true;
});
}
bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg);
}
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
// Returns the number of plot cells in a layout. Custom layouts carry an
// explicit plotCount; uniform ones are cols × rows.
function layoutPlotCount(cls) {
const entry = LAYOUTS.find(l => l[1] === cls);
if (entry) {
if (entry.length >= 5) return entry[4];
return entry[2] * entry[3];
}
const m = cls.match(/^l(\d+)x(\d+)$/);
return m ? parseInt(m[1]) * parseInt(m[2]) : 1;
}
// Build a small SVG grid thumbnail for a layout entry. Custom (non-uniform)
// layouts draw their own cell arrangement.
function layoutSVG(entry) {
const W = 28, H = 20, GAP = 1.5, PAD = 1.5;
let rects = '';
if (entry[1] === 'l1p2') {
// 1+2: one full-width cell on top, two side by side below.
const cw = (W - PAD * 2 - GAP) / 2;
const ch = (H - PAD * 2 - GAP) / 2;
const y2 = (PAD + ch + GAP).toFixed(1);
const x2 = (PAD + cw + GAP).toFixed(1);
rects += `<rect x="${PAD}" y="${PAD}" width="${(W - PAD * 2).toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
rects += `<rect x="${PAD}" y="${y2}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
rects += `<rect x="${x2}" y="${y2}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
} else {
const [, , cols, rows] = entry;
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols;
const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = (PAD + c * (cw + GAP)).toFixed(1);
const y = (PAD + r * (ch + GAP)).toFixed(1);
rects += `<rect x="${x}" y="${y}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
}
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
+ `<rect width="${W}" height="${H}" rx="2" fill="#11111b"/>`
+ `<g fill="#45475a">${rects}</g></svg>`;
}
// Apply a new layout: switch the grid class and auto-add/remove plots.
// Plots with traces are preserved; empty plots are removed first when shrinking.
function applyLayout(cls) {
const entry = LAYOUTS.find(l => l[1] === cls);
if (!entry) return;
const [label, , cols, rows] = entry;
currentLayout = cls;
// Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied.
if (cols !== _gridCols || rows !== _gridRows) {
colFrs = Array(cols).fill(1);
rowFrs = Array(rows).fill(1);
_gridCols = cols; _gridRows = rows;
}
const grid = document.getElementById('plot-grid');
grid.className = cls; // keeps other grid CSS rules
updateGridTemplate(); // override template with current fr sizes
setupResizeHandles(cols, rows);
// Update button label
const btn = document.getElementById('btn-layout');
if (btn) btn.innerHTML = layoutSVG(entry) + ' <span>' + label + '</span> ▾';
// Update active state in menu
document.querySelectorAll('.layout-menu-item')
.forEach(el => el.classList.toggle('active', el.dataset.layout === cls));
const needed = layoutPlotCount(cls);
// Remove excess plots — prefer empty ones to preserve trace assignments.
while (plots.length > needed) {
let removeId = plots[plots.length - 1].id;
for (let i = plots.length - 1; i >= 0; i--) {
if (plots[i].traces.length === 0) { removeId = plots[i].id; break; }
}
deletePlot(removeId);
}
// Add missing plots (DOM only — uPlot created below after layout settles).
while (plots.length < needed) addPlot();
// Recreate all uPlot instances once the CSS grid has sized the cells.
requestAnimationFrame(() => {
plots.forEach(p => {
createUPlot(p);
p.needsRedraw = true;
});
setTimeout(() => plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
}), 60);
});
}
function buildLayoutMenu() {
const menu = document.getElementById('layout-menu');
LAYOUTS.forEach(entry => {
const [label, cls] = entry;
const item = document.createElement('button');
item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : '');
item.dataset.layout = cls;
item.innerHTML = layoutSVG(entry) + '<span>' + label + '</span>';
item.addEventListener('click', () => {
applyLayout(cls);
menu.classList.remove('open');
});
menu.appendChild(item);
});
// Toggle menu open/close
document.getElementById('btn-layout').addEventListener('click', e => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.top = (r.bottom + 4) + 'px';
menu.classList.toggle('open');
});
// Close when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('#layout-menu') && !e.target.closest('#btn-layout'))
menu.classList.remove('open');
});
}
/* ════════════════════════════════════════════════════════════════
Export CSV (all plots) — fetches full-resolution data from ring
════════════════════════════════════════════════════════════════ */
// Shared busy state for the export dropdown: prevents re-entry and shows
// progress on the selector while a (possibly large) export runs.
let exportBusy = false;
function setExportBusy(busy) {
exportBusy = busy;
const sel = document.getElementById('export-select');
if (!sel) return;
sel.disabled = busy;
const ph = sel.querySelector('option[value=""]');
if (ph) ph.textContent = busy ? '\u23f3 Exporting\u2026' : '\u23ea Export';
}
async function exportAllCSV() {
if (exportBusy) return;
const inTrigMode = trig.enabled && trig.snapshot !== null;
// Collect unique signal keys across all plots (preserving order).
const keys = [];
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
if (!keys.length) return;
// Determine time range to export.
let t0, t1, relOffset = 0;
if (inTrigMode) {
// Export the full trigger window around the trigger event.
t0 = trig.trigTime - activePreSec();
t1 = trig.trigTime + activePostSec();
relOffset = trig.trigTime;
} else {
// Use the current zoom range if active, else the rolling window.
const refPlot = plots.find(p => p.xRange);
if (refPlot) {
[t0, t1] = refPlot.xRange;
} else {
let plotNow = -Infinity;
keys.forEach(k => {
const buf = buffers[k];
if (buf && buf.size > 0) {
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > plotNow) plotNow = t;
}
});
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
t0 = plotNow - windowSec;
t1 = plotNow;
}
}
if (!(t1 > t0)) return;
exportBusy = true;
// Cap the export. A full window at a megasample rate is hundreds of MB raw
// (the old exact-timestamp merge exploded into millions of rows and crashed
// the tab); ask the hub for a min/max-decimated envelope — the same scope
// style reduction the live view uses — and cap the number of rows.
const BUDGET = 100000; // max rows per signal
setExportBusy(true);
let ringSignals = null;
if (!inTrigMode) {
try {
ringSignals = await wsZoomRequest(t0, t1, BUDGET, keys);
} catch (e) {
console.warn('CSV export: ring fetch failed, falling back to local data', e);
}
}
setExportBusy(false);
// Per-signal raw source: hub ring (whole window, decimated) → trigger
// snapshot (already \u226420k pts) → local push buffer.
const slices = keys.map(key => {
if (!inTrigMode) {
const rd = ringSignals && ringSignals[key];
if (rd && rd.t && rd.t.length > 0) return { key, t: rd.t, v: rd.v };
}
if (inTrigMode) {
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
return { key, t: raw.t, v: raw.v };
}
const buf = buffers[key];
const sl = buf ? getBufferSliceRange(buf, t0, t1) : { t: new Float64Array(0), v: new Float64Array(0) };
return { key, t: sl.t, v: sl.v };
});
const present = slices.filter(s => s.t.length > 0);
if (!present.length) return;
// Master time grid = the signal with the most samples; every other signal is
// resampled onto it (linear, no extrapolation). Cells outside a signal's own
// span stay empty rather than being fabricated, so continuous signals export
// without holes and no value is invented.
let master = present[0];
present.forEach(s => { if (s.t.length > master.t.length) master = s; });
const cals = new Map(keys.map(k => [k, calForKey(k)]));
const displayKeys = keys.map(k => {
const name = k.includes(':') ? k.split(':').slice(1).join(':') : k;
const u = unitForKey(k);
const h = u ? name + ' [' + u + ']' : name;
return '"' + h.replace(/"/g, '""') + '"';
});
const timeCol = '"' + (inTrigMode ? 'time_rel_s' : 'time_s') + '"';
const hdr = [timeCol, ...displayKeys].join(',');
const rows = new Array(master.t.length);
for (let i = 0; i < master.t.length; i++) {
const tAbs = master.t[i];
const cells = present.map(s => {
if (s === master) {
return Calib.applyCal(master.v[i], cals.get(s.key));
}
const v = interpSortedRaw(s.t, s.v, tAbs);
return v === null ? '' : Calib.applyCal(v, cals.get(s.key));
});
const tt = inTrigMode ? tAbs - relOffset : tAbs;
rows[i] = [tt.toFixed(9), ...cells].join(',');
}
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'signals_' + Date.now() + '.csv';
a.click();
URL.revokeObjectURL(a.href);
}
/* ════════════════════════════════════════════════════════════════
Plot management
════════════════════════════════════════════════════════════════ */
function addPlot() {
const id = nextPlotId++;
const card = document.createElement('div');
card.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = `
<div class="plot-card-header">
<span class="plot-title" id="ptitle-${id}" title="Click to configure">Plot ${id}</span>
<div class="sig-badges" id="badges-${id}"></div>
<div class="plot-cursor-ro" id="pcur-${id}" style="display:none">
<span class="pcur-a" id="pcur-a-${id}">A: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-b" id="pcur-b-${id}">B: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-dv" id="pcur-dv-${id}">ΔV: —</span>
</div>
</div>
<div class="plot-cfg-bar" id="pcfg-${id}" style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${id}" style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn" data-mode="normal" title="One vertical scale per signal (oscilloscope)">Normal</button>
<button class="ctx-btn pcfg-mode-btn active" data-mode="unified" title="One vertical scale shared by every signal">Unified</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<!-- Unified mode has one scale for the whole plot, so it is configured
here rather than from any single signal's panel. -->
<div class="pcfg-vs-group" style="display:none;align-items:center;gap:4px">
<div class="vstb-sep"></div>
<span class="vstb-label">Y-Scale</span>
<div class="ctx-btns pcfg-vs-mode">
<button class="ctx-btn active" data-mode="auto">Auto</button>
<button class="ctx-btn" data-mode="range">Range</button>
<button class="ctx-btn" data-mode="manual">Manual</button>
</div>
<div class="pcfg-vs-manual" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">V/div</label>
<input type="number" class="ctx-num pcfg-vs-vdiv" min="1e-30" step="any" value="1">
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label>
<input type="number" class="ctx-num pcfg-vs-offset" step="any" value="0">
</div>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
</div>`;
card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', e => {
e.preventDefault(); card.classList.remove('drag-over');
const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key);
});
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id);
const p = { id, title: 'Plot ' + id, mode: 'unified', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
plots.push(p);
// Wire config toolbar
initPlotCfgBar(id, p);
card.querySelector('#ptitle-' + id).addEventListener('click', () => {
if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id);
});
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id;
}
function addTraceTo(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
if (p.traces.includes(signalKey)) return;
p.traces.push(signalKey);
document.querySelector('#hint-' + plotId).style.display = 'none';
addBadge(plotId, signalKey);
// Recreate uPlot with new series list
createUPlot(p);
p.needsRedraw = true;
updatePlotCursorReadouts();
}
function removeTraceFrom(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
p.traces = p.traces.filter(t => t !== signalKey);
removeBadge(plotId, signalKey);
// If the removed trace was active, close toolbar and pick a new active signal.
if (plotActiveSignal[plotId] === signalKey) {
if (_vsMenuPlotId === plotId) hideVScaleMenu();
const newActive = p.traces[0] || null;
if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId];
}
createUPlot(p);
p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
updatePlotCursorReadouts();
}
function addBadge(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return;
const color = getSigStyle(key).color;
const badge = document.createElement('span');
badge.className = 'sig-badge'; badge.dataset.key = key;
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
// Show signal name without the "sourceId:" prefix.
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
const nameSpan = document.createElement('span'); nameSpan.textContent = displayName;
// Small vscale info text (V/div + offset when not in auto mode).
const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info';
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); });
// Left-click: select + show vscale toolbar; click same signal again to deselect.
badge.addEventListener('click', e => {
if (e.target === x) return;
const isActive = plotActiveSignal[plotId] === key;
const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId;
if (isActive && toolbarVisible) {
setActiveSig(plotId, null);
hideVScaleMenu();
} else {
setActiveSig(plotId, key);
showVScaleMenu(key, plotId);
}
});
// Right-click: open signal style (color/width/…) menu.
badge.addEventListener('contextmenu', e => {
e.preventDefault();
showSignalMenu(key, plotId, e.clientX, e.clientY);
});
badge.appendChild(dot);
badge.appendChild(nameSpan);
badge.appendChild(infoSpan);
badge.appendChild(x);
c.appendChild(badge);
// Auto-activate the first signal added to this plot.
if (!plotActiveSignal[plotId]) setActiveSig(plotId, key);
}
function removeBadge(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (b) b.remove();
}
function deletePlot(plotId) {
const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return;
const p = plots[idx];
if (p.ro) p.ro.disconnect();
if (p.uplot) p.uplot.destroy();
decimCacheEvict(plotId);
plots.splice(idx, 1);
document.querySelector('[data-plot-id="' + plotId + '"]').remove();
}
/* ════════════════════════════════════════════════════════════════
Render loop
════════════════════════════════════════════════════════════════ */
let _dbgTick = 0;
let _dataGen = 0; // incremented each time new data arrives
function renderDirtyPlots() {
// Schedule the next frame FIRST: an exception below must never kill the
// animation loop, or every plot would freeze until a page refresh.
requestAnimationFrame(renderDirtyPlots);
try {
// Compute global "now" once — shared by all rolling-window plots this frame.
const globalPlotNow = getGlobalNow();
// Lightweight diagnostic: every ~10 s log buffer count
if (++_dbgTick % 300 === 0) {
let bufCount = 0, totalPts = 0;
Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } });
if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`);
}
// Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen
// zoom window) and clear it back to rolling. Run unconditionally before the
// data-rebuild loop so the stale plot is correctly handled this frame.
if (!trig.enabled && !globalPause) {
plots.forEach(p => {
if (!p.uplot || !p.xRange) return;
const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0);
if (!hasData) return;
const stale = p.traces.every(key => {
const buf = buffers[key];
if (!buf || buf.size === 0) return true;
const oldest = buf.t[buf.size === buf.cap ? buf.head : 0];
return p.xRange[1] < oldest;
});
if (stale) {
p.xRange = null;
syncLocked = false;
const btnR = document.getElementById('btn-sync-resume');
if (btnR) btnR.style.display = 'none';
}
});
}
// Live rolling mode: pin the cursors to the moving viewport so they stay put
// on screen instead of scrolling off the left edge as time advances.
if (cursors.mode === 'on' && !trig.enabled && !globalPause &&
plots.some(p => p.uplot && !p.xRange && p.traces.length > 0)) {
if (_cursorAnchorNow !== null && globalPlotNow !== _cursorAnchorNow) {
const shift = globalPlotNow - _cursorAnchorNow;
if (cursors.tA !== null) cursors.tA += shift;
if (cursors.tB !== null) cursors.tB += shift;
cursorsDirty = true;
updateCursorReadout();
}
_cursorAnchorNow = globalPlotNow;
} else {
_cursorAnchorNow = null;
}
// Fast path: cursor-only redraw (no data rebuild needed)
if (cursorsDirty) {
cursorsDirty = false;
plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); });
}
// Rolling-window plots: mark dirty every frame for smooth continuous scrolling.
// When no new data arrived since the last render, only advance the viewport
// via setScale instead of rebuilding all data arrays (much cheaper).
// A filling trigger window is redrawn for the same reason: new samples keep
// arriving, they just land on a fixed axis instead of a moving one.
if ((!trig.enabled || trigFilling()) && !globalPause) {
plots.forEach(p => {
if (!p.uplot || (p.xRange && !trigFilling()) || p.traces.length === 0) return;
p.needsRedraw = true;
});
}
plots.forEach(p => {
if (!p.needsRedraw || !p.uplot || p.traces.length === 0) return;
try {
const inTrigModeNow = inTrigWindow();
// The x tick formatter and the cursor-sync group are baked into the uPlot
// options at construction. A plot built in live mode therefore keeps
// formatting the capture's relative seconds as absolute wall-clock time
// (and vice-versa), so rebuild whenever the mode flips.
if (p.uplot._trigMode !== inTrigModeNow) createUPlot(p);
const isRolling = !trig.enabled && !p.xRange;
// Fast path: rolling-window plot with no new data — just shift viewport
// anchored to buffer timestamps so the x-range only advances when
// signal data actually moves forward.
if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) {
p.needsRedraw = false;
zoomGuard = true;
// Use the same per-plot anchor as the rebuild path, so the rolling window
// does not jump when the frame switches between the two.
const plotNow = computePlotNow(p);
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
zoomGuard = false;
return;
}
p.needsRedraw = false;
p.lastDataGen = _dataGen;
const data = buildUPlotData(p, inTrigModeNow);
// setData internally triggers the setScale hook in uPlot (it reaffirms the
// current scale even with auto:false). Keep zoomGuard raised across the
// entire setData + setScale block so the hook never calls onZoom and freezes
// p.xRange unintentionally. The guard is safe here: JS is single-threaded so
// no genuine user drag-zoom event can fire during this synchronous block.
zoomGuard = true;
p.uplot.setData(data);
// Re-apply the x-scale after setData so the viewport stays correct.
if (trig.enabled && !inTrigModeNow) {
// Armed / waiting for trigger: keep the current scale frozen.
} else if (inTrigModeNow) {
const preS = activePreSec();
const postS = activePostSec();
p.uplot.setScale('x', {
min: p.xRange ? p.xRange[0] : -preS,
max: p.xRange ? p.xRange[1] : postS
});
} else if (p.xRange) {
// Zoomed: re-apply so scale is correct after setData
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
} else {
// Rolling window: use same anchor as buildLiveData (min-of-max per source).
const plotNow = computePlotNow(p);
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
}
zoomGuard = false;
p._errCount = 0;
} catch (e) {
// One bad plot must not kill the whole render loop. Track consecutive
// failures and self-heal by rebuilding the uPlot instance.
p._errCount = (p._errCount || 0) + 1;
console.error(`[render] plot ${p.id}:`, e);
p.needsRedraw = true; // retry next frame
if (p._errCount >= 30) {
p._errCount = 0;
try { createUPlot(p); } catch (e2) { console.error(`[render] rebuild plot ${p.id}:`, e2); }
}
}
});
// Keep per-plot cursor value readouts in sync with live data.
if (cursors.mode === 'on') updatePlotCursorReadouts();
} catch (e) {
console.error('[render]', e);
}
}
/* ════════════════════════════════════════════════════════════════
Global controls
════════════════════════════════════════════════════════════════ */
document.getElementById('btn-pause-global').addEventListener('click', () => {
globalPause = !globalPause;
const btn = document.getElementById('btn-pause-global');
btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause';
btn.classList.toggle('active', globalPause);
if (!globalPause) plots.forEach(p => { p.needsRedraw = true; });
updateCursorBtnVisibility();
});
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
sendWindow();
// Grow any buffer that cannot reach back over the new window. It fills from
// live data, so widening the window shows the past arriving rather than
// already there — but the buffer must at least stop discarding it.
Object.keys(buffers).forEach(key => {
buffers[key] = growBufferForWindow(buffers[key], windowSec);
});
// Evict rolling-mode decimation cache — window size change invalidates all cached results.
plots.forEach(p => decimCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot
if (trig.enabled && !trig.snapshot) return;
plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; });
});
/* ════════════════════════════════════════════════════════════════
Sidebar toggle
════════════════════════════════════════════════════════════════ */
let sidebarOpen = true;
function setSidebar(open) {
sidebarOpen = open;
document.getElementById('sidebar').classList.toggle('collapsed', !open);
document.getElementById('btn-sidebar').classList.toggle('active', open);
setTimeout(() => {
plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
});
}, 200);
}
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
/* ════════════════════════════════════════════════════════════════
Multi-source management
════════════════════════════════════════════════════════════════ */
// The hub's calibration table is authoritative: replace ours wholesale.
function onCalibration(msg) {
calTable.replaceAll(msg.cal || []);
applyCalibrationChanged();
}
function onSources(msg) {
const srcs = msg.sources || [];
const newIds = new Set(srcs.map(s => s.id));
// Remove sources that disappeared.
Object.keys(sourcesMap).forEach(id => {
if (!newIds.has(id)) {
const prefix = id + ':';
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
delete sourcesMap[id];
}
});
// Update or create entries.
srcs.forEach(s => {
if (!sourcesMap[s.id]) {
sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] };
} else {
Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state });
}
});
buildSidebar();
if (statsOpen) _refreshStatsSelector();
maybeRestoreViewLate();
}
function addSourceWS(label, addr, multicastGroup, dataPort) {
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = { type: 'addSource', label, addr };
if (multicastGroup) { msg.multicastGroup = multicastGroup; }
if (dataPort) { msg.dataPort = dataPort; }
ws.send(JSON.stringify(msg));
}
}
function removeSource(id) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'removeSource', id }));
}
}
function saveConfigWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'saveSources' }));
}
}
function reloadConfigWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reloadConfig' }));
}
}
function setCalibrationWS(source, signal, scale, offset, unit) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'setCalibration', source, signal, scale, offset, unit }));
}
}
// Called after the calibration table changes, from any source (local edit,
// hub broadcast, or reload). Mirrors to localStorage and re-renders everything
// that shows a value or a unit.
function applyCalibrationChanged() {
persistCalibration();
buildSidebar(); // unit badges
plots.forEach(p => { p.needsRedraw = true; });
refreshVScaleMenu();
// The threshold is held in calibrated units, so a calibration change alters
// the raw value the hub must compare against — resend it.
if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); }
}
// Last config acknowledgement, kept outside the DOM because buildSidebar()
// discards and recreates this whole section on every `sources` broadcast.
let _cfgStatus = null; // {ok: bool, text: string} or null
function renderCfgStatus(el) {
el.className = 'cfg-status';
if (!_cfgStatus) { el.textContent = ''; return; }
el.classList.add(_cfgStatus.ok ? 'ok' : 'err');
el.textContent = _cfgStatus.text;
}
function onConfigAck(msg) {
const saved = msg.type === 'configSaved';
if (msg.ok) {
_cfgStatus = { ok: true, text: (saved ? 'Saved: ' : 'Reloaded: ') + (msg.path || 'config file') };
} else {
_cfgStatus = { ok: false, text: (saved ? 'Save' : 'Reload') + ' failed: ' + (msg.error || 'unknown error') };
}
const el = document.getElementById('cfg-status');
if (el) renderCfgStatus(el);
}
function makeSourcesConfigSection() {
const section = document.createElement('div');
section.className = 'add-source-section';
const title = document.createElement('div');
title.className = 'add-source-title';
title.innerHTML = '<span class="add-src-arrow">&#x25B6;</span> Sources &amp; Config';
const body = document.createElement('div');
body.className = 'add-source-body';
const addrInput = document.createElement('input');
addrInput.className = 'add-src-input'; addrInput.type = 'text';
addrInput.placeholder = 'host:port';
const labelInput = document.createElement('input');
labelInput.className = 'add-src-input'; labelInput.type = 'text';
labelInput.placeholder = 'label (optional)';
const mcastInput = document.createElement('input');
mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';
const dataPortInput = document.createElement('input');
dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
dataPortInput.placeholder = 'data port (multicast only)';
dataPortInput.min = '1'; dataPortInput.max = '65535';
const addBtn = document.createElement('button');
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
addBtn.addEventListener('click', () => {
const addr = addrInput.value.trim(); if (!addr) return;
const mcastGroup = mcastInput.value.trim();
const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
const btnRow = document.createElement('div');
btnRow.className = 'cfg-btn-row';
const saveBtn = document.createElement('button');
saveBtn.className = 'add-src-btn save-src-btn';
saveBtn.textContent = 'Save';
saveBtn.title = 'Write the source list and all signal calibration to the hub\u2019s config file';
saveBtn.addEventListener('click', () => {
_cfgStatus = null;
const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
saveConfigWS();
});
const reloadBtn = document.createElement('button');
reloadBtn.className = 'add-src-btn reload-src-btn';
reloadBtn.textContent = 'Reload';
reloadBtn.title = 'Re-read the config file: calibration is replaced wholesale, '
+ 'missing sources are added, and no running source is stopped';
reloadBtn.addEventListener('click', () => {
_cfgStatus = null;
const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
reloadConfigWS();
});
btnRow.append(saveBtn, reloadBtn);
const status = document.createElement('div');
status.id = 'cfg-status';
renderCfgStatus(status);
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, btnRow, status);
section.append(title, body);
title.addEventListener('click', () => {
const open = section.classList.toggle('open');
title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
});
return section;
}
/* ════════════════════════════════════════════════════════════════
Utility
════════════════════════════════════════════════════════════════ */
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ════════════════════════════════════════════════════════════════
VScale menu (left-click on badge)
════════════════════════════════════════════════════════════════ */
let _vsMenuKey = null, _vsMenuPlotId = null;
// Re-read the calibration fields from calTable for the currently open toolbar.
// Safe to call when the toolbar is closed.
function refreshVScaleMenu() {
if (!_vsMenuKey) return;
const cal = calForKey(_vsMenuKey);
const base = baseSigForKey(_vsMenuKey);
const meta = findSignalMeta(_vsMenuKey);
const n = meta ? numElements(meta) : 1;
const lbl = document.getElementById('vscale-cal-lbl');
lbl.textContent = n > 1 ? 'Cal (' + base + ', ' + n + ' elem)' : 'Cal (' + base + ')';
const scaleEl = document.getElementById('vscale-cal-scale');
const offsetEl = document.getElementById('vscale-cal-offset');
const unitEl = document.getElementById('vscale-cal-unit');
// Skip the field the user is currently typing in, so a hub broadcast does not
// yank the caret out from under them.
const focused = document.activeElement;
if (focused !== scaleEl) scaleEl.value = cal.scale;
if (focused !== offsetEl) offsetEl.value = cal.offset;
if (focused !== unitEl) unitEl.value = cal.unit;
[scaleEl, offsetEl, unitEl].forEach(el => {
if (focused !== el) el.classList.remove('cal-invalid');
});
const srcLabel = srcLabelForKey(_vsMenuKey);
const usable = srcLabel !== '' && base !== '';
[scaleEl, offsetEl, unitEl, document.getElementById('btn-cal-reset')]
.forEach(el => { el.disabled = !usable; });
}
function showVScaleMenu(key, plotId) {
hideSignalMenu();
// If the toolbar was open for a different plot, hide that bar first.
if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) {
const oldBar = document.getElementById('vstb-' + _vsMenuPlotId);
if (oldBar) oldBar.style.display = 'none';
}
_vsMenuKey = key; _vsMenuPlotId = plotId;
const menu = document.getElementById('vscale-menu');
const p = plots.find(q => q.id === plotId);
// Unified mode has a single scale for the whole plot, so it is edited from
// the plot config bar; this per-signal panel keeps only the calibration.
const isUnified = !!p && p.mode === 'unified';
const vs = getVScale(_vsMenuPlotId, key);
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
document.getElementById('vscale-menu-title').textContent =
isUnified ? 'Signal' : 'V-Scale';
document.getElementById('vscale-menu-key').textContent = displayName;
document.getElementById('vscale-mode-btns').style.display = isUnified ? 'none' : 'flex';
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.mode === vs.mode));
// Disable Range button when the signal it applies to has no defined range.
const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]');
if (rangeBtn) {
const meta = findSignalMeta(key);
const hasRange = !!meta && meta.rangeMin != null && meta.rangeMax != null;
rangeBtn.disabled = !hasRange;
rangeBtn.title = hasRange ? '' : 'No range defined for this signal';
}
const isManual = !isUnified && vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none';
// Pre-fill V/div and Offset with the resolved or stored values.
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0);
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-offset').value = parseFloat(ofs.toPrecision(6));
// Type row (Analog/Digital) only shown in mixed mode.
const isMixed = p && p.mode === 'mixed';
document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none';
if (isMixed) {
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
}
refreshVScaleMenu();
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
bar.appendChild(menu);
bar.style.display = 'block';
}
menu.style.display = 'block';
}
function hideVScaleMenu() {
const menu = document.getElementById('vscale-menu');
// Return the menu element to body so it is detached from any plot card.
menu.style.display = 'none';
document.body.appendChild(menu);
// Hide the vscale bar of the previously active plot.
if (_vsMenuPlotId != null) {
const bar = document.getElementById('vstb-' + _vsMenuPlotId);
if (bar) bar.style.display = 'none';
}
_vsMenuKey = null; _vsMenuPlotId = null;
}
/* ─── Array index picker ─────────────────────────────────────────────────── */
let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0;
function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) {
_aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1;
const menu = document.getElementById('array-idx-picker');
const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey;
document.getElementById('aip-sig').textContent = displayName;
document.getElementById('aip-range').textContent = '(0 ' + (n - 1) + ')';
const idxInput = document.getElementById('aip-idx');
idxInput.max = n - 1; idxInput.value = 0;
menu.style.display = 'block';
// Centre the picker on screen.
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120;
menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px';
menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px';
idxInput.focus(); idxInput.select();
}
function _aipConfirm() {
const idxInput = document.getElementById('aip-idx');
const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0));
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnConfirm) _aipOnConfirm(idx);
_aipOnConfirm = _aipOnCancel = null;
}
function _aipCancel() {
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnCancel) _aipOnCancel();
_aipOnConfirm = _aipOnCancel = null;
}
function initArrayIdxPicker() {
document.getElementById('aip-ok').addEventListener('click', _aipConfirm);
document.getElementById('aip-cancel').addEventListener('click', _aipCancel);
document.getElementById('aip-idx').addEventListener('keydown', e => {
if (e.key === 'Enter') _aipConfirm();
if (e.key === 'Escape') _aipCancel();
});
}
function initVScaleMenu() {
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
const newMode = btn.dataset.mode;
if (newMode === 'manual' && vs.mode !== 'manual') {
// Seed V/div and Offset from currently resolved values.
vs.divValue = vs._resolvedDiv || 1;
vs.offset = vs._resolvedOffset || 0; // raw value at screen centre
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6));
}
vs.mode = newMode;
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const isManual = vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none';
refreshPlotForKey(_vsMenuKey);
});
});
document.getElementById('vscale-vdiv').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
refreshPlotForKey(_vsMenuKey);
});
// "Offset" is the raw value shown at screen centre. It is unbounded, so a
// signal can be referenced to a level far outside the currently plotted range.
document.getElementById('vscale-offset').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
const v = parseFloat(e.target.value);
vs.offset = isFinite(v) ? v : 0;
refreshPlotForKey(_vsMenuKey);
});
// Type buttons (Analog / Digital) — only active in mixed mode.
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey || !_vsMenuPlotId) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.digitalInMixed = btn.dataset.type === 'digital';
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b =>
b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
// Rebuild needed because series rendering changes (digital = no spanGaps, etc.)
const p = plots.find(q => q.id === _vsMenuPlotId);
if (p) { createUPlot(p); p.needsRedraw = true; }
});
});
// ── Calibration ───────────────────────────────────────────────────────
// Commit the three fields as one entry. Validation mirrors the hub exactly
// (Calib.normaliseCal); an invalid value marks the field and is not sent, so
// the last accepted value stays in force.
function commitCal() {
if (!_vsMenuKey) return;
const scaleEl = document.getElementById('vscale-cal-scale');
const offsetEl = document.getElementById('vscale-cal-offset');
const unitEl = document.getElementById('vscale-cal-unit');
const source = srcLabelForKey(_vsMenuKey);
const signal = baseSigForKey(_vsMenuKey);
const entry = Calib.normaliseCal({
source, signal,
scale: parseFloat(scaleEl.value),
offset: parseFloat(offsetEl.value),
unit: unitEl.value,
});
const scaleBad = entry === null && !(isFinite(parseFloat(scaleEl.value)) && parseFloat(scaleEl.value) !== 0);
scaleEl.classList.toggle('cal-invalid', scaleBad);
offsetEl.classList.toggle('cal-invalid', entry === null && !isFinite(parseFloat(offsetEl.value)));
if (entry === null) return;
calTable.set(entry);
setCalibrationWS(entry.source, entry.signal, entry.scale, entry.offset, entry.unit);
applyCalibrationChanged();
}
document.getElementById('vscale-cal-scale').addEventListener('change', commitCal);
document.getElementById('vscale-cal-offset').addEventListener('change', commitCal);
document.getElementById('vscale-cal-unit').addEventListener('change', commitCal);
document.getElementById('btn-cal-reset').addEventListener('click', () => {
if (!_vsMenuKey) return;
const source = srcLabelForKey(_vsMenuKey);
const signal = baseSigForKey(_vsMenuKey);
if (!source || !signal) return;
calTable.set({ source, signal, scale: 1, offset: 0, unit: '' });
setCalibrationWS(source, signal, 1, 0, '');
applyCalibrationChanged();
refreshVScaleMenu();
});
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}
/* ════════════════════════════════════════════════════════════════
Signal style context menu
════════════════════════════════════════════════════════════════ */
let _ctxMenuKey = null;
function showSignalMenu(key, plotId, x, y) {
_ctxMenuKey = key;
const menu = document.getElementById('sig-ctx-menu');
const style = getSigStyle(key);
document.getElementById('ctx-menu-key').textContent = key;
document.getElementById('ctx-color').value = style.color;
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width);
});
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.dash === style.dash);
});
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.marker === style.marker);
});
document.getElementById('ctx-marker-size').value = style.markerSize;
document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px';
menu.style.display = 'block';
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200;
menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px';
menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px';
}
function hideSignalMenu() {
document.getElementById('sig-ctx-menu').style.display = 'none';
_ctxMenuKey = null;
}
function initSignalMenu() {
document.getElementById('ctx-color').addEventListener('input', e => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { color: e.target.value });
});
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) });
document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash });
document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker });
document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
document.getElementById('ctx-marker-size').addEventListener('input', e => {
const sz = parseInt(e.target.value, 10);
document.getElementById('ctx-marker-size-val').textContent = sz + 'px';
if (!_ctxMenuKey) return;
setSigStyle(_ctxMenuKey, { markerSize: sz });
});
document.addEventListener('click', e => {
if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } });
}
/* ════════════════════════════════════════════════════════════════
Source Statistics panel
════════════════════════════════════════════════════════════════ */
const sourceStats = {}; // sourceId → StatInfo (from backend 1 Hz message)
let statsOpen = false;
let statsSelectedSrc = null; // currently displayed source id
function onStats(msg) {
const incoming = msg.sources || {};
Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; });
if (statsOpen) renderStats();
}
// Rebuild the source selector options; preserve selection when possible.
function _refreshStatsSelector() {
const sel = document.getElementById('stats-source-sel');
if (!sel) return;
const prev = statsSelectedSrc;
sel.innerHTML = '';
const srcs = Object.values(sourcesMap);
srcs.forEach(src => {
const opt = document.createElement('option');
opt.value = src.id;
opt.textContent = src.label || src.id;
sel.appendChild(opt);
});
// Restore previous selection or default to first
if (prev && sourcesMap[prev]) {
sel.value = prev;
} else if (srcs.length > 0) {
sel.value = srcs[0].id;
}
statsSelectedSrc = sel.value || null;
}
// Frontend latency for one source: wallNow newest calibrated buffer timestamp.
function sourceLatencyMs(srcId) {
const prefix = srcId + ':';
const wallNow = Date.now() / 1000;
let best = null;
Object.keys(buffers).forEach(key => {
if (!key.startsWith(prefix)) return;
const buf = buffers[key];
if (!buf || buf.size === 0) return;
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
const lag = (wallNow - newest) * 1000;
if (best === null || lag < best) best = lag;
});
return best;
}
function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; }
function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; }
function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; }
function _statsKV(label, value, cls) {
return `<div class="stats-kv"><span class="stats-k">${escHtml(label)}</span><span class="stats-v${cls ? ' ' + cls : ''}">${escHtml(value)}</span></div>`;
}
function _histHTML(si) {
if (!si.cycleHist || !si.cycleHist.length) return '';
const maxC = Math.max(...si.cycleHist, 1);
const bars = si.cycleHist.map(c => {
const pct = Math.max(Math.round((c / maxC) * 100), 1);
return `<div class="hist-bar" style="height:${pct}%" title="${c} samples"></div>`;
}).join('');
return `<div class="stats-hist">
<div class="hist-bars">${bars}</div>
<div class="hist-labels"><span>${si.cycleHistMin.toFixed(3)}</span><span>${si.cycleHistMax.toFixed(3)} ms</span></div>
</div>`;
}
function renderStats() {
const body = document.getElementById('stats-body');
if (!body) return;
const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null;
if (!src) {
body.innerHTML = '<span class="stats-empty">No source selected</span>';
return;
}
const si = sourceStats[src.id];
const latMs = sourceLatencyMs(src.id);
const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok';
const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—';
body.innerHTML = `
<div class="stats-section">
<div class="stats-section-label">Connection</div>
<div class="stats-row">
${_statsKV('Address', src.addr)}
${_statsKV('Latency', _fmtMs(latMs))}
${_statsKV('Lost / Rx', lossText, lossColor)}
${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section">
<div class="stats-section-label">Cycle rate</div>
<div class="stats-row">
${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')}
${_statsKV(σ', si ? _fmtHz(si.rateStdHz) : '—')}
${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')}
${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')}
</div>
</div>
<hr class="stats-sep">
<div class="stats-section stats-section-grow">
<div class="stats-section-label">Cycle time histogram</div>
<div class="stats-row" style="margin-bottom:6px">
${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')}
${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')}
${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')}
${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
</div>
${si ? _histHTML(si) : '<span class="stats-empty">No data yet</span>'}
</div>`;
}
function toggleStats() {
statsOpen = !statsOpen;
document.getElementById('stats-panel').classList.toggle('open', statsOpen);
document.getElementById('btn-stats').classList.toggle('active', statsOpen);
if (statsOpen) {
_refreshStatsSelector();
renderStats();
}
}
// Refresh latency and stats every second while panel is open.
setInterval(() => { if (statsOpen) renderStats(); }, 1000);
/* ════════════════════════════════════════════════════════════════
Init
════════════════════════════════════════════════════════════════ */
buildLayoutMenu();
applyLayout('l1x1');
buildSidebar(); // show "Add Source" section even before WS connection
initArrayIdxPicker();
initVScaleMenu();
initSignalMenu();
// Restore monotonic TS checkbox from localStorage (visual state before WS reply).
{
const cb = document.getElementById('cb-monotonic');
if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1';
}
// Export every stored sample of the plotted signals as a Parquet file, served
// by the Go hub's /api/export. Full resolution (no decimation) and hole-free
// (each signal keeps its own timestamps — long format). The file can be huge
// (hundreds of MB at high rates), so stream it to disk when the File System
// Access API is available instead of holding it in a Blob.
async function exportParquet() {
if (exportBusy) return;
const inTrigMode = trig.enabled && trig.snapshot !== null;
const keys = [];
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
if (!keys.length) return;
// Same range resolution as the CSV export.
let t0, t1;
if (inTrigMode) {
t0 = trig.trigTime - activePreSec();
t1 = trig.trigTime + activePostSec();
} else {
const refPlot = plots.find(p => p.xRange);
if (refPlot) {
[t0, t1] = refPlot.xRange;
} else {
let plotNow = -Infinity;
keys.forEach(k => {
const buf = buffers[k];
if (buf && buf.size > 0) {
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > plotNow) plotNow = t;
}
});
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
t0 = plotNow - windowSec;
t1 = plotNow;
}
}
if (!(t1 > t0)) return;
exportBusy = true;
setExportBusy(true);
try {
const url = '/api/export?t0=' + t0.toFixed(9) + '&t1=' + t1.toFixed(9) +
'&signals=' + encodeURIComponent(keys.join(','));
const resp = await fetch(url);
if (!resp.ok) {
alert('Parquet export failed (HTTP ' + resp.status + ').\n\n' +
'The /api/export endpoint is provided by the Go hub; the C++ ' +
'StreamHub does not serve it.');
return;
}
const filename = 'signals_' + Date.now() + '.parquet';
if (window.showSaveFilePicker && resp.body) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'Parquet', accept: { 'application/vnd.apache.parquet': ['.parquet'] } }],
});
const writable = await handle.createWritable();
await resp.body.pipeTo(writable);
return;
} catch (e) {
if (e && e.name === 'AbortError') return; // user cancelled the picker
console.warn('parquet export: file picker failed, falling back to Blob', e);
}
}
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) {
console.warn('parquet export failed', e);
alert('Parquet export failed: ' + e.message);
} finally {
setExportBusy(false);
}
}
// Export dropdown: dispatch on selection, then reset to the placeholder so the
// same format can be chosen again.
document.getElementById('export-select').addEventListener('change', () => {
const sel = document.getElementById('export-select');
const fmt = sel.value;
sel.value = '';
if (fmt === 'csv') exportAllCSV();
else if (fmt === 'parquet') exportParquet();
});
/* ════════════════════════════════════════════════════════════════
View-state persistence (cookie)
════════════════════════════════════════════════════════════════ */
// The whole client view — layout, plots (traces/titles/modes), window, trigger
// configuration, rulers, sources — is serialised into one cookie so a reload
// restores the previous view. Cookies are size-limited, so the state degrades
// gracefully (rulers → trigger → sources → traces) when it would not fit.
const VIEW_COOKIE = 'udpscope.view';
const VIEW_COOKIE_MAX = 3500; // encoded chars; browsers cap cookies at ~4 KiB
function packViewState() {
const state = {
v: 1,
windowSec: windowSec,
layout: currentLayout,
plots: plots.map(p => ({
title: p.title,
mode: p.mode,
traces: p.traces.map(k => {
const colon = k.indexOf(':');
const name = colon >= 0 ? k.slice(colon + 1) : k;
return { key: k, label: srcLabelForKey(k), name };
}),
})),
trig: {
enabled: trig.enabled, signal: trig.signal, edge: trig.edge,
threshold: trig.threshold, windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec,
},
rulers: {
mode: rulers.mode,
plotId: plots.findIndex(p => p.id === rulers.plotId),
states: plots.map(p => {
const rs = rulerState[p.id];
return rs ? { yA: rs.yA, yB: rs.yB } : { yA: null, yB: null };
}),
},
sources: Object.values(sourcesMap).map(s => ({
label: s.label || s.addr || s.id, addr: s.addr,
})),
};
let s = JSON.stringify(state);
const tooBig = () => encodeURIComponent(s).length > VIEW_COOKIE_MAX;
if (tooBig()) { delete state.rulers; s = JSON.stringify(state); }
if (tooBig()) { delete state.trig; s = JSON.stringify(state); }
if (tooBig()) { delete state.sources; s = JSON.stringify(state); }
if (tooBig()) {
state.plots = state.plots.map(p => ({ title: p.title, mode: p.mode }));
s = JSON.stringify(state);
}
if (tooBig()) { state.plots = []; s = JSON.stringify(state); }
return s;
}
// Saves are gated until the saved view has been re-applied (phase 2) or the
// grace timeout fires: otherwise the very first periodic save would overwrite
// the cookie with the not-yet-restored (empty) state and destroy it.
let _viewSaveReady = false;
function saveViewState() {
if (!_viewSaveReady) return;
try {
const s = packViewState();
document.cookie = VIEW_COOKIE + '=' + encodeURIComponent(s) +
'; path=/; max-age=31536000; SameSite=Lax';
} catch (e) {
console.warn('view cookie save failed', e);
}
}
function readViewState() {
try {
const prefix = VIEW_COOKIE + '=';
const m = document.cookie.split('; ').find(c => c.startsWith(prefix));
if (!m) return null;
const st = JSON.parse(decodeURIComponent(m.slice(prefix.length)));
return (st && st.v === 1) ? st : null;
} catch (e) {
return null;
}
}
// Phase 1 (init): layout, plot cards (titles/modes), window, rulers. Traces and
// the trigger need sources/signals loaded, so they are applied in phase 2.
function restoreViewState() {
const st = readViewState();
if (!st) return;
if (st.layout && LAYOUTS.some(l => l[1] === st.layout)) applyLayout(st.layout);
const plotState = st.plots || [];
plotState.forEach((ps, i) => {
const p = plots[i];
if (!p) return;
if (ps.title && ps.title !== 'Plot ' + p.id) {
p.title = ps.title;
const tEl = document.getElementById('ptitle-' + p.id);
if (tEl) tEl.textContent = ps.title;
const inp = document.querySelector('#pcfg-' + p.id + ' .pcfg-title-input');
if (inp) inp.value = ps.title;
}
if (ps.mode && ps.mode !== p.mode) {
p.mode = ps.mode;
document.querySelectorAll('#pcfg-' + p.id + ' .pcfg-mode-btn')
.forEach(b => b.classList.toggle('active', b.dataset.mode === ps.mode));
}
});
if (st.windowSec != null) {
windowSec = st.windowSec;
const sel = document.getElementById('window-select');
if (sel && [...sel.options].some(o => o.value === String(st.windowSec))) {
sel.value = String(st.windowSec);
}
}
if (st.rulers) {
rulers.mode = st.rulers.mode === 'on' ? 'on' : 'off';
const btn = document.getElementById('btn-ruler');
if (btn) btn.classList.toggle('active', rulers.mode === 'on');
(st.rulers.states || []).forEach((rs, i) => {
const p = plots[i];
if (!p || !rs) return;
const cur = getRulerState(p.id);
cur.yA = rs.yA; cur.yB = rs.yB;
});
if (st.rulers.plotId != null && plots[st.rulers.plotId]) {
rulers.plotId = plots[st.rulers.plotId].id;
}
}
}
// Rebuild a saved trace key against the current sources: source ids change
// across restarts, so match by label and fall back to the saved id-key.
function restoreTraceKey(entry) {
const src = Object.values(sourcesMap).find(s => (s.label || s.id) === entry.label);
return src ? (src.id + ':' + entry.name) : entry.key;
}
// Phase 2 (first sources + signals): reconcile sources, re-apply traces, then
// the trigger configuration and the window to the hub.
let _viewLateRestored = false;
function maybeRestoreViewLate() {
if (_viewLateRestored) return;
const st = readViewState();
if (!st) { _viewLateRestored = true; return; }
// Add any saved sources the hub does not already have (it persists its own).
const known = Object.values(sourcesMap).map(s => (s.label || s.addr || s.id) + '\u0000' + s.addr);
let added = false;
(st.sources || []).forEach(sv => {
const k = (sv.label || sv.addr) + '\u0000' + (sv.addr || '');
if (!known.includes(k)) { addSourceWS(sv.label, sv.addr, sv.multicastGroup, sv.dataPort); added = true; }
});
if (added) return; // re-enter when the new sources appear
// Traces and the trigger selector need at least one source with signals.
if (!Object.values(sourcesMap).some(s => (s.signals || []).length > 0)) return;
_viewLateRestored = true;
_viewSaveReady = true;
(st.plots || []).forEach((ps, i) => {
const p = plots[i];
if (!p) return;
(ps.traces || []).forEach(t => addTraceTo(p.id, restoreTraceKey(t)));
});
if (st.windowSec != null) {
windowSec = st.windowSec;
sendWindow();
}
const t = st.trig;
if (t) {
trig.edge = t.edge || trig.edge;
if (t.threshold != null) trig.threshold = t.threshold;
if (t.windowSec != null) trig.windowSec = t.windowSec;
if (t.prePercent != null) trig.prePercent = t.prePercent;
trig.mode = t.mode || trig.mode;
if (t.holdoffSec != null) trig.holdoffSec = t.holdoffSec;
trig.signal = t.signal || '';
const el = id => document.getElementById(id);
if (el('trig-edge')) el('trig-edge').value = trig.edge;
if (el('trig-window')) el('trig-window').value = String(trig.windowSec);
if (el('trig-mode')) el('trig-mode').value = trig.mode;
if (el('trig-holdoff')) el('trig-holdoff').value = trig.holdoffSec;
if (el('trig-pre')) el('trig-pre').value = String(trig.prePercent);
if (el('trig-pre-val')) el('trig-pre-val').textContent = trig.prePercent + '%';
refreshTrigThresholdField();
const selSig = document.getElementById('trig-signal');
if (selSig && trig.signal) {
const base = trig.signal.replace(/\[\d+\]$/, '');
if ([...selSig.options].some(o => o.value === base)) selSig.value = base;
}
if (t.enabled) openTrigBar(true);
else updateTrigStatusBadge('idle');
}
}
// Periodic save keeps the cookie current without wiring every control; the
// pagehide save captures the final state on close/reload.
setInterval(saveViewState, 3000);
window.addEventListener('pagehide', saveViewState);
// Hub down / never connected: stop gating after 10 s so layout, window and
// rulers still persist even though traces and the trigger could not be
// restored.
setTimeout(() => { _viewSaveReady = true; }, 10000);
document.getElementById('history-badge').addEventListener('click', toggleHistoryPanel);
document.getElementById('btn-hist-cancel').addEventListener('click', toggleHistoryPanel);
document.getElementById('btn-hist-apply').addEventListener('click', applyHistoryBudget);
document.getElementById('btn-stats').addEventListener('click', toggleStats);
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
document.getElementById('stats-source-sel').addEventListener('change', e => {
statsSelectedSrc = e.target.value || null;
renderStats();
});
restoreViewState();
resolveHub().then(connectWS);
requestAnimationFrame(renderDirtyPlots);
fetch('/version').then(r => r.text()).then(v => {
document.getElementById('build-version').textContent = 'v' + v;
}).catch(() => { });