working prototype
This commit is contained in:
+405
-133
@@ -7,8 +7,8 @@ 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 = [
|
const TRACE_COLORS = [
|
||||||
'#89b4fa','#a6e3a1','#f38ba8','#fab387','#cba6f7',
|
'#89b4fa', '#a6e3a1', '#f38ba8', '#fab387', '#cba6f7',
|
||||||
'#94e2d5','#89dceb','#b4befe','#f9e2af','#f5c2e7',
|
'#94e2d5', '#89dceb', '#b4befe', '#f9e2af', '#f5c2e7',
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@@ -29,6 +29,26 @@ function getTraceColor(key) {
|
|||||||
return traceColorMap[key];
|
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
|
// Sync: shared uPlot cursor crosshair across all live plots
|
||||||
const LIVE_SYNC = uPlot.sync('live');
|
const LIVE_SYNC = uPlot.sync('live');
|
||||||
const TRIG_SYNC = uPlot.sync('trig');
|
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]
|
// Layout — [label, cssClass, cols, rows]
|
||||||
const LAYOUTS = [
|
const LAYOUTS = [
|
||||||
['1×1','l1x1',1,1],['1×2','l1x2',1,2],['2×1','l2x1',2,1],['1×3','l1x3',1,3],
|
['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],
|
['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1],
|
||||||
];
|
];
|
||||||
let currentLayout = 'l1x1';
|
let currentLayout = 'l1x1';
|
||||||
|
|
||||||
@@ -60,9 +80,9 @@ let currentLayout = 'l1x1';
|
|||||||
Trigger state
|
Trigger state
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
const trig = {
|
const trig = {
|
||||||
enabled:false, signal:'', edge:'rising', threshold:0, windowSec:1,
|
enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1,
|
||||||
prePercent:20, mode:'normal', stopped:false,
|
prePercent: 20, mode: 'normal', stopped: false,
|
||||||
armed:false, prevVal:null, collecting:false, trigTime:null, snapshot:null,
|
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; }
|
function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; }
|
||||||
@@ -73,13 +93,13 @@ function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100;
|
|||||||
let ws = null, wsBackoff = 1000;
|
let ws = null, wsBackoff = 1000;
|
||||||
function connectWS() {
|
function connectWS() {
|
||||||
ws = new WebSocket('ws://' + location.host + '/ws');
|
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 = () => {
|
ws.onclose = () => {
|
||||||
setStatus('red','Disconnected (reconnecting…)');
|
setStatus('red', 'Disconnected (reconnecting…)');
|
||||||
setTimeout(connectWS, wsBackoff);
|
setTimeout(connectWS, wsBackoff);
|
||||||
wsBackoff = Math.min(wsBackoff * 2, 30000);
|
wsBackoff = Math.min(wsBackoff * 2, 30000);
|
||||||
};
|
};
|
||||||
ws.onerror = () => {};
|
ws.onerror = () => { };
|
||||||
ws.onmessage = evt => {
|
ws.onmessage = evt => {
|
||||||
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
|
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
|
||||||
if (msg.type === 'config') onConfig(msg);
|
if (msg.type === 'config') onConfig(msg);
|
||||||
@@ -113,7 +133,7 @@ setInterval(() => {
|
|||||||
} else if (tsEl) {
|
} else if (tsEl) {
|
||||||
tsEl.textContent = '';
|
tsEl.textContent = '';
|
||||||
}
|
}
|
||||||
if (age > 1000) setStatus('orange', 'No data for ' + (age/1000).toFixed(1) + 's');
|
if (age > 1000) setStatus('orange', 'No data for ' + (age / 1000).toFixed(1) + 's');
|
||||||
else setStatus('green', 'Streaming');
|
else setStatus('green', 'Streaming');
|
||||||
} else if (tsEl) {
|
} else if (tsEl) {
|
||||||
tsEl.textContent = '';
|
tsEl.textContent = '';
|
||||||
@@ -128,8 +148,8 @@ function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !
|
|||||||
|
|
||||||
function onConfig(msg) {
|
function onConfig(msg) {
|
||||||
const newSigs = msg.signals || [];
|
const newSigs = msg.signals || [];
|
||||||
const fp = s => s.name+':'+s.typeCode+':'+(s.numRows||1)+':'+(s.numCols||1)+':'+(s.timeMode||0);
|
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 changed = newSigs.length !== signals.length || newSigs.some((s, i) => fp(s) !== fp(signals[i]));
|
||||||
signals = newSigs;
|
signals = newSigs;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
buffers = {};
|
buffers = {};
|
||||||
@@ -137,7 +157,7 @@ function onConfig(msg) {
|
|||||||
const n = numElements(sig);
|
const n = numElements(sig);
|
||||||
if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); }
|
if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); }
|
||||||
else if (n === 1) { buffers[sig.name] = makeBuffer(); }
|
else if (n === 1) { buffers[sig.name] = makeBuffer(); }
|
||||||
else { for (let i = 0; i < n; i++) buffers[sig.name+'['+i+']'] = makeBuffer(); }
|
else { for (let i = 0; i < n; i++) buffers[sig.name + '[' + i + ']'] = makeBuffer(); }
|
||||||
});
|
});
|
||||||
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
|
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
|
||||||
zoomHistory.length = 0;
|
zoomHistory.length = 0;
|
||||||
@@ -160,7 +180,7 @@ function onData(msg) {
|
|||||||
for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]);
|
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.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();
|
finaliseTriggerCapture();
|
||||||
if (!trig.enabled) {
|
if (!trig.enabled) {
|
||||||
plots.forEach(p => {
|
plots.forEach(p => {
|
||||||
@@ -179,17 +199,17 @@ function checkTrigger(sigs) {
|
|||||||
const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur;
|
const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur;
|
||||||
if (prev === null) continue;
|
if (prev === null) continue;
|
||||||
const e = trig.edge, thr = trig.threshold;
|
const e = trig.edge, thr = trig.threshold;
|
||||||
if ((e==='rising' &&prev<thr&&cur>=thr) ||
|
if ((e === 'rising' && prev < thr && cur >= thr) ||
|
||||||
(e==='falling'&&prev>thr&&cur<=thr) ||
|
(e === 'falling' && prev > thr && cur <= thr) ||
|
||||||
(e==='both'&&((prev<thr&&cur>=thr)||(prev>thr&&cur<=thr)))) {
|
(e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) {
|
||||||
fireTrigger(sd.t ? sd.t[i] : Date.now()/1000); break;
|
fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fireTrigger(t) {
|
function fireTrigger(t) {
|
||||||
// Clear the previous snapshot here (not in trigArm) so the last waveform
|
// Clear the previous snapshot here (not in trigArm) so the last waveform
|
||||||
// and trigger marker stay visible while the trigger is rearmed and waiting.
|
// 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);
|
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
|
||||||
}
|
}
|
||||||
function finaliseTriggerCapture() {
|
function finaliseTriggerCapture() {
|
||||||
@@ -208,25 +228,23 @@ function finaliseTriggerCapture() {
|
|||||||
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
|
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
|
||||||
showRearmBtn(trig.mode === 'single');
|
showRearmBtn(trig.mode === 'single');
|
||||||
showStopBtn(trig.mode === 'normal');
|
showStopBtn(trig.mode === 'normal');
|
||||||
// Reset cursor positions (mode changed) and show cursor button now that snapshot exists
|
// Show cursor button now that snapshot exists (positions preserved from before)
|
||||||
cursors.tA = null; cursors.tB = null; updateCursorReadout();
|
|
||||||
updateCursorBtnVisibility();
|
updateCursorBtnVisibility();
|
||||||
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
|
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
|
||||||
if (trig.mode === 'normal' && !trig.stopped)
|
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() {
|
function trigArm() {
|
||||||
// Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
|
// 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
|
// visible while waiting for the next event. fireTrigger() clears it when a new
|
||||||
// trigger actually fires.
|
// 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');
|
showRearmBtn(false); updateTrigStatusBadge('armed');
|
||||||
cursors.tA = null; cursors.tB = null; updateCursorReadout();
|
|
||||||
updateCursorBtnVisibility();
|
updateCursorBtnVisibility();
|
||||||
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
|
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
|
||||||
}
|
}
|
||||||
function trigDisarm() {
|
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');
|
setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle');
|
||||||
}
|
}
|
||||||
function setAllCardsCollecting(on) {
|
function setAllCardsCollecting(on) {
|
||||||
@@ -235,10 +253,10 @@ function setAllCardsCollecting(on) {
|
|||||||
function updateTrigStatusBadge(state) {
|
function updateTrigStatusBadge(state) {
|
||||||
const el = document.getElementById('trig-status-badge');
|
const el = document.getElementById('trig-status-badge');
|
||||||
el.className = state;
|
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 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 showStopBtn(v) { document.getElementById('btn-trig-stop').style.display = v ? 'inline-block' : 'none'; }
|
||||||
function updateStopBtn() {
|
function updateStopBtn() {
|
||||||
const btn = document.getElementById('btn-trig-stop');
|
const btn = document.getElementById('btn-trig-stop');
|
||||||
btn.textContent = trig.stopped ? 'Resume' : 'Stop';
|
btn.textContent = trig.stopped ? 'Resume' : 'Stop';
|
||||||
@@ -249,7 +267,7 @@ function updateStopBtn() {
|
|||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
function makeBuffer(cap) {
|
function makeBuffer(cap) {
|
||||||
cap = cap || DEFAULT_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) {
|
function pushBuffer(buf, t, v) {
|
||||||
buf.t[buf.head] = t; buf.v[buf.head] = v;
|
buf.t[buf.head] = t; buf.v[buf.head] = v;
|
||||||
@@ -265,10 +283,10 @@ function getBufferSliceRange(buf, t0, t1) {
|
|||||||
const physAt = k => (start + k) % cap;
|
const physAt = k => (start + k) % cap;
|
||||||
|
|
||||||
let lo = 0, hi = size;
|
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;
|
const kStart = lo;
|
||||||
lo = kStart; hi = size;
|
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;
|
const kEnd = lo, len = kEnd - kStart;
|
||||||
if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };
|
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
|
// Binary-search slice of a sorted contiguous Float64Array pair
|
||||||
function sliceTypedArrayRange(t, v, t0, t1) {
|
function sliceTypedArrayRange(t, v, t0, t1) {
|
||||||
let lo = 0, hi = t.length;
|
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;
|
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) };
|
return { t: t.subarray(s, lo), v: v.subarray(s, lo) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,28 +352,60 @@ function getKeySamplingRate(key) {
|
|||||||
return sig ? (sig.samplingRate || 0) : 0;
|
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.
|
// LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold.
|
||||||
function lttb(t, v, threshold) {
|
function lttb(t, v, threshold) {
|
||||||
const len = t.length;
|
const len = t.length;
|
||||||
if (len <= threshold) return { t, v };
|
if (len <= threshold) return { t, v };
|
||||||
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
|
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
|
||||||
outT[0] = t[0]; outV[0] = v[0];
|
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);
|
const every = (len - 2) / (threshold - 2);
|
||||||
let a = 0;
|
let a = 0;
|
||||||
for (let i = 0; i < threshold - 2; i++) {
|
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;
|
let avgT = 0, avgV = 0, n = 0;
|
||||||
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
|
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
|
||||||
if (n) { avgT /= n; avgV /= 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;
|
let maxA = -1, next = rS;
|
||||||
const aT = t[a], aV = v[a];
|
const aT = t[a], aV = v[a];
|
||||||
for (let j = rS; j < rE; j++) {
|
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; }
|
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 };
|
return { t: outT, v: outV };
|
||||||
}
|
}
|
||||||
@@ -368,11 +418,11 @@ function fmtLiveTick(u, vals) {
|
|||||||
return vals.map(v => {
|
return vals.map(v => {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
const d = new Date(v * 1000);
|
const d = new Date(v * 1000);
|
||||||
const hh = String(d.getHours()).padStart(2,'0');
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
const mm = String(d.getMinutes()).padStart(2,'0');
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
const ss = String(d.getSeconds()).padStart(2,'0');
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
const ms = String(d.getMilliseconds()).padStart(3,'0');
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||||
return hh+':'+mm+':'+ss+'.'+ms;
|
return hh + ':' + mm + ':' + ss + '.' + ms;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks)
|
// Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks)
|
||||||
@@ -380,13 +430,14 @@ function fmtTrigTick(u, vals) {
|
|||||||
return vals.map(v => {
|
return vals.map(v => {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
|
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';
|
return sign + abs.toFixed(3) + 's';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw a vertical marker line at x=0 (the trigger event) in trigger mode.
|
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
|
||||||
function drawTriggerMarker(u) {
|
// line on any plot that shows the trigger signal.
|
||||||
|
function drawTriggerMarker(u, p) {
|
||||||
if (!trig.enabled || !trig.snapshot) return;
|
if (!trig.enabled || !trig.snapshot) return;
|
||||||
const { ctx, bbox } = u;
|
const { ctx, bbox } = u;
|
||||||
if (!bbox) return;
|
if (!bbox) return;
|
||||||
@@ -394,53 +445,178 @@ function drawTriggerMarker(u) {
|
|||||||
if (x < bbox.left || x > bbox.left + bbox.width) return;
|
if (x < bbox.left || x > bbox.left + bbox.width) return;
|
||||||
const px = Math.round(x);
|
const px = Math.round(x);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = 'rgba(203,166,247,0.9)';
|
// Thin dashed vertical line at t=0
|
||||||
ctx.lineWidth = 1.5;
|
ctx.strokeStyle = 'rgba(203,166,247,0.7)';
|
||||||
ctx.setLineDash([]);
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 3]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px, bbox.top);
|
ctx.moveTo(px, bbox.top);
|
||||||
ctx.lineTo(px, bbox.top + bbox.height);
|
ctx.lineTo(px, bbox.top + bbox.height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Small "T" label
|
ctx.setLineDash([]);
|
||||||
ctx.fillStyle = 'rgba(203,166,247,0.9)';
|
ctx.fillStyle = 'rgba(203,166,247,0.7)';
|
||||||
ctx.font = 'bold 10px monospace';
|
ctx.font = 'bold 9px monospace';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillText('T', px + 3, bbox.top + 2);
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw custom cursor A/B lines onto the canvas (called from draw hook)
|
// Linearly interpolate series value at time t from uPlot's current rendered data.
|
||||||
function drawCursorLines(u) {
|
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;
|
const { ctx, bbox } = u;
|
||||||
if (!bbox) return;
|
if (!bbox) return;
|
||||||
const drawLine = (val, color) => {
|
|
||||||
|
const drawLine = (val, color, label) => {
|
||||||
if (val === null) return;
|
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;
|
if (x < bbox.left || x > bbox.left + bbox.width) return;
|
||||||
|
const px = Math.round(x);
|
||||||
ctx.save();
|
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.strokeStyle = color;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.setLineDash([5, 4]);
|
ctx.setLineDash([5, 4]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(Math.round(x), bbox.top);
|
ctx.moveTo(px, bbox.top);
|
||||||
ctx.lineTo(Math.round(x), bbox.top + bbox.height);
|
ctx.lineTo(px, bbox.top + bbox.height);
|
||||||
ctx.stroke();
|
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();
|
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
|
// Build uPlot opts for a given plot object
|
||||||
function makeUPlotOpts(p, inTrigMode) {
|
function makeUPlotOpts(p, inTrigMode) {
|
||||||
const seriesArr = [{}]; // time (index 0)
|
const seriesArr = [{}]; // time (index 0)
|
||||||
p.traces.forEach(key => {
|
p.traces.forEach(key => {
|
||||||
|
const style = getSigStyle(key);
|
||||||
|
const pathsFn = makeSeriesPath(key);
|
||||||
seriesArr.push({
|
seriesArr.push({
|
||||||
label: key,
|
label: key,
|
||||||
stroke: getTraceColor(key),
|
stroke: style.color,
|
||||||
width: 1.5,
|
width: style.width,
|
||||||
points: { show: false },
|
points: { show: style.marker === 'circle', size: style.markerSize + 2, fill: style.color },
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
|
...(pathsFn ? { paths: pathsFn } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -457,21 +633,25 @@ function makeUPlotOpts(p, inTrigMode) {
|
|||||||
},
|
},
|
||||||
select: { show: true },
|
select: { show: true },
|
||||||
scales: {
|
scales: {
|
||||||
x: { time: false, auto: false,
|
x: {
|
||||||
|
time: false, auto: false,
|
||||||
min: p.xRange ? p.xRange[0] : 0,
|
min: p.xRange ? p.xRange[0] : 0,
|
||||||
max: p.xRange ? p.xRange[1] : windowSec },
|
max: p.xRange ? p.xRange[1] : windowSec
|
||||||
|
},
|
||||||
y: { auto: true },
|
y: { auto: true },
|
||||||
},
|
},
|
||||||
series: seriesArr,
|
series: seriesArr,
|
||||||
axes: [
|
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 },
|
||||||
{ stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, size: 50 },
|
values: xVals, size: 36, space: 90
|
||||||
|
},
|
||||||
|
{ stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 },
|
||||||
],
|
],
|
||||||
legend: { show: false },
|
legend: { show: false },
|
||||||
padding: [4, 4, 0, 0],
|
padding: [4, 4, 0, 0],
|
||||||
hooks: {
|
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.
|
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
|
||||||
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
||||||
// All programmatic setScale calls happen without a preceding setSelect, so the
|
// All programmatic setScale calls happen without a preceding setSelect, so the
|
||||||
@@ -484,7 +664,7 @@ function makeUPlotOpts(p, inTrigMode) {
|
|||||||
if (min == null || max == null || max <= min) return;
|
if (min == null || max == null || max <= min) return;
|
||||||
onZoom(p.id, min, max);
|
onZoom(p.id, min, max);
|
||||||
}],
|
}],
|
||||||
ready: [ u => { u._ready = true; } ],
|
ready: [u => { u._ready = true; }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -527,19 +707,20 @@ function createUPlot(p) {
|
|||||||
|
|
||||||
// Update pointer style based on what's under the mouse
|
// Update pointer style based on what's under the mouse
|
||||||
p.uplot.over.addEventListener('mousemove', e => {
|
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.style.cursor = snap ? 'ew-resize' : '';
|
||||||
});
|
});
|
||||||
p.uplot.over.addEventListener('mouseleave', () => {
|
p.uplot.over.addEventListener('mouseleave', () => {
|
||||||
p.uplot.over.style.cursor = '';
|
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 => {
|
p.uplot.over.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0 || e.shiftKey) return; // shift is pan
|
if (e.button !== 0 || e.shiftKey) return; // shift is pan
|
||||||
const snap = _cursorAtClientX(e.clientX);
|
if (cursors.mode !== 'on') return;
|
||||||
const target = snap || (cursors.mode !== 'off' ? cursors.mode : null);
|
const target = _cursorAtClientX(e.clientX);
|
||||||
if (!target) return;
|
if (!target) return; // not near a cursor — let uPlot handle zoom
|
||||||
|
|
||||||
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
|
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -907,43 +1088,48 @@ document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
|
|||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
Cursor controls
|
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.
|
// Show the cursor button only when paused or in trigger-snapshot mode.
|
||||||
// Hides and clears cursors whenever neither condition holds.
|
|
||||||
function updateCursorBtnVisibility() {
|
function updateCursorBtnVisibility() {
|
||||||
const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null);
|
const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null);
|
||||||
const btn = document.getElementById('btn-cursor');
|
const btn = document.getElementById('btn-cursor');
|
||||||
btn.style.display = canUseCursors ? '' : 'none';
|
btn.style.display = canUseCursors ? '' : 'none';
|
||||||
if (!canUseCursors && cursors.mode !== 'off') {
|
if (!canUseCursors && cursors.mode !== 'off') {
|
||||||
cursors.mode = 'off';
|
cursors.mode = 'off';
|
||||||
cursors.tA = null; cursors.tB = null;
|
cursors.tA = null; cursors.tB = null; // context changed, clear positions
|
||||||
btn.textContent = CURSOR_LABELS.off;
|
btn.textContent = 'Cursors';
|
||||||
btn.className = 'ctrl-btn';
|
btn.classList.remove('active');
|
||||||
document.getElementById('cursor-readout').classList.remove('visible');
|
document.getElementById('cursor-readout').classList.remove('visible');
|
||||||
cursorsDirty = true;
|
cursorsDirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const CURSOR_MODES = ['off','A','B'];
|
|
||||||
|
|
||||||
document.getElementById('btn-cursor').addEventListener('click', () => {
|
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');
|
const btn = document.getElementById('btn-cursor');
|
||||||
btn.textContent = CURSOR_LABELS[cursors.mode];
|
btn.textContent = 'Cursors';
|
||||||
btn.className = 'ctrl-btn'
|
btn.classList.toggle('active', cursors.mode === 'on');
|
||||||
+ (cursors.mode==='A' ? ' cursor-a' : cursors.mode==='B' ? ' cursor-b' : '');
|
if (cursors.mode === 'on') {
|
||||||
if (cursors.mode === 'off') {
|
// Auto-place at 25%/75% of current x range on first use
|
||||||
cursors.tA = null; cursors.tB = null;
|
if (cursors.tA === null && cursors.tB === null) {
|
||||||
document.getElementById('cursor-readout').classList.remove('visible');
|
const refPlot = plots.find(p => p.uplot);
|
||||||
cursorsDirty = true;
|
if (refPlot) {
|
||||||
} else {
|
const { min, max } = refPlot.uplot.scales.x;
|
||||||
document.getElementById('cursor-readout').classList.add('visible');
|
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() {
|
function updateCursorReadout() {
|
||||||
const ro = document.getElementById('cursor-readout');
|
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);
|
ro.classList.toggle('visible', active);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
@@ -952,20 +1138,20 @@ function updateCursorReadout() {
|
|||||||
if (v === null) return '—';
|
if (v === null) return '—';
|
||||||
if (trig.enabled && trig.snapshot) {
|
if (trig.enabled && trig.snapshot) {
|
||||||
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
|
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);
|
const d = new Date(v * 1000);
|
||||||
return String(d.getHours()).padStart(2,'0') + ':'
|
return String(d.getHours()).padStart(2, '0') + ':'
|
||||||
+ String(d.getMinutes()).padStart(2,'0') + ':'
|
+ String(d.getMinutes()).padStart(2, '0') + ':'
|
||||||
+ String(d.getSeconds()).padStart(2,'0') + '.'
|
+ String(d.getSeconds()).padStart(2, '0') + '.'
|
||||||
+ String(d.getMilliseconds()).padStart(3,'0');
|
+ String(d.getMilliseconds()).padStart(3, '0');
|
||||||
};
|
};
|
||||||
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
|
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
|
||||||
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
|
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
|
||||||
if (cursors.tA !== null && cursors.tB !== null) {
|
if (cursors.tA !== null && cursors.tB !== null) {
|
||||||
const dt = cursors.tB - cursors.tA, abs = Math.abs(dt);
|
const dt = cursors.tB - cursors.tA, abs = Math.abs(dt);
|
||||||
const s = dt >= 0 ? '+' : '-';
|
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;
|
document.getElementById('cur-dt').textContent = 'ΔT: ' + str;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('cur-dt').textContent = 'ΔT: —';
|
document.getElementById('cur-dt').textContent = 'ΔT: —';
|
||||||
@@ -1001,10 +1187,12 @@ function openTrigBar(open) {
|
|||||||
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
|
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
|
||||||
}
|
}
|
||||||
// Resize uPlot instances after trigbar height changes
|
// Resize uPlot instances after trigbar height changes
|
||||||
setTimeout(() => { plots.forEach(p => {
|
setTimeout(() => {
|
||||||
|
plots.forEach(p => {
|
||||||
if (!p.uplot) return;
|
if (!p.uplot) return;
|
||||||
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
|
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
|
||||||
}); }, 220);
|
});
|
||||||
|
}, 220);
|
||||||
}
|
}
|
||||||
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
|
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
|
||||||
document.getElementById('trig-signal').addEventListener('change', e => {
|
document.getElementById('trig-signal').addEventListener('change', e => {
|
||||||
@@ -1012,7 +1200,7 @@ document.getElementById('trig-signal').addEventListener('change', e => {
|
|||||||
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
|
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-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-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-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); });
|
||||||
document.getElementById('trig-pre').addEventListener('input', e => {
|
document.getElementById('trig-pre').addEventListener('input', e => {
|
||||||
trig.prePercent = parseInt(e.target.value, 10);
|
trig.prePercent = parseInt(e.target.value, 10);
|
||||||
@@ -1048,7 +1236,7 @@ function buildTrigSignalSelect() {
|
|||||||
const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o);
|
const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o);
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < n; i++) {
|
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);
|
o.value = key; o.textContent = key; sel.appendChild(o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1067,22 +1255,22 @@ function buildSidebar() {
|
|||||||
list.innerHTML = '<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">No signals</div>';
|
list.innerHTML = '<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">No signals</div>';
|
||||||
return;
|
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 => {
|
signals.forEach(sig => {
|
||||||
const n = numElements(sig), temporal = isTemporal(sig);
|
const n = numElements(sig), temporal = isTemporal(sig);
|
||||||
const typeName = typeNames[sig.typeCode] || '?';
|
const typeName = typeNames[sig.typeCode] || '?';
|
||||||
if (n === 1 || temporal) {
|
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 {
|
} else {
|
||||||
const group = document.createElement('div'); group.className = 'array-group';
|
const group = document.createElement('div'); group.className = 'array-group';
|
||||||
const header = document.createElement('div'); header.className = 'array-header';
|
const header = document.createElement('div'); header.className = 'array-header';
|
||||||
header.innerHTML = '<span class="array-arrow">▶</span><span class="sig-name">'+escHtml(sig.name)+'</span>'
|
header.innerHTML = '<span class="array-arrow">▶</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
|
||||||
+ (sig.unit?'<span class="sig-unit">'+escHtml(sig.unit)+'</span>':'')
|
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
|
||||||
+ '<span class="type-badge">['+n+'] '+typeName+'</span>';
|
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
|
||||||
header.addEventListener('click', () => header.classList.toggle('open'));
|
header.addEventListener('click', () => header.classList.toggle('open'));
|
||||||
const children = document.createElement('div'); children.className = 'array-children';
|
const children = document.createElement('div'); children.className = 'array-children';
|
||||||
for (let i = 0; i < n; i++) {
|
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);
|
child.className = 'array-child'; children.appendChild(child);
|
||||||
}
|
}
|
||||||
group.appendChild(header); group.appendChild(children); list.appendChild(group);
|
group.appendChild(header); group.appendChild(children); list.appendChild(group);
|
||||||
@@ -1092,9 +1280,9 @@ function buildSidebar() {
|
|||||||
function makeDraggable(key, label, typeName, unit) {
|
function makeDraggable(key, label, typeName, unit) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'sig-item'; item.draggable = true;
|
item.className = 'sig-item'; item.draggable = true;
|
||||||
item.innerHTML = '<span class="sig-name">'+escHtml(label)+'</span>'
|
item.innerHTML = '<span class="sig-name">' + escHtml(label) + '</span>'
|
||||||
+ (unit?'<span class="sig-unit">'+escHtml(unit)+'</span>':'')
|
+ (unit ? '<span class="sig-unit">' + escHtml(unit) + '</span>' : '')
|
||||||
+ '<span class="type-badge">'+escHtml(typeName)+'</span>';
|
+ '<span class="type-badge">' + escHtml(typeName) + '</span>';
|
||||||
item.addEventListener('dragstart', e => {
|
item.addEventListener('dragstart', e => {
|
||||||
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
|
||||||
requestAnimationFrame(() => item.classList.add('dragging'));
|
requestAnimationFrame(() => item.classList.add('dragging'));
|
||||||
@@ -1126,7 +1314,8 @@ function layoutSVG(cols, rows) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
|
||||||
+ `<g fill="currentColor">${rects}</g></svg>`;
|
+ `<rect width="${W}" height="${H}" rx="2" fill="#11111b"/>`
|
||||||
|
+ `<g fill="#45475a">${rects}</g></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply a new layout: switch the grid class and auto-add/remove plots.
|
// Apply a new layout: switch the grid class and auto-add/remove plots.
|
||||||
@@ -1134,7 +1323,7 @@ function layoutSVG(cols, rows) {
|
|||||||
function applyLayout(cls) {
|
function applyLayout(cls) {
|
||||||
const entry = LAYOUTS.find(l => l[1] === cls);
|
const entry = LAYOUTS.find(l => l[1] === cls);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const [label,, cols, rows] = entry;
|
const [label, , cols, rows] = entry;
|
||||||
currentLayout = cls;
|
currentLayout = cls;
|
||||||
document.getElementById('plot-grid').className = cls;
|
document.getElementById('plot-grid').className = cls;
|
||||||
|
|
||||||
@@ -1249,7 +1438,7 @@ function addPlot() {
|
|||||||
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
|
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
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('dragleave', () => card.classList.remove('drag-over'));
|
||||||
card.addEventListener('drop', e => {
|
card.addEventListener('drop', e => {
|
||||||
e.preventDefault(); card.classList.remove('drag-over');
|
e.preventDefault(); card.classList.remove('drag-over');
|
||||||
@@ -1258,8 +1447,8 @@ function addPlot() {
|
|||||||
|
|
||||||
document.getElementById('plot-grid').appendChild(card);
|
document.getElementById('plot-grid').appendChild(card);
|
||||||
|
|
||||||
const plotBody = card.querySelector('#pbody-'+id);
|
const plotBody = card.querySelector('#pbody-' + id);
|
||||||
const p = { id, traces:[], div: plotBody, needsRedraw:false, xRange:null, uplot:null, ro:null };
|
const p = { id, traces: [], div: plotBody, needsRedraw: false, xRange: null, uplot: null, ro: null };
|
||||||
plots.push(p);
|
plots.push(p);
|
||||||
// uPlot creation is handled by applyLayout (batch, after DOM settles).
|
// uPlot creation is handled by applyLayout (batch, after DOM settles).
|
||||||
return id;
|
return id;
|
||||||
@@ -1269,7 +1458,7 @@ function addTraceTo(plotId, signalKey) {
|
|||||||
const p = plots.find(p => p.id === plotId); if (!p) return;
|
const p = plots.find(p => p.id === plotId); if (!p) return;
|
||||||
if (p.traces.includes(signalKey)) return;
|
if (p.traces.includes(signalKey)) return;
|
||||||
p.traces.push(signalKey);
|
p.traces.push(signalKey);
|
||||||
document.querySelector('#hint-'+plotId).style.display = 'none';
|
document.querySelector('#hint-' + plotId).style.display = 'none';
|
||||||
addBadge(plotId, signalKey);
|
addBadge(plotId, signalKey);
|
||||||
// Recreate uPlot with new series list
|
// Recreate uPlot with new series list
|
||||||
createUPlot(p);
|
createUPlot(p);
|
||||||
@@ -1282,24 +1471,28 @@ function removeTraceFrom(plotId, signalKey) {
|
|||||||
removeBadge(plotId, signalKey);
|
removeBadge(plotId, signalKey);
|
||||||
createUPlot(p);
|
createUPlot(p);
|
||||||
p.needsRedraw = true;
|
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) {
|
function addBadge(plotId, key) {
|
||||||
const c = document.getElementById('badges-'+plotId); if (!c) return;
|
const c = document.getElementById('badges-' + plotId); if (!c) return;
|
||||||
if (c.querySelector('[data-key="'+CSS.escape(key)+'"]')) return;
|
if (c.querySelector('[data-key="' + CSS.escape(key) + '"]')) return;
|
||||||
const color = getTraceColor(key);
|
const color = getSigStyle(key).color;
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'sig-badge'; badge.dataset.key = key;
|
badge.className = 'sig-badge'; badge.dataset.key = key;
|
||||||
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
|
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));
|
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);
|
badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x);
|
||||||
c.appendChild(badge);
|
c.appendChild(badge);
|
||||||
}
|
}
|
||||||
function removeBadge(plotId, key) {
|
function removeBadge(plotId, key) {
|
||||||
const c = document.getElementById('badges-'+plotId); if (!c) return;
|
const c = document.getElementById('badges-' + plotId); if (!c) return;
|
||||||
const b = c.querySelector('[data-key="'+CSS.escape(key)+'"]'); if (b) b.remove();
|
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.ro) p.ro.disconnect();
|
||||||
if (p.uplot) p.uplot.destroy();
|
if (p.uplot) p.uplot.destroy();
|
||||||
plots.splice(idx, 1);
|
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 newest = b.t[(b.head - 1 + b.cap) % b.cap];
|
||||||
const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap];
|
const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap];
|
||||||
const sliceLen = getBufferSlice(b).t.length;
|
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');
|
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}`));
|
plots.forEach(p => console.log(`[plot${p.id}] traces=${JSON.stringify(p.traces)} xRange=${JSON.stringify(p.xRange)} needsRedraw=${p.needsRedraw}`));
|
||||||
@@ -1395,8 +1588,10 @@ function renderDirtyPlots() {
|
|||||||
} else if (inTrigMode) {
|
} 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();
|
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
|
||||||
p.uplot.setScale('x', { min: p.xRange ? p.xRange[0] : -preS,
|
p.uplot.setScale('x', {
|
||||||
max: p.xRange ? p.xRange[1] : postS });
|
min: p.xRange ? p.xRange[0] : -preS,
|
||||||
|
max: p.xRange ? p.xRange[1] : postS
|
||||||
|
});
|
||||||
} else if (p.xRange) {
|
} else if (p.xRange) {
|
||||||
// Zoomed: re-apply so scale is correct after setData
|
// Zoomed: re-apply so scale is correct after setData
|
||||||
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
|
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
|
||||||
@@ -1447,9 +1642,11 @@ function setSidebar(open) {
|
|||||||
sidebarOpen = open;
|
sidebarOpen = open;
|
||||||
document.getElementById('sidebar').classList.toggle('collapsed', !open);
|
document.getElementById('sidebar').classList.toggle('collapsed', !open);
|
||||||
document.getElementById('btn-sidebar').classList.toggle('active', open);
|
document.getElementById('btn-sidebar').classList.toggle('active', open);
|
||||||
setTimeout(() => { plots.forEach(p => {
|
setTimeout(() => {
|
||||||
|
plots.forEach(p => {
|
||||||
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
|
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
|
||||||
}); }, 200);
|
});
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
|
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
|
||||||
|
|
||||||
@@ -1457,7 +1654,81 @@ document.getElementById('btn-sidebar').addEventListener('click', () => setSideba
|
|||||||
Utility
|
Utility
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g, '&').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();
|
buildLayoutMenu();
|
||||||
applyLayout('l1x1');
|
applyLayout('l1x1');
|
||||||
|
initSignalMenu();
|
||||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||||
connectWS();
|
connectWS();
|
||||||
requestAnimationFrame(renderDirtyPlots);
|
requestAnimationFrame(renderDirtyPlots);
|
||||||
fetch('/version').then(r => r.text()).then(v => {
|
fetch('/version').then(r => r.text()).then(v => {
|
||||||
document.getElementById('build-version').textContent = 'v' + v;
|
document.getElementById('build-version').textContent = 'v' + v;
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
|
|||||||
@@ -116,6 +116,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="layout-menu"></div>
|
<div id="layout-menu"></div>
|
||||||
|
|
||||||
|
<!-- ── Signal style context menu ─────────────────────────────── -->
|
||||||
|
<div id="sig-ctx-menu" style="display:none">
|
||||||
|
<div class="ctx-menu-header">Style: <span id="ctx-menu-key" class="ctx-menu-key"></span></div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Color</label>
|
||||||
|
<input type="color" id="ctx-color">
|
||||||
|
</div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Width</label>
|
||||||
|
<div class="ctx-btns" id="ctx-width-btns">
|
||||||
|
<button class="ctx-btn" data-w="1">1px</button>
|
||||||
|
<button class="ctx-btn active" data-w="1.5">1.5</button>
|
||||||
|
<button class="ctx-btn" data-w="2">2px</button>
|
||||||
|
<button class="ctx-btn" data-w="3">3px</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Line</label>
|
||||||
|
<div class="ctx-btns" id="ctx-dash-btns">
|
||||||
|
<button class="ctx-btn active" data-dash="solid">——</button>
|
||||||
|
<button class="ctx-btn" data-dash="dashed">╌╌</button>
|
||||||
|
<button class="ctx-btn" data-dash="dotted">·····</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Marker</label>
|
||||||
|
<div class="ctx-btns" id="ctx-marker-btns">
|
||||||
|
<button class="ctx-btn active" data-marker="none">none</button>
|
||||||
|
<button class="ctx-btn" data-marker="circle">●</button>
|
||||||
|
<button class="ctx-btn" data-marker="square">■</button>
|
||||||
|
<button class="ctx-btn" data-marker="cross">✕</button>
|
||||||
|
<button class="ctx-btn" data-marker="diamond">◆</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Size</label>
|
||||||
|
<input type="range" class="ctx-range" id="ctx-marker-size" min="2" max="10" value="4">
|
||||||
|
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -239,19 +239,13 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
|||||||
min-height:0; position:relative; overflow:hidden;
|
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); }
|
.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 {
|
.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);
|
background:rgba(17,17,27,0.88); backdrop-filter:blur(6px);
|
||||||
border-bottom:1px solid var(--surface1);
|
border-bottom:1px solid var(--surface1);
|
||||||
display:flex; align-items:center; gap:5px;
|
display:flex; align-items:center; gap:5px;
|
||||||
padding:3px 8px; min-height:26px; overflow:hidden;
|
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 {
|
.plot-title {
|
||||||
font-size:11px; color:var(--subtext1); font-weight:600;
|
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);
|
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 ──────────────────────────────────────────────── */
|
||||||
#empty-state {
|
#empty-state {
|
||||||
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ WEBUI_DIR="${REPO_ROOT}/Client/WebUI"
|
|||||||
WEBUI_BIN="${WEBUI_DIR}/udpstreamer-webui"
|
WEBUI_BIN="${WEBUI_DIR}/udpstreamer-webui"
|
||||||
if [ "${START_WEBUI}" -eq 1 ] && [ ! -x "${WEBUI_BIN}" ]; then
|
if [ "${START_WEBUI}" -eq 1 ] && [ ! -x "${WEBUI_BIN}" ]; then
|
||||||
echo "==> Building WebUI..."
|
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."
|
echo "==> WebUI build done."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user