5028 lines
212 KiB
JavaScript
5028 lines
212 KiB
JavaScript
'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;
|
||
}
|
||
}
|
||
return cached || null; // stale entry, or nothing to draw yet
|
||
}
|
||
|
||
// Evict stale decimation cache entries for a plot (call when zoom range changes).
|
||
function decimCacheEvict(plotId) {
|
||
const prefix = plotId + ':';
|
||
for (const k of [...decimCache.keys()]) {
|
||
if (k.startsWith(prefix)) decimCache.delete(k);
|
||
}
|
||
for (const k of [...decimPending.keys()]) {
|
||
if (k.startsWith(prefix)) decimPending.delete(k);
|
||
}
|
||
}
|
||
|
||
/* Min/max (peak-envelope) decimation — the same algorithm as decimate-worker.js
|
||
and minMaxDecimate() in the Go hub, run inline when the worker result is not
|
||
ready yet. Splits the range into threshold/2 buckets and keeps each bucket's
|
||
smallest and largest sample in the order the two occurred, so a one-sample
|
||
spike survives being drawn at 1/1000 of its resolution. */
|
||
function decimate(t, v, threshold) {
|
||
const len = t.length;
|
||
if (len <= threshold || threshold < 4) return { t, v };
|
||
const buckets = threshold >> 1;
|
||
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
|
||
let n = 0;
|
||
for (let b = 0; b < buckets; b++) {
|
||
const lo = Math.floor(b * len / buckets);
|
||
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
|
||
if (lo >= hi) continue;
|
||
let iMin = lo, iMax = lo;
|
||
for (let j = lo + 1; j < hi; j++) {
|
||
if (v[j] < v[iMin]) iMin = j;
|
||
if (v[j] > v[iMax]) iMax = j;
|
||
}
|
||
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
|
||
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
|
||
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
|
||
}
|
||
return { t: outT.subarray(0, n), v: outV.subarray(0, n) };
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Hybrid zoom: hi-res data fetched from /api/zoom on demand
|
||
════════════════════════════════════════════════════════════════ */
|
||
/* Plot x-ranges are stored relative to the trigger instant while a capture is on
|
||
screen, but the hub only knows absolute time — so every range that leaves this
|
||
client (and every comparison against a reply) goes through here. */
|
||
function absXRange(range) {
|
||
if (!range) return null;
|
||
if (!inTrigWindow() || trig.trigTime === null) return range;
|
||
return [trig.trigTime + range[0], trig.trigTime + range[1]];
|
||
}
|
||
|
||
function cancelZoomFetch() {
|
||
if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; }
|
||
}
|
||
function scheduleZoomFetch(t0, t1) {
|
||
cancelZoomFetch();
|
||
_zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150);
|
||
}
|
||
|
||
async function doZoomFetch(t0, t1) {
|
||
// Collect all signal keys across all plots; each key encodes sourceId as prefix.
|
||
const allKeys = new Set();
|
||
plots.forEach(p => p.traces.forEach(k => allKeys.add(k)));
|
||
if (allKeys.size === 0) return;
|
||
|
||
const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2);
|
||
|
||
// Fire regular zoom + history zoom in parallel
|
||
const promises = [];
|
||
|
||
// Regular zoom (in-memory ring)
|
||
promises.push(wsZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
|
||
console.warn('zoom fetch:', e); return null;
|
||
}));
|
||
|
||
// History zoom (disk) — only if history is enabled
|
||
if (historyMeta && historyMeta.enabled) {
|
||
promises.push(wsHistoryZoomRequest(t0, t1, targetPts, [...allKeys]).catch(e => {
|
||
console.warn('history zoom fetch:', e); return null;
|
||
}));
|
||
}
|
||
|
||
const [sigs, histSigs] = await Promise.all(promises);
|
||
|
||
// Merge: history data first (older), regular zoom overlays (newer wins)
|
||
const merged = {};
|
||
if (histSigs) {
|
||
Object.entries(histSigs).forEach(([k, sd]) => {
|
||
if (!sd || !sd.t || !sd.v) return;
|
||
merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
|
||
});
|
||
}
|
||
if (sigs) {
|
||
Object.entries(sigs).forEach(([k, sd]) => {
|
||
if (!sd || !sd.t || !sd.v || sd.t.length === 0) return;
|
||
const zt = Float64Array.from(sd.t), zv = Float64Array.from(sd.v);
|
||
if (merged[k] && merged[k].t.length > 0) {
|
||
// Merge: use history for t < ring oldest, ring for the rest
|
||
const ht = merged[k].t, hv = merged[k].v;
|
||
const ringOldest = zt[0];
|
||
// Find cutoff in history data
|
||
let cutIdx = ht.length;
|
||
for (let i = 0; i < ht.length; i++) {
|
||
if (ht[i] >= ringOldest) { cutIdx = i; break; }
|
||
}
|
||
if (cutIdx > 0) {
|
||
// Prepend history data before ring data
|
||
const mt = new Float64Array(cutIdx + zt.length);
|
||
const mv = new Float64Array(cutIdx + zv.length);
|
||
mt.set(ht.subarray(0, cutIdx));
|
||
mv.set(hv.subarray(0, cutIdx));
|
||
mt.set(zt, cutIdx);
|
||
mv.set(zv, cutIdx);
|
||
merged[k] = { t: mt, v: mv };
|
||
} else {
|
||
merged[k] = { t: zt, v: zv };
|
||
}
|
||
} else {
|
||
merged[k] = { t: zt, v: zv };
|
||
}
|
||
});
|
||
}
|
||
|
||
plots.forEach(p => {
|
||
// Only store if this plot's range still matches what we fetched.
|
||
const abs = absXRange(p.xRange);
|
||
if (!abs || Math.abs(abs[0] - t0) > 1e-9 || Math.abs(abs[1] - t1) > 1e-9) return;
|
||
const plotSigs = {};
|
||
p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; });
|
||
if (Object.keys(plotSigs).length > 0) {
|
||
zoomData[p.id] = { signals: plotSigs, t0, t1 };
|
||
p.needsRedraw = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Build uPlot data arrays from server-fetched hi-res signal data.
|
||
// Sparse signals (0 or 1 pts in range) are supplemented with local buffer
|
||
// bracket points so a line is always drawn across the visible area.
|
||
function buildDataFromFetched(p, fetchedSignals, targetPts) {
|
||
const t0 = p.xRange ? p.xRange[0] : -Infinity;
|
||
const t1 = p.xRange ? p.xRange[1] : Infinity;
|
||
|
||
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
|
||
for (const key of p.traces) {
|
||
let sd = fetchedSignals[key];
|
||
if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
|
||
if (!sd || !sd.t.length) continue;
|
||
const rate = getKeySamplingRate(key);
|
||
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
|
||
masterRate = rate; masterCount = sd.t.length; masterKey = key;
|
||
}
|
||
}
|
||
|
||
let masterSd = fetchedSignals[masterKey];
|
||
if (!masterSd || masterSd.t.length < 2)
|
||
masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1);
|
||
if (!masterSd || !masterSd.t.length)
|
||
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
|
||
|
||
const dec = decimate(masterSd.t, masterSd.v, targetPts);
|
||
const sharedT = dec.t;
|
||
const yArrays = [];
|
||
for (const key of p.traces) {
|
||
if (key === masterKey) { yArrays.push(dec.v); continue; }
|
||
let sd = fetchedSignals[key];
|
||
if (!sd || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
|
||
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
|
||
}
|
||
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
uPlot helpers
|
||
════════════════════════════════════════════════════════════════ */
|
||
// ─── Time formatting helpers ──────────────────────────────────────────────────
|
||
|
||
// Returns the span (in seconds) of the currently visible x-axis across all plots.
|
||
// Falls back to windowSec when no uPlot instances exist yet.
|
||
function currentXSpan() {
|
||
for (const p of plots) {
|
||
if (p.uplot) {
|
||
const s = p.uplot.scales.x;
|
||
if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min);
|
||
}
|
||
}
|
||
return windowSec;
|
||
}
|
||
|
||
// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on
|
||
// refSpan (e.g. the visible x-range or the value itself).
|
||
// sign = '+' prefix only when showSign is true (default false for ΔT display).
|
||
function fmtDuration(sec, refSpan, showSign) {
|
||
const abs = Math.abs(sec);
|
||
const sign = showSign ? (sec < 0 ? '−' : '+') : (sec < 0 ? '−' : '');
|
||
if (refSpan < 1e-6) { // nanosecond range
|
||
return sign + (abs * 1e9).toFixed(1) + ' ns';
|
||
} else if (refSpan < 1e-3) { // microsecond range
|
||
return sign + (abs * 1e6).toFixed(3) + ' µs';
|
||
} else if (refSpan < 1) { // millisecond range
|
||
return sign + (abs * 1e3).toFixed(3) + ' ms';
|
||
} else { // second range
|
||
return sign + abs.toFixed(6) + ' s';
|
||
}
|
||
}
|
||
|
||
// Format a Unix-seconds timestamp → HH:MM:SS.fraction
|
||
// The number of sub-second digits adapts to the visible x-range span.
|
||
function fmtLiveTime(v, span) {
|
||
const d = new Date(v * 1000);
|
||
const hh = String(d.getHours()).padStart(2, '0');
|
||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||
const frac = v - Math.floor(v); // sub-second part, full float64 precision
|
||
if (span < 1e-6) {
|
||
// show 9 decimal places (ns precision)
|
||
const ns = Math.round(frac * 1e9);
|
||
return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0');
|
||
} else if (span < 1e-3) {
|
||
// show 6 decimal places (µs precision)
|
||
const us = Math.round(frac * 1e6);
|
||
return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0');
|
||
} else if (span < 1) {
|
||
// show 3 decimal places (ms precision) — tick labels only show ms
|
||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||
return hh + ':' + mm + ':' + ss + '.' + ms;
|
||
} else {
|
||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||
return hh + ':' + mm + ':' + ss + '.' + ms;
|
||
}
|
||
}
|
||
|
||
// Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks)
|
||
// Precision adapts to the visible x-range (via u.scales.x.{min,max}).
|
||
function fmtLiveTick(u, vals) {
|
||
const span = (u.scales.x && u.scales.x.max != null)
|
||
? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec;
|
||
return vals.map(v => v == null ? '' : fmtLiveTime(v, span));
|
||
}
|
||
|
||
// Format relative seconds → auto-scaled unit (used for trigger x-axis ticks)
|
||
// Unit (s/ms/µs/ns) is determined by the visible x-range span.
|
||
function fmtTrigTick(u, vals) {
|
||
const span = (u.scales.x && u.scales.x.max != null)
|
||
? Math.abs(u.scales.x.max - u.scales.x.min) : 1;
|
||
return vals.map(v => v == null ? '' : fmtDuration(v, span, true));
|
||
}
|
||
|
||
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
|
||
// line on any plot that shows the trigger signal.
|
||
function drawTriggerMarker(u, p) {
|
||
if (!inTrigWindow()) return;
|
||
const { ctx, bbox } = u;
|
||
if (!bbox) return;
|
||
const x = u.valToPos(0, 'x', true);
|
||
if (x < bbox.left || x > bbox.left + bbox.width) return;
|
||
const px = Math.round(x);
|
||
ctx.save();
|
||
// Thin dashed vertical line at t=0
|
||
ctx.strokeStyle = 'rgba(203,166,247,0.7)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 3]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, bbox.top);
|
||
ctx.lineTo(px, bbox.top + bbox.height);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = 'rgba(203,166,247,0.7)';
|
||
ctx.font = 'bold 9px monospace';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('T', px + 3, bbox.top + 2);
|
||
// Horizontal threshold line — only on plots that contain the trigger signal.
|
||
// The trigger may target one element of an array ("sig[3]"), while traces are
|
||
// keyed by the base name, so compare on the base key.
|
||
const trigBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
|
||
if (p && trigBase && p.traces.includes(trigBase)) {
|
||
// Normalise the calibrated threshold to this plot's vscale for the trigger signal.
|
||
const tvs = sigVScale[p.id + ':' + trigBase];
|
||
let threshNorm = trig.threshold;
|
||
if (tvs) {
|
||
const dv = tvs._resolvedDiv || tvs.divValue || 1;
|
||
const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
|
||
threshNorm = (trig.threshold - ofs) / dv;
|
||
}
|
||
const y = u.valToPos(threshNorm, 'y', true);
|
||
if (y >= bbox.top && y <= bbox.top + bbox.height) {
|
||
const py = Math.round(y);
|
||
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
|
||
ctx.lineWidth = 0.75;
|
||
ctx.setLineDash([3, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(bbox.left, py);
|
||
ctx.lineTo(bbox.left + bbox.width, py);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = 'rgba(203,166,247,0.45)';
|
||
ctx.font = '9px monospace';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Linearly interpolate series value at time t from uPlot's current rendered data.
|
||
function interpAtTime(u, si, t) {
|
||
const td = u.data[0], vd = u.data[si];
|
||
if (!td || td.length === 0 || !vd) return null;
|
||
let lo = 0, hi = td.length - 1;
|
||
while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; }
|
||
if (lo === 0) return vd[0] ?? null;
|
||
const t0 = td[lo - 1], t1 = td[lo];
|
||
const v0 = vd[lo - 1], v1 = vd[lo];
|
||
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
|
||
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
|
||
}
|
||
|
||
// Draw horizontal separator lines between signal bands in digital/mixed mode.
|
||
function drawBandSeparators(u, p) {
|
||
if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return;
|
||
const n = p.traces.length;
|
||
if (n < 2) return;
|
||
const { ctx, bbox } = u;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const bandH = 8 / n;
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(127,132,156,0.25)';
|
||
ctx.lineWidth = dpr;
|
||
for (let i = 1; i < n; i++) {
|
||
const yNorm = 4 - i * bandH; // boundary between band i-1 and band i
|
||
const yPx = u.valToPos(yNorm, 'y', true);
|
||
ctx.beginPath();
|
||
ctx.moveTo(bbox.left, yPx);
|
||
ctx.lineTo(bbox.left + bbox.width, yPx);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Redraw the active signal's line on top of all series with a wider stroke, so it
|
||
// visually appears in the foreground regardless of series draw order.
|
||
function drawActiveSeries(u, p) {
|
||
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
|
||
const activeKey = plotActiveSignal[p.id];
|
||
if (!activeKey) return;
|
||
const idx = p.traces.indexOf(activeKey);
|
||
if (idx < 0) return;
|
||
const xs = u.data[0];
|
||
const ys = u.data[idx + 1]; // +1 because index 0 is time
|
||
if (!xs || !ys) return;
|
||
const style = getSigStyle(activeKey);
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const { ctx } = u;
|
||
ctx.save();
|
||
ctx.strokeStyle = style.color;
|
||
ctx.lineWidth = style.width * 2 * dpr;
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineCap = 'round';
|
||
ctx.beginPath();
|
||
let started = false;
|
||
for (let i = 0; i < xs.length; i++) {
|
||
const yv = ys[i];
|
||
if (yv == null || !isFinite(yv)) { started = false; continue; }
|
||
const xPx = u.valToPos(xs[i], 'x', true);
|
||
const yPx = u.valToPos(yv, 'y', true);
|
||
if (!started) { ctx.moveTo(xPx, yPx); started = true; }
|
||
else ctx.lineTo(xPx, yPx);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
|
||
// Draw offset position markers (right-pointing triangles) on the left edge of
|
||
// the plot for each signal. The marker sits at the signal's zero level:
|
||
// y_norm = (0 - offset)/div, so signals with different offsets are visually
|
||
// separated and dragging the marker adjusts the offset directly.
|
||
// Active signal marker is larger and outlined in white.
|
||
//
|
||
// Unified mode has a single scale for the whole plot, so it gets one neutral
|
||
// marker instead of N identical triangles stacked on the same pixel.
|
||
function offsetMarkerEntries(p) {
|
||
if (p.mode === 'unified') {
|
||
const vs = sigVScale[p.id + ':' + UNIFIED_VS_KEY];
|
||
return vs ? [{ key: null, vs, color: '#9399b2' }] : [];
|
||
}
|
||
return p.traces
|
||
.map(key => ({ key, vs: sigVScale[p.id + ':' + key], color: getSigStyle(key).color }))
|
||
.filter(e => e.vs);
|
||
}
|
||
|
||
function drawOffsetMarkers(u, p) {
|
||
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
|
||
const { ctx, bbox } = u;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
offsetMarkerEntries(p).forEach(({ key, vs, color }) => {
|
||
const dv = vs._resolvedDiv || vs.divValue || 1;
|
||
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||
const yCtr = u.valToPos(-ofs / dv, 'y', true);
|
||
const isActive = key !== null && plotActiveSignal[p.id] === key;
|
||
const mH = (isActive ? 7 : 5) * dpr;
|
||
const mW = (isActive ? 10 : 7) * dpr;
|
||
if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
|
||
ctx.save();
|
||
ctx.fillStyle = color;
|
||
// Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area
|
||
ctx.beginPath();
|
||
ctx.moveTo(bbox.left + dpr, yCtr);
|
||
ctx.lineTo(bbox.left - mW + dpr, yCtr - mH);
|
||
ctx.lineTo(bbox.left - mW + dpr, yCtr + mH);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
if (isActive) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
|
||
ctx.lineWidth = dpr;
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
});
|
||
}
|
||
|
||
function drawSeriesMarkers(u, p) {
|
||
if (!u.bbox) return;
|
||
const { ctx, bbox } = u;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||
ctx.clip();
|
||
p.traces.forEach((key, idx) => {
|
||
const style = getSigStyle(key);
|
||
if (style.marker === 'none' || style.marker === 'circle') return;
|
||
const si = idx + 1;
|
||
const xd = u.data[0], yd = u.data[si];
|
||
if (!xd || !yd) return;
|
||
const sz = style.markerSize;
|
||
ctx.strokeStyle = style.color;
|
||
ctx.fillStyle = style.color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([]);
|
||
for (let i = 0; i < xd.length; i++) {
|
||
if (yd[i] == null) continue;
|
||
const cx = u.valToPos(xd[i], 'x', true);
|
||
const cy = u.valToPos(yd[i], 'y', true);
|
||
if (cx < bbox.left || cx > bbox.left + bbox.width ||
|
||
cy < bbox.top || cy > bbox.top + bbox.height) continue;
|
||
ctx.beginPath();
|
||
if (style.marker === 'square') {
|
||
ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill();
|
||
} else if (style.marker === 'cross') {
|
||
ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy);
|
||
ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2);
|
||
ctx.stroke();
|
||
} else if (style.marker === 'diamond') {
|
||
ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy);
|
||
ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy);
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
}
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
// Draw cursor A/B vertical lines and signal value labels (called from draw hook).
|
||
function drawCursorLines(u, p) {
|
||
if (cursors.mode !== 'on') return;
|
||
const { ctx, bbox } = u;
|
||
if (!bbox) return;
|
||
|
||
const drawLine = (val, color, label) => {
|
||
if (val === null) return;
|
||
const x = u.valToPos(val, 'x', true);
|
||
if (x < bbox.left || x > bbox.left + bbox.width) return;
|
||
const px = Math.round(x);
|
||
ctx.save();
|
||
// Clip to plot area
|
||
ctx.beginPath();
|
||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||
ctx.clip();
|
||
// Vertical dashed cursor line
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([5, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, bbox.top);
|
||
ctx.lineTo(px, bbox.top + bbox.height);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
// Cursor label at top
|
||
ctx.fillStyle = color;
|
||
ctx.font = 'bold 12px monospace';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(label, px + 14, bbox.top + 2);
|
||
// Per-trace: diamond at crossing point + value label
|
||
if (p) {
|
||
const activeKey = plotActiveSignal[p.id];
|
||
p.traces.forEach((key, idx) => {
|
||
const isActive = key === activeKey || (!activeKey && p.traces.length === 1);
|
||
const DSZ = isActive ? 9 : 5; // diamond half-size in px
|
||
const vNorm = interpAtTime(u, idx + 1, val);
|
||
if (vNorm === null) return;
|
||
const cy = u.valToPos(vNorm, 'y', true);
|
||
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
|
||
// 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 100k–300k pts — far more than uPlot
|
||
// needs for a 1200px-wide canvas. Always decimate via the background worker
|
||
// (stale-while-revalidate: use cached result; fall back to sync on first render).
|
||
// The rolling-mode key is constant per (plot, signal) — the data generation is
|
||
// carried separately so the cache holds one entry per plot instead of one per
|
||
// push tick, which used to grow without bound for the whole session.
|
||
const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
||
const cacheKey = isRolling
|
||
? `${p.id}:${masterKey}:rolling`
|
||
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
|
||
let sharedT, masterV;
|
||
if (masterRaw.t.length <= targetPts) {
|
||
// Data already sparse enough — use directly (no decimation needed).
|
||
sharedT = masterRaw.t;
|
||
masterV = masterRaw.v;
|
||
} else {
|
||
const cached = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts,
|
||
isRolling ? _dataGen : undefined);
|
||
let dec;
|
||
if (cached) {
|
||
dec = cached;
|
||
} else {
|
||
// Worker job submitted — sync fallback this frame so the plot isn't blank.
|
||
dec = decimate(masterRaw.t, masterRaw.v, targetPts);
|
||
}
|
||
sharedT = dec.t;
|
||
masterV = dec.v;
|
||
}
|
||
|
||
const yArrays = [];
|
||
for (const key of p.traces) {
|
||
if (key === masterKey) { yArrays.push(masterV); continue; }
|
||
const sl = slices[key];
|
||
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
|
||
}
|
||
|
||
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||
}
|
||
|
||
function buildTrigData(p) {
|
||
const trigT = trig.trigTime;
|
||
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
|
||
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
|
||
|
||
if (p.traces.length === 0) return [new Float64Array(0)];
|
||
|
||
const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
|
||
const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;
|
||
|
||
const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
||
|
||
/* The capture frame is the whole window in 20 000 points, so zooming into it
|
||
magnifies that decimation instead of resolving detail. When the hub has
|
||
answered a zoom for exactly this range — out of the ring while the window is
|
||
still in it, out of the disk history afterwards — that reply is the same
|
||
samples at the zoomed resolution, so it wins. It is only ever a supplement:
|
||
an empty or stale reply falls back to the snapshot, which is always there. */
|
||
const zd = p.xRange ? zoomData[p.id] : null;
|
||
const fetched = (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9)
|
||
? zd.signals : null;
|
||
/* Only when it spans the view. The window rolls out of the ring soon after the
|
||
capture (the ring is sized at ~1.2× the window), so a late reply can cover
|
||
just the newest sliver of the range — and half a trace at high resolution is
|
||
worse than a whole one at low resolution. */
|
||
const edgeTol = (t1 - t0) * 0.02;
|
||
const covers = (sd) => sd && sd.t.length > 1 &&
|
||
sd.t[0] <= t0 + edgeTol && sd.t[sd.t.length - 1] >= t1 - edgeTol;
|
||
|
||
// Slice all traces; pick master by samplingRate first, then sample count
|
||
const slices = {};
|
||
let usedFetched = false;
|
||
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
|
||
for (const key of p.traces) {
|
||
const hi = fetched ? fetched[key] : null;
|
||
const src = covers(hi) ? hi : trig.snapshot[key];
|
||
if (!src) continue;
|
||
if (src === hi) usedFetched = true;
|
||
const sl = sliceTypedArrayRange(src.t, src.v, t0, t1);
|
||
slices[key] = sl;
|
||
const rate = getKeySamplingRate(key);
|
||
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
|
||
masterRate = rate; masterCount = sl.t.length; masterKey = key;
|
||
}
|
||
}
|
||
|
||
const masterRaw = slices[masterKey];
|
||
if (!masterRaw || masterRaw.t.length === 0)
|
||
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
|
||
|
||
// Trig snapshot is fixed (no new data arrives after capture), so the cache
|
||
// key only needs range + data length. A fetched slice can replace a
|
||
// same-length snapshot slice for the same range, so it is tagged separately.
|
||
const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}:${usedFetched ? 'hi' : 'snap'}`;
|
||
const cachedDec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
|
||
const dec = cachedDec || decimate(masterRaw.t, masterRaw.v, targetPts);
|
||
// Convert absolute → relative seconds
|
||
const sharedT = new Float64Array(dec.t.length);
|
||
for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT;
|
||
|
||
const yArrays = [];
|
||
for (const key of p.traces) {
|
||
if (key === masterKey) { yArrays.push(dec.v); continue; }
|
||
const sl = slices[key];
|
||
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||
const relT = new Float64Array(sl.t.length);
|
||
for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT;
|
||
yArrays.push(resampleLinear(relT, sl.v, sharedT));
|
||
}
|
||
|
||
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||
}
|
||
|
||
/* Trace drawn while a fired trigger's window is still filling. Identical
|
||
framing to buildTrigData (relative seconds, fixed [-pre, +post] axis) but fed
|
||
from the live push buffers, so the waveform sweeps in from the left instead
|
||
of the plot sitting frozen until the hub finishes the capture. */
|
||
function buildTrigFillData(p) {
|
||
if (p.traces.length === 0) return [new Float64Array(0)];
|
||
|
||
const trigT = trig.trigTime;
|
||
const preS = trigPreSec(), postS = trigPostSec();
|
||
const t0 = p.xRange ? trigT + p.xRange[0] : trigT - preS;
|
||
const t1 = p.xRange ? trigT + p.xRange[1] : trigT + postS;
|
||
|
||
const targetPts = Math.max(DECIM_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
||
|
||
const slices = {};
|
||
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
|
||
for (const key of p.traces) {
|
||
const buf = buffers[key];
|
||
if (!buf || buf.size === 0) continue;
|
||
const sl = getBufferSliceRange(buf, t0, t1);
|
||
slices[key] = sl;
|
||
const rate = getKeySamplingRate(key);
|
||
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
|
||
masterRate = rate; masterCount = sl.t.length; masterKey = key;
|
||
}
|
||
}
|
||
|
||
const masterRaw = slices[masterKey];
|
||
if (!masterRaw || masterRaw.t.length === 0)
|
||
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
|
||
|
||
// The window keeps growing, so key the cache on the data generation the same
|
||
// way the rolling live path does rather than on the (fixed) range.
|
||
let sharedAbsT, masterV;
|
||
if (masterRaw.t.length <= targetPts) {
|
||
sharedAbsT = masterRaw.t;
|
||
masterV = masterRaw.v;
|
||
} else {
|
||
const cacheKey = `${p.id}:${masterKey}:trigfill`;
|
||
const dec = decimateAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts, _dataGen) ||
|
||
decimate(masterRaw.t, masterRaw.v, targetPts);
|
||
sharedAbsT = dec.t;
|
||
masterV = dec.v;
|
||
}
|
||
|
||
const sharedT = new Float64Array(sharedAbsT.length);
|
||
for (let i = 0; i < sharedAbsT.length; i++) sharedT[i] = sharedAbsT[i] - trigT;
|
||
|
||
const yArrays = [];
|
||
for (const key of p.traces) {
|
||
if (key === masterKey) { yArrays.push(masterV); continue; }
|
||
const sl = slices[key];
|
||
if (!sl || sl.t.length === 0) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||
const relT = new Float64Array(sl.t.length);
|
||
for (let i = 0; i < sl.t.length; i++) relT[i] = sl.t[i] - trigT;
|
||
yArrays.push(resampleLinear(relT, sl.v, sharedT));
|
||
}
|
||
|
||
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Zoom sync
|
||
════════════════════════════════════════════════════════════════ */
|
||
let syncLocked = false;
|
||
|
||
function onZoom(sourcePlotId, min, max) {
|
||
// Push current range to history before applying new zoom
|
||
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
|
||
zoomHistory.push(prevRange);
|
||
if (zoomHistory.length > 30) zoomHistory.shift();
|
||
document.getElementById('btn-zoom-back').style.display = '';
|
||
|
||
// Store zoom on source plot
|
||
const src = plots.find(p => p.id === sourcePlotId);
|
||
if (src) src.xRange = [min, max];
|
||
|
||
// Show Auto button in live mode
|
||
if (!trig.enabled && !syncLocked) {
|
||
syncLocked = true;
|
||
document.getElementById('btn-sync-resume').style.display = '';
|
||
}
|
||
|
||
// Propagate to other plots
|
||
zoomGuard = true;
|
||
plots.forEach(p => {
|
||
if (p.id === sourcePlotId) return;
|
||
p.xRange = [min, max];
|
||
if (p.uplot) p.uplot.setScale('x', { min, max });
|
||
});
|
||
zoomGuard = false;
|
||
|
||
// Evict stale decimation cache entries — new zoom range needs fresh decimation.
|
||
plots.forEach(p => decimCacheEvict(p.id));
|
||
|
||
// Mark all plots dirty (re-slice data to the new range for full resolution)
|
||
plots.forEach(p => { p.needsRedraw = true; });
|
||
|
||
// Schedule hi-res fetch from ring buffers. A capture is a snapshot the hub
|
||
// already decimated to 20 000 pts for the whole window, so zooming into it
|
||
// needs the same round trip as live zooming does, not just a bigger axis.
|
||
const abs = absXRange([min, max]);
|
||
scheduleZoomFetch(abs[0], abs[1]);
|
||
}
|
||
|
||
// Undo last zoom/pan action
|
||
function zoomBack() {
|
||
if (!zoomHistory.length) return;
|
||
const prev = zoomHistory.pop();
|
||
if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none';
|
||
|
||
// Discard stale zoom data regardless of direction.
|
||
Object.keys(zoomData).forEach(k => delete zoomData[k]);
|
||
cancelZoomFetch();
|
||
|
||
if (prev === null) {
|
||
// Was at auto/rolling state before the zoom
|
||
resetZoom();
|
||
} else {
|
||
zoomGuard = true;
|
||
plots.forEach(p => {
|
||
p.xRange = [...prev];
|
||
if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] });
|
||
p.needsRedraw = true;
|
||
});
|
||
zoomGuard = false;
|
||
const abs = absXRange(prev);
|
||
scheduleZoomFetch(abs[0], abs[1]);
|
||
}
|
||
}
|
||
|
||
// Reset to auto/rolling window (clears all zoom)
|
||
function resetZoom() {
|
||
Object.keys(zoomData).forEach(k => delete zoomData[k]);
|
||
cancelZoomFetch();
|
||
syncLocked = false;
|
||
document.getElementById('btn-sync-resume').style.display = 'none';
|
||
if (inTrigWindow()) {
|
||
const preS = activePreSec();
|
||
const postS = activePostSec();
|
||
zoomGuard = true;
|
||
plots.forEach(p => {
|
||
p.xRange = null;
|
||
if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS });
|
||
p.needsRedraw = true;
|
||
});
|
||
zoomGuard = false;
|
||
} else {
|
||
// Back to rolling window — setScale to current window, render loop keeps it moving
|
||
plots.forEach(p => {
|
||
p.xRange = null;
|
||
if (!globalPause) p.needsRedraw = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Fit x-axis to all data currently in buffers (or full trigger snapshot)
|
||
function zoomFit() {
|
||
if (inTrigWindow()) {
|
||
resetZoom(); // "Fit" in trigger mode = show full trigger window
|
||
return;
|
||
}
|
||
// Find oldest/newest timestamps across all visible signals
|
||
let gMin = Infinity, gMax = -Infinity;
|
||
plots.forEach(p => {
|
||
p.traces.forEach(key => {
|
||
const buf = buffers[key]; if (!buf || buf.size === 0) return;
|
||
const startIdx = (buf.size === buf.cap) ? buf.head : 0;
|
||
const oldestT = buf.t[startIdx];
|
||
const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||
if (oldestT < gMin) gMin = oldestT;
|
||
if (newestT > gMax) gMax = newestT;
|
||
});
|
||
});
|
||
if (!isFinite(gMin) || gMin >= gMax) return;
|
||
|
||
// Push to history
|
||
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
|
||
zoomHistory.push(prevRange);
|
||
document.getElementById('btn-zoom-back').style.display = '';
|
||
if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; }
|
||
|
||
zoomGuard = true;
|
||
plots.forEach(p => {
|
||
p.xRange = [gMin, gMax];
|
||
if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax });
|
||
p.needsRedraw = true;
|
||
});
|
||
zoomGuard = false;
|
||
scheduleZoomFetch(gMin, gMax);
|
||
}
|
||
|
||
// Auto = return to rolling window / full trigger window
|
||
function exitSyncLock() { resetZoom(); }
|
||
|
||
document.getElementById('btn-sync-resume').addEventListener('click', resetZoom);
|
||
document.getElementById('btn-zoom-back').addEventListener('click', zoomBack);
|
||
document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Cursor controls
|
||
════════════════════════════════════════════════════════════════ */
|
||
// Cursors are always available — in live rolling mode they are pinned to the
|
||
// moving viewport by the render loop.
|
||
function updateCursorBtnVisibility() {
|
||
document.getElementById('btn-cursor').style.display = '';
|
||
}
|
||
|
||
/* Place cursors A/B at 25 %/75 % of what is currently on screen.
|
||
|
||
Cursor positions are absolute — Unix seconds live, seconds from the trigger
|
||
under one — so a zoom, a pan or a new capture can leave them outside the
|
||
viewport entirely, with no way to get them back: they are dragged by grabbing
|
||
their line, and an off-screen line cannot be grabbed. */
|
||
function 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() {
|
||
// 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;
|
||
|
||
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;
|
||
p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow });
|
||
zoomGuard = false;
|
||
return;
|
||
}
|
||
|
||
p.needsRedraw = false;
|
||
p.lastDataGen = _dataGen;
|
||
|
||
const data = buildUPlotData(p, inTrigModeNow);
|
||
|
||
// setData internally triggers the setScale hook in uPlot (it reaffirms the
|
||
// current scale even with auto:false). Keep zoomGuard raised across the
|
||
// entire setData + setScale block so the hook never calls onZoom and freezes
|
||
// p.xRange unintentionally. The guard is safe here: JS is single-threaded so
|
||
// no genuine user drag-zoom event can fire during this synchronous block.
|
||
zoomGuard = true;
|
||
p.uplot.setData(data);
|
||
|
||
// Re-apply the x-scale after setData so the viewport stays correct.
|
||
if (trig.enabled && !inTrigModeNow) {
|
||
// Armed / waiting for trigger: keep the current scale frozen.
|
||
} else if (inTrigModeNow) {
|
||
const preS = 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;
|
||
});
|
||
|
||
// Keep per-plot cursor value readouts in sync with live data.
|
||
if (cursors.mode === 'on') updatePlotCursorReadouts();
|
||
|
||
requestAnimationFrame(renderDirtyPlots);
|
||
}
|
||
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Global controls
|
||
════════════════════════════════════════════════════════════════ */
|
||
document.getElementById('btn-pause-global').addEventListener('click', () => {
|
||
globalPause = !globalPause;
|
||
const btn = document.getElementById('btn-pause-global');
|
||
btn.textContent = globalPause ? '▶ Resume' : '⏸ Pause';
|
||
btn.classList.toggle('active', globalPause);
|
||
if (!globalPause) plots.forEach(p => { p.needsRedraw = true; });
|
||
updateCursorBtnVisibility();
|
||
});
|
||
|
||
document.getElementById('window-select').addEventListener('change', e => {
|
||
windowSec = parseFloat(e.target.value);
|
||
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">▶</span> Sources & 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
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(() => { });
|