diff --git a/Client/WebUI/static/app.js b/Client/WebUI/static/app.js index c3b2c12..0b1b829 100644 --- a/Client/WebUI/static/app.js +++ b/Client/WebUI/static/app.js @@ -2,33 +2,53 @@ /* ════════════════════════════════════════════════════════════════ Constants ════════════════════════════════════════════════════════════════ */ -const DEFAULT_CAP = 10_000; +const DEFAULT_CAP = 10_000; const TEMPORAL_CAP = 600_000; -const LTTB_MIN = 200; // never decimate below this many points +const LTTB_MIN = 200; // never decimate below this many points const TRACE_COLORS = [ - '#89b4fa','#a6e3a1','#f38ba8','#fab387','#cba6f7', - '#94e2d5','#89dceb','#b4befe','#f9e2af','#f5c2e7', + '#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7', + '#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7', ]; /* ════════════════════════════════════════════════════════════════ Globals ════════════════════════════════════════════════════════════════ */ -let signals = []; -let buffers = {}; -let plots = []; -let nextPlotId = 1; -let windowSec = 5; +let signals = []; +let buffers = {}; +let plots = []; +let nextPlotId = 1; +let windowSec = 5; let globalPause = false; -let lastDataAt = 0; +let lastDataAt = 0; const traceColorMap = {}; -let colorIdx = 0; +let colorIdx = 0; function getTraceColor(key) { if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length]; return traceColorMap[key]; } +// Per-signal style overrides (color, width, dash, marker, markerSize). +const sigStyle = {}; +function getSigStyle(key) { + if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 }; + return sigStyle[key]; +} +function setSigStyle(key, updates) { + const s = getSigStyle(key); + Object.assign(s, updates); + if (updates.color) { + traceColorMap[key] = updates.color; + // Update badge dots for this key across all plots + document.querySelectorAll(`.sig-badge[data-key="${CSS.escape(key)}"] .trace-dot`).forEach(dot => { + dot.style.background = updates.color; + }); + } + // Recreate uPlot for all plots containing this key + plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } }); +} + // Sync: shared uPlot cursor crosshair across all live plots const LIVE_SYNC = uPlot.sync('live'); const TRIG_SYNC = uPlot.sync('trig'); @@ -51,8 +71,8 @@ let cursorsDirty = false; // if true, redraw all plots to update cursor lines // Layout — [label, cssClass, cols, rows] const LAYOUTS = [ - ['1×1','l1x1',1,1],['1×2','l1x2',1,2],['2×1','l2x1',2,1],['1×3','l1x3',1,3], - ['3×1','l3x1',3,1],['2×2','l2x2',2,2],['1×4','l1x4',1,4],['4×1','l4x1',4,1], + ['1×1', 'l1x1', 1, 1], ['1×2', 'l1x2', 1, 2], ['2×1', 'l2x1', 2, 1], ['1×3', 'l1x3', 1, 3], + ['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1], ]; let currentLayout = 'l1x1'; @@ -60,11 +80,11 @@ let currentLayout = 'l1x1'; Trigger state ════════════════════════════════════════════════════════════════ */ const trig = { - enabled:false, signal:'', edge:'rising', threshold:0, windowSec:1, - prePercent:20, mode:'normal', stopped:false, - armed:false, prevVal:null, collecting:false, trigTime:null, snapshot:null, + enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1, + prePercent: 20, mode: 'normal', stopped: false, + armed: false, prevVal: null, collecting: false, trigTime: null, snapshot: null, }; -function trigPreSec() { return trig.windowSec * trig.prePercent / 100; } +function trigPreSec() { return trig.windowSec * trig.prePercent / 100; } function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; } /* ════════════════════════════════════════════════════════════════ @@ -73,17 +93,17 @@ function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; let ws = null, wsBackoff = 1000; function connectWS() { ws = new WebSocket('ws://' + location.host + '/ws'); - ws.onopen = () => { wsBackoff = 1000; setStatus('orange','Connected – waiting for data'); }; + ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); }; ws.onclose = () => { - setStatus('red','Disconnected (reconnecting…)'); + setStatus('red', 'Disconnected (reconnecting…)'); setTimeout(connectWS, wsBackoff); wsBackoff = Math.min(wsBackoff * 2, 30000); }; - ws.onerror = () => {}; + ws.onerror = () => { }; ws.onmessage = evt => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } - if (msg.type === 'config') onConfig(msg); - else if (msg.type === 'data') onData(msg); + if (msg.type === 'config') onConfig(msg); + else if (msg.type === 'data') onData(msg); }; } @@ -91,7 +111,7 @@ function connectWS() { Status LED ════════════════════════════════════════════════════════════════ */ function setStatus(s, t) { - document.getElementById('status-led').className = s; + document.getElementById('status-led').className = s; document.getElementById('status-text').textContent = t; } setInterval(() => { @@ -113,8 +133,8 @@ setInterval(() => { } else if (tsEl) { tsEl.textContent = ''; } - if (age > 1000) setStatus('orange', 'No data for ' + (age/1000).toFixed(1) + 's'); - else setStatus('green', 'Streaming'); + if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's'); + else setStatus('green', 'Streaming'); } else if (tsEl) { tsEl.textContent = ''; } @@ -124,20 +144,20 @@ setInterval(() => { Config handler ════════════════════════════════════════════════════════════════ */ function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); } -function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; } +function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; } function onConfig(msg) { const newSigs = msg.signals || []; - const fp = s => s.name+':'+s.typeCode+':'+(s.numRows||1)+':'+(s.numCols||1)+':'+(s.timeMode||0); - const changed = newSigs.length !== signals.length || newSigs.some((s,i) => fp(s) !== fp(signals[i])); + const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0); + const changed = newSigs.length !== signals.length || newSigs.some((s, i) => fp(s) !== fp(signals[i])); signals = newSigs; if (changed) { buffers = {}; signals.forEach(sig => { const n = numElements(sig); - if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); } - else if (n === 1) { buffers[sig.name] = makeBuffer(); } - else { for (let i = 0; i < n; i++) buffers[sig.name+'['+i+']'] = makeBuffer(); } + if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); } + else if (n === 1) { buffers[sig.name] = makeBuffer(); } + else { for (let i = 0; i < n; i++) buffers[sig.name + '[' + i + ']'] = makeBuffer(); } }); trigDisarm(); trig.snapshot = null; trig.prevVal = null; zoomHistory.length = 0; @@ -155,12 +175,12 @@ function onData(msg) { const sigs = msg.signals; if (!sigs) return; Object.keys(sigs).forEach(key => { const buf = buffers[key]; if (!buf) return; - const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return; + const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return; const len = Math.min(sd.t.length, sd.v.length); for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]); }); if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs); - if (trig.enabled && trig.collecting && (Date.now()/1000) >= trig.trigTime + trigPostSec()) + if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec()) finaliseTriggerCapture(); if (!trig.enabled) { plots.forEach(p => { @@ -179,17 +199,17 @@ function checkTrigger(sigs) { const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur; if (prev === null) continue; const e = trig.edge, thr = trig.threshold; - if ((e==='rising' &&prev=thr) || - (e==='falling'&&prev>thr&&cur<=thr) || - (e==='both'&&((prev=thr)||(prev>thr&&cur<=thr)))) { - fireTrigger(sd.t ? sd.t[i] : Date.now()/1000); break; + if ((e === 'rising' && prev < thr && cur >= thr) || + (e === 'falling' && prev > thr && cur <= thr) || + (e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) { + fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break; } } } function fireTrigger(t) { // Clear the previous snapshot here (not in trigArm) so the last waveform // and trigger marker stay visible while the trigger is rearmed and waiting. - trig.snapshot=null; trig.armed=false; trig.collecting=true; trig.trigTime=t; + trig.snapshot = null; trig.armed = false; trig.collecting = true; trig.trigTime = t; updateTrigStatusBadge('waiting'); setAllCardsCollecting(true); } function finaliseTriggerCapture() { @@ -202,31 +222,29 @@ function finaliseTriggerCapture() { }); // Tag snapshot with the window parameters captured at trigger time so that // the render loop uses the correct bounds even if UI controls are changed later. - snap._preS = trigPreSec(); + snap._preS = trigPreSec(); snap._postS = trigPostSec(); trig.snapshot = snap; setAllCardsCollecting(false); updateTrigStatusBadge('triggered'); showRearmBtn(trig.mode === 'single'); showStopBtn(trig.mode === 'normal'); - // Reset cursor positions (mode changed) and show cursor button now that snapshot exists - cursors.tA = null; cursors.tB = null; updateCursorReadout(); + // Show cursor button now that snapshot exists (positions preserved from before) updateCursorBtnVisibility(); plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); if (trig.mode === 'normal' && !trig.stopped) - setTimeout(() => { if (trig.enabled && trig.mode==='normal' && !trig.stopped) trigArm(); }, 200); + setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200); } function trigArm() { // Do NOT clear trig.snapshot here — keep the last waveform and trigger marker // visible while waiting for the next event. fireTrigger() clears it when a new // trigger actually fires. - trig.armed=true; trig.collecting=false; trig.prevVal=null; + trig.armed = true; trig.collecting = false; trig.prevVal = null; showRearmBtn(false); updateTrigStatusBadge('armed'); - cursors.tA = null; cursors.tB = null; updateCursorReadout(); updateCursorBtnVisibility(); plots.forEach(p => { p.xRange = null; p.needsRedraw = true; }); } function trigDisarm() { - trig.armed=false; trig.collecting=false; trig.trigTime=null; trig.stopped=false; + trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false; setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle'); } function setAllCardsCollecting(on) { @@ -235,10 +253,10 @@ function setAllCardsCollecting(on) { function updateTrigStatusBadge(state) { const el = document.getElementById('trig-status-badge'); el.className = state; - el.textContent = {idle:'IDLE',armed:'ARMED',waiting:'COLLECTING',triggered:'TRIGGERED'}[state]||'IDLE'; + el.textContent = { idle: 'IDLE', armed: 'ARMED', waiting: 'COLLECTING', triggered: 'TRIGGERED' }[state] || 'IDLE'; } -function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v?'inline-block':'none'; } -function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v?'inline-block':'none'; } +function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v ? 'inline-block' : 'none'; } +function showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v ? 'inline-block' : 'none'; } function updateStopBtn() { const btn = document.getElementById('btn-trig-stop'); btn.textContent = trig.stopped ? 'Resume' : 'Stop'; @@ -249,7 +267,7 @@ function updateStopBtn() { ════════════════════════════════════════════════════════════════ */ function makeBuffer(cap) { cap = cap || DEFAULT_CAP; - return { t: new Float64Array(cap), v: new Float64Array(cap), head:0, size:0, cap }; + return { t: new Float64Array(cap), v: new Float64Array(cap), head: 0, size: 0, cap }; } function pushBuffer(buf, t, v) { buf.t[buf.head] = t; buf.v[buf.head] = v; @@ -261,14 +279,14 @@ function pushBuffer(buf, t, v) { function getBufferSliceRange(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 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; } + 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; } + while (lo < hi) { const m = (lo + hi) >>> 1; if (buf.t[physAt(m)] <= t1) lo = m + 1; else hi = m; } const kEnd = lo, len = kEnd - kStart; if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) }; @@ -316,9 +334,9 @@ function getBufferSlice(buf) { // Binary-search slice of a sorted contiguous Float64Array pair function sliceTypedArrayRange(t, v, t0, t1) { let lo = 0, hi = t.length; - while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] < t0) lo=m+1; else hi=m; } + while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] < t0) lo = m + 1; else hi = m; } const s = lo; lo = s; hi = t.length; - while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] <= t1) lo=m+1; else hi=m; } + while (lo < hi) { const m = (lo + hi) >>> 1; if (t[m] <= t1) lo = m + 1; else hi = m; } return { t: t.subarray(s, lo), v: v.subarray(s, lo) }; } @@ -334,28 +352,60 @@ function getKeySamplingRate(key) { return sig ? (sig.samplingRate || 0) : 0; } +// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default). +function makeSeriesPath(key) { + const style = getSigStyle(key); + if (style.dash === 'solid') return null; + const dashPat = style.dash === 'dashed' ? [6, 4] : [2, 3]; + return (u, si) => { + const xd = u.data[0], yd = u.data[si]; + if (!xd || !yd || !u.bbox) return { stroke: null, fill: null }; + const { ctx, bbox } = u; + ctx.save(); + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width; + ctx.setLineDash(dashPat); + ctx.lineJoin = 'round'; + ctx.beginPath(); + let moved = false; + for (let i = 0; i < xd.length; i++) { + if (yd[i] == null) { moved = false; continue; } + const cx = u.valToPos(xd[i], 'x', true); + const cy = u.valToPos(yd[i], 'y', true); + if (!moved) { ctx.moveTo(cx, cy); moved = true; } + else ctx.lineTo(cx, cy); + } + ctx.stroke(); + ctx.restore(); + return { stroke: null, fill: null }; // tell uPlot not to draw anything on top + }; +} + // LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold. function lttb(t, v, threshold) { const len = t.length; if (len <= threshold) return { t, v }; const outT = new Float64Array(threshold), outV = new Float64Array(threshold); outT[0] = t[0]; outV[0] = v[0]; - outT[threshold-1] = t[len-1]; outV[threshold-1] = v[len-1]; + outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1]; const every = (len - 2) / (threshold - 2); let a = 0; for (let i = 0; i < threshold - 2; i++) { - const avgS = Math.floor((i+1)*every)+1, avgE = Math.min(Math.floor((i+2)*every)+1, len); + const avgS = Math.floor((i + 1) * every) + 1, avgE = Math.min(Math.floor((i + 2) * every) + 1, len); let avgT = 0, avgV = 0, n = 0; for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; } if (n) { avgT /= n; avgV /= n; } - const rS = Math.floor(i*every)+1, rE = Math.min(Math.floor((i+1)*every)+1, len); + const rS = Math.floor(i * every) + 1, rE = Math.min(Math.floor((i + 1) * every) + 1, len); let maxA = -1, next = rS; const aT = t[a], aV = v[a]; for (let j = rS; j < rE; j++) { - const area = Math.abs((aT-avgT)*(v[j]-aV) - (aT-t[j])*(avgV-aV)); + const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV)); if (area > maxA) { maxA = area; next = j; } } - outT[i+1] = t[next]; outV[i+1] = v[next]; a = next; + outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next; } return { t: outT, v: outV }; } @@ -368,11 +418,11 @@ function fmtLiveTick(u, vals) { return vals.map(v => { if (v == null) return ''; const d = new Date(v * 1000); - const hh = String(d.getHours()).padStart(2,'0'); - const mm = String(d.getMinutes()).padStart(2,'0'); - const ss = String(d.getSeconds()).padStart(2,'0'); - const ms = String(d.getMilliseconds()).padStart(3,'0'); - return hh+':'+mm+':'+ss+'.'+ms; + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return hh + ':' + mm + ':' + ss + '.' + ms; }); } // Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks) @@ -380,13 +430,14 @@ function fmtTrigTick(u, vals) { return vals.map(v => { if (v == null) return ''; const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; - if (abs < 1) return sign + (abs*1000).toFixed(1) + 'ms'; + if (abs < 1) return sign + (abs * 1000).toFixed(1) + 'ms'; return sign + abs.toFixed(3) + 's'; }); } -// Draw a vertical marker line at x=0 (the trigger event) in trigger mode. -function drawTriggerMarker(u) { +// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold +// line on any plot that shows the trigger signal. +function drawTriggerMarker(u, p) { if (!trig.enabled || !trig.snapshot) return; const { ctx, bbox } = u; if (!bbox) return; @@ -394,61 +445,186 @@ function drawTriggerMarker(u) { if (x < bbox.left || x > bbox.left + bbox.width) return; const px = Math.round(x); ctx.save(); - ctx.strokeStyle = 'rgba(203,166,247,0.9)'; - ctx.lineWidth = 1.5; - ctx.setLineDash([]); + // Thin dashed vertical line at t=0 + ctx.strokeStyle = 'rgba(203,166,247,0.7)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(px, bbox.top); ctx.lineTo(px, bbox.top + bbox.height); ctx.stroke(); - // Small "T" label - ctx.fillStyle = 'rgba(203,166,247,0.9)'; - ctx.font = 'bold 10px monospace'; + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(203,166,247,0.7)'; + ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top'; ctx.fillText('T', px + 3, bbox.top + 2); + // Horizontal threshold line — only on plots that contain the trigger signal + if (p && trig.signal && p.traces.includes(trig.signal)) { + const y = u.valToPos(trig.threshold, 'y', true); + if (y >= bbox.top && y <= bbox.top + bbox.height) { + const py = Math.round(y); + ctx.strokeStyle = 'rgba(203,166,247,0.45)'; + ctx.lineWidth = 0.75; + ctx.setLineDash([3, 4]); + ctx.beginPath(); + ctx.moveTo(bbox.left, py); + ctx.lineTo(bbox.left + bbox.width, py); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(203,166,247,0.45)'; + ctx.font = '9px monospace'; + ctx.textBaseline = 'bottom'; + ctx.fillText(trig.threshold.toPrecision(4), bbox.left + 4, py - 1); + } + } ctx.restore(); } -// Draw custom cursor A/B lines onto the canvas (called from draw hook) -function drawCursorLines(u) { +// Linearly interpolate series value at time t from uPlot's current rendered data. +function interpAtTime(u, si, t) { + const td = u.data[0], vd = u.data[si]; + if (!td || td.length === 0 || !vd) return null; + let lo = 0, hi = td.length - 1; + while (lo < hi) { const m = (lo + hi) >> 1; if (td[m] < t) lo = m + 1; else hi = m; } + if (lo === 0) return vd[0] ?? null; + const t0 = td[lo - 1], t1 = td[lo]; + const v0 = vd[lo - 1], v1 = vd[lo]; + if (v0 == null || v1 == null) return v0 ?? v1 ?? null; + return v0 + (t - t0) / (t1 - t0) * (v1 - v0); +} + +// Draw custom marker shapes (square, cross, diamond) for all series in a plot. +// Circle markers are handled natively by uPlot's points option. +function drawSeriesMarkers(u, p) { + if (!u.bbox) return; + const { ctx, bbox } = u; + ctx.save(); + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + p.traces.forEach((key, idx) => { + const style = getSigStyle(key); + if (style.marker === 'none' || style.marker === 'circle') return; + const si = idx + 1; + const xd = u.data[0], yd = u.data[si]; + if (!xd || !yd) return; + const sz = style.markerSize; + ctx.strokeStyle = style.color; + ctx.fillStyle = style.color; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + for (let i = 0; i < xd.length; i++) { + if (yd[i] == null) continue; + const cx = u.valToPos(xd[i], 'x', true); + const cy = u.valToPos(yd[i], 'y', true); + if (cx < bbox.left || cx > bbox.left + bbox.width || + cy < bbox.top || cy > bbox.top + bbox.height) continue; + ctx.beginPath(); + if (style.marker === 'square') { + ctx.rect(cx - sz / 2, cy - sz / 2, sz, sz); ctx.fill(); + } else if (style.marker === 'cross') { + ctx.moveTo(cx - sz / 2, cy); ctx.lineTo(cx + sz / 2, cy); + ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx, cy + sz / 2); + ctx.stroke(); + } else if (style.marker === 'diamond') { + ctx.moveTo(cx, cy - sz / 2); ctx.lineTo(cx + sz / 2, cy); + ctx.lineTo(cx, cy + sz / 2); ctx.lineTo(cx - sz / 2, cy); + ctx.closePath(); ctx.fill(); + } + } + }); + ctx.restore(); +} + +// Draw cursor A/B vertical lines and signal value labels (called from draw hook). +function drawCursorLines(u, p) { + if (cursors.mode !== 'on') return; const { ctx, bbox } = u; if (!bbox) return; - const drawLine = (val, color) => { + + const drawLine = (val, color, label) => { if (val === null) return; - const x = u.valToPos(val, 'x', true); // canvas pixels + const x = u.valToPos(val, 'x', true); if (x < bbox.left || x > bbox.left + bbox.width) return; + const px = Math.round(x); ctx.save(); + // Clip to plot area + ctx.beginPath(); + ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height); + ctx.clip(); + // Vertical dashed cursor line ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); ctx.beginPath(); - ctx.moveTo(Math.round(x), bbox.top); - ctx.lineTo(Math.round(x), bbox.top + bbox.height); + ctx.moveTo(px, bbox.top); + ctx.lineTo(px, bbox.top + bbox.height); ctx.stroke(); + ctx.setLineDash([]); + // Cursor label at top + ctx.fillStyle = color; + ctx.font = 'bold 12px monospace'; + ctx.textBaseline = 'top'; + ctx.fillText(label, px + 14, bbox.top + 2); + // Per-trace: diamond at crossing point + value label + if (p) { + const DSZ = 5; // diamond half-size in px + p.traces.forEach((key, idx) => { + const v = interpAtTime(u, idx + 1, val); + if (v === null) return; + const cy = u.valToPos(v, 'y', true); + if (cy < bbox.top || cy > bbox.top + bbox.height) return; + const tc = getSigStyle(key).color; + // Diamond marker at intersection + ctx.fillStyle = tc; + ctx.strokeStyle = tc; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px, cy - DSZ); + ctx.lineTo(px + DSZ, cy); + ctx.lineTo(px, cy + DSZ); + ctx.lineTo(px - DSZ, cy); + ctx.closePath(); + ctx.fill(); + // Value text next to diamond + const str = Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString(); + ctx.fillStyle = tc; + ctx.font = '11px monospace'; + const currentAlign = ctx.textAlign; + ctx.textAlign = "left"; // horizontal alignment + ctx.textBaseline = "middle"; + ctx.fillText(str, px + DSZ + 4, cy); + ctx.textAlign = currentAlign; + }); + } ctx.restore(); }; - drawLine(cursors.tA, 'rgba(137,220,235,0.85)'); - drawLine(cursors.tB, 'rgba(249,226,175,0.85)'); + + drawLine(cursors.tA, 'rgba(137,220,235,0.85)', 'A'); + drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B'); } // Build uPlot opts for a given plot object function makeUPlotOpts(p, inTrigMode) { const seriesArr = [{}]; // time (index 0) p.traces.forEach(key => { + const style = getSigStyle(key); + const pathsFn = makeSeriesPath(key); seriesArr.push({ label: key, - stroke: getTraceColor(key), - width: 1.5, - points: { show: false }, + stroke: style.color, + width: style.width, + points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color }, spanGaps: true, + ...(pathsFn ? { paths: pathsFn } : {}), }); }); const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals) - : (u, vals) => fmtLiveTick(u, vals); + : (u, vals) => fmtLiveTick(u, vals); return { - width: Math.max(p.div.clientWidth || 100, 50), + width: Math.max(p.div.clientWidth || 100, 50), height: Math.max(p.div.clientHeight || 100, 50), cursor: { sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false }, @@ -457,34 +633,38 @@ function makeUPlotOpts(p, inTrigMode) { }, select: { show: true }, scales: { - x: { time: false, auto: false, - min: p.xRange ? p.xRange[0] : 0, - max: p.xRange ? p.xRange[1] : windowSec }, + x: { + time: false, auto: false, + min: p.xRange ? p.xRange[0] : 0, + max: p.xRange ? p.xRange[1] : windowSec + }, y: { auto: true }, }, series: seriesArr, axes: [ - { stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, - values: xVals, size: 36, space: 90 }, - { stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, size: 50 }, + { + stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, + values: xVals, size: 36, space: 90 + }, + { stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 }, ], legend: { show: false }, padding: [4, 4, 0, 0], hooks: { - draw: [ u => { drawCursorLines(u); drawTriggerMarker(u); } ], + draw: [u => { 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 // flag is false and onZoom is never called unintentionally. setSelect: [(u) => { u._userZoom = (u.select.width > 0); }], - setScale: [(u, key) => { + setScale: [(u, key) => { if (key !== 'x' || !u._userZoom || !u._ready) return; u._userZoom = false; const { min, max } = u.scales.x; if (min == null || max == null || max <= min) return; onZoom(p.id, min, max); }], - ready: [ u => { u._ready = true; } ], + ready: [u => { u._ready = true; }], }, }; } @@ -527,41 +707,42 @@ function createUPlot(p) { // Update pointer style based on what's under the mouse p.uplot.over.addEventListener('mousemove', e => { - const snap = _cursorAtClientX(e.clientX); + const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null; p.uplot.over.style.cursor = snap ? 'ew-resize' : ''; }); p.uplot.over.addEventListener('mouseleave', () => { p.uplot.over.style.cursor = ''; }); - // Mousedown: start drag (intercept before uPlot to suppress zoom selection) + // Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line). + // If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior. p.uplot.over.addEventListener('mousedown', e => { if (e.button !== 0 || e.shiftKey) return; // shift is pan - const snap = _cursorAtClientX(e.clientX); - const target = snap || (cursors.mode !== 'off' ? cursors.mode : null); - if (!target) return; + if (cursors.mode !== 'on') return; + const target = _cursorAtClientX(e.clientX); + if (!target) return; // not near a cursor — let uPlot handle zoom e.stopImmediatePropagation(); // prevent uPlot drag-zoom e.preventDefault(); // Set cursor position immediately on mousedown if (target === 'A') cursors.tA = _cursorValFromEvent(e); - else cursors.tB = _cursorValFromEvent(e); + else cursors.tB = _cursorValFromEvent(e); updateCursorReadout(); cursorsDirty = true; const onMove = ev => { if (target === 'A') cursors.tA = _cursorValFromEvent(ev); - else cursors.tB = _cursorValFromEvent(ev); + else cursors.tB = _cursorValFromEvent(ev); updateCursorReadout(); cursorsDirty = true; }; const onUp = () => { document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); + document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.addEventListener('mouseup', onUp); }, true); // capture:true so we fire before uPlot's own handlers // Pan support: Shift+left-drag pans the current view (synced across all plots). @@ -630,7 +811,7 @@ function createUPlot(p) { if (p.ro) { p.ro.disconnect(); } p.ro = new ResizeObserver(() => { if (!p.uplot) return; - const w = Math.max(p.div.clientWidth || 50, 50); + const w = Math.max(p.div.clientWidth || 50, 50); const h = Math.max(p.div.clientHeight || 50, 50); zoomGuard = true; p.uplot.setSize({ width: w, height: h }); @@ -738,7 +919,7 @@ function buildLiveData(p) { function buildTrigData(p) { const trigT = trig.trigTime; - const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); + const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); if (p.traces.length === 0) return [new Float64Array(0)]; @@ -844,7 +1025,7 @@ function resetZoom() { syncLocked = false; document.getElementById('btn-sync-resume').style.display = 'none'; if (trig.enabled && trig.snapshot) { - const preS = trig.snapshot._preS || trigPreSec(); + const preS = trig.snapshot._preS || trigPreSec(); const postS = trig.snapshot._postS || trigPostSec(); zoomGuard = true; plots.forEach(p => { @@ -874,8 +1055,8 @@ function zoomFit() { p.traces.forEach(key => { const buf = buffers[key]; if (!buf || buf.size === 0) return; const startIdx = (buf.size === buf.cap) ? buf.head : 0; - const oldestT = buf.t[startIdx]; - const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + const oldestT = buf.t[startIdx]; + const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; if (oldestT < gMin) gMin = oldestT; if (newestT > gMax) gMax = newestT; }); @@ -902,48 +1083,53 @@ function exitSyncLock() { resetZoom(); } document.getElementById('btn-sync-resume').addEventListener('click', resetZoom); document.getElementById('btn-zoom-back').addEventListener('click', zoomBack); -document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit); +document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit); /* ════════════════════════════════════════════════════════════════ Cursor controls ════════════════════════════════════════════════════════════════ */ -const CURSOR_LABELS = { off:'Cursor', A:'Cursor A', B:'Cursor B' }; - // Show the cursor button only when paused or in trigger-snapshot mode. -// Hides and clears cursors whenever neither condition holds. function updateCursorBtnVisibility() { const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null); const btn = document.getElementById('btn-cursor'); btn.style.display = canUseCursors ? '' : 'none'; if (!canUseCursors && cursors.mode !== 'off') { cursors.mode = 'off'; - cursors.tA = null; cursors.tB = null; - btn.textContent = CURSOR_LABELS.off; - btn.className = 'ctrl-btn'; + cursors.tA = null; cursors.tB = null; // context changed, clear positions + btn.textContent = 'Cursors'; + btn.classList.remove('active'); document.getElementById('cursor-readout').classList.remove('visible'); cursorsDirty = true; } } -const CURSOR_MODES = ['off','A','B']; document.getElementById('btn-cursor').addEventListener('click', () => { - cursors.mode = CURSOR_MODES[(CURSOR_MODES.indexOf(cursors.mode)+1) % 3]; + cursors.mode = cursors.mode === 'off' ? 'on' : 'off'; const btn = document.getElementById('btn-cursor'); - btn.textContent = CURSOR_LABELS[cursors.mode]; - btn.className = 'ctrl-btn' - + (cursors.mode==='A' ? ' cursor-a' : cursors.mode==='B' ? ' cursor-b' : ''); - if (cursors.mode === 'off') { - cursors.tA = null; cursors.tB = null; - document.getElementById('cursor-readout').classList.remove('visible'); - cursorsDirty = true; - } else { + btn.textContent = 'Cursors'; + btn.classList.toggle('active', cursors.mode === 'on'); + if (cursors.mode === 'on') { + // Auto-place at 25%/75% of current x range on first use + if (cursors.tA === null && cursors.tB === null) { + const refPlot = plots.find(p => p.uplot); + if (refPlot) { + const { min, max } = refPlot.uplot.scales.x; + const span = max - min; + cursors.tA = min + span * 0.25; + cursors.tB = min + span * 0.75; + } + } + updateCursorReadout(); document.getElementById('cursor-readout').classList.add('visible'); + } else { + document.getElementById('cursor-readout').classList.remove('visible'); } + cursorsDirty = true; }); function updateCursorReadout() { const ro = document.getElementById('cursor-readout'); - const active = cursors.mode !== 'off' || cursors.tA !== null || cursors.tB !== null; + const active = cursors.mode === 'on'; ro.classList.toggle('visible', active); if (!active) return; @@ -952,20 +1138,20 @@ function updateCursorReadout() { if (v === null) return '—'; if (trig.enabled && trig.snapshot) { const abs = Math.abs(v), sign = v < 0 ? '-' : '+'; - return abs < 1 ? sign+(abs*1000).toFixed(3)+'ms' : sign+abs.toFixed(6)+'s'; + return abs < 1 ? sign + (abs * 1000).toFixed(3) + 'ms' : sign + abs.toFixed(6) + 's'; } const d = new Date(v * 1000); - return String(d.getHours()).padStart(2,'0') + ':' - + String(d.getMinutes()).padStart(2,'0') + ':' - + String(d.getSeconds()).padStart(2,'0') + '.' - + String(d.getMilliseconds()).padStart(3,'0'); + return String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0') + '.' + + String(d.getMilliseconds()).padStart(3, '0'); }; document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA); document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB); if (cursors.tA !== null && cursors.tB !== null) { const dt = cursors.tB - cursors.tA, abs = Math.abs(dt); const s = dt >= 0 ? '+' : '-'; - const str = abs < 1 ? s+(abs*1000).toFixed(3)+'ms' : s+abs.toFixed(6)+'s'; + const str = abs < 1 ? s + (abs * 1000).toFixed(3) + 'ms' : s + abs.toFixed(6) + 's'; document.getElementById('cur-dt').textContent = 'ΔT: ' + str; } else { document.getElementById('cur-dt').textContent = 'ΔT: —'; @@ -1001,19 +1187,21 @@ function openTrigBar(open) { if (trig.signal) trigArm(); else updateTrigStatusBadge('idle'); } // Resize uPlot instances after trigbar height changes - setTimeout(() => { plots.forEach(p => { - if (!p.uplot) return; - p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); - }); }, 220); + setTimeout(() => { + plots.forEach(p => { + if (!p.uplot) return; + p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); + }); + }, 220); } document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled)); document.getElementById('trig-signal').addEventListener('change', e => { trig.signal = e.target.value; trig.prevVal = null; if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm(); }); -document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; }); -document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value)||0; if (trig.armed) trig.prevVal = null; }); -document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); }); +document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; }); +document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; if (trig.armed) trig.prevVal = null; }); +document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); }); document.getElementById('trig-pre').addEventListener('input', e => { trig.prePercent = parseInt(e.target.value, 10); document.getElementById('trig-pre-val').textContent = trig.prePercent + '%'; @@ -1048,7 +1236,7 @@ function buildTrigSignalSelect() { const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o); } else { for (let i = 0; i < n; i++) { - const key = sig.name+'['+i+']', o = document.createElement('option'); + const key = sig.name + '[' + i + ']', o = document.createElement('option'); o.value = key; o.textContent = key; sel.appendChild(o); } } @@ -1067,22 +1255,22 @@ function buildSidebar() { list.innerHTML = '
No signals
'; return; } - const typeNames = ['u8','i8','u16','i16','u32','i32','u64','i64','f32','f64']; + const typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64']; signals.forEach(sig => { const n = numElements(sig), temporal = isTemporal(sig); const typeName = typeNames[sig.typeCode] || '?'; if (n === 1 || temporal) { - list.appendChild(makeDraggable(sig.name, sig.name, temporal?'['+n+'] '+typeName:typeName, sig.unit||'')); + list.appendChild(makeDraggable(sig.name, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || '')); } else { const group = document.createElement('div'); group.className = 'array-group'; const header = document.createElement('div'); header.className = 'array-header'; - header.innerHTML = ''+escHtml(sig.name)+'' - + (sig.unit?''+escHtml(sig.unit)+'':'') - + '['+n+'] '+typeName+''; + header.innerHTML = '' + escHtml(sig.name) + '' + + (sig.unit ? '' + escHtml(sig.unit) + '' : '') + + '[' + n + '] ' + typeName + ''; header.addEventListener('click', () => header.classList.toggle('open')); const children = document.createElement('div'); children.className = 'array-children'; for (let i = 0; i < n; i++) { - const key = sig.name+'['+i+']', child = makeDraggable(key, key, typeName, sig.unit||''); + const key = sig.name + '[' + i + ']', child = makeDraggable(key, key, typeName, sig.unit || ''); child.className = 'array-child'; children.appendChild(child); } group.appendChild(header); group.appendChild(children); list.appendChild(group); @@ -1092,9 +1280,9 @@ function buildSidebar() { function makeDraggable(key, label, typeName, unit) { const item = document.createElement('div'); item.className = 'sig-item'; item.draggable = true; - item.innerHTML = ''+escHtml(label)+'' - + (unit?''+escHtml(unit)+'':'') - + ''+escHtml(typeName)+''; + item.innerHTML = '' + escHtml(label) + '' + + (unit ? '' + escHtml(unit) + '' : '') + + '' + escHtml(typeName) + ''; item.addEventListener('dragstart', e => { e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy'; requestAnimationFrame(() => item.classList.add('dragging')); @@ -1126,7 +1314,8 @@ function layoutSVG(cols, rows) { } } return `` - + `${rects}`; + + `` + + `${rects}`; } // Apply a new layout: switch the grid class and auto-add/remove plots. @@ -1134,7 +1323,7 @@ function layoutSVG(cols, rows) { function applyLayout(cls) { const entry = LAYOUTS.find(l => l[1] === cls); if (!entry) return; - const [label,, cols, rows] = entry; + const [label, , cols, rows] = entry; currentLayout = cls; document.getElementById('plot-grid').className = cls; @@ -1190,7 +1379,7 @@ function buildLayoutMenu() { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); menu.style.left = r.left + 'px'; - menu.style.top = (r.bottom + 4) + 'px'; + menu.style.top = (r.bottom + 4) + 'px'; menu.classList.toggle('open'); }); @@ -1224,7 +1413,7 @@ function exportAllCSV() { const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t))); const sortedT = Array.from(allT).sort((a, b) => a - b); const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t, i) => m.set(t, s.v[i])); return m; }); - const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...keys].join(','); + const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...keys].join(','); const rows = sortedT.map(t => [t.toFixed(9), ...lookups.map(lk => lk.has(t) ? lk.get(t) : '')].join(',')); const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); @@ -1249,7 +1438,7 @@ function addPlot() {
⚡ Collecting…
`; - card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='copy'; card.classList.add('drag-over'); }); + card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; card.classList.add('drag-over'); }); card.addEventListener('dragleave', () => card.classList.remove('drag-over')); card.addEventListener('drop', e => { e.preventDefault(); card.classList.remove('drag-over'); @@ -1258,8 +1447,8 @@ 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 }; + const plotBody = card.querySelector('#pbody-' + id); + const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null }; plots.push(p); // uPlot creation is handled by applyLayout (batch, after DOM settles). return id; @@ -1269,7 +1458,7 @@ function addTraceTo(plotId, signalKey) { const p = plots.find(p => p.id === plotId); if (!p) return; if (p.traces.includes(signalKey)) return; p.traces.push(signalKey); - document.querySelector('#hint-'+plotId).style.display = 'none'; + document.querySelector('#hint-' + plotId).style.display = 'none'; addBadge(plotId, signalKey); // Recreate uPlot with new series list createUPlot(p); @@ -1282,24 +1471,28 @@ function removeTraceFrom(plotId, signalKey) { removeBadge(plotId, signalKey); createUPlot(p); p.needsRedraw = true; - if (!p.traces.length) document.querySelector('#hint-'+plotId).style.display = ''; + if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = ''; } function addBadge(plotId, key) { - const c = document.getElementById('badges-'+plotId); if (!c) return; - if (c.querySelector('[data-key="'+CSS.escape(key)+'"]')) return; - const color = getTraceColor(key); + const c = document.getElementById('badges-' + plotId); if (!c) return; + if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return; + const color = getSigStyle(key).color; const badge = document.createElement('span'); badge.className = 'sig-badge'; badge.dataset.key = key; const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color; - const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×'; + const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×'; x.addEventListener('click', () => removeTraceFrom(plotId, key)); + badge.addEventListener('contextmenu', e => { + e.preventDefault(); + showSignalMenu(key, plotId, e.clientX, e.clientY); + }); badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x); c.appendChild(badge); } function removeBadge(plotId, key) { - const c = document.getElementById('badges-'+plotId); if (!c) return; - const b = c.querySelector('[data-key="'+CSS.escape(key)+'"]'); if (b) b.remove(); + const c = document.getElementById('badges-' + plotId); if (!c) return; + const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (b) b.remove(); } @@ -1309,7 +1502,7 @@ function deletePlot(plotId) { if (p.ro) p.ro.disconnect(); if (p.uplot) p.uplot.destroy(); plots.splice(idx, 1); - document.querySelector('[data-plot-id="'+plotId+'"]').remove(); + document.querySelector('[data-plot-id="' + plotId + '"]').remove(); } /* ════════════════════════════════════════════════════════════════ @@ -1330,7 +1523,7 @@ function renderDirtyPlots() { const newest = b.t[(b.head - 1 + b.cap) % b.cap]; const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap]; const sliceLen = getBufferSlice(b).t.length; - console.log(`[buf] ${k}: size=${b.size} oldest=${oldest.toFixed(3)} newest=${newest.toFixed(3)} wallNow=${wallNow.toFixed(3)} ts-age=${(wallNow-newest).toFixed(3)}s slice(${windowSec}s)=${sliceLen}pts`); + console.log(`[buf] ${k}: size=${b.size} oldest=${oldest.toFixed(3)} newest=${newest.toFixed(3)} wallNow=${wallNow.toFixed(3)} ts-age=${(wallNow - newest).toFixed(3)}s slice(${windowSec}s)=${sliceLen}pts`); }); if (!anyData) console.log('[buf] all buffers empty — no data received yet'); plots.forEach(p => console.log(`[plot${p.id}] traces=${JSON.stringify(p.traces)} xRange=${JSON.stringify(p.xRange)} needsRedraw=${p.needsRedraw}`)); @@ -1393,10 +1586,12 @@ function renderDirtyPlots() { if (trig.enabled && !inTrigMode) { // Armed / waiting for trigger: keep the current scale frozen. } else if (inTrigMode) { - const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); + const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec(); const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec(); - p.uplot.setScale('x', { min: p.xRange ? p.xRange[0] : -preS, - max: p.xRange ? p.xRange[1] : postS }); + p.uplot.setScale('x', { + min: p.xRange ? p.xRange[0] : -preS, + max: p.xRange ? p.xRange[1] : postS + }); } else if (p.xRange) { // Zoomed: re-apply so scale is correct after setData p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] }); @@ -1447,9 +1642,11 @@ function setSidebar(open) { sidebarOpen = open; document.getElementById('sidebar').classList.toggle('collapsed', !open); document.getElementById('btn-sidebar').classList.toggle('active', open); - setTimeout(() => { plots.forEach(p => { - if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); - }); }, 200); + setTimeout(() => { + plots.forEach(p => { + if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight }); + }); + }, 200); } document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen)); @@ -1457,7 +1654,81 @@ document.getElementById('btn-sidebar').addEventListener('click', () => setSideba Utility ════════════════════════════════════════════════════════════════ */ function escHtml(s) { - return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/* ════════════════════════════════════════════════════════════════ + Signal style context menu + ════════════════════════════════════════════════════════════════ */ +let _ctxMenuKey = null; + +function showSignalMenu(key, plotId, x, y) { + _ctxMenuKey = key; + const menu = document.getElementById('sig-ctx-menu'); + const style = getSigStyle(key); + document.getElementById('ctx-menu-key').textContent = key; + document.getElementById('ctx-color').value = style.color; + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', parseFloat(btn.dataset.w) === style.width); + }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.dash === style.dash); + }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.marker === style.marker); + }); + document.getElementById('ctx-marker-size').value = style.markerSize; + document.getElementById('ctx-marker-size-val').textContent = style.markerSize + 'px'; + menu.style.display = 'block'; + const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 200; + menu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px'; +} + +function hideSignalMenu() { + document.getElementById('sig-ctx-menu').style.display = 'none'; + _ctxMenuKey = null; +} + +function initSignalMenu() { + document.getElementById('ctx-color').addEventListener('input', e => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { color: e.target.value }); + }); + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { width: parseFloat(btn.dataset.w) }); + document.querySelectorAll('#ctx-width-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { dash: btn.dataset.dash }); + document.querySelectorAll('#ctx-dash-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { marker: btn.dataset.marker }); + document.querySelectorAll('#ctx-marker-btns .ctx-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + document.getElementById('ctx-marker-size').addEventListener('input', e => { + const sz = parseInt(e.target.value, 10); + document.getElementById('ctx-marker-size-val').textContent = sz + 'px'; + if (!_ctxMenuKey) return; + setSigStyle(_ctxMenuKey, { markerSize: sz }); + }); + document.addEventListener('click', e => { + if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu(); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') hideSignalMenu(); }); } /* ════════════════════════════════════════════════════════════════ @@ -1465,9 +1736,10 @@ function escHtml(s) { ════════════════════════════════════════════════════════════════ */ buildLayoutMenu(); applyLayout('l1x1'); +initSignalMenu(); document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); connectWS(); requestAnimationFrame(renderDirtyPlots); fetch('/version').then(r => r.text()).then(v => { document.getElementById('build-version').textContent = 'v' + v; -}).catch(() => {}); +}).catch(() => { }); diff --git a/Client/WebUI/static/index.html b/Client/WebUI/static/index.html index f750a5f..402a231 100644 --- a/Client/WebUI/static/index.html +++ b/Client/WebUI/static/index.html @@ -116,6 +116,48 @@
+ + + + diff --git a/Client/WebUI/static/style.css b/Client/WebUI/static/style.css index 1acadea..a3c1217 100644 --- a/Client/WebUI/static/style.css +++ b/Client/WebUI/static/style.css @@ -239,19 +239,13 @@ input[type=range].trig-range::-webkit-slider-thumb { min-height:0; position:relative; overflow:hidden; } .plot-card.drag-over { background:rgba(137,180,250,0.04); box-shadow:inset 0 0 0 2px var(--accent); } -/* Header: hidden by default, slides in on hover */ +/* Header: always visible, part of normal card flex flow */ .plot-card-header { - position:absolute; top:0; left:0; right:0; z-index:5; + z-index:5; flex-shrink:0; background:rgba(17,17,27,0.88); backdrop-filter:blur(6px); border-bottom:1px solid var(--surface1); display:flex; align-items:center; gap:5px; padding:3px 8px; min-height:26px; overflow:hidden; - opacity:0; transform:translateY(-100%); - transition:opacity 0.12s ease, transform 0.12s ease; - pointer-events:none; -} -.plot-card:hover .plot-card-header { - opacity:1; transform:translateY(0); pointer-events:auto; } .plot-title { font-size:11px; color:var(--subtext1); font-weight:600; @@ -289,6 +283,35 @@ input[type=range].trig-range::-webkit-slider-thumb { border:1px solid var(--mauve); } +/* ── Signal style context menu ────────────────────────────────── */ +#sig-ctx-menu { + position:fixed; z-index:300; + background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius); + box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; min-width:210px; +} +.ctx-menu-header { + font-size:11px; color:var(--subtext0); margin-bottom:8px; + padding-bottom:6px; border-bottom:1px solid var(--surface0); + white-space:nowrap; overflow:hidden; text-overflow:ellipsis; +} +.ctx-menu-key { color:var(--accent); font-weight:600; } +.ctx-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; } +.ctx-row label { font-size:11px; color:var(--subtext0); width:42px; flex-shrink:0; } +.ctx-btns { display:flex; gap:3px; flex-wrap:wrap; } +.ctx-btn { + background:var(--surface0); border:1px solid var(--surface1); border-radius:4px; + color:var(--subtext1); font-size:11px; padding:2px 7px; cursor:pointer; + transition:background var(--transition),border-color var(--transition),color var(--transition); +} +.ctx-btn:hover { background:var(--surface1); border-color:var(--accent); } +.ctx-btn.active { background:var(--surface1); border-color:var(--accent); color:var(--accent); } +#ctx-color { + width:28px; height:22px; border:1px solid var(--surface1); border-radius:4px; + background:transparent; cursor:pointer; padding:1px; +} +.ctx-range { width:80px; } +.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; } + /* ── Empty state ──────────────────────────────────────────────── */ #empty-state { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); diff --git a/Test/MARTeApp/run.sh b/Test/MARTeApp/run.sh index bf18da3..13e695e 100755 --- a/Test/MARTeApp/run.sh +++ b/Test/MARTeApp/run.sh @@ -61,7 +61,7 @@ WEBUI_DIR="${REPO_ROOT}/Client/WebUI" WEBUI_BIN="${WEBUI_DIR}/udpstreamer-webui" if [ "${START_WEBUI}" -eq 1 ] && [ ! -x "${WEBUI_BIN}" ]; then echo "==> Building WebUI..." - (cd "${WEBUI_DIR}" && rm udpstreamer-webui && go build -o udpstreamer-webui ./...) + (cd "${WEBUI_DIR}" && go build -o udpstreamer-webui ./...) echo "==> WebUI build done." fi