Compare commits

...

2 Commits

Author SHA1 Message Date
Martino Ferrari d5b1986761 Improved cursors functionality 2026-05-27 16:46:06 +02:00
Martino Ferrari 915c6fc126 WebUI: resizable plots, digital/mixed modes, buffer fix, zoom brackets, updated docs
- Resizable plot grid: drag handles between plots adjust column/row fr sizes
- Plot configuration toolbar (click title): edit title, select Normal/Mixed/Digital mode
- Digital mode: logic-analyzer banded layout, signals quantized to hi/lo per band
- Mixed mode: banded layout where each signal is independently analog or digital
- Per-signal vscale toolbar embedded inline below plot header (badge click to open)
- Active signal highlighted in foreground with increased line width
- Signal offset markers draggable on Y axis; per-plot vscale state isolation
- Buffer sizing based on signal sampling rate (up to 60s @ configured rate)
- growBuffer: live buffer expansion without data loss on window/rate change
- Zoom bracket lines: nearest out-of-range points included for continuity
- Updated Docs/WebUI.md to reflect current uPlot-based implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:29:09 +02:00
4 changed files with 680 additions and 81 deletions
+478 -30
View File
@@ -2,13 +2,35 @@
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000;
// Temporal signals receive 50 pts/tick × 30 Hz = 1 500 pts/s from the server.
// 50 000 cap → ~33 s of rolling history; well beyond the max supported window.
// Zoom uses /api/zoom (server ring buffer) so the cap only affects rolling display.
const TEMPORAL_CAP = 50_000;
// Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history.
const MAX_WINDOW_SEC = 60;
// Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v).
const MAX_CAP = 600_000;
// Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz.
const DEFAULT_CAP = 100_000;
// Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s.
// TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz.
const TEMPORAL_CAP = Math.min(MAX_CAP, 500_000);
const LTTB_MIN = 200; // never decimate below this many points
// Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz.
function bufferCapForRate(rateHz) {
if (!rateHz || rateHz <= 0) return DEFAULT_CAP;
return Math.min(MAX_CAP, Math.ceil(rateHz * MAX_WINDOW_SEC * 1.5));
}
// Return a new, larger circular buffer that preserves all existing samples.
function growBuffer(buf, newCap) {
if (newCap <= buf.cap) return buf;
const nb = makeBuffer(newCap);
const start = buf.size === buf.cap ? buf.head : 0;
for (let i = 0; i < buf.size; i++) {
const idx = (start + i) % buf.cap;
pushBuffer(nb, buf.t[idx], buf.v[idx]);
}
return nb;
}
const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
@@ -63,7 +85,7 @@ function setSigStyle(key, updates) {
// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
function getVScale(plotId, key) {
const vsKey = plotId + ':' + key;
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null };
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false };
return sigVScale[vsKey];
}
@@ -112,9 +134,47 @@ function resolveVScale(plotId, key, rawY) {
return { divValue, offset, screenPos };
}
// Mixed mode: each signal occupies a fixed band; within that band it is either
// quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed.
function applyMixedNorm(p, yArrays) {
const n = p.traces.length;
if (n === 0) return yArrays;
const bandH = 8 / n;
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const vs = getVScale(p.id, key);
const centerY = 4 - (ki + 0.5) * bandH;
const hi = centerY + bandH * 0.35;
const lo = centerY - bandH * 0.35;
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
const out = new Float64Array(rawY.length);
if (vs.digitalInMixed) {
const threshold = isFinite(min) ? (min + max) / 2 : 0.5;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo);
}
} else {
if (!isFinite(min)) { min = 0; max = 1; }
if (min === max) { min -= 1; max += 1; }
const range = max - min, bandRange = hi - lo;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : lo + (v - min) / range * bandRange;
}
}
return out;
});
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) {
if (p.mode === 'digital') return applyDigitalNorm(p, yArrays);
if (p.mode === 'mixed') return applyMixedNorm(p, yArrays);
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
@@ -127,6 +187,32 @@ function applyVScaleNorm(p, yArrays) {
});
}
// 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) {
@@ -139,6 +225,7 @@ function setActiveSig(plotId, key) {
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.
@@ -205,6 +292,9 @@ const LAYOUTS = [
['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1],
];
let currentLayout = 'l1x1';
let colFrs = [1]; // fractional column sizes (sum = cols)
let rowFrs = [1]; // fractional row sizes (sum = rows)
let _gridCols = 1, _gridRows = 1;
/* ════════════════════════════════════════════════════════════════
Trigger state
@@ -300,9 +390,10 @@ function onConfig(msg) {
newSigs.forEach(sig => {
const n = numElements(sig);
const base = prefix + sig.name;
const sigCap = bufferCapForRate(sig.samplingRate);
if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
else if (n === 1) { buffers[base] = makeBuffer(); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); }
else if (n === 1) { buffers[base] = makeBuffer(sigCap); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); }
});
if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
@@ -382,6 +473,13 @@ function onBinaryData(buf) {
bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP);
buffers[fullKey] = bufObj;
}
// If the buffer was created before the CONFIG message arrived (or with an
// underestimated capacity), grow it now that we know the signal's rate.
const detectedRate = getKeySamplingRate(fullKey);
if (detectedRate > 0) {
const needed = bufferCapForRate(detectedRate);
if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; }
}
// Read t and v values in one pass (v array starts at off + n*8)
const tOff = off, vOff = off + n * 8;
@@ -565,6 +663,64 @@ function getBufferSliceRange(buf, t0, t1) {
}
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
@@ -790,17 +946,26 @@ async function doZoomFetch(t0, t1) {
}
// 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) {
const sd = fetchedSignals[key];
if (!sd || !sd.t || !sd.t.length) continue;
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;
}
}
const masterSd = fetchedSignals[masterKey];
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))];
@@ -809,7 +974,8 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
const sd = fetchedSignals[key];
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));
}
@@ -959,10 +1125,32 @@ function interpAtTime(u, si, t) {
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) return;
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const activeKey = plotActiveSignal[p.id];
if (!activeKey) return;
const idx = p.traces.indexOf(activeKey);
@@ -995,7 +1183,7 @@ function drawActiveSeries(u, p) {
// Draw offset position markers (right-pointing triangles) on the left edge of the plot
// for each signal. Active signal marker is larger and outlined in white.
function drawOffsetMarkers(u, p) {
if (!u.bbox) return;
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
@@ -1100,8 +1288,10 @@ function drawCursorLines(u, p) {
ctx.fillText(label, px + 14, bbox.top + 2);
// Per-trace: diamond at crossing point + value label
if (p) {
const DSZ = 5; // diamond half-size in px
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);
@@ -1175,16 +1365,19 @@ function computePlotNow(p) {
// 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 = makeSeriesPath(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: style.width,
points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color },
spanGaps: true,
width: isBanded ? 1.5 : style.width,
points: { show: false },
spanGaps: !isDigSig,
...(pathsFn ? { paths: pathsFn } : {}),
});
});
@@ -1228,11 +1421,24 @@ function makeUPlotOpts(p, inTrigMode) {
},
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60,
// Fixed 9 splits at integer divisions [-4..4] matching the normalized Y scale.
splits: () => [-4, -3, -2, -1, 0, 1, 2, 3, 4],
// Labels show real-unit values of the active signal for the plot.
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
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;
});
}
const activeKey = plotActiveSignal[p.id];
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
@@ -1246,7 +1452,7 @@ function makeUPlotOpts(p, inTrigMode) {
legend: { show: false },
padding: [4, 4, 0, 0],
hooks: {
draw: [u => { drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
// All programmatic setScale calls happen without a preceding setSelect, so the
@@ -1529,12 +1735,15 @@ function buildLiveData(p) {
}
// 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 = getBufferSliceRange(buf, t0, t1);
const sl = sliceFn(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
@@ -1813,10 +2022,56 @@ document.getElementById('btn-cursor').addEventListener('click', () => {
cursorsDirty = true;
});
// Format a signal value for the per-plot cursor readout.
function fmtVal(v) {
if (v === null || v === undefined) return '—';
return Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString();
}
// Interpolate the real-unit value of the active/sole signal in plot p at time t.
function getValueAtCursor(p, t) {
if (!p.uplot || t === null) return null;
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
if (!key) return null;
const idx = p.traces.indexOf(key);
if (idx < 0) return null;
const vNorm = interpAtTime(p.uplot, idx + 1, t);
if (vNorm === null) return null;
// Un-normalize: y_norm = (y_raw - offset) / divValue + screenPos
const vs = sigVScale[p.id + ':' + key];
if (!vs) return vNorm;
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const sp = vs.screenPos || 0;
return (vNorm - sp) * dv + ofs;
}
// Update per-plot cursor value readouts (A, B, ΔV) for all plots.
function updatePlotCursorReadouts() {
plots.forEach(p => {
const el = document.getElementById('pcur-' + p.id);
if (!el) return;
// Show only when cursors are on and the plot has an active or sole signal.
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
if (cursors.mode !== 'on' || !key || !p.uplot) {
el.style.display = 'none';
return;
}
const vA = getValueAtCursor(p, cursors.tA);
const vB = getValueAtCursor(p, cursors.tB);
const dv = (vA !== null && vB !== null) ? vB - vA : null;
document.getElementById('pcur-a-' + p.id).textContent = 'A: ' + fmtVal(vA);
document.getElementById('pcur-b-' + p.id).textContent = 'B: ' + fmtVal(vB);
document.getElementById('pcur-dv-' + p.id).textContent = 'ΔV: ' + fmtVal(dv);
el.style.display = 'flex';
});
}
function updateCursorReadout() {
const ro = document.getElementById('cursor-readout');
const active = cursors.mode === 'on';
ro.classList.toggle('visible', active);
updatePlotCursorReadouts();
if (!active) return;
// Use the current visible x-range to pick the display unit.
@@ -2046,6 +2301,127 @@ function makeDraggable(key, label, typeName, unit) {
return item;
}
/* ════════════════════════════════════════════════════════════════
Grid resize handles
════════════════════════════════════════════════════════════════ */
function updateGridTemplate() {
const grid = document.getElementById('plot-grid');
grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' ');
grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' ');
// Reposition all handles
document.querySelectorAll('.resize-handle-v').forEach(h => {
const i = parseInt(h.dataset.col);
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
document.querySelectorAll('.resize-handle-h').forEach(h => {
const i = parseInt(h.dataset.row);
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
// Notify uPlot of new sizes
plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); });
}
function setupResizeHandles(cols, rows) {
const grid = document.getElementById('plot-grid');
grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove());
const makeHandle = (cls, dataset, onDown) => {
const h = document.createElement('div');
h.className = cls;
Object.assign(h.dataset, dataset);
h.addEventListener('mousedown', onDown);
grid.appendChild(h);
return h;
};
for (let i = 0; i < cols - 1; i++) {
const h = makeHandle('resize-handle-v', { col: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = colFrs.reduce((a, b) => a + b, 0);
const pairSum = colFrs[i] + colFrs[i + 1];
const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientX - rect.left) / rect.width;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore));
colFrs[i] = newI; colFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
for (let i = 0; i < rows - 1; i++) {
const h = makeHandle('resize-handle-h', { row: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = rowFrs.reduce((a, b) => a + b, 0);
const pairSum = rowFrs[i] + rowFrs[i + 1];
const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientY - rect.top) / rect.height;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore));
rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
}
/* ════════════════════════════════════════════════════════════════
Plot config toolbar (click plot title to open)
════════════════════════════════════════════════════════════════ */
let _pcfgOpenId = null;
function showPlotCfg(plotId) {
if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg();
_pcfgOpenId = plotId;
document.getElementById('pcfg-' + plotId).style.display = 'block';
}
function hidePlotCfg() {
if (_pcfgOpenId == null) return;
const bar = document.getElementById('pcfg-' + _pcfgOpenId);
if (bar) bar.style.display = 'none';
_pcfgOpenId = null;
}
function initPlotCfgBar(plotId, p) {
const bar = document.getElementById('pcfg-' + plotId);
if (!bar) return;
const titleInput = bar.querySelector('.pcfg-title-input');
const titleEl = document.getElementById('ptitle-' + plotId);
titleInput.addEventListener('input', () => {
p.title = titleInput.value || ('Plot ' + plotId);
if (titleEl) titleEl.textContent = p.title;
});
bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const newMode = btn.dataset.mode;
if (p.mode === newMode) return;
p.mode = newMode;
bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode));
createUPlot(p);
p.needsRedraw = true;
});
});
bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg);
}
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
@@ -2080,7 +2456,18 @@ function applyLayout(cls) {
if (!entry) return;
const [label, , cols, rows] = entry;
currentLayout = cls;
document.getElementById('plot-grid').className = 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');
@@ -2105,11 +2492,10 @@ function applyLayout(cls) {
while (plots.length < needed) addPlot();
// Recreate all uPlot instances once the CSS grid has sized the cells.
// This also updates axis visibility (which axes show labels depends on grid position).
requestAnimationFrame(() => {
plots.forEach(p => {
createUPlot(p);
p.needsRedraw = true; // force immediate data+scale refresh so x/y ranges are preserved
p.needsRedraw = true;
});
setTimeout(() => plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
@@ -2266,8 +2652,28 @@ function addPlot() {
card.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = `
<div class="plot-card-header">
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
<span class="plot-title" id="ptitle-${id}" title="Click to configure">Plot ${id}</span>
<div class="sig-badges" id="badges-${id}"></div>
<div class="plot-cursor-ro" id="pcur-${id}" style="display:none">
<span class="pcur-a" id="pcur-a-${id}">A: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-b" id="pcur-b-${id}">B: —</span>
<span class="pcur-sep">│</span>
<span class="pcur-dv" id="pcur-dv-${id}">ΔV: —</span>
</div>
</div>
<div class="plot-cfg-bar" id="pcfg-${id}" style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${id}" style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn active" data-mode="normal">Normal</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
@@ -2285,8 +2691,15 @@ function addPlot() {
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id);
const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
const p = { id, title: 'Plot ' + id, mode: 'normal', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
plots.push(p);
// Wire config toolbar
initPlotCfgBar(id, p);
card.querySelector('#ptitle-' + id).addEventListener('click', () => {
if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id);
});
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id;
}
@@ -2300,6 +2713,7 @@ function addTraceTo(plotId, signalKey) {
// Recreate uPlot with new series list
createUPlot(p);
p.needsRedraw = true;
updatePlotCursorReadouts();
}
function removeTraceFrom(plotId, signalKey) {
@@ -2315,6 +2729,7 @@ function removeTraceFrom(plotId, signalKey) {
createUPlot(p);
p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
updatePlotCursorReadouts();
}
function addBadge(plotId, key) {
@@ -2486,6 +2901,9 @@ function renderDirtyPlots() {
zoomGuard = false;
});
// Keep per-plot cursor value readouts in sync with live data.
if (cursors.mode === 'on') updatePlotCursorReadouts();
requestAnimationFrame(renderDirtyPlots);
}
@@ -2504,6 +2922,14 @@ document.getElementById('btn-pause-global').addEventListener('click', () => {
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
// Grow any buffers that can't hold the full new window for their signal's rate.
Object.keys(buffers).forEach(key => {
const rate = getKeySamplingRate(key);
if (rate > 0) {
const needed = Math.min(MAX_CAP, Math.ceil(rate * windowSec * 1.5));
if (needed > buffers[key].cap) buffers[key] = growBuffer(buffers[key], needed);
}
});
// Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots.forEach(p => lttbCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot
@@ -2676,6 +3102,15 @@ function showVScaleMenu(key, plotId) {
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
// Type row (Analog/Digital) only shown in mixed mode.
const p = plots.find(q => q.id === plotId);
const isMixed = p && p.mode === 'mixed';
document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none';
if (isMixed) {
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
}
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
@@ -2775,6 +3210,19 @@ function initVScaleMenu() {
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
refreshPlotForKey(_vsMenuKey);
});
// Type buttons (Analog / Digital) — only active in mixed mode.
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey || !_vsMenuPlotId) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.digitalInMixed = btn.dataset.type === 'digital';
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b =>
b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
// Rebuild needed because series rendering changes (digital = no spanGaps, etc.)
const p = plots.find(q => q.id === _vsMenuPlotId);
if (p) { createUPlot(p); p.needsRedraw = true; }
});
});
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}
+7
View File
@@ -188,6 +188,13 @@
<label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div>
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Type</label>
<div class="ctx-btns" id="vscale-type-btns">
<button class="ctx-btn active" data-type="analog">Analog</button>
<button class="ctx-btn" data-type="digital">Digital</button>
</div>
</div>
<button id="btn-vscale-close" class="vstb-close" title="Close"></button>
</div>
</div>
+36 -4
View File
@@ -219,6 +219,19 @@ input[type=range].trig-range::-webkit-slider-thumb {
#plot-grid {
flex:1; min-height:0; display:grid; gap:0; padding:0; overflow:hidden;
border-top:1px solid var(--surface0); border-left:1px solid var(--surface0);
position:relative;
}
.resize-handle-v {
position:absolute; top:0; bottom:0; width:6px; cursor:col-resize; z-index:20;
transform:translateX(-50%); background:transparent; transition:background 0.15s;
}
.resize-handle-h {
position:absolute; left:0; right:0; height:6px; cursor:row-resize; z-index:20;
transform:translateY(-50%); background:transparent; transition:background 0.15s;
}
.resize-handle-v:hover,.resize-handle-v.dragging,
.resize-handle-h:hover,.resize-handle-h.dragging {
background:rgba(137,180,250,0.35);
}
#plot-grid.l1x1 { grid-template-columns:1fr; grid-template-rows:1fr; }
#plot-grid.l2x1 { grid-template-columns:1fr 1fr; grid-template-rows:1fr; }
@@ -249,11 +262,16 @@ input[type=range].trig-range::-webkit-slider-thumb {
}
.plot-title {
font-size:11px; color:var(--subtext1); font-weight:600;
cursor:text; outline:none; border:1px solid transparent; border-radius:3px;
padding:1px 3px; background:transparent; white-space:nowrap;
flex-shrink:0; max-width:80px; overflow:hidden; text-overflow:ellipsis;
cursor:pointer; border:1px solid transparent; border-radius:3px;
padding:1px 4px; background:transparent; white-space:nowrap; user-select:none;
flex-shrink:0; max-width:100px; overflow:hidden; text-overflow:ellipsis;
transition:border-color 0.15s, background 0.15s;
}
.plot-title:hover { border-color:var(--surface1); background:rgba(88,91,112,0.4); }
.plot-cfg-bar {
flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1);
padding:3px 8px;
}
.plot-title:focus { border-color:var(--accent); background:var(--surface1); color:var(--text); }
.sig-badges { display:flex; flex-wrap:nowrap; gap:3px; flex:1; overflow:hidden; min-width:0; }
.sig-badge {
display:inline-flex; align-items:center; gap:3px;
@@ -346,6 +364,20 @@ input[type=range].trig-range::-webkit-slider-thumb {
.vstb-close:hover { color:var(--red); }
.plot-vscale-bar { display:none; }
/* ── Per-plot cursor value readout (in plot card header) ────────── */
.plot-cursor-ro {
margin-left:auto; flex-shrink:0;
align-items:center; gap:5px;
font-size:10px; font-family:monospace;
background:var(--surface0); border:1px solid var(--surface1);
border-radius:4px; padding:1px 7px; white-space:nowrap;
overflow:hidden;
}
.pcur-a { color:var(--sky); }
.pcur-b { color:var(--yellow); }
.pcur-dv { color:var(--subtext1); }
.pcur-sep { color:var(--surface2); }
/* ── Badge vscale info & active state ───────────────────────────── */
.vscale-info {
font-size:9px; color:var(--overlay0); font-family:monospace; margin-left:2px; white-space:nowrap;
+159 -47
View File
@@ -3,16 +3,16 @@
The WebUI is a Go binary that acts as a bridge between UDPStreamer and any web browser.
It receives UDP packets from UDPStreamer, reassembles fragmented data, and re-publishes
decoded signal values over a WebSocket to the browser. The browser renders live plots
using [Plotly.js](https://plotly.com/javascript/).
using [uPlot](https://github.com/leeoniya/uPlot).
```
MARTe2 RT app
│ UDP (binary protocol)
udpstreamer-webui (Go)
│ WebSocket (JSON)
│ WebSocket (binary frames)
Browser (index.html + Plotly.js)
Browser (index.html + uPlot)
```
---
@@ -55,18 +55,19 @@ Open `http://localhost:8080` in any modern browser.
### Layout
```
┌─────────────────────────────────────────────────────────┐
MARTe2 UDP Streamer ● Streaming Window: [5 s▾] ⏸ │ ← top bar
├────────────────────────────────────────────────────────┤
│ Signals │ Plots [1×1][2×1][2×2]… [+Add]
│──────────│──────────────────────────────────────────────│
│ Counter │ ┌─────────────────┐ ┌──────────────────┐
│ Time │ │ Plot 1 │ │ Plot 2 │
│ Sine1 │ │ (drop signals) │ │ (drop signals) │
│ Sine2 │ │ │ │ │
│ Ch1[1000]│ └─────────────────┘ └──────────────────┘
│ Ch2[1000]│ │
└──────────┴──────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────────────────
☰ UDP Scope │ ⊞ 1×1 ▾ │ A: — B: — ΔT: — │ Window: [5s▾] Fit ⬇ CSV ⚡ Trigger ⏸ Pause │
├──────────────────┬────────────────────────────────────────────────────────────┤
│ Signals [Plot title] ○ ○ ○ ← signal badges
│──────────────────│──────────────────────────────────────────────────────────
│ Counter · u32 │ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ Time · f64 │ │ Plot 1 │ │ Plot 2 │ │
│ Sine1 · f32 │ │ (drop signals here) │ │ (drop signals here) │
│ Sine2 · f32 │ │ │ │ │ │
│ Ch1 · [1000] f32 │ └─────────────────────┘ └─────────────────────────────┘ │
└──────────────────┴────────────────────────────────────────────────────────────┘
│ ● Streaming [📊 Stats] v1.0.0 │
└───────────────────────────────────────────────────────────────────────────────┘
```
### Signal Sidebar
@@ -79,12 +80,73 @@ Signals received in the CONFIG packet are listed in the sidebar:
- **Spatial arrays** — `TimeMode = PacketTime` arrays are shown as an expandable
group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently.
Click the sidebar toggle button (☰) to collapse/expand the signal list.
### Adding Plots
1. Click **+ Add Plot** in the toolbar.
1. Select a layout from the layout menu (⊞ button in the top bar).
2. Drag a signal from the sidebar onto a plot panel.
3. Multiple signals can be overlaid on the same plot.
4. Click the **×** badge to remove a trace.
4. Click the **×** inside a signal badge to remove a trace.
### Plot Layout
The plot grid supports multiple layouts selectable from the layout menu (⊞):
| Layout | Description |
|--------|-------------|
| 1×1 | Single plot |
| 2×1 | Two columns |
| 1×2 | Two rows |
| 2×2 | Four plots |
| 3×1 | Three columns |
| … | More layouts available |
**Resizing plots**: When multiple plots are shown, drag the dividers between them
to resize. Vertical dividers resize column widths; horizontal dividers resize row
heights. Sizes are stored as fractional grid units (fr).
### Plot Configuration
Click the **plot title** to open the configuration toolbar:
- **Title** — edit the plot's display name.
- **Mode** — select the plot display mode:
- **Normal** — standard time-series oscilloscope view (default).
- **Mixed** — signals are arranged in horizontal bands (like digital mode), but
each signal can independently be displayed as analog (auto-scaled within its band)
or digital (quantized to high/low within its band).
- **Digital** — logic-analyzer style; each signal occupies a fixed horizontal band
and is quantized to high/low based on its data range midpoint as threshold.
### Signal Badges and Selection
Each signal assigned to a plot appears as a colored badge in the plot header.
- **Click a badge** — selects the signal and opens the V-Scale toolbar for that
signal. The selected signal is drawn on top with increased line width.
- **Click again** — deselects the signal and closes the V-Scale toolbar.
- **Right-click a badge** — opens the style context menu (color, line width, dash
style, markers).
- **Click ×** on a badge — removes the signal from the plot.
### V-Scale Toolbar
When a signal is selected (via badge click), an inline toolbar appears below the
plot header showing per-signal vertical scale controls:
| Control | Description |
|---------|-------------|
| **Auto** | Automatically fits the signal vertically |
| **Range** | Shows V/div and Pos controls for manual positioning |
| **Manual** | Fixed V/div with free positioning |
| **V/div** | Volts (or units) per division |
| **Pos (div)** | Screen position in divisions (draggable offset marker on Y axis) |
| **Type** (Mixed mode only) | Toggle between **Analog** and **Digital** for this signal |
| **✕** | Close the toolbar and deselect the signal |
Offset markers (small triangles on the Y axis) show each signal's position and can
be dragged to reposition signals without opening the toolbar.
### Plot Controls
@@ -93,18 +155,76 @@ Signals received in the CONFIG packet are listed in the sidebar:
| **⏸ / ▶** (per plot) | Pause / resume that plot; paused plots allow zoom and pan |
| **⬇** (per plot) | Export all visible traces to CSV |
| **🗑** (per plot) | Delete the plot |
| **⏸ Pause All** (top bar) | Pause all plots simultaneously |
| **⏸ Pause** (top bar) | Pause all plots simultaneously |
| **↺ Auto** (top bar) | Resume all paused plots and snap back to live view |
| **Window** (top bar) | Adjust the rolling time window (1 s 60 s) |
| Layout buttons | Switch between 1×1, 2×1, 1×2, 2×2, 3×1, … grid layouts |
| Sidebar **←** | Collapse the signal list to maximise plot area |
| **Fit** (top bar) | Fit all plots to their current data range |
| **⬇ CSV** (top bar) | Export all signals from all plots to CSV |
| Layout button | Switch between grid layouts |
| Sidebar **☰** | Toggle the signal list panel |
### Status LED
### Cursor System
| Colour | Meaning |
|--------|---------|
| Red (solid) | No WebSocket connection to browser |
| Orange (pulsing) | WebSocket connected but no data received in > 1 s |
| Green (pulsing) | Data is being received normally |
Two vertical cursors (A and B) can be placed on plots:
- Enable with the **Cursor** button (shown when a plot is paused/zoomed).
- The top bar readout shows **A**, **B**, and **ΔT** (time between cursors).
- Cursors follow the mouse within the plot area.
### Zoom
- **Drag** left or right on a paused plot to zoom into a time range.
- **← Back** button steps back through zoom history.
- **Fit** returns to the full data view.
- When zooming into a region with few or no data points, the nearest data points
outside the zoom window are included so that connecting lines are drawn across
the view rather than showing blank space.
### Trigger System
Click **⚡ Trigger** in the top bar to open the trigger bar:
| Control | Description |
|---------|-------------|
| **Signal** | Select the trigger source signal |
| **Edge** | Rising ↑, Falling ↓, or Both ↕ |
| **Threshold** | Trigger level |
| **Window** | Capture duration after trigger (100 µs 10 s) |
| **Pre** | Pre-trigger buffer percentage (0100%) |
| **Mode** | **Normal** (re-arms automatically) or **Single** (fires once) |
| **Rearm** | Manually re-arm the trigger |
| **Stop** | Cancel waiting trigger |
For array signals, clicking the trigger signal selector prompts for the element
index to use as the trigger source.
### Status Bar
The status bar at the bottom shows:
- **LED indicator**: red (disconnected), orange pulsing (connected, no data), green pulsing (streaming).
- **Status text**: connection state and data age.
- **📊 Stats** button: opens the source statistics panel (packet counts, rates, errors).
- **Build version**: server build tag.
---
## Data Buffering
Signal buffers are sized based on the signal's configured sampling rate from the
CONFIG packet:
```
capacity = min(600 000, ceil(samplingRate × 60 s × 1.5))
```
This ensures up to 60 seconds of history is retained for any signal, regardless of
sample rate. If a signal's sampling rate is not configured, a default capacity of
100 000 samples is used. Temporal arrays (burst signals) use a fixed capacity of
500 000 samples.
Buffers grow automatically during streaming if a higher effective sample rate is
detected. Existing data is preserved during growth.
---
@@ -117,15 +237,15 @@ main()
├── hub.Run() ← event loop: register/unregister WS clients, batch data at 30 Hz
├── udpClient.Run() ← reconnects on silence; parses UDP packets; feeds hub.dataCh
└── http.ListenAndServe()
├── GET / ← serves embedded index.html
├── GET / ← serves embedded static files
└── GET /ws ← upgrades to WebSocket; launches per-client read/write pumps
```
### WebSocket Message Format
All messages are JSON. Two types are sent from server to browser:
Two message types are sent from server to browser.
#### `config` message
#### `config` message (JSON)
Sent immediately when a browser client connects (if a CONFIG has been received from
UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet.
@@ -144,7 +264,7 @@ UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet.
"rangeMin": -10.0,
"rangeMax": 10.0,
"timeMode": 0,
"samplingRate": 0.0,
"samplingRate": 1000.0,
"timeSignalIdx": 4294967295,
"unit": "V"
}
@@ -152,27 +272,19 @@ UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet.
}
```
#### `data` message
#### `data` message (binary)
Sent at ≤ 30 Hz, batching all UDP packets received since the last tick.
Each signal carries its own time axis to support mixed scalar + temporal-array signals.
Uses a compact binary format: a fixed header followed by per-signal blocks.
```json
{
"type": "data",
"signals": {
"Sine1": { "t": [1747123456.001, 1747123456.002], "v": [3.14, 2.71] },
"Counter": { "t": [1747123456.001, 1747123456.002], "v": [12340, 12341] },
"Ch1": { "t": [1747123456.000, 1747123456.0001, ...], "v": [0.12, 0.13, ...] }
}
}
```
Each signal block contains:
- Signal name (length-prefixed)
- Number of samples
- Timestamp array (float64 LE, Unix seconds)
- Value array (float64 LE)
- `t` — Unix timestamp in seconds (float64) for each sample.
- `v` — physical value (after dequantization if applicable).
- For **temporal arrays**, `t` and `v` have `N × batchSize` entries
(up to 2000 per 30 Hz tick after server-side decimation).
- For **spatial arrays** the keys are `"Ch1[0]"`, `"Ch1[1]"`, etc.
For **temporal arrays**, each packet contributes `N` samples (one per array element).
For **spatial arrays**, keys are `"Ch1[0]"`, `"Ch1[1]"`, etc.
---