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:
Martino Ferrari
2026-05-27 16:29:09 +02:00
parent cf174edde8
commit 915c6fc126
4 changed files with 604 additions and 80 deletions
+416 -29
View File
@@ -2,13 +2,35 @@
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000;
// Temporal signals receive 50 pts/tick × 30 Hz = 1 500 pts/s from the server.
// 50 000 cap → ~33 s of rolling history; well beyond the max supported window.
// Zoom uses /api/zoom (server ring buffer) so the cap only affects rolling display.
const TEMPORAL_CAP = 50_000;
// Largest window option in the UI (seconds). Buffers are pre-sized to hold this much history.
const MAX_WINDOW_SEC = 60;
// Hard ceiling per buffer (~9.6 MB per signal at Float64 t+v).
const MAX_CAP = 600_000;
// Default capacity for signals whose rate is unknown: covers 60 s at ~1.5 kHz.
const DEFAULT_CAP = 100_000;
// Temporal signals receive up to 50 pts/tick × 200 Hz = 10 000 pts/s.
// TEMPORAL_CAP covers the full 60-second window for rates up to ~8 kHz.
const TEMPORAL_CAP = Math.min(MAX_CAP, 500_000);
const LTTB_MIN = 200; // never decimate below this many points
// Compute a buffer capacity that holds MAX_WINDOW_SEC * 1.5 of data at rateHz.
function bufferCapForRate(rateHz) {
if (!rateHz || rateHz <= 0) return DEFAULT_CAP;
return Math.min(MAX_CAP, Math.ceil(rateHz * MAX_WINDOW_SEC * 1.5));
}
// Return a new, larger circular buffer that preserves all existing samples.
function growBuffer(buf, newCap) {
if (newCap <= buf.cap) return buf;
const nb = makeBuffer(newCap);
const start = buf.size === buf.cap ? buf.head : 0;
for (let i = 0; i < buf.size; i++) {
const idx = (start + i) % buf.cap;
pushBuffer(nb, buf.t[idx], buf.v[idx]);
}
return nb;
}
const TRACE_COLORS = [
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
@@ -63,7 +85,7 @@ function setSigStyle(key, updates) {
// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
function getVScale(plotId, key) {
const vsKey = plotId + ':' + key;
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null };
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null, digitalInMixed: false };
return sigVScale[vsKey];
}
@@ -112,9 +134,47 @@ function resolveVScale(plotId, key, rawY) {
return { divValue, offset, screenPos };
}
// Mixed mode: each signal occupies a fixed band; within that band it is either
// quantized (digital) or auto-scaled (analog) based on vs.digitalInMixed.
function applyMixedNorm(p, yArrays) {
const n = p.traces.length;
if (n === 0) return yArrays;
const bandH = 8 / n;
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const vs = getVScale(p.id, key);
const centerY = 4 - (ki + 0.5) * bandH;
const hi = centerY + bandH * 0.35;
const lo = centerY - bandH * 0.35;
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
const out = new Float64Array(rawY.length);
if (vs.digitalInMixed) {
const threshold = isFinite(min) ? (min + max) / 2 : 0.5;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo);
}
} else {
if (!isFinite(min)) { min = 0; max = 1; }
if (min === max) { min -= 1; max += 1; }
const range = max - min, bandRange = hi - lo;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : lo + (v - min) / range * bandRange;
}
}
return out;
});
}
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) {
if (p.mode === 'digital') return applyDigitalNorm(p, yArrays);
if (p.mode === 'mixed') return applyMixedNorm(p, yArrays);
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
@@ -127,6 +187,32 @@ function applyVScaleNorm(p, yArrays) {
});
}
// Digital mode: quantize each signal to lo/hi within its own horizontal band.
// Signals are arranged top-to-bottom matching badge order (index 0 = top).
function applyDigitalNorm(p, yArrays) {
const n = p.traces.length;
if (n === 0) return yArrays;
const bandH = 8 / n; // total Y span is 8 divisions (-4 to +4)
return yArrays.map((rawY, ki) => {
// Top-down: signal 0 is at top (highest Y value)
const centerY = 4 - (ki + 0.5) * bandH;
const hi = centerY + bandH * 0.35;
const lo = centerY - bandH * 0.35;
// Threshold: midpoint of min/max
let min = Infinity, max = -Infinity;
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i]; if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
}
const threshold = isFinite(min) ? (min + max) / 2 : 0.5;
const out = new Float64Array(rawY.length);
for (let i = 0; i < rawY.length; i++) {
const v = rawY[i];
out[i] = (v == null || !isFinite(v)) ? NaN : (v >= threshold ? hi : lo);
}
return out;
});
}
// Set the active (Y-axis-labelled) signal for a plot and update badge highlights.
function setActiveSig(plotId, key) {
if (key === null || key === undefined) {
@@ -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],
];
let currentLayout = 'l1x1';
let colFrs = [1]; // fractional column sizes (sum = cols)
let rowFrs = [1]; // fractional row sizes (sum = rows)
let _gridCols = 1, _gridRows = 1;
/* ════════════════════════════════════════════════════════════════
Trigger state
@@ -300,9 +389,10 @@ function onConfig(msg) {
newSigs.forEach(sig => {
const n = numElements(sig);
const base = prefix + sig.name;
const sigCap = bufferCapForRate(sig.samplingRate);
if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
else if (n === 1) { buffers[base] = makeBuffer(); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); }
else if (n === 1) { buffers[base] = makeBuffer(sigCap); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); }
});
if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
@@ -382,6 +472,13 @@ function onBinaryData(buf) {
bufObj = makeBuffer(n > 100 ? TEMPORAL_CAP : DEFAULT_CAP);
buffers[fullKey] = bufObj;
}
// If the buffer was created before the CONFIG message arrived (or with an
// underestimated capacity), grow it now that we know the signal's rate.
const detectedRate = getKeySamplingRate(fullKey);
if (detectedRate > 0) {
const needed = bufferCapForRate(detectedRate);
if (needed > bufObj.cap) { bufObj = growBuffer(bufObj, needed); buffers[fullKey] = bufObj; }
}
// Read t and v values in one pass (v array starts at off + n*8)
const tOff = off, vOff = off + n * 8;
@@ -565,6 +662,64 @@ function getBufferSliceRange(buf, t0, t1) {
}
return { t: outT, v: outV };
}
// Like getBufferSliceRange but also includes the nearest point just outside each
// boundary so that a line is always drawn across the visible area even when the
// zoom window contains only 0 or 1 samples.
function getBufferSliceRangeWithBrackets(buf, t0, t1) {
if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const { cap, size, head } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; }
const kEnd = lo;
// Expand by one on each side for bracketing points.
const kFrom = Math.max(0, kStart - 1);
const kTo = Math.min(size, kEnd + 1);
const len = kTo - kFrom;
if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const outT = new Float64Array(len), outV = new Float64Array(len);
for (let i = 0; i < len; i++) {
const idx = physAt(kFrom + i);
outT[i] = buf.t[idx]; outV[i] = buf.v[idx];
}
return { t: outT, v: outV };
}
// Supplement sparse fetched signal data ({t, v} Float64Arrays) with the nearest
// bracketing points from the local circular buffer, so lines are always drawn
// across the zoom window even if the server returned 0 or 1 points.
function supplementWithBrackets(sd, buf, t0, t1) {
if (!buf || buf.size === 0) return sd;
if (sd && sd.t.length >= 2) return sd; // already enough points
const { size, head, cap } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] < t0) lo = m + 1; else hi = m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; }
const kEnd = lo;
const leftK = kStart > 0 ? kStart - 1 : -1;
const rightK = kEnd < size ? kEnd : -1;
const tArr = [], vArr = [];
if (leftK >= 0) { tArr.push(buf.t[physAt(leftK)]); vArr.push(buf.v[physAt(leftK)]); }
if (sd) { for (let i = 0; i < sd.t.length; i++) { tArr.push(sd.t[i]); vArr.push(sd.v[i]); } }
if (rightK >= 0) { tArr.push(buf.t[physAt(rightK)]); vArr.push(buf.v[physAt(rightK)]); }
if (tArr.length === 0) return sd;
return { t: Float64Array.from(tArr), v: Float64Array.from(vArr) };
}
// getGlobalNow returns the reference "now" for the rolling window.
// Always anchors to the newest timestamp found in any buffer so the rolling
// window tracks real data regardless of any clock skew between the Go server
@@ -790,17 +945,26 @@ async function doZoomFetch(t0, t1) {
}
// Build uPlot data arrays from server-fetched hi-res signal data.
// Sparse signals (0 or 1 pts in range) are supplemented with local buffer
// bracket points so a line is always drawn across the visible area.
function buildDataFromFetched(p, fetchedSignals, targetPts) {
const t0 = p.xRange ? p.xRange[0] : -Infinity;
const t1 = p.xRange ? p.xRange[1] : Infinity;
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const sd = fetchedSignals[key];
if (!sd || !sd.t || !sd.t.length) continue;
let sd = fetchedSignals[key];
if (!sd || !sd.t || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) continue;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
masterRate = rate; masterCount = sd.t.length; masterKey = key;
}
}
const masterSd = fetchedSignals[masterKey];
let masterSd = fetchedSignals[masterKey];
if (!masterSd || masterSd.t.length < 2)
masterSd = supplementWithBrackets(masterSd, buffers[masterKey], t0, t1);
if (!masterSd || !masterSd.t.length)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
@@ -809,7 +973,8 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
const sd = fetchedSignals[key];
let sd = fetchedSignals[key];
if (!sd || sd.t.length < 2) sd = supplementWithBrackets(sd, buffers[key], t0, t1);
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
}
@@ -959,10 +1124,32 @@ function interpAtTime(u, si, t) {
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
}
// Draw horizontal separator lines between signal bands in digital/mixed mode.
function drawBandSeparators(u, p) {
if (!u.bbox || (p.mode !== 'digital' && p.mode !== 'mixed')) return;
const n = p.traces.length;
if (n < 2) return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
const bandH = 8 / n;
ctx.save();
ctx.strokeStyle = 'rgba(127,132,156,0.25)';
ctx.lineWidth = dpr;
for (let i = 1; i < n; i++) {
const yNorm = 4 - i * bandH; // boundary between band i-1 and band i
const yPx = u.valToPos(yNorm, 'y', true);
ctx.beginPath();
ctx.moveTo(bbox.left, yPx);
ctx.lineTo(bbox.left + bbox.width, yPx);
ctx.stroke();
}
ctx.restore();
}
// Redraw the active signal's line on top of all series with a wider stroke, so it
// visually appears in the foreground regardless of series draw order.
function drawActiveSeries(u, p) {
if (!u.bbox) return;
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const activeKey = plotActiveSignal[p.id];
if (!activeKey) return;
const idx = p.traces.indexOf(activeKey);
@@ -995,7 +1182,7 @@ function drawActiveSeries(u, p) {
// Draw offset position markers (right-pointing triangles) on the left edge of the plot
// for each signal. Active signal marker is larger and outlined in white.
function drawOffsetMarkers(u, p) {
if (!u.bbox) return;
if (!u.bbox || p.mode === 'digital' || p.mode === 'mixed') return;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
@@ -1175,16 +1362,19 @@ function computePlotNow(p) {
// Build uPlot opts for a given plot object
function makeUPlotOpts(p, inTrigMode) {
const isBanded = p.mode === 'digital' || p.mode === 'mixed';
const seriesArr = [{}]; // time (index 0)
p.traces.forEach(key => {
const style = getSigStyle(key);
const pathsFn = makeSeriesPath(key);
const pathsFn = isBanded ? null : makeSeriesPath(key);
const vs = isBanded ? getVScale(p.id, key) : null;
const isDigSig = p.mode === 'digital' || (p.mode === 'mixed' && vs && vs.digitalInMixed);
seriesArr.push({
label: key,
stroke: style.color,
width: style.width,
points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color },
spanGaps: true,
width: isBanded ? 1.5 : style.width,
points: { show: false },
spanGaps: !isDigSig,
...(pathsFn ? { paths: pathsFn } : {}),
});
});
@@ -1228,11 +1418,24 @@ function makeUPlotOpts(p, inTrigMode) {
},
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60,
// Fixed 9 splits at integer divisions [-4..4] matching the normalized Y scale.
splits: () => [-4, -3, -2, -1, 0, 1, 2, 3, 4],
// Labels show real-unit values of the active signal for the plot.
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
splits: () => {
if (isBanded && p.traces.length > 0) {
const n = p.traces.length, bandH = 8 / n;
return p.traces.map((_, i) => 4 - (i + 0.5) * bandH);
}
return [-4, -3, -2, -1, 0, 1, 2, 3, 4];
},
values: (u, vals) => {
if (isBanded && p.traces.length > 0) {
const n = p.traces.length, bandH = 8 / n;
return vals.map(v => {
if (v == null) return '';
const i = Math.round((4 - v) / bandH - 0.5);
if (i < 0 || i >= n) return '';
const k = p.traces[i];
return k.includes(':') ? k.split(':').slice(1).join(':') : k;
});
}
const activeKey = plotActiveSignal[p.id];
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
@@ -1246,7 +1449,7 @@ function makeUPlotOpts(p, inTrigMode) {
legend: { show: false },
padding: [4, 4, 0, 0],
hooks: {
draw: [u => { drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
// All programmatic setScale calls happen without a preceding setSelect, so the
@@ -1529,12 +1732,15 @@ function buildLiveData(p) {
}
// Slice all traces; pick master by sampling rate then count.
// In zoom mode, include one bracketing point on each side so lines are drawn
// across the visible area even when the window contains 0 or 1 samples.
const sliceFn = isRolling ? getBufferSliceRange : getBufferSliceRangeWithBrackets;
const slices = {};
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const buf = buffers[key];
if (!buf || buf.size === 0) continue;
const sl = getBufferSliceRange(buf, t0, t1);
const sl = sliceFn(buf, t0, t1);
slices[key] = sl;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sl.t.length > masterCount)) {
@@ -2046,6 +2252,127 @@ function makeDraggable(key, label, typeName, unit) {
return item;
}
/* ════════════════════════════════════════════════════════════════
Grid resize handles
════════════════════════════════════════════════════════════════ */
function updateGridTemplate() {
const grid = document.getElementById('plot-grid');
grid.style.gridTemplateColumns = colFrs.map(f => f + 'fr').join(' ');
grid.style.gridTemplateRows = rowFrs.map(f => f + 'fr').join(' ');
// Reposition all handles
document.querySelectorAll('.resize-handle-v').forEach(h => {
const i = parseInt(h.dataset.col);
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
document.querySelectorAll('.resize-handle-h').forEach(h => {
const i = parseInt(h.dataset.row);
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
});
// Notify uPlot of new sizes
plots.forEach(p => { if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); });
}
function setupResizeHandles(cols, rows) {
const grid = document.getElementById('plot-grid');
grid.querySelectorAll('.resize-handle-v, .resize-handle-h').forEach(el => el.remove());
const makeHandle = (cls, dataset, onDown) => {
const h = document.createElement('div');
h.className = cls;
Object.assign(h.dataset, dataset);
h.addEventListener('mousedown', onDown);
grid.appendChild(h);
return h;
};
for (let i = 0; i < cols - 1; i++) {
const h = makeHandle('resize-handle-v', { col: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = colFrs.reduce((a, b) => a + b, 0);
const pairSum = colFrs[i] + colFrs[i + 1];
const leftBefore = colFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientX - rect.left) / rect.width;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - leftBefore));
colFrs[i] = newI; colFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = colFrs.reduce((a, b) => a + b, 0);
h.style.left = (colFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
for (let i = 0; i < rows - 1; i++) {
const h = makeHandle('resize-handle-h', { row: i }, e => {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const tot = rowFrs.reduce((a, b) => a + b, 0);
const pairSum = rowFrs[i] + rowFrs[i + 1];
const topBefore = rowFrs.slice(0, i).reduce((a, b) => a + b, 0);
h.classList.add('dragging');
const onMove = ev => {
const pct = (ev.clientY - rect.top) / rect.height;
const newI = Math.max(0.1 * pairSum, Math.min(0.9 * pairSum, pct * tot - topBefore));
rowFrs[i] = newI; rowFrs[i + 1] = pairSum - newI;
updateGridTemplate();
};
const onUp = () => { h.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const tot = rowFrs.reduce((a, b) => a + b, 0);
h.style.top = (rowFrs.slice(0, i + 1).reduce((a, b) => a + b, 0) / tot * 100) + '%';
}
}
/* ════════════════════════════════════════════════════════════════
Plot config toolbar (click plot title to open)
════════════════════════════════════════════════════════════════ */
let _pcfgOpenId = null;
function showPlotCfg(plotId) {
if (_pcfgOpenId != null && _pcfgOpenId !== plotId) hidePlotCfg();
_pcfgOpenId = plotId;
document.getElementById('pcfg-' + plotId).style.display = 'block';
}
function hidePlotCfg() {
if (_pcfgOpenId == null) return;
const bar = document.getElementById('pcfg-' + _pcfgOpenId);
if (bar) bar.style.display = 'none';
_pcfgOpenId = null;
}
function initPlotCfgBar(plotId, p) {
const bar = document.getElementById('pcfg-' + plotId);
if (!bar) return;
const titleInput = bar.querySelector('.pcfg-title-input');
const titleEl = document.getElementById('ptitle-' + plotId);
titleInput.addEventListener('input', () => {
p.title = titleInput.value || ('Plot ' + plotId);
if (titleEl) titleEl.textContent = p.title;
});
bar.querySelectorAll('.pcfg-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const newMode = btn.dataset.mode;
if (p.mode === newMode) return;
p.mode = newMode;
bar.querySelectorAll('.pcfg-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === newMode));
createUPlot(p);
p.needsRedraw = true;
});
});
bar.querySelector('.pcfg-close-btn').addEventListener('click', hidePlotCfg);
}
/* ════════════════════════════════════════════════════════════════
Layout management
════════════════════════════════════════════════════════════════ */
@@ -2080,7 +2407,18 @@ function applyLayout(cls) {
if (!entry) return;
const [label, , cols, rows] = entry;
currentLayout = cls;
document.getElementById('plot-grid').className = cls;
// Reset fr sizes when layout dimensions change; keep sizes when same layout is re-applied.
if (cols !== _gridCols || rows !== _gridRows) {
colFrs = Array(cols).fill(1);
rowFrs = Array(rows).fill(1);
_gridCols = cols; _gridRows = rows;
}
const grid = document.getElementById('plot-grid');
grid.className = cls; // keeps other grid CSS rules
updateGridTemplate(); // override template with current fr sizes
setupResizeHandles(cols, rows);
// Update button label
const btn = document.getElementById('btn-layout');
@@ -2105,11 +2443,10 @@ function applyLayout(cls) {
while (plots.length < needed) addPlot();
// Recreate all uPlot instances once the CSS grid has sized the cells.
// This also updates axis visibility (which axes show labels depends on grid position).
requestAnimationFrame(() => {
plots.forEach(p => {
createUPlot(p);
p.needsRedraw = true; // force immediate data+scale refresh so x/y ranges are preserved
p.needsRedraw = true;
});
setTimeout(() => plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
@@ -2266,9 +2603,22 @@ function addPlot() {
card.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = `
<div class="plot-card-header">
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
<span class="plot-title" id="ptitle-${id}" title="Click to configure">Plot ${id}</span>
<div class="sig-badges" id="badges-${id}"></div>
</div>
<div class="plot-cfg-bar" id="pcfg-${id}" style="display:none">
<div class="vstb-header">
<span class="vstb-label">Title</span>
<input type="text" class="ctx-num pcfg-title-input" value="Plot ${id}" style="width:90px">
<span class="vstb-label" style="margin-left:6px">Mode</span>
<div class="ctx-btns">
<button class="ctx-btn pcfg-mode-btn active" data-mode="normal">Normal</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="mixed">Mixed</button>
<button class="ctx-btn pcfg-mode-btn" data-mode="digital">Digital</button>
</div>
<button class="vstb-close pcfg-close-btn" title="Close">✕</button>
</div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
@@ -2285,8 +2635,15 @@ function addPlot() {
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-' + id);
const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
const p = { id, title: 'Plot ' + id, mode: 'normal', traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null, lastDataGen: -1 };
plots.push(p);
// Wire config toolbar
initPlotCfgBar(id, p);
card.querySelector('#ptitle-' + id).addEventListener('click', () => {
if (_pcfgOpenId === id) hidePlotCfg(); else showPlotCfg(id);
});
// uPlot creation is handled by applyLayout (batch, after DOM settles).
return id;
}
@@ -2504,6 +2861,14 @@ document.getElementById('btn-pause-global').addEventListener('click', () => {
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
// Grow any buffers that can't hold the full new window for their signal's rate.
Object.keys(buffers).forEach(key => {
const rate = getKeySamplingRate(key);
if (rate > 0) {
const needed = Math.min(MAX_CAP, Math.ceil(rate * windowSec * 1.5));
if (needed > buffers[key].cap) buffers[key] = growBuffer(buffers[key], needed);
}
});
// Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots.forEach(p => lttbCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot
@@ -2676,6 +3041,15 @@ function showVScaleMenu(key, plotId) {
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
// Type row (Analog/Digital) only shown in mixed mode.
const p = plots.find(q => q.id === plotId);
const isMixed = p && p.mode === 'mixed';
document.getElementById('vscale-type-row').style.display = isMixed ? 'flex' : 'none';
if (isMixed) {
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
}
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
@@ -2775,6 +3149,19 @@ function initVScaleMenu() {
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
refreshPlotForKey(_vsMenuKey);
});
// Type buttons (Analog / Digital) — only active in mixed mode.
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey || !_vsMenuPlotId) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.digitalInMixed = btn.dataset.type === 'digital';
document.querySelectorAll('#vscale-type-btns .ctx-btn').forEach(b =>
b.classList.toggle('active', b.dataset.type === (vs.digitalInMixed ? 'digital' : 'analog')));
// Rebuild needed because series rendering changes (digital = no spanGaps, etc.)
const p = plots.find(q => q.id === _vsMenuPlotId);
if (p) { createUPlot(p); p.needsRedraw = true; }
});
});
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}