From 915c6fc126227867d8f6a020cd0a28893048d63c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 27 May 2026 16:29:09 +0200 Subject: [PATCH] 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 --- Client/WebUI/static/app.js | 445 ++++++++++++++++++++++++++++++--- Client/WebUI/static/index.html | 7 + Client/WebUI/static/style.css | 26 +- Docs/WebUI.md | 206 +++++++++++---- 4 files changed, 604 insertions(+), 80 deletions(-) diff --git a/Client/WebUI/static/app.js b/Client/WebUI/static/app.js index 362abae..61db711 100644 --- a/Client/WebUI/static/app.js +++ b/Client/WebUI/static/app.js @@ -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 = `
-
Plot ${id}
+ Plot ${id}
+
Drop signals here
@@ -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); } diff --git a/Client/WebUI/static/index.html b/Client/WebUI/static/index.html index 03ba106..5073340 100644 --- a/Client/WebUI/static/index.html +++ b/Client/WebUI/static/index.html @@ -188,6 +188,13 @@
+ diff --git a/Client/WebUI/static/style.css b/Client/WebUI/static/style.css index 40f02af..93d920f 100644 --- a/Client/WebUI/static/style.css +++ b/Client/WebUI/static/style.css @@ -219,6 +219,19 @@ input[type=range].trig-range::-webkit-slider-thumb { #plot-grid { flex:1; min-height:0; display:grid; gap:0; padding:0; overflow:hidden; border-top:1px solid var(--surface0); border-left:1px solid var(--surface0); + position:relative; +} +.resize-handle-v { + position:absolute; top:0; bottom:0; width:6px; cursor:col-resize; z-index:20; + transform:translateX(-50%); background:transparent; transition:background 0.15s; +} +.resize-handle-h { + position:absolute; left:0; right:0; height:6px; cursor:row-resize; z-index:20; + transform:translateY(-50%); background:transparent; transition:background 0.15s; +} +.resize-handle-v:hover,.resize-handle-v.dragging, +.resize-handle-h:hover,.resize-handle-h.dragging { + background:rgba(137,180,250,0.35); } #plot-grid.l1x1 { grid-template-columns:1fr; grid-template-rows:1fr; } #plot-grid.l2x1 { grid-template-columns:1fr 1fr; grid-template-rows:1fr; } @@ -249,11 +262,16 @@ input[type=range].trig-range::-webkit-slider-thumb { } .plot-title { font-size:11px; color:var(--subtext1); font-weight:600; - cursor:text; outline:none; border:1px solid transparent; border-radius:3px; - padding:1px 3px; background:transparent; white-space:nowrap; - flex-shrink:0; max-width:80px; overflow:hidden; text-overflow:ellipsis; + cursor:pointer; border:1px solid transparent; border-radius:3px; + padding:1px 4px; background:transparent; white-space:nowrap; user-select:none; + flex-shrink:0; max-width:100px; overflow:hidden; text-overflow:ellipsis; + transition:border-color 0.15s, background 0.15s; +} +.plot-title:hover { border-color:var(--surface1); background:rgba(88,91,112,0.4); } +.plot-cfg-bar { + flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1); + padding:3px 8px; } -.plot-title:focus { border-color:var(--accent); background:var(--surface1); color:var(--text); } .sig-badges { display:flex; flex-wrap:nowrap; gap:3px; flex:1; overflow:hidden; min-width:0; } .sig-badge { display:inline-flex; align-items:center; gap:3px; diff --git a/Docs/WebUI.md b/Docs/WebUI.md index d4d4a52..be3266a 100644 --- a/Docs/WebUI.md +++ b/Docs/WebUI.md @@ -3,16 +3,16 @@ The WebUI is a Go binary that acts as a bridge between UDPStreamer and any web browser. It receives UDP packets from UDPStreamer, reassembles fragmented data, and re-publishes decoded signal values over a WebSocket to the browser. The browser renders live plots -using [Plotly.js](https://plotly.com/javascript/). +using [uPlot](https://github.com/leeoniya/uPlot). ``` MARTe2 RT app │ UDP (binary protocol) ▼ udpstreamer-webui (Go) - │ WebSocket (JSON) + │ WebSocket (binary frames) ▼ -Browser (index.html + Plotly.js) +Browser (index.html + uPlot) ``` --- @@ -55,18 +55,19 @@ Open `http://localhost:8080` in any modern browser. ### Layout ``` -┌─────────────────────────────────────────────────────────┐ -│ MARTe2 UDP Streamer ● Streaming Window: [5 s▾] ⏸ │ ← top bar -├──────────┬──────────────────────────────────────────────┤ -│ Signals │ Plots [1×1][2×1][2×2]… [+Add] │ -│──────────│──────────────────────────────────────────────│ -│ Counter │ ┌─────────────────┐ ┌──────────────────┐ │ -│ Time │ │ Plot 1 │ │ Plot 2 │ │ -│ Sine1 │ │ (drop signals) │ │ (drop signals) │ │ -│ Sine2 │ │ │ │ │ │ -│ Ch1[1000]│ └─────────────────┘ └──────────────────┘ │ -│ Ch2[1000]│ │ -└──────────┴──────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ ☰ UDP Scope │ ⊞ 1×1 ▾ │ A: — B: — ΔT: — │ Window: [5s▾] Fit ⬇ CSV ⚡ Trigger ⏸ Pause │ +├──────────────────┬────────────────────────────────────────────────────────────┤ +│ Signals │ [Plot title] ○ ○ ○ ← signal badges │ +│──────────────────│────────────────────────────────────────────────────────── │ +│ Counter · u32 │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ +│ Time · f64 │ │ Plot 1 │ │ Plot 2 │ │ +│ Sine1 · f32 │ │ (drop signals here) │ │ (drop signals here) │ │ +│ Sine2 · f32 │ │ │ │ │ │ +│ Ch1 · [1000] f32 │ └─────────────────────┘ └─────────────────────────────┘ │ +└──────────────────┴────────────────────────────────────────────────────────────┘ +│ ● Streaming [📊 Stats] v1.0.0 │ +└───────────────────────────────────────────────────────────────────────────────┘ ``` ### Signal Sidebar @@ -79,12 +80,73 @@ Signals received in the CONFIG packet are listed in the sidebar: - **Spatial arrays** — `TimeMode = PacketTime` arrays are shown as an expandable group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently. +Click the sidebar toggle button (☰) to collapse/expand the signal list. + ### Adding Plots -1. Click **+ Add Plot** in the toolbar. +1. Select a layout from the layout menu (⊞ button in the top bar). 2. Drag a signal from the sidebar onto a plot panel. 3. Multiple signals can be overlaid on the same plot. -4. Click the **×** badge to remove a trace. +4. Click the **×** inside a signal badge to remove a trace. + +### Plot Layout + +The plot grid supports multiple layouts selectable from the layout menu (⊞): + +| Layout | Description | +|--------|-------------| +| 1×1 | Single plot | +| 2×1 | Two columns | +| 1×2 | Two rows | +| 2×2 | Four plots | +| 3×1 | Three columns | +| … | More layouts available | + +**Resizing plots**: When multiple plots are shown, drag the dividers between them +to resize. Vertical dividers resize column widths; horizontal dividers resize row +heights. Sizes are stored as fractional grid units (fr). + +### Plot Configuration + +Click the **plot title** to open the configuration toolbar: + +- **Title** — edit the plot's display name. +- **Mode** — select the plot display mode: + - **Normal** — standard time-series oscilloscope view (default). + - **Mixed** — signals are arranged in horizontal bands (like digital mode), but + each signal can independently be displayed as analog (auto-scaled within its band) + or digital (quantized to high/low within its band). + - **Digital** — logic-analyzer style; each signal occupies a fixed horizontal band + and is quantized to high/low based on its data range midpoint as threshold. + +### Signal Badges and Selection + +Each signal assigned to a plot appears as a colored badge in the plot header. + +- **Click a badge** — selects the signal and opens the V-Scale toolbar for that + signal. The selected signal is drawn on top with increased line width. +- **Click again** — deselects the signal and closes the V-Scale toolbar. +- **Right-click a badge** — opens the style context menu (color, line width, dash + style, markers). +- **Click ×** on a badge — removes the signal from the plot. + +### V-Scale Toolbar + +When a signal is selected (via badge click), an inline toolbar appears below the +plot header showing per-signal vertical scale controls: + +| Control | Description | +|---------|-------------| +| **Auto** | Automatically fits the signal vertically | +| **Range** | Shows V/div and Pos controls for manual positioning | +| **Manual** | Fixed V/div with free positioning | +| **V/div** | Volts (or units) per division | +| **Pos (div)** | Screen position in divisions (draggable offset marker on Y axis) | +| **Type** (Mixed mode only) | Toggle between **Analog** and **Digital** for this signal | +| **✕** | Close the toolbar and deselect the signal | + +Offset markers (small triangles on the Y axis) show each signal's position and can +be dragged to reposition signals without opening the toolbar. ### Plot Controls @@ -93,18 +155,76 @@ Signals received in the CONFIG packet are listed in the sidebar: | **⏸ / ▶** (per plot) | Pause / resume that plot; paused plots allow zoom and pan | | **⬇** (per plot) | Export all visible traces to CSV | | **🗑** (per plot) | Delete the plot | -| **⏸ Pause All** (top bar) | Pause all plots simultaneously | +| **⏸ Pause** (top bar) | Pause all plots simultaneously | +| **↺ Auto** (top bar) | Resume all paused plots and snap back to live view | | **Window** (top bar) | Adjust the rolling time window (1 s – 60 s) | -| Layout buttons | Switch between 1×1, 2×1, 1×2, 2×2, 3×1, … grid layouts | -| Sidebar **←** | Collapse the signal list to maximise plot area | +| **Fit** (top bar) | Fit all plots to their current data range | +| **⬇ CSV** (top bar) | Export all signals from all plots to CSV | +| Layout button | Switch between grid layouts | +| Sidebar **☰** | Toggle the signal list panel | -### Status LED +### Cursor System -| Colour | Meaning | -|--------|---------| -| Red (solid) | No WebSocket connection to browser | -| Orange (pulsing) | WebSocket connected but no data received in > 1 s | -| Green (pulsing) | Data is being received normally | +Two vertical cursors (A and B) can be placed on plots: + +- Enable with the **Cursor** button (shown when a plot is paused/zoomed). +- The top bar readout shows **A**, **B**, and **ΔT** (time between cursors). +- Cursors follow the mouse within the plot area. + +### Zoom + +- **Drag** left or right on a paused plot to zoom into a time range. +- **← Back** button steps back through zoom history. +- **Fit** returns to the full data view. +- When zooming into a region with few or no data points, the nearest data points + outside the zoom window are included so that connecting lines are drawn across + the view rather than showing blank space. + +### Trigger System + +Click **⚡ Trigger** in the top bar to open the trigger bar: + +| Control | Description | +|---------|-------------| +| **Signal** | Select the trigger source signal | +| **Edge** | Rising ↑, Falling ↓, or Both ↕ | +| **Threshold** | Trigger level | +| **Window** | Capture duration after trigger (100 µs – 10 s) | +| **Pre** | Pre-trigger buffer percentage (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 ├── udpClient.Run() ← reconnects on silence; parses UDP packets; feeds hub.dataCh └── http.ListenAndServe() - ├── GET / ← serves embedded index.html + ├── GET / ← serves embedded static files └── GET /ws ← upgrades to WebSocket; launches per-client read/write pumps ``` ### WebSocket Message Format -All messages are JSON. Two types are sent from server to browser: +Two message types are sent from server to browser. -#### `config` message +#### `config` message (JSON) Sent immediately when a browser client connects (if a CONFIG has been received from UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet. @@ -144,7 +264,7 @@ UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet. "rangeMin": -10.0, "rangeMax": 10.0, "timeMode": 0, - "samplingRate": 0.0, + "samplingRate": 1000.0, "timeSignalIdx": 4294967295, "unit": "V" } @@ -152,27 +272,19 @@ UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet. } ``` -#### `data` message +#### `data` message (binary) Sent at ≤ 30 Hz, batching all UDP packets received since the last tick. -Each signal carries its own time axis to support mixed scalar + temporal-array signals. +Uses a compact binary format: a fixed header followed by per-signal blocks. -```json -{ - "type": "data", - "signals": { - "Sine1": { "t": [1747123456.001, 1747123456.002], "v": [3.14, 2.71] }, - "Counter": { "t": [1747123456.001, 1747123456.002], "v": [12340, 12341] }, - "Ch1": { "t": [1747123456.000, 1747123456.0001, ...], "v": [0.12, 0.13, ...] } - } -} -``` +Each signal block contains: +- Signal name (length-prefixed) +- Number of samples +- Timestamp array (float64 LE, Unix seconds) +- Value array (float64 LE) -- `t` — Unix timestamp in seconds (float64) for each sample. -- `v` — physical value (after dequantization if applicable). -- For **temporal arrays**, `t` and `v` have `N × batchSize` entries - (up to 2000 per 30 Hz tick after server-side decimation). -- For **spatial arrays** the keys are `"Ch1[0]"`, `"Ch1[1]"`, etc. +For **temporal arrays**, each packet contributes `N` samples (one per array element). +For **spatial arrays**, keys are `"Ch1[0]"`, `"Ch1[1]"`, etc. ---