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 = `