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 Constants
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000; // Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history.
// Temporal signals receive 50 pts/tick × 30 Hz = 1 500 pts/s from the server. const MAX_WINDOW_SEC = 60;
// 50 000 cap → ~33 s of rolling history; well beyond the max supported window. // Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v).
// Zoom uses /api/zoom (server ring buffer) so the cap only affects rolling display. const MAX_CAP = 600_000;
const TEMPORAL_CAP = 50_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 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 = [ const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7', '#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7', '#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. // vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
function getVScale(plotId, key) { function getVScale(plotId, key) {
const vsKey = 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]; return sigVScale[vsKey];
} }
@@ -112,9 +134,47 @@ function resolveVScale(plotId, key, rawY) {
return { divValue, offset, screenPos }; 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). // 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. // Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) { 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) => { return yArrays.map((rawY, ki) => {
const key = p.traces[ki]; const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY); 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. // Set the active (Y-axis-labelled) signal for a plot and update badge highlights.
function setActiveSig(plotId, key) { function setActiveSig(plotId, key) {
if (key === null || key === undefined) { 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)); b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key));
const p = plots.find(q => q.id === plotId); const p = plots.find(q => q.id === plotId);
if (p && p.uplot) p.uplot.redraw(false); if (p && p.uplot) p.uplot.redraw(false);
updatePlotCursorReadouts();
} }
// Mark plots containing key dirty and refresh badge vscale text. // 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], ['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1],
]; ];
let currentLayout = 'l1x1'; 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 Trigger state
@@ -300,9 +390,10 @@ function onConfig(msg) {
newSigs.forEach(sig => { newSigs.forEach(sig => {
const n = numElements(sig); const n = numElements(sig);
const base = prefix + sig.name; const base = prefix + sig.name;
const sigCap = bufferCapForRate(sig.samplingRate);
if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); } if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
else if (n === 1) { buffers[base] = makeBuffer(); } else if (n === 1) { buffers[base] = makeBuffer(sigCap); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); } else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); }
}); });
if (trig.signal && trig.signal.startsWith(prefix)) { if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null; trigDisarm(); trig.snapshot = null; trig.prevVal = null;
@@ -382,6 +473,13 @@ function onBinaryData(buf) {
bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP); bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP);
buffers[fullKey] = bufObj; 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) // Read t and v values in one pass (v array starts at off + n*8)
const tOff = off, vOff = off + n * 8; const tOff = off, vOff = off + n * 8;
@@ -565,6 +663,64 @@ function getBufferSliceRange(buf, t0, t1) {
} }
return { t: outT, v: outV }; 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. // getGlobalNow returns the reference "now" for the rolling window.
// Always anchors to the newest timestamp found in any buffer so the rolling // 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 // 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. // 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) { 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; let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) { for (const key of p.traces) {
const sd = fetchedSignals[key]; let sd = fetchedSignals[key];
if (!sd || !sd.t || !sd.t.length) continue; if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) continue;
const rate = getKeySamplingRate(key); const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) { if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
masterRate = rate; masterCount = sd.t.length; masterKey = key; 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) if (!masterSd || !masterSd.t.length)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))]; return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
@@ -809,7 +974,8 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
const yArrays = []; const yArrays = [];
for (const key of p.traces) { for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; } 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; } if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT)); 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); 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 // 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. // visually appears in the foreground regardless of series draw order.
function drawActiveSeries(u, p) { function drawActiveSeries(u, p) {
if (!u.bbox) return; if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const activeKey = plotActiveSignal[p.id]; const activeKey = plotActiveSignal[p.id];
if (!activeKey) return; if (!activeKey) return;
const idx = p.traces.indexOf(activeKey); 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 // 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. // for each signal. Active signal marker is larger and outlined in white.
function drawOffsetMarkers(u, p) { function drawOffsetMarkers(u, p) {
if (!u.bbox) return; if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const { ctx, bbox } = u; const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -1100,8 +1288,10 @@ function drawCursorLines(u, p) {
ctx.fillText(label, px + 14, bbox.top + 2); ctx.fillText(label, px + 14, bbox.top + 2);
// Per-trace: diamond at crossing point + value label // Per-trace: diamond at crossing point + value label
if (p) { if (p) {
const DSZ = 5; // diamond half-size in px const activeKey = plotActiveSignal[p.id];
p.traces.forEach((key, idx) => { 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); const vNorm = interpAtTime(u, idx + 1, val);
if (vNorm === null) return; if (vNorm === null) return;
const cy = u.valToPos(vNorm, 'y', true); const cy = u.valToPos(vNorm, 'y', true);
@@ -1175,16 +1365,19 @@ function computePlotNow(p) {
// Build uPlot opts for a given plot object // Build uPlot opts for a given plot object
function makeUPlotOpts(p, inTrigMode) { function makeUPlotOpts(p, inTrigMode) {
const isBanded = p.mode === 'digital' || p.mode === 'mixed';
const seriesArr = [{}]; // time (index 0) const seriesArr = [{}]; // time (index 0)
p.traces.forEach(key => { p.traces.forEach(key => {
const style = getSigStyle(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({ seriesArr.push({
label: key, label: key,
stroke: style.color, stroke: style.color,
width: style.width, width: isBanded ? 1.5 : style.width,
points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color }, points: { show: false },
spanGaps: true, spanGaps: !isDigSig,
...(pathsFn ? { paths: pathsFn } : {}), ...(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, 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: () => {
splits: () => [-4, -3, -2, -1, 0, 1, 2, 3, 4], if (isBanded && p.traces.length > 0) {
// Labels show real-unit values of the active signal for the plot. const n = p.traces.length, bandH = 8 / n;
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset return p.traces.map((_, i) => 4 - (i + 0.5) * bandH);
}
return [-4, -3, -2, -1, 0, 1, 2, 3, 4];
},
values: (u, vals) => { 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 activeKey = plotActiveSignal[p.id];
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null; const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1)); if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
@@ -1246,7 +1452,7 @@ function makeUPlotOpts(p, inTrigMode) {
legend: { show: false }, legend: { show: false },
padding: [4, 4, 0, 0], padding: [4, 4, 0, 0],
hooks: { 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. // Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true). // uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
// All programmatic setScale calls happen without a preceding setSelect, so the // 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. // 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 = {}; const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1; let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) { for (const key of p.traces) {
const buf = buffers[key]; const buf = buffers[key];
if (!buf || buf.size === 0) continue; if (!buf || buf.size === 0) continue;
const sl = getBufferSliceRange(buf, t0, t1); const sl = sliceFn(buf, t0, t1);
slices[key] = sl; slices[key] = sl;
const rate = getKeySamplingRate(key); const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) { if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
@@ -1813,10 +2022,56 @@ document.getElementById('btn-cursor').addEventListener('click', () => {
cursorsDirty = true; 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() { function updateCursorReadout() {
const ro = document.getElementById('cursor-readout'); const ro = document.getElementById('cursor-readout');
const active = cursors.mode === 'on'; const active = cursors.mode === 'on';
ro.classList.toggle('visible', active); ro.classList.toggle('visible', active);
updatePlotCursorReadouts();
if (!active) return; if (!active) return;
// Use the current visible x-range to pick the display unit. // Use the current visible x-range to pick the display unit.
@@ -2046,6 +2301,127 @@ function makeDraggable(key, label, typeName, unit) {
return item; 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 Layout management
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
@@ -2080,7 +2456,18 @@ function applyLayout(cls) {
if (!entry) return; if (!entry) return;
const [label, , cols, rows] = entry; const [label, , cols, rows] = entry;
currentLayout = cls; 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 // Update button label
const btn = document.getElementById('btn-layout'); const btn = document.getElementById('btn-layout');
@@ -2105,11 +2492,10 @@ function applyLayout(cls) {
while (plots.length < needed) addPlot(); while (plots.length < needed) addPlot();
// Recreate all uPlot instances once the CSS grid has sized the cells. // 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(() => { requestAnimationFrame(() => {
plots.forEach(p => { plots.forEach(p => {
createUPlot(p); createUPlot(p);
p.needsRedraw = true; // force immediate data+scale refresh so x/y ranges are preserved p.needsRedraw = true;
}); });
setTimeout(() => plots.forEach(p => { setTimeout(() => plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); 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.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = ` card.innerHTML = `
<div class="plot-card-header"> <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="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>
<div class="plot-vscale-bar" id="vstb-${id}"></div> <div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}"> <div class="plot-body" id="pbody-${id}">
@@ -2285,8 +2691,15 @@ function addPlot() {
document.getElementById('plot-grid').appendChild(card); document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id); 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); 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). // uPlot creation is handled by applyLayout (batch, after DOM settles).
return id; return id;
} }
@@ -2300,6 +2713,7 @@ function addTraceTo(plotId, signalKey) {
// Recreate uPlot with new series list // Recreate uPlot with new series list
createUPlot(p); createUPlot(p);
p.needsRedraw = true; p.needsRedraw = true;
updatePlotCursorReadouts();
} }
function removeTraceFrom(plotId, signalKey) { function removeTraceFrom(plotId, signalKey) {
@@ -2315,6 +2729,7 @@ function removeTraceFrom(plotId, signalKey) {
createUPlot(p); createUPlot(p);
p.needsRedraw = true; p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = ''; if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
updatePlotCursorReadouts();
} }
function addBadge(plotId, key) { function addBadge(plotId, key) {
@@ -2486,6 +2901,9 @@ function renderDirtyPlots() {
zoomGuard = false; zoomGuard = false;
}); });
// Keep per-plot cursor value readouts in sync with live data.
if (cursors.mode === 'on') updatePlotCursorReadouts();
requestAnimationFrame(renderDirtyPlots); requestAnimationFrame(renderDirtyPlots);
} }
@@ -2504,6 +2922,14 @@ document.getElementById('btn-pause-global').addEventListener('click', () => {
document.getElementById('window-select').addEventListener('change', e => { document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value); 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. // Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots.forEach(p => lttbCacheEvict(p.id)); plots.forEach(p => lttbCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot // 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-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4)); 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. // Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId); const bar = document.getElementById('vstb-' + plotId);
if (bar) { if (bar) {
@@ -2775,6 +3210,19 @@ function initVScaleMenu() {
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0)); vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
refreshPlotForKey(_vsMenuKey); 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); document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
} }
+7
View File
@@ -188,6 +188,13 @@
<label class="vstb-lbl">Pos</label> <label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0"> <input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div> </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> <button id="btn-vscale-close" class="vstb-close" title="Close"></button>
</div> </div>
</div> </div>
+36 -4
View File
@@ -219,6 +219,19 @@ input[type=range].trig-range::-webkit-slider-thumb {
#plot-grid { #plot-grid {
flex:1; min-height:0; display:grid; gap:0; padding:0; overflow:hidden; 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); 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.l1x1 { grid-template-columns:1fr; grid-template-rows:1fr; }
#plot-grid.l2x1 { grid-template-columns:1fr 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 { .plot-title {
font-size:11px; color:var(--subtext1); font-weight:600; font-size:11px; color:var(--subtext1); font-weight:600;
cursor:text; outline:none; border:1px solid transparent; border-radius:3px; cursor:pointer; border:1px solid transparent; border-radius:3px;
padding:1px 3px; background:transparent; white-space:nowrap; padding:1px 4px; background:transparent; white-space:nowrap; user-select:none;
flex-shrink:0; max-width:80px; overflow:hidden; text-overflow:ellipsis; 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-badges { display:flex; flex-wrap:nowrap; gap:3px; flex:1; overflow:hidden; min-width:0; }
.sig-badge { .sig-badge {
display:inline-flex; align-items:center; gap:3px; 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); } .vstb-close:hover { color:var(--red); }
.plot-vscale-bar { display:none; } .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 ───────────────────────────── */ /* ── Badge vscale info & active state ───────────────────────────── */
.vscale-info { .vscale-info {
font-size:9px; color:var(--overlay0); font-family:monospace; margin-left:2px; white-space:nowrap; 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. 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 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 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 MARTe2 RT app
│ UDP (binary protocol) │ UDP (binary protocol)
udpstreamer-webui (Go) 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 ### Layout
``` ```
┌─────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────────────────────────
MARTe2 UDP Streamer ● Streaming Window: [5 s▾] ⏸ │ ← top bar ☰ UDP Scope │ ⊞ 1×1 ▾ │ A: — B: — ΔT: — │ Window: [5s▾] Fit ⬇ CSV ⚡ Trigger ⏸ Pause │
├────────────────────────────────────────────────────────┤ ├──────────────────┬────────────────────────────────────────────────────────────┤
│ Signals │ Plots [1×1][2×1][2×2]… [+Add] │ Signals [Plot title] ○ ○ ○ ← signal badges
│──────────│──────────────────────────────────────────────│ │──────────────────│──────────────────────────────────────────────────────────
│ Counter │ ┌─────────────────┐ ┌──────────────────┐ │ Counter · u32 │ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ Time │ │ Plot 1 │ │ Plot 2 │ │ Time · f64 │ │ Plot 1 │ │ Plot 2 │ │
│ Sine1 │ │ (drop signals) │ │ (drop signals) │ │ Sine1 · f32 │ │ (drop signals here) │ │ (drop signals here) │
│ Sine2 │ │ │ │ │ │ Sine2 · f32 │ │ │ │ │ │
│ Ch1[1000]│ └─────────────────┘ └──────────────────┘ │ Ch1 · [1000] f32 │ └─────────────────────┘ └─────────────────────────────┘ │
│ Ch2[1000]│ │ └──────────────────┴────────────────────────────────────────────────────────────┘
└──────────┴──────────────────────────────────────────────┘ │ ● Streaming [📊 Stats] v1.0.0 │
└───────────────────────────────────────────────────────────────────────────────┘
``` ```
### Signal Sidebar ### 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 - **Spatial arrays** — `TimeMode = PacketTime` arrays are shown as an expandable
group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently. group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently.
Click the sidebar toggle button (☰) to collapse/expand the signal list.
### Adding Plots ### 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. 2. Drag a signal from the sidebar onto a plot panel.
3. Multiple signals can be overlaid on the same plot. 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 ### 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) | Pause / resume that plot; paused plots allow zoom and pan |
| **⬇** (per plot) | Export all visible traces to CSV | | **⬇** (per plot) | Export all visible traces to CSV |
| **🗑** (per plot) | Delete the plot | | **🗑** (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) | | **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 | | **Fit** (top bar) | Fit all plots to their current data range |
| Sidebar **←** | Collapse the signal list to maximise plot area | | **⬇ 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 | Two vertical cursors (A and B) can be placed on plots:
|--------|---------|
| Red (solid) | No WebSocket connection to browser | - Enable with the **Cursor** button (shown when a plot is paused/zoomed).
| Orange (pulsing) | WebSocket connected but no data received in > 1 s | - The top bar readout shows **A**, **B**, and **ΔT** (time between cursors).
| Green (pulsing) | Data is being received normally | - 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 ├── hub.Run() ← event loop: register/unregister WS clients, batch data at 30 Hz
├── udpClient.Run() ← reconnects on silence; parses UDP packets; feeds hub.dataCh ├── udpClient.Run() ← reconnects on silence; parses UDP packets; feeds hub.dataCh
└── http.ListenAndServe() └── http.ListenAndServe()
├── GET / ← serves embedded index.html ├── GET / ← serves embedded static files
└── GET /ws ← upgrades to WebSocket; launches per-client read/write pumps └── GET /ws ← upgrades to WebSocket; launches per-client read/write pumps
``` ```
### WebSocket Message Format ### 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 Sent immediately when a browser client connects (if a CONFIG has been received from
UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet. 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, "rangeMin": -10.0,
"rangeMax": 10.0, "rangeMax": 10.0,
"timeMode": 0, "timeMode": 0,
"samplingRate": 0.0, "samplingRate": 1000.0,
"timeSignalIdx": 4294967295, "timeSignalIdx": 4294967295,
"unit": "V" "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. 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 Each signal block contains:
{ - Signal name (length-prefixed)
"type": "data", - Number of samples
"signals": { - Timestamp array (float64 LE, Unix seconds)
"Sine1": { "t": [1747123456.001, 1747123456.002], "v": [3.14, 2.71] }, - Value array (float64 LE)
"Counter": { "t": [1747123456.001, 1747123456.002], "v": [12340, 12341] },
"Ch1": { "t": [1747123456.000, 1747123456.0001, ...], "v": [0.12, 0.13, ...] }
}
}
```
- `t` — Unix timestamp in seconds (float64) for each sample. For **temporal arrays**, each packet contributes `N` samples (one per array element).
- `v` — physical value (after dequantization if applicable). For **spatial arrays**, keys are `"Ch1[0]"`, `"Ch1[1]"`, etc.
- 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.
--- ---