working prototype

This commit is contained in:
Martino Ferrari
2026-05-19 10:48:06 +02:00
parent bdcdb39d21
commit e6ab50e90e
4 changed files with 521 additions and 184 deletions
+330 -58
View File
@@ -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');
@@ -208,8 +228,7 @@ 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)
@@ -221,7 +240,6 @@ function trigArm() {
// 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; });
} }
@@ -334,6 +352,38 @@ 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;
@@ -385,8 +435,9 @@ function fmtTrigTick(u, vals) {
}); });
} }
// 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 },
values: xVals, size: 36, space: 90
},
{ stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 }, { stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, 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
@@ -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;
@@ -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 => {
@@ -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.
@@ -1288,12 +1477,16 @@ function removeTraceFrom(plotId, signalKey) {
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);
} }
@@ -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));
@@ -1460,11 +1657,86 @@ function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
/* ════════════════════════════════════════════════════════════════
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(); });
}
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Init Init
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
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);
+42
View File
@@ -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>
+31 -8
View File
@@ -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%);
+1 -1
View File
@@ -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