included jitter correction on client
This commit is contained in:
@@ -89,6 +89,15 @@ function getVScale(plotId, key) {
|
||||
return sigVScale[vsKey];
|
||||
}
|
||||
|
||||
// Round a raw units-per-division up to the next 1/2/5×10ⁿ step so the Y-axis
|
||||
// gridlines land on human-readable values.
|
||||
function niceDiv(x) {
|
||||
if (!isFinite(x) || x <= 0) return 1;
|
||||
const p = Math.pow(10, Math.floor(Math.log10(x)));
|
||||
const m = x / p;
|
||||
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;
|
||||
}
|
||||
|
||||
function findSignalMeta(key) {
|
||||
const colon = key.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
@@ -107,8 +116,8 @@ function resolveVScale(plotId, key, rawY) {
|
||||
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;
|
||||
const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8);
|
||||
const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue;
|
||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||
return { divValue, offset, screenPos };
|
||||
}
|
||||
@@ -120,7 +129,9 @@ function resolveVScale(plotId, key, rawY) {
|
||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||
return { divValue, offset, screenPos };
|
||||
}
|
||||
// Auto: fit data in central 6 of 8 divisions, centered at screenPos
|
||||
// Auto: fit data in central 6 of 8 divisions, centered at screenPos.
|
||||
// Both the V/div and the centre offset are snapped so gridlines (and the
|
||||
// zero line, when in view) fall on round values.
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let i = 0; i < rawY.length; i++) {
|
||||
const v = rawY[i];
|
||||
@@ -128,8 +139,8 @@ function resolveVScale(plotId, key, rawY) {
|
||||
}
|
||||
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;
|
||||
const divValue = niceDiv(Math.max((max - min) / 6, 1e-30));
|
||||
const offset = Math.round((max + min) / 2 / divValue) * divValue;
|
||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||
return { divValue, offset, screenPos };
|
||||
}
|
||||
@@ -285,6 +296,12 @@ let _zoomFetchTimer = null;
|
||||
// trig mode → relative seconds from trigger
|
||||
const cursors = { mode: 'off', tA: null, tB: null };
|
||||
let cursorsDirty = false; // if true, redraw all plots to update cursor lines
|
||||
// Rolling-window anchor used to keep cursors visually fixed while live data scrolls.
|
||||
let _cursorAnchorNow = null;
|
||||
|
||||
// Horizontal value rulers — stored in normalized division units (the shared
|
||||
// y scale, -4.5…4.5) so one pair applies to every plot regardless of V/div.
|
||||
const rulers = { mode: 'off', yA: null, yB: null };
|
||||
|
||||
// Layout — [label, cssClass, cols, rows]
|
||||
const LAYOUTS = [
|
||||
@@ -329,7 +346,15 @@ async function resolveHub() {
|
||||
function connectWS() {
|
||||
ws = new WebSocket('ws://' + HUB + '/ws');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); };
|
||||
ws.onopen = () => {
|
||||
wsBackoff = 1000;
|
||||
setStatus('orange', 'Connected – waiting for data');
|
||||
// Restore monotonic TS preference from localStorage.
|
||||
const monoPref = localStorage.getItem('udpscope.monotonic') === '1';
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: monoPref }));
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setStatus('red', 'Disconnected (reconnecting…)');
|
||||
setTimeout(connectWS, wsBackoff);
|
||||
@@ -346,10 +371,26 @@ function connectWS() {
|
||||
else if (msg.type === 'zoom') onZoomReply(msg);
|
||||
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
|
||||
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
|
||||
else if (msg.type === 'triggerState') onTriggerState(msg);
|
||||
else if (msg.type === 'monotonicState') onMonotonicState(msg);
|
||||
};
|
||||
}
|
||||
|
||||
/* Monotonic timestamp snapping — when enabled, the hub snaps small inter-frame
|
||||
timestamp deviations (< 5 ms) to the ideal gap, eliminating overlaps/gaps
|
||||
caused by software-dispatch jitter. */
|
||||
function onMonotonicState(msg) {
|
||||
const cb = document.getElementById('cb-monotonic');
|
||||
if (!cb) return;
|
||||
cb.checked = !!msg.enabled;
|
||||
localStorage.setItem('udpscope.monotonic', msg.enabled ? '1' : '0');
|
||||
}
|
||||
document.getElementById('cb-monotonic').addEventListener('change', e => {
|
||||
localStorage.setItem('udpscope.monotonic', e.target.checked ? '1' : '0');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: e.target.checked }));
|
||||
}
|
||||
});
|
||||
|
||||
/* WS zoom request/reply — replaces the Go hub's /api/zoom HTTP endpoint.
|
||||
Resolves with the {key:{t,v}} signals map; rejects on timeout/closure. */
|
||||
let _zoomReqId = 0;
|
||||
@@ -882,24 +923,41 @@ function makeSeriesPath(key) {
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
LTTB Web Worker — offloads decimation off the main thread.
|
||||
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
|
||||
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>" when zoomed, or
|
||||
"<plotId>:<masterKey>:rolling" in live mode, where the data
|
||||
generation is passed alongside instead of being part of the key.
|
||||
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
|
||||
On cache-miss → render falls back to sync lttb once (first zoom render only),
|
||||
On cache-miss → render falls back to sync lttb once (first render only),
|
||||
then worker takes over for subsequent updates.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const lttbCache = new Map(); // key → {t, v}
|
||||
const lttbPending = new Set(); // keys currently in-flight
|
||||
const lttbCache = new Map(); // key → {t, v, gen}
|
||||
const lttbPending = new Map(); // key → generation currently in flight
|
||||
// Hard ceiling on cached decimations. Rolling-mode keys are stable (one entry
|
||||
// per plot), but zoom keys embed the range, so without a cap the map would grow
|
||||
// for the whole session.
|
||||
const LTTB_CACHE_MAX = 256;
|
||||
|
||||
// Store a decimation, re-inserting so Map iteration order stays oldest-first.
|
||||
function lttbCacheStore(key, entry) {
|
||||
lttbCache.delete(key);
|
||||
lttbCache.set(key, entry);
|
||||
while (lttbCache.size > LTTB_CACHE_MAX) {
|
||||
lttbCache.delete(lttbCache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
let _lttbWorker = null;
|
||||
try {
|
||||
_lttbWorker = new Worker('lttb-worker.js');
|
||||
_lttbWorker.onmessage = function({ data: { id, t, v } }) {
|
||||
const gen = lttbPending.get(id);
|
||||
lttbPending.delete(id);
|
||||
lttbCache.set(id, { t, v });
|
||||
// Invalidate and redraw the owning plot.
|
||||
lttbCacheStore(id, { t, v, gen });
|
||||
// Invalidate and redraw the owning plot. Clearing lastDataGen defeats the
|
||||
// render loop's no-new-data fast path so this fresh result is actually drawn.
|
||||
const plotId = parseInt(id.split(':')[0], 10);
|
||||
const p = plots.find(q => q.id === plotId);
|
||||
if (p) { p.needsRedraw = true; }
|
||||
if (p) { p.needsRedraw = true; p.lastDataGen = -1; }
|
||||
};
|
||||
_lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
|
||||
} catch(e) {
|
||||
@@ -907,14 +965,20 @@ try {
|
||||
}
|
||||
|
||||
// Submit a LTTB job to the worker (or run sync if worker unavailable).
|
||||
// Returns cached {t, v} if fresh, null if a worker job was just submitted,
|
||||
// or a sync result if the worker is unavailable.
|
||||
function lttbAsync(cacheKey, t, v, threshold) {
|
||||
// `gen` identifies the input data behind a key that does not itself change with
|
||||
// the data (rolling mode). A cached entry computed for an older generation is
|
||||
// still returned — stale-while-revalidate — while a fresh job runs. Callers
|
||||
// whose key already encodes the input (zoom ranges) pass no generation.
|
||||
// Returns the cached {t, v} (fresh or stale), or null on the first render.
|
||||
function lttbAsync(cacheKey, t, v, threshold, gen) {
|
||||
const cached = lttbCache.get(cacheKey);
|
||||
if (cached) return cached; // cache hit — use immediately
|
||||
if (cached && cached.gen === gen) return cached; // fresh — nothing to do
|
||||
|
||||
// At most one job per key in flight: submitting on every generation change
|
||||
// would let the worker's message queue grow without bound whenever it cannot
|
||||
// keep up with the push rate.
|
||||
if (!lttbPending.has(cacheKey)) {
|
||||
lttbPending.add(cacheKey);
|
||||
lttbPending.set(cacheKey, gen);
|
||||
if (_lttbWorker) {
|
||||
// Send copies so the main thread retains the originals.
|
||||
const tCopy = new Float64Array(t);
|
||||
@@ -924,12 +988,13 @@ function lttbAsync(cacheKey, t, v, threshold) {
|
||||
} else {
|
||||
// Synchronous fallback (worker unavailable).
|
||||
const result = lttb(t, v, threshold);
|
||||
lttbCache.set(cacheKey, result);
|
||||
result.gen = gen;
|
||||
lttbCacheStore(cacheKey, result);
|
||||
lttbPending.delete(cacheKey);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null; // worker job in-flight — caller should use fallback
|
||||
return cached || null; // stale entry, or nothing to draw yet
|
||||
}
|
||||
|
||||
// Evict stale LTTB cache entries for a plot (call when zoom range changes).
|
||||
@@ -938,7 +1003,7 @@ function lttbCacheEvict(plotId) {
|
||||
for (const k of [...lttbCache.keys()]) {
|
||||
if (k.startsWith(prefix)) lttbCache.delete(k);
|
||||
}
|
||||
for (const k of [...lttbPending]) {
|
||||
for (const k of [...lttbPending.keys()]) {
|
||||
if (k.startsWith(prefix)) lttbPending.delete(k);
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1512,51 @@ function drawCursorLines(u, p) {
|
||||
drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
|
||||
}
|
||||
|
||||
// Convert a normalized (division) y value to the active signal's raw units.
|
||||
function rulerRawValue(p, yNorm) {
|
||||
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
|
||||
const vs = key ? sigVScale[p.id + ':' + key] : null;
|
||||
if (!vs) return null;
|
||||
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
|
||||
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||
return (yNorm - (vs.screenPos || 0)) * dv + ofs;
|
||||
}
|
||||
|
||||
// Draw the horizontal value rulers (called from the draw hook).
|
||||
function drawRulerLines(u, p) {
|
||||
if (rulers.mode !== 'on') return;
|
||||
const { ctx, bbox } = u;
|
||||
if (!bbox) return;
|
||||
|
||||
const drawLine = (yNorm, color, label) => {
|
||||
if (yNorm === null) return;
|
||||
const y = Math.round(u.valToPos(yNorm, 'y', true));
|
||||
if (y < bbox.top || y > bbox.top + bbox.height) return;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||||
ctx.clip();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bbox.left, y);
|
||||
ctx.lineTo(bbox.left + bbox.width, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'bottom';
|
||||
const raw = p ? rulerRawValue(p, yNorm) : null;
|
||||
ctx.fillText(label + (raw !== null ? ' ' + _fmtVal(raw) : ''), bbox.left + 4, y - 2);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
drawLine(rulers.yA, 'rgba(166,227,161,0.85)', 'Y1');
|
||||
drawLine(rulers.yB, 'rgba(243,139,168,0.85)', 'Y2');
|
||||
}
|
||||
|
||||
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
|
||||
// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p,
|
||||
// so no live source shows a blank right edge.
|
||||
@@ -1563,7 +1673,7 @@ function makeUPlotOpts(p, inTrigMode) {
|
||||
legend: { show: false },
|
||||
padding: [4, 4, 0, 0],
|
||||
hooks: {
|
||||
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
|
||||
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawRulerLines(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
|
||||
@@ -1617,34 +1727,60 @@ function createUPlot(p) {
|
||||
return min + pct * (max - min);
|
||||
}
|
||||
|
||||
// Update pointer style based on what's under the mouse
|
||||
function _rulerAtClientY(clientY) {
|
||||
const rect = p.uplot.over.getBoundingClientRect();
|
||||
const { min, max } = p.uplot.scales.y;
|
||||
const toY = val => rect.top + (1 - (val - min) / (max - min)) * rect.height;
|
||||
if (rulers.yA !== null && Math.abs(clientY - toY(rulers.yA)) <= CURSOR_SNAP_PX) return 'A';
|
||||
if (rulers.yB !== null && Math.abs(clientY - toY(rulers.yB)) <= CURSOR_SNAP_PX) return 'B';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _rulerValFromEvent(e) {
|
||||
const rect = p.uplot.over.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
const { min, max } = p.uplot.scales.y;
|
||||
return max - pct * (max - min);
|
||||
}
|
||||
|
||||
// Update pointer style based on what's under the mouse, and drive the
|
||||
// time/value hover readout.
|
||||
p.uplot.over.addEventListener('mousemove', e => {
|
||||
const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
||||
p.uplot.over.style.cursor = snap ? 'ew-resize' : '';
|
||||
const snapX = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
||||
const snapY = !snapX && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
|
||||
p.uplot.over.style.cursor = snapX ? 'ew-resize' : (snapY ? 'ns-resize' : '');
|
||||
showHoverReadout(p, e);
|
||||
});
|
||||
p.uplot.over.addEventListener('mouseleave', () => {
|
||||
p.uplot.over.style.cursor = '';
|
||||
hideHoverReadout();
|
||||
});
|
||||
|
||||
// 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
|
||||
if (cursors.mode !== 'on') return;
|
||||
const target = _cursorAtClientX(e.clientX);
|
||||
if (!target) return; // not near a cursor — let uPlot handle zoom
|
||||
const target = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
||||
const yTarget = !target && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
|
||||
if (!target && !yTarget) 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);
|
||||
if (yTarget) {
|
||||
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(e);
|
||||
else rulers.yB = _rulerValFromEvent(e);
|
||||
} else if (target === 'A') cursors.tA = _cursorValFromEvent(e);
|
||||
else cursors.tB = _cursorValFromEvent(e);
|
||||
updateCursorReadout();
|
||||
cursorsDirty = true;
|
||||
|
||||
const onMove = ev => {
|
||||
if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
|
||||
if (yTarget) {
|
||||
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(ev);
|
||||
else rulers.yB = _rulerValFromEvent(ev);
|
||||
} else if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
|
||||
else cursors.tB = _cursorValFromEvent(ev);
|
||||
updateCursorReadout();
|
||||
cursorsDirty = true;
|
||||
@@ -1871,10 +2007,12 @@ function buildLiveData(p) {
|
||||
// the full window slice can easily reach 100k–300k pts — far more than uPlot
|
||||
// needs for a 1200px-wide canvas. Always run LTTB via the background worker
|
||||
// (stale-while-revalidate: use cached result; fall back to sync on first render).
|
||||
// Rolling-mode cache key uses _dataGen so the result refreshes on new data.
|
||||
// The rolling-mode key is constant per (plot, signal) — the data generation is
|
||||
// carried separately so the cache holds one entry per plot instead of one per
|
||||
// push tick, which used to grow without bound for the whole session.
|
||||
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
||||
const cacheKey = isRolling
|
||||
? `${p.id}:${masterKey}:rolling:${_dataGen}`
|
||||
? `${p.id}:${masterKey}:rolling`
|
||||
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
|
||||
let sharedT, masterV;
|
||||
if (masterRaw.t.length <= targetPts) {
|
||||
@@ -1882,7 +2020,8 @@ function buildLiveData(p) {
|
||||
sharedT = masterRaw.t;
|
||||
masterV = masterRaw.v;
|
||||
} else {
|
||||
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
|
||||
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts,
|
||||
isRolling ? _dataGen : undefined);
|
||||
let dec;
|
||||
if (cached) {
|
||||
dec = cached;
|
||||
@@ -2094,19 +2233,10 @@ document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Cursor controls
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
// Show the cursor button only when paused or in trigger-snapshot mode.
|
||||
// Cursors are always available — in live rolling mode they are pinned to the
|
||||
// moving viewport by the render loop.
|
||||
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; // context changed, clear positions
|
||||
btn.textContent = 'Cursors';
|
||||
btn.classList.remove('active');
|
||||
document.getElementById('cursor-readout').classList.remove('visible');
|
||||
cursorsDirty = true;
|
||||
}
|
||||
document.getElementById('btn-cursor').style.display = '';
|
||||
}
|
||||
|
||||
document.getElementById('btn-cursor').addEventListener('click', () => {
|
||||
@@ -2133,6 +2263,18 @@ document.getElementById('btn-cursor').addEventListener('click', () => {
|
||||
cursorsDirty = true;
|
||||
});
|
||||
|
||||
document.getElementById('btn-ruler').addEventListener('click', () => {
|
||||
rulers.mode = rulers.mode === 'off' ? 'on' : 'off';
|
||||
const btn = document.getElementById('btn-ruler');
|
||||
btn.classList.toggle('active', rulers.mode === 'on');
|
||||
if (rulers.mode === 'on' && rulers.yA === null && rulers.yB === null) {
|
||||
// Auto-place at ±2 divisions from the centre on first use.
|
||||
rulers.yA = -2; rulers.yB = 2;
|
||||
}
|
||||
updateCursorReadout();
|
||||
cursorsDirty = true;
|
||||
});
|
||||
|
||||
// Format a signal value for the per-plot cursor readout.
|
||||
function fmtVal(v) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
@@ -2178,11 +2320,80 @@ function updatePlotCursorReadouts() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Hover readout ──────────────────────────────────────────────────────── */
|
||||
// Un-normalize a plotted value of trace `key` in plot `p` back to raw units.
|
||||
function rawFromNorm(p, key, vNorm) {
|
||||
const vs = sigVScale[p.id + ':' + key];
|
||||
if (!vs) return vNorm;
|
||||
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
|
||||
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||
return (vNorm - (vs.screenPos || 0)) * dv + ofs;
|
||||
}
|
||||
|
||||
function hideHoverReadout() {
|
||||
document.getElementById('hover-readout').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show the time under the mouse plus every trace's value at that time.
|
||||
function showHoverReadout(p, e) {
|
||||
const el = document.getElementById('hover-readout');
|
||||
if (!p.uplot || p.traces.length === 0) { el.style.display = 'none'; return; }
|
||||
|
||||
const rect = p.uplot.over.getBoundingClientRect();
|
||||
const { min, max } = p.uplot.scales.x;
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const t = min + pct * (max - min);
|
||||
const span = Math.abs(max - min);
|
||||
|
||||
const tStr = (trig.enabled && trig.snapshot) ? fmtDuration(t, span, true) : fmtLiveTime(t, span);
|
||||
let html = '<div class="hov-time">' + escHtml(tStr) + '</div>';
|
||||
p.traces.forEach((key, idx) => {
|
||||
const vNorm = interpAtTime(p.uplot, idx + 1, t);
|
||||
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
|
||||
const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm));
|
||||
html += '<div class="hov-row"><span class="hov-dot" style="background:' +
|
||||
escHtml(getSigStyle(key).color) + '"></span>' +
|
||||
'<span class="hov-name">' + escHtml(name) + '</span>' +
|
||||
'<span class="hov-val">' + escHtml(val) + '</span></div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
el.style.display = 'block';
|
||||
|
||||
// Keep the tooltip inside the viewport.
|
||||
const w = el.offsetWidth, h = el.offsetHeight;
|
||||
let x = e.clientX + 14, y = e.clientY + 14;
|
||||
if (x + w > window.innerWidth - 4) x = e.clientX - w - 14;
|
||||
if (y + h > window.innerHeight - 4) y = e.clientY - h - 14;
|
||||
el.style.left = Math.max(4, x) + 'px';
|
||||
el.style.top = Math.max(4, y) + 'px';
|
||||
}
|
||||
|
||||
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the first
|
||||
// plot that has an active (or sole) signal.
|
||||
function updateRulerReadout() {
|
||||
const box = document.getElementById('ruler-readout');
|
||||
const on = rulers.mode === 'on';
|
||||
box.style.display = on ? '' : 'none';
|
||||
if (!on) return;
|
||||
const ref = plots.find(p => p.uplot && p.traces.length > 0 &&
|
||||
rulerRawValue(p, 0) !== null);
|
||||
const conv = y => (y === null || !ref) ? null : rulerRawValue(ref, y);
|
||||
const vA = conv(rulers.yA), vB = conv(rulers.yB);
|
||||
document.getElementById('cur-y1').textContent = 'Y1: ' + fmtVal(vA);
|
||||
document.getElementById('cur-y2').textContent = 'Y2: ' + fmtVal(vB);
|
||||
document.getElementById('cur-dy').textContent =
|
||||
'ΔY: ' + fmtVal(vA !== null && vB !== null ? vB - vA : null);
|
||||
}
|
||||
|
||||
function updateCursorReadout() {
|
||||
const ro = document.getElementById('cursor-readout');
|
||||
const active = cursors.mode === 'on';
|
||||
ro.classList.toggle('visible', active);
|
||||
ro.classList.toggle('visible', active || rulers.mode === 'on');
|
||||
updatePlotCursorReadouts();
|
||||
updateRulerReadout();
|
||||
['cur-ta', 'cur-tb', 'cur-dt'].forEach(id => {
|
||||
document.getElementById(id).style.display = active ? '' : 'none';
|
||||
});
|
||||
if (!active) return;
|
||||
|
||||
// Use the current visible x-range to pick the display unit.
|
||||
@@ -2286,6 +2497,12 @@ document.getElementById('trig-mode').addEventListener('change', e => {
|
||||
}
|
||||
});
|
||||
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
|
||||
// Force: capture the window around the newest sample regardless of the threshold.
|
||||
document.getElementById('btn-trig-force').addEventListener('click', () => {
|
||||
if (!trig.enabled || !trig.signal) return;
|
||||
sendTrigConfig();
|
||||
wsSend({ type: 'forceTrigger' });
|
||||
});
|
||||
document.getElementById('btn-trig-stop').addEventListener('click', () => {
|
||||
if (!trig.enabled || trig.mode !== 'normal') return;
|
||||
trig.stopped = !trig.stopped;
|
||||
@@ -2943,6 +3160,22 @@ function renderDirtyPlots() {
|
||||
});
|
||||
}
|
||||
|
||||
// Live rolling mode: pin the cursors to the moving viewport so they stay put
|
||||
// on screen instead of scrolling off the left edge as time advances.
|
||||
if (cursors.mode === 'on' && !trig.enabled && !globalPause &&
|
||||
plots.some(p => p.uplot && !p.xRange && p.traces.length > 0)) {
|
||||
if (_cursorAnchorNow !== null && globalPlotNow !== _cursorAnchorNow) {
|
||||
const shift = globalPlotNow - _cursorAnchorNow;
|
||||
if (cursors.tA !== null) cursors.tA += shift;
|
||||
if (cursors.tB !== null) cursors.tB += shift;
|
||||
cursorsDirty = true;
|
||||
updateCursorReadout();
|
||||
}
|
||||
_cursorAnchorNow = globalPlotNow;
|
||||
} else {
|
||||
_cursorAnchorNow = null;
|
||||
}
|
||||
|
||||
// Fast path: cursor-only redraw (no data rebuild needed)
|
||||
if (cursorsDirty) {
|
||||
cursorsDirty = false;
|
||||
@@ -3204,11 +3437,15 @@ function showVScaleMenu(key, plotId) {
|
||||
|
||||
const isManual = vs.mode === 'manual';
|
||||
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||
document.getElementById('vscale-offset-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.
|
||||
// Pre-fill V/div and Offset with the resolved or stored values; Position
|
||||
// always shows the current screenPos.
|
||||
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
|
||||
const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0);
|
||||
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
|
||||
document.getElementById('vscale-offset').value = parseFloat(ofs.toPrecision(6));
|
||||
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||
|
||||
// Type row (Analog/Digital) only shown in mixed mode.
|
||||
@@ -3293,8 +3530,9 @@ function initVScaleMenu() {
|
||||
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)
|
||||
vs.offset = vs._resolvedOffset || 0; // raw value at screen centre
|
||||
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
|
||||
document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6));
|
||||
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||
}
|
||||
vs.mode = newMode;
|
||||
@@ -3302,6 +3540,7 @@ function initVScaleMenu() {
|
||||
btn.classList.add('active');
|
||||
const isManual = vs.mode === 'manual';
|
||||
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||
document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none';
|
||||
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
||||
refreshPlotForKey(_vsMenuKey);
|
||||
});
|
||||
@@ -3312,6 +3551,16 @@ function initVScaleMenu() {
|
||||
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
|
||||
refreshPlotForKey(_vsMenuKey);
|
||||
});
|
||||
// "Offset" is the raw value shown at screen centre. Unlike Position (which
|
||||
// is clamped to the ±4 visible divisions) it is unbounded, so a signal can be
|
||||
// referenced to a level far outside the currently plotted range.
|
||||
document.getElementById('vscale-offset').addEventListener('input', e => {
|
||||
if (!_vsMenuKey) return;
|
||||
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
|
||||
const v = parseFloat(e.target.value);
|
||||
vs.offset = isFinite(v) ? v : 0;
|
||||
refreshPlotForKey(_vsMenuKey);
|
||||
});
|
||||
// "Position (div)" moves the marker and signal together on screen.
|
||||
document.getElementById('vscale-pos').addEventListener('input', e => {
|
||||
if (!_vsMenuKey) return;
|
||||
@@ -3551,6 +3800,11 @@ buildSidebar(); // show "Add Source" section even before WS connection
|
||||
initArrayIdxPicker();
|
||||
initVScaleMenu();
|
||||
initSignalMenu();
|
||||
// Restore monotonic TS checkbox from localStorage (visual state before WS reply).
|
||||
{
|
||||
const cb = document.getElementById('cb-monotonic');
|
||||
if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1';
|
||||
}
|
||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
||||
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
|
||||
|
||||
Reference in New Issue
Block a user