WebUI: per-signal vscale toolbar, active-signal highlighting, zoom fix

- Per-signal, per-plot vertical scale state (sigVScale keyed by plotId:signalKey)
  so the same signal in two plots has fully independent vscale config
- Active signal redrawn on top of all series with 2× line width for clear
  visual identification; badge click toggles selection and opens/closes the
  embedded vscale toolbar (click same badge again to deselect)
- Vscale configurator moved from floating popup to a slim toolbar strip
  anchored inside the plot card, with an × close button
- Trigger dropdown shows one entry per array signal with [0…N-1] label;
  opening it shows an index-picker dialog to choose the element
- Zoom resampling: when server returns no data for a zoomed range, fall
  back to the local circular buffer instead of returning empty arrays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-05-27 15:42:00 +02:00
parent b15e637f14
commit 3dd0d863fa
13 changed files with 1508 additions and 231 deletions
+612 -68
View File
@@ -39,6 +39,12 @@ function getSigStyle(key) {
if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
return sigStyle[key];
}
// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset}
const sigVScale = {};
// Active signal per plot: plotId → key
const plotActiveSignal = {};
function setSigStyle(key, updates) {
const s = getSigStyle(key);
Object.assign(s, updates);
@@ -53,6 +59,121 @@ function setSigStyle(key, updates) {
plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
}
/* ─── VScale helpers ─────────────────────────────────────────────────────── */
// 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 };
return sigVScale[vsKey];
}
function findSignalMeta(key) {
const colon = key.indexOf(':');
if (colon < 0) return null;
const src = sourcesMap[key.slice(0, colon)];
if (!src) return null;
return src.signals.find(s => s.name === key.slice(colon + 1)) || null;
}
// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array.
// y_norm = (y_raw - offset) / divValue + screenPos
// divValue: units per division offset: raw value at screen center screenPos: divisions from center
// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use.
function resolveVScale(plotId, key, rawY) {
const vs = getVScale(plotId, key);
const screenPos = vs.screenPos || 0;
if (vs.mode === 'range') {
const meta = findSignalMeta(key);
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
const divValue = (meta.rangeMax - meta.rangeMin) / 8;
const offset = (meta.rangeMin + meta.rangeMax) / 2;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Fall through to auto if no range
}
if (vs.mode === 'manual') {
const divValue = Math.max(vs.divValue || 1, 1e-30);
const offset = vs.offset != null ? vs.offset : 0;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// Auto: fit data in central 6 of 8 divisions, centered at screenPos
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; }
}
if (!isFinite(min)) { min = -1; max = 1; }
if (min === max) { min -= 1; max += 1; }
const divValue = Math.max((max - min) / 6, 1e-30);
const offset = (max + min) / 2;
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
return { divValue, offset, screenPos };
}
// 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) {
return yArrays.map((rawY, ki) => {
const key = p.traces[ki];
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
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 - offset) / divValue + screenPos;
}
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) {
delete plotActiveSignal[plotId];
} else {
plotActiveSignal[plotId] = key;
}
const c = document.getElementById('badges-' + plotId);
if (c) c.querySelectorAll('.sig-badge').forEach(b =>
b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key));
const p = plots.find(q => q.id === plotId);
if (p && p.uplot) p.uplot.redraw(false);
}
// Mark plots containing key dirty and refresh badge vscale text.
function refreshPlotForKey(key) {
plots.forEach(p => {
if (p.traces.includes(key)) {
p.needsRedraw = true;
_updateBadgeVScaleInfo(p.id, key);
}
});
}
// Format a numeric value concisely for badge/axis display.
function _fmtVal(v) {
if (v == null || !isFinite(v)) return '?';
const abs = Math.abs(v);
if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1);
return parseFloat(v.toPrecision(3)).toString();
}
// Refresh the vscale info text inside a badge.
function _updateBadgeVScaleInfo(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return;
const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return;
const vs = sigVScale[plotId + ':' + key];
if (!vs) { infoEl.textContent = ''; return; }
const divValue = vs._resolvedDiv || vs.divValue || 1;
const sp = vs.screenPos || 0;
let txt = _fmtVal(divValue) + '/div';
if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div';
infoEl.textContent = txt;
}
// Sync: shared uPlot cursor crosshair across all live plots
const LIVE_SYNC = uPlot.sync('live');
const TRIG_SYNC = uPlot.sync('trig');
@@ -692,32 +813,83 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
}
return [sharedT, ...yArrays];
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
/* ════════════════════════════════════════════════════════════════
uPlot helpers
════════════════════════════════════════════════════════════════ */
// Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks)
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');
// ─── Time formatting helpers ──────────────────────────────────────────────────
// Returns the span (in seconds) of the currently visible x-axis across all plots.
// Falls back to windowSec when no uPlot instances exist yet.
function currentXSpan() {
for (const p of plots) {
if (p.uplot) {
const s = p.uplot.scales.x;
if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min);
}
}
return windowSec;
}
// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on
// refSpan (e.g. the visible x-range or the value itself).
// sign = '+' prefix only when showSign is true (default false for ΔT display).
function fmtDuration(sec, refSpan, showSign) {
const abs = Math.abs(sec);
const sign = showSign ? (sec < 0 ? '' : '+') : (sec < 0 ? '' : '');
if (refSpan < 1e-6) { // nanosecond range
return sign + (abs * 1e9).toFixed(1) + ' ns';
} else if (refSpan < 1e-3) { // microsecond range
return sign + (abs * 1e6).toFixed(3) + ' µs';
} else if (refSpan < 1) { // millisecond range
return sign + (abs * 1e3).toFixed(3) + ' ms';
} else { // second range
return sign + abs.toFixed(6) + ' s';
}
}
// Format a Unix-seconds timestamp → HH:MM:SS.fraction
// The number of sub-second digits adapts to the visible x-range span.
function fmtLiveTime(v, span) {
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 frac = v - Math.floor(v); // sub-second part, full float64 precision
if (span < 1e-6) {
// show 9 decimal places (ns precision)
const ns = Math.round(frac * 1e9);
return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0');
} else if (span < 1e-3) {
// show 6 decimal places (µs precision)
const us = Math.round(frac * 1e6);
return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0');
} else if (span < 1) {
// show 3 decimal places (ms precision) — tick labels only show ms
const ms = String(d.getMilliseconds()).padStart(3, '0');
return hh + ':' + mm + ':' + ss + '.' + ms;
});
} else {
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)
// Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks)
// Precision adapts to the visible x-range (via u.scales.x.{min,max}).
function fmtLiveTick(u, vals) {
const span = (u.scales.x && u.scales.x.max != null)
? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec;
return vals.map(v => v == null ? '' : fmtLiveTime(v, span));
}
// Format relative seconds → auto-scaled unit (used for trigger x-axis ticks)
// Unit (s/ms/µs/ns) is determined by the visible x-range span.
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';
return sign + abs.toFixed(3) + 's';
});
const span = (u.scales.x && u.scales.x.max != null)
? Math.abs(u.scales.x.max - u.scales.x.min) : 1;
return vals.map(v => v == null ? '' : fmtDuration(v, span, true));
}
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
@@ -745,7 +917,16 @@ function drawTriggerMarker(u, p) {
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);
// Normalize the raw threshold to this plot's vscale for the trigger signal.
const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null;
let threshNorm = trig.threshold;
if (tvs) {
const dv = tvs._resolvedDiv || tvs.divValue || 1;
const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
const sp = tvs.screenPos || 0;
threshNorm = (trig.threshold - ofs) / dv + sp;
}
const y = u.valToPos(threshNorm, 'y', true);
if (y >= bbox.top && y <= bbox.top + bbox.height) {
const py = Math.round(y);
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
@@ -778,6 +959,72 @@ function interpAtTime(u, si, t) {
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
}
// 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;
const activeKey = plotActiveSignal[p.id];
if (!activeKey) return;
const idx = p.traces.indexOf(activeKey);
if (idx < 0) return;
const xs = u.data[0];
const ys = u.data[idx + 1]; // +1 because index 0 is time
if (!xs || !ys) return;
const style = getSigStyle(activeKey);
const dpr = window.devicePixelRatio || 1;
const { ctx } = u;
ctx.save();
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width * 2 * dpr;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
let started = false;
for (let i = 0; i < xs.length; i++) {
const yv = ys[i];
if (yv == null || !isFinite(yv)) { started = false; continue; }
const xPx = u.valToPos(xs[i], 'x', true);
const yPx = u.valToPos(yv, 'y', true);
if (!started) { ctx.moveTo(xPx, yPx); started = true; }
else ctx.lineTo(xPx, yPx);
}
ctx.stroke();
ctx.restore();
}
// 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;
const { ctx, bbox } = u;
const dpr = window.devicePixelRatio || 1;
p.traces.forEach(key => {
const vs = sigVScale[p.id + ':' + key];
const screenPos = vs ? (vs.screenPos || 0) : 0;
const yCtr = u.valToPos(screenPos, 'y', true);
const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr;
const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr;
if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
const isActive = plotActiveSignal[p.id] === key;
ctx.save();
ctx.fillStyle = getSigStyle(key).color;
// Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area
ctx.beginPath();
ctx.moveTo(bbox.left + dpr, yCtr);
ctx.lineTo(bbox.left - mW + dpr, yCtr - mH);
ctx.lineTo(bbox.left - mW + dpr, yCtr + mH);
ctx.closePath();
ctx.fill();
if (isActive) {
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
ctx.lineWidth = dpr;
ctx.stroke();
}
ctx.restore();
});
}
// 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) {
@@ -855,10 +1102,20 @@ function drawCursorLines(u, p) {
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);
const vNorm = interpAtTime(u, idx + 1, val);
if (vNorm === null) return;
const cy = u.valToPos(vNorm, 'y', true);
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
// Un-transform normalized value back to real units for display
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
const vs = sigVScale[p.id + ':' + key];
let vReal = vNorm;
if (vs) {
const dv = vs._resolvedDiv || vs.divValue || 1;
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const sp = vs.screenPos || 0;
vReal = (vNorm - sp) * dv + ofs;
}
const tc = getSigStyle(key).color;
// Diamond marker at intersection
ctx.fillStyle = tc;
@@ -871,13 +1128,13 @@ function drawCursorLines(u, p) {
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();
// Value text next to diamond (real units)
const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString();
ctx.fillStyle = tc;
ctx.font = '11px monospace';
const currentAlign = ctx.textAlign;
ctx.textAlign = "left"; // horizontal alignment
ctx.textBaseline = "middle";
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(str, px + DSZ + 4, cy);
ctx.textAlign = currentAlign;
});
@@ -955,20 +1212,41 @@ function makeUPlotOpts(p, inTrigMode) {
}
return { time: false, auto: false, min: xMin, max: xMax };
})(),
y: { auto: true },
y: { auto: false, min: -4.5, max: 4.5 },
},
series: seriesArr,
axes: [
{
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
values: xVals, size: 36, space: 90
values: xVals, size: 36,
// Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines).
splits: (u, _ai, sMin, sMax) => {
const n = 10, span = sMax - sMin;
if (span === 0) return [sMin];
return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n);
},
},
{
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
values: (u, vals) => {
const activeKey = plotActiveSignal[p.id];
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
const divValue = vs._resolvedDiv || vs.divValue || 1;
const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
const screenPos = vs.screenPos || 0;
return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset));
},
},
{ 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, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
draw: [u => { 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
@@ -1062,6 +1340,59 @@ function createUPlot(p) {
document.addEventListener('mouseup', onUp);
}, true); // capture:true so we fire before uPlot's own handlers
// ── Offset marker drag ─────────────────────────────────────────────────────
// Detect mousedown near the left edge of the plot area (marker triangle zone).
// Dragging moves the marker AND the signal together by changing screenPos.
p.div.addEventListener('mousedown', e => {
if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return;
const canvas = p.uplot.ctx.canvas;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const plotLeftCss = rect.left + p.uplot.bbox.left / dpr;
const markerZone = 12; // CSS px hit area to left/right of plot edge
if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return;
// Find which marker was hit (closest to screenPos canvas position per signal).
let hitKey = null, hitDist = Infinity;
p.traces.forEach(key => {
const vs = sigVScale[p.id + ':' + key];
const screenPos = vs ? (vs.screenPos || 0) : 0;
const yDev = p.uplot.valToPos(screenPos, 'y', true);
const yCss = rect.top + yDev / dpr;
const dist = Math.abs(e.clientY - yCss);
if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; }
});
if (!hitKey) return;
e.preventDefault();
e.stopPropagation();
setActiveSig(p.id, hitKey);
const vs = getVScale(p.id, hitKey);
const startY = e.clientY;
const startScreenPos = vs.screenPos || 0;
const overRect = p.uplot.over.getBoundingClientRect();
const onMove = ev => {
const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm
// Y scale spans 9 divisions over plot height; drag up → higher screenPos.
const dNorm = -dy / overRect.height * 9;
vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm));
// Keep "Position" input in sync if the vscale menu is open for this signal.
if (_vsMenuKey === hitKey) {
document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4));
}
refreshPlotForKey(hitKey);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, true);
// Pan support: Shift+left-drag pans the current view (synced across all plots).
// Works in both zoomed mode (xRange set) and rolling mode (freezes the window first).
let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0;
@@ -1191,7 +1522,9 @@ function buildLiveData(p) {
if (p.xRange) {
const zd = zoomData[p.id];
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
return buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
// Only use server data if it actually has samples; otherwise fall through to local buffer.
if (fetched[0] && fetched[0].length > 0) return fetched;
}
}
@@ -1249,7 +1582,7 @@ function buildLiveData(p) {
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
}
return [sharedT, ...yArrays];
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
function buildTrigData(p) {
@@ -1301,7 +1634,7 @@ function buildTrigData(p) {
yArrays.push(resampleLinear(relT, sl.v, sharedT));
}
return [sharedT, ...yArrays];
return [sharedT, ...applyVScaleNorm(p, yArrays)];
}
/* ════════════════════════════════════════════════════════════════
@@ -1486,26 +1819,24 @@ function updateCursorReadout() {
ro.classList.toggle('visible', active);
if (!active) return;
// Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms
// Use the current visible x-range to pick the display unit.
const span = currentXSpan();
// Format a cursor position: trigger mode → signed relative duration;
// live mode → absolute wall time with span-appropriate precision.
const fmt = v => {
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';
}
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');
if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true);
return fmtLiveTime(v, span);
};
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';
document.getElementById('cur-dt').textContent = 'ΔT: ' + str;
const dt = cursors.tB - cursors.tA;
// ΔT auto-scales by its own magnitude for precision regardless of x-range.
document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true);
} else {
document.getElementById('cur-dt').textContent = 'ΔT: —';
}
@@ -1549,7 +1880,24 @@ function openTrigBar(open) {
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
trig.signal = e.target.value; trig.prevVal = null;
const val = e.target.value;
if (!val) { trig.signal = ''; trigDisarm(); return; }
// Array signal: ask for element index via picker dialog.
const meta = findSignalMeta(val);
const n = meta ? numElements(meta) : 1;
if (meta && !isTemporal(meta) && n > 1) {
showArrayIdxPicker(val, n, idx => {
trig.signal = val + '[' + idx + ']'; trig.prevVal = null;
if (trig.enabled) trigArm();
}, () => {
// Cancelled: revert selection to current trig.signal base or empty.
const sel = document.getElementById('trig-signal');
const base = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
sel.value = base || '';
});
return;
}
trig.signal = val; 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; });
@@ -1581,28 +1929,30 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => {
Trigger signal selector
════════════════════════════════════════════════════════════════ */
function buildTrigSignalSelect() {
const sel = document.getElementById('trig-signal'), cur = sel.value;
const sel = document.getElementById('trig-signal');
// Preserve the currently active trig.signal (may include an array index like "[3]").
const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
sel.innerHTML = '<option value="">— none —</option>';
Object.values(sourcesMap).forEach(src => {
const prefix = src.id + ':';
const srcLabel = src.label || src.addr || src.id;
(src.signals || []).forEach(sig => {
const n = numElements(sig);
const key = prefix + sig.name;
const o = document.createElement('option');
o.value = key;
if (isTemporal(sig) || n === 1) {
const key = prefix + sig.name;
const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o);
o.textContent = srcLabel + ': ' + sig.name;
} else {
for (let i = 0; i < n; i++) {
const key = prefix + sig.name + '[' + i + ']';
const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o);
}
// Array: single entry; user chooses index via dialog on selection.
o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']';
}
sel.appendChild(o);
});
});
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
trig.signal = sel.value;
// Restore selection: match base key so array element "sig[3]" selects "sig" option.
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
// Do NOT overwrite trig.signal here — an array element selection must be preserved.
}
/* ════════════════════════════════════════════════════════════════
@@ -1919,6 +2269,7 @@ function addPlot() {
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
<div class="sig-badges" id="badges-${id}"></div>
</div>
<div class="plot-vscale-bar" id="vstb-${id}"></div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
@@ -1955,6 +2306,12 @@ function removeTraceFrom(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
p.traces = p.traces.filter(t => t !== signalKey);
removeBadge(plotId, signalKey);
// If the removed trace was active, close toolbar and pick a new active signal.
if (plotActiveSignal[plotId] === signalKey) {
if (_vsMenuPlotId === plotId) hideVScaleMenu();
const newActive = p.traces[0] || null;
if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId];
}
createUPlot(p);
p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
@@ -1966,17 +2323,46 @@ function addBadge(plotId, key) {
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;
// Show signal name without the "sourceId:" prefix.
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
const nameSpan = document.createElement('span'); nameSpan.textContent = displayName;
// Small vscale info text (V/div + offset when not in auto mode).
const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info';
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
x.addEventListener('click', () => removeTraceFrom(plotId, key));
x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); });
// Left-click: select + show vscale toolbar; click same signal again to deselect.
badge.addEventListener('click', e => {
if (e.target === x) return;
const isActive = plotActiveSignal[plotId] === key;
const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId;
if (isActive && toolbarVisible) {
setActiveSig(plotId, null);
hideVScaleMenu();
} else {
setActiveSig(plotId, key);
showVScaleMenu(key, plotId);
}
});
// Right-click: open signal style (color/width/…) menu.
badge.addEventListener('contextmenu', e => {
e.preventDefault();
showSignalMenu(key, plotId, e.clientX, e.clientY);
});
// Show signal name without the "sourceId:" prefix in the badge label.
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
badge.appendChild(dot); badge.appendChild(document.createTextNode(displayName)); badge.appendChild(x);
badge.appendChild(dot);
badge.appendChild(nameSpan);
badge.appendChild(infoSpan);
badge.appendChild(x);
c.appendChild(badge);
// Auto-activate the first signal added to this plot.
if (!plotActiveSignal[plotId]) setActiveSig(plotId, key);
}
function removeBadge(plotId, key) {
const c = document.getElementById('badges-' + plotId); if (!c) return;
@@ -2167,9 +2553,12 @@ function onSources(msg) {
if (statsOpen) _refreshStatsSelector();
}
function addSourceWS(label, addr) {
function addSourceWS(label, addr, multicastGroup, dataPort) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'addSource', label, addr }));
const msg = { type: 'addSource', label, addr };
if (multicastGroup) { msg.multicastGroup = multicastGroup; }
if (dataPort) { msg.dataPort = dataPort; }
ws.send(JSON.stringify(msg));
}
}
@@ -2204,12 +2593,23 @@ function makeAddSourceSection() {
labelInput.className = 'add-src-input'; labelInput.type = 'text';
labelInput.placeholder = 'label (optional)';
const mcastInput = document.createElement('input');
mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';
const dataPortInput = document.createElement('input');
dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
dataPortInput.placeholder = 'data port (multicast only)';
dataPortInput.min = '1'; dataPortInput.max = '65535';
const addBtn = document.createElement('button');
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
addBtn.addEventListener('click', () => {
const addr = addrInput.value.trim(); if (!addr) return;
addSourceWS(labelInput.value.trim(), addr);
addrInput.value = ''; labelInput.value = '';
const mcastGroup = mcastInput.value.trim();
const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
@@ -2218,7 +2618,7 @@ function makeAddSourceSection() {
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
saveBtn.addEventListener('click', saveSourcesWS);
body.append(addrInput, labelInput, addBtn, saveBtn);
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
section.append(title, body);
title.addEventListener('click', () => {
@@ -2236,6 +2636,148 @@ function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ════════════════════════════════════════════════════════════════
VScale menu (left-click on badge)
════════════════════════════════════════════════════════════════ */
let _vsMenuKey = null, _vsMenuPlotId = null;
function showVScaleMenu(key, plotId) {
hideSignalMenu();
// If the toolbar was open for a different plot, hide that bar first.
if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) {
const oldBar = document.getElementById('vstb-' + _vsMenuPlotId);
if (oldBar) oldBar.style.display = 'none';
}
_vsMenuKey = key; _vsMenuPlotId = plotId;
const menu = document.getElementById('vscale-menu');
const vs = getVScale(_vsMenuPlotId, key);
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
document.getElementById('vscale-menu-key').textContent = displayName;
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.mode === vs.mode));
// Disable Range button when signal has no defined range.
const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]');
if (rangeBtn) {
const meta = findSignalMeta(key);
const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null;
rangeBtn.disabled = !hasRange;
rangeBtn.title = hasRange ? '' : 'No range defined for this signal';
}
const isManual = vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
// Pre-fill V/div with resolved or stored value; Position always shows current screenPos.
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
// Move the toolbar div into this plot's vscale bar.
const bar = document.getElementById('vstb-' + plotId);
if (bar) {
bar.appendChild(menu);
bar.style.display = 'block';
}
menu.style.display = 'block';
}
function hideVScaleMenu() {
const menu = document.getElementById('vscale-menu');
// Return the menu element to body so it is detached from any plot card.
menu.style.display = 'none';
document.body.appendChild(menu);
// Hide the vscale bar of the previously active plot.
if (_vsMenuPlotId != null) {
const bar = document.getElementById('vstb-' + _vsMenuPlotId);
if (bar) bar.style.display = 'none';
}
_vsMenuKey = null; _vsMenuPlotId = null;
}
/* ─── Array index picker ─────────────────────────────────────────────────── */
let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0;
function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) {
_aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1;
const menu = document.getElementById('array-idx-picker');
const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey;
document.getElementById('aip-sig').textContent = displayName;
document.getElementById('aip-range').textContent = '(0 ' + (n - 1) + ')';
const idxInput = document.getElementById('aip-idx');
idxInput.max = n - 1; idxInput.value = 0;
menu.style.display = 'block';
// Centre the picker on screen.
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120;
menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px';
menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px';
idxInput.focus(); idxInput.select();
}
function _aipConfirm() {
const idxInput = document.getElementById('aip-idx');
const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0));
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnConfirm) _aipOnConfirm(idx);
_aipOnConfirm = _aipOnCancel = null;
}
function _aipCancel() {
document.getElementById('array-idx-picker').style.display = 'none';
if (_aipOnCancel) _aipOnCancel();
_aipOnConfirm = _aipOnCancel = null;
}
function initArrayIdxPicker() {
document.getElementById('aip-ok').addEventListener('click', _aipConfirm);
document.getElementById('aip-cancel').addEventListener('click', _aipCancel);
document.getElementById('aip-idx').addEventListener('keydown', e => {
if (e.key === 'Enter') _aipConfirm();
if (e.key === 'Escape') _aipCancel();
});
}
function initVScaleMenu() {
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
const newMode = btn.dataset.mode;
if (newMode === 'manual' && vs.mode !== 'manual') {
// Seed V/div from currently resolved value; screenPos stays as-is.
vs.divValue = vs._resolvedDiv || 1;
vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal)
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
}
vs.mode = newMode;
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const isManual = vs.mode === 'manual';
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
refreshPlotForKey(_vsMenuKey);
});
});
document.getElementById('vscale-vdiv').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
refreshPlotForKey(_vsMenuKey);
});
// "Position (div)" moves the marker and signal together on screen.
document.getElementById('vscale-pos').addEventListener('input', e => {
if (!_vsMenuKey) return;
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
refreshPlotForKey(_vsMenuKey);
});
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
}
/* ════════════════════════════════════════════════════════════════
Signal style context menu
════════════════════════════════════════════════════════════════ */
@@ -2307,7 +2849,7 @@ function initSignalMenu() {
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(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } });
}
/* ════════════════════════════════════════════════════════════════
@@ -2449,6 +2991,8 @@ setInterval(() => { if (statsOpen) renderStats(); }, 1000);
buildLayoutMenu();
applyLayout('l1x1');
buildSidebar(); // show "Add Source" section even before WS connection
initArrayIdxPicker();
initVScaleMenu();
initSignalMenu();
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
document.getElementById('btn-stats').addEventListener('click', toggleStats);
+33
View File
@@ -158,6 +158,39 @@
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
</div>
</div>
<!-- ── Array index picker (trigger signal) ──────────────────────── -->
<div id="array-idx-picker" style="display:none">
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div>
<div class="ctx-row">
<label>Index</label>
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
<span id="aip-range" style="font-size:10px;color:var(--overlay0)"></span>
</div>
<div class="ctx-row" style="justify-content:flex-end;gap:6px">
<button class="ctx-btn" id="aip-cancel">Cancel</button>
<button class="ctx-btn active" id="aip-ok">OK</button>
</div>
</div>
<!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
<div id="vscale-menu" style="display:none">
<div class="vstb-header">
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span>
<div class="ctx-btns" id="vscale-mode-btns">
<button class="ctx-btn active" data-mode="auto">Auto</button>
<button class="ctx-btn" data-mode="range">Range</button>
<button class="ctx-btn" data-mode="manual">Manual</button>
</div>
<div id="vscale-manual-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">V/div</label>
<input type="number" id="vscale-vdiv" class="ctx-num" min="1e-30" step="any" value="1">
</div>
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div>
<button id="btn-vscale-close" class="vstb-close" title="Close"></button>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+42
View File
@@ -311,6 +311,48 @@ input[type=range].trig-range::-webkit-slider-thumb {
}
.ctx-range { width:80px; }
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
.ctx-num {
width:90px; background:var(--surface0); border:1px solid var(--surface1); border-radius:4px;
color:var(--text); font-size:11px; padding:2px 6px;
}
.ctx-num:focus { outline:none; border-color:var(--accent); }
.ctx-btn:disabled { opacity:0.35; cursor:not-allowed; border-color:var(--surface1); }
/* ── Array index picker ─────────────────────────────────────────── */
#array-idx-picker {
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:200px;
}
/* ── VScale toolbar (embedded in plot card) ──────────────────────── */
#vscale-menu {
flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1);
padding:3px 8px;
}
.vstb-header {
display:flex; align-items:center; gap:8px; flex-wrap:nowrap; overflow-x:auto;
scrollbar-width:none;
}
.vstb-header::-webkit-scrollbar { display:none; }
.vstb-label { font-size:11px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
.vstb-lbl { font-size:10px; color:var(--overlay0); white-space:nowrap; }
.vstb-close {
margin-left:auto; flex-shrink:0;
background:transparent; border:none; color:var(--overlay0);
cursor:pointer; font-size:11px; padding:0 3px; line-height:1;
transition:color var(--transition);
}
.vstb-close:hover { color:var(--red); }
.plot-vscale-bar { display:none; }
/* ── Badge vscale info & active state ───────────────────────────── */
.vscale-info {
font-size:9px; color:var(--overlay0); font-family:monospace; margin-left:2px; white-space:nowrap;
}
.sig-badge { cursor:pointer; }
.sig-badge-active { outline:1px solid rgba(255,255,255,0.35); background:rgba(88,91,112,0.9); }
.sig-badge-active .vscale-info { color:var(--subtext0); }
/* ── Source groups ────────────────────────────────────────────── */
.source-group { margin-bottom:2px; }