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
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user