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>
This commit is contained in:
+416
-29
@@ -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) {
|
||||||
@@ -205,6 +291,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 +389,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 +472,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 +662,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 +945,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 +973,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 +1124,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 +1182,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;
|
||||||
|
|
||||||
@@ -1175,16 +1362,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 +1418,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 +1449,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 +1732,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)) {
|
||||||
@@ -2046,6 +2252,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 +2407,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 +2443,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,9 +2603,22 @@ 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>
|
</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-vscale-bar" id="vstb-${id}"></div>
|
||||||
<div class="plot-body" id="pbody-${id}">
|
<div class="plot-body" id="pbody-${id}">
|
||||||
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
|
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
|
||||||
@@ -2285,8 +2635,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;
|
||||||
}
|
}
|
||||||
@@ -2504,6 +2861,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 +3041,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 +3149,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+159
-47
@@ -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 (0–100%) |
|
||||||
|
| **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user