multi source
This commit is contained in:
+211
-48
@@ -14,7 +14,8 @@ const TRACE_COLORS = [
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Globals
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
let signals = [];
|
||||
// sourcesMap: id → {id, label, addr, state, signals:[]}
|
||||
const sourcesMap = {};
|
||||
let buffers = {};
|
||||
let plots = [];
|
||||
let nextPlotId = 1;
|
||||
@@ -102,7 +103,8 @@ function connectWS() {
|
||||
ws.onerror = () => { };
|
||||
ws.onmessage = evt => {
|
||||
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
|
||||
if (msg.type === 'config') onConfig(msg);
|
||||
if (msg.type === 'sources') onSources(msg);
|
||||
else if (msg.type === 'config') onConfig(msg);
|
||||
else if (msg.type === 'data') onData(msg);
|
||||
};
|
||||
}
|
||||
@@ -147,19 +149,32 @@ function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
|
||||
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }
|
||||
|
||||
function onConfig(msg) {
|
||||
const sid = msg.sourceId;
|
||||
if (!sid) return;
|
||||
// Ensure source exists (may arrive before 'sources' message in some edge cases).
|
||||
if (!sourcesMap[sid]) {
|
||||
sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] };
|
||||
}
|
||||
const src = sourcesMap[sid];
|
||||
const newSigs = msg.signals || [];
|
||||
const oldSigs = src.signals || [];
|
||||
const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0);
|
||||
const changed = newSigs.length !== signals.length || newSigs.some((s, i) => fp(s) !== fp(signals[i]));
|
||||
signals = newSigs;
|
||||
const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i]));
|
||||
src.signals = newSigs;
|
||||
if (changed) {
|
||||
buffers = {};
|
||||
signals.forEach(sig => {
|
||||
// Remove old buffers for this source only (prefix: "sid:").
|
||||
const prefix = sid + ':';
|
||||
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
|
||||
newSigs.forEach(sig => {
|
||||
const n = numElements(sig);
|
||||
if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); }
|
||||
else if (n === 1) { buffers[sig.name] = makeBuffer(); }
|
||||
else { for (let i = 0; i < n; i++) buffers[sig.name + '[' + i + ']'] = makeBuffer(); }
|
||||
const base = prefix + sig.name;
|
||||
if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
|
||||
else if (n === 1) { buffers[base] = makeBuffer(); }
|
||||
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); }
|
||||
});
|
||||
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
|
||||
if (trig.signal && trig.signal.startsWith(prefix)) {
|
||||
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
|
||||
}
|
||||
zoomHistory.length = 0;
|
||||
document.getElementById('btn-zoom-back').style.display = 'none';
|
||||
}
|
||||
@@ -345,11 +360,17 @@ function sliceTypedArrayRange(t, v, t0, t1) {
|
||||
// Temporal array signals have a meaningful SamplingRate; scalars return 0.
|
||||
// Used to prefer high-freq signals as the master time grid regardless of trace order.
|
||||
function getKeySamplingRate(key) {
|
||||
const direct = signals.find(s => s.name === key);
|
||||
if (direct) return direct.samplingRate || 0;
|
||||
// Array element key like "Sig[3]" — strip the index
|
||||
const sig = signals.find(s => key.startsWith(s.name + '['));
|
||||
return sig ? (sig.samplingRate || 0) : 0;
|
||||
// key format: "sourceId:signalName" or "sourceId:signalName[i]"
|
||||
for (const src of Object.values(sourcesMap)) {
|
||||
const prefix = src.id + ':';
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const localKey = key.slice(prefix.length);
|
||||
const direct = (src.signals || []).find(s => s.name === localKey);
|
||||
if (direct) return direct.samplingRate || 0;
|
||||
const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '['));
|
||||
if (sig) return sig.samplingRate || 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default).
|
||||
@@ -1230,16 +1251,23 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => {
|
||||
function buildTrigSignalSelect() {
|
||||
const sel = document.getElementById('trig-signal'), cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— none —</option>';
|
||||
signals.forEach(sig => {
|
||||
const n = numElements(sig);
|
||||
if (isTemporal(sig) || n === 1) {
|
||||
const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o);
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = sig.name + '[' + i + ']', o = document.createElement('option');
|
||||
o.value = key; o.textContent = key; sel.appendChild(o);
|
||||
Object.values(sourcesMap).forEach(src => {
|
||||
const prefix = src.id + ':';
|
||||
const srcLabel = src.label || src.addr || src.id;
|
||||
(src.signals || []).forEach(sig => {
|
||||
const n = numElements(sig);
|
||||
if (isTemporal(sig) || n === 1) {
|
||||
const key = prefix + sig.name;
|
||||
const o = document.createElement('option');
|
||||
o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o);
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = prefix + sig.name + '[' + i + ']';
|
||||
const o = document.createElement('option');
|
||||
o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
|
||||
trig.signal = sel.value;
|
||||
@@ -1248,34 +1276,79 @@ function buildTrigSignalSelect() {
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Sidebar
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
|
||||
|
||||
function buildSidebar() {
|
||||
const list = document.getElementById('signal-list');
|
||||
list.innerHTML = '';
|
||||
if (!signals.length) {
|
||||
list.innerHTML = '<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">No signals</div>';
|
||||
return;
|
||||
}
|
||||
const typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
|
||||
signals.forEach(sig => {
|
||||
const n = numElements(sig), temporal = isTemporal(sig);
|
||||
const typeName = typeNames[sig.typeCode] || '?';
|
||||
if (n === 1 || temporal) {
|
||||
list.appendChild(makeDraggable(sig.name, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
|
||||
} else {
|
||||
const group = document.createElement('div'); group.className = 'array-group';
|
||||
const header = document.createElement('div'); header.className = 'array-header';
|
||||
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>' : '')
|
||||
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
|
||||
header.addEventListener('click', () => header.classList.toggle('open'));
|
||||
const children = document.createElement('div'); children.className = 'array-children';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = sig.name + '[' + i + ']', child = makeDraggable(key, key, typeName, sig.unit || '');
|
||||
child.className = 'array-child'; children.appendChild(child);
|
||||
const sources = Object.values(sourcesMap);
|
||||
|
||||
sources.forEach(src => {
|
||||
const sigs = src.signals || [];
|
||||
const prefix = src.id + ':';
|
||||
|
||||
// Source header
|
||||
const grp = document.createElement('div');
|
||||
grp.className = 'source-group';
|
||||
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'source-group-header';
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'source-state-dot ' + (src.state || 'disconnected');
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'source-name';
|
||||
nameEl.textContent = src.label || src.addr || src.id;
|
||||
nameEl.title = src.addr || '';
|
||||
|
||||
const addrEl = document.createElement('span');
|
||||
addrEl.className = 'source-addr';
|
||||
if (src.label && src.addr) addrEl.textContent = src.addr;
|
||||
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.className = 'source-remove-btn';
|
||||
rmBtn.title = 'Remove source';
|
||||
rmBtn.textContent = '×';
|
||||
rmBtn.addEventListener('click', () => removeSource(src.id));
|
||||
|
||||
hdr.append(dot, nameEl, addrEl, rmBtn);
|
||||
grp.appendChild(hdr);
|
||||
|
||||
sigs.forEach(sig => {
|
||||
const n = numElements(sig), temporal = isTemporal(sig);
|
||||
const typeName = _typeNames[sig.typeCode] || '?';
|
||||
const globalKey = prefix + sig.name;
|
||||
if (n === 1 || temporal) {
|
||||
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
|
||||
} else {
|
||||
const group = document.createElement('div'); group.className = 'array-group';
|
||||
const header = document.createElement('div'); header.className = 'array-header';
|
||||
header.innerHTML = '<span class="array-arrow">▶</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
|
||||
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
|
||||
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
|
||||
header.addEventListener('click', () => header.classList.toggle('open'));
|
||||
const children = document.createElement('div'); children.className = 'array-children';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = globalKey + '[' + i + ']';
|
||||
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
|
||||
child.className = 'array-child'; children.appendChild(child);
|
||||
}
|
||||
group.appendChild(header); group.appendChild(children); grp.appendChild(group);
|
||||
}
|
||||
group.appendChild(header); group.appendChild(children); list.appendChild(group);
|
||||
}
|
||||
});
|
||||
|
||||
list.appendChild(grp);
|
||||
});
|
||||
|
||||
if (!sources.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
|
||||
empty.textContent = 'No sources configured';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
list.appendChild(makeAddSourceSection());
|
||||
}
|
||||
function makeDraggable(key, label, typeName, unit) {
|
||||
const item = document.createElement('div');
|
||||
@@ -1487,7 +1560,9 @@ function addBadge(plotId, key) {
|
||||
e.preventDefault();
|
||||
showSignalMenu(key, plotId, e.clientX, e.clientY);
|
||||
});
|
||||
badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x);
|
||||
// Show signal name without the "sourceId:" prefix in the badge label.
|
||||
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
||||
badge.appendChild(dot); badge.appendChild(document.createTextNode(displayName)); badge.appendChild(x);
|
||||
c.appendChild(badge);
|
||||
}
|
||||
function removeBadge(plotId, key) {
|
||||
@@ -1650,6 +1725,93 @@ function setSidebar(open) {
|
||||
}
|
||||
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Multi-source management
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
function onSources(msg) {
|
||||
const srcs = msg.sources || [];
|
||||
const newIds = new Set(srcs.map(s => s.id));
|
||||
// Remove sources that disappeared.
|
||||
Object.keys(sourcesMap).forEach(id => {
|
||||
if (!newIds.has(id)) {
|
||||
const prefix = id + ':';
|
||||
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
|
||||
delete sourcesMap[id];
|
||||
}
|
||||
});
|
||||
// Update or create entries.
|
||||
srcs.forEach(s => {
|
||||
if (!sourcesMap[s.id]) {
|
||||
sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] };
|
||||
} else {
|
||||
Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state });
|
||||
}
|
||||
});
|
||||
buildSidebar();
|
||||
}
|
||||
|
||||
function addSourceWS(label, addr) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'addSource', label, addr }));
|
||||
}
|
||||
}
|
||||
|
||||
function removeSource(id) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'removeSource', id }));
|
||||
}
|
||||
}
|
||||
|
||||
function saveSourcesWS() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'saveSources' }));
|
||||
}
|
||||
}
|
||||
|
||||
function makeAddSourceSection() {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'add-source-section';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'add-source-title';
|
||||
title.innerHTML = '<span class="add-src-arrow">▶</span> Add Source';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'add-source-body';
|
||||
|
||||
const addrInput = document.createElement('input');
|
||||
addrInput.className = 'add-src-input'; addrInput.type = 'text';
|
||||
addrInput.placeholder = 'host:port';
|
||||
|
||||
const labelInput = document.createElement('input');
|
||||
labelInput.className = 'add-src-input'; labelInput.type = 'text';
|
||||
labelInput.placeholder = 'label (optional)';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
|
||||
addBtn.addEventListener('click', () => {
|
||||
const addr = addrInput.value.trim(); if (!addr) return;
|
||||
addSourceWS(labelInput.value.trim(), addr);
|
||||
addrInput.value = ''; labelInput.value = '';
|
||||
});
|
||||
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'add-src-btn save-src-btn';
|
||||
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
|
||||
saveBtn.addEventListener('click', saveSourcesWS);
|
||||
|
||||
body.append(addrInput, labelInput, addBtn, saveBtn);
|
||||
section.append(title, body);
|
||||
|
||||
title.addEventListener('click', () => {
|
||||
const open = section.classList.toggle('open');
|
||||
title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
|
||||
});
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Utility
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
@@ -1736,6 +1898,7 @@ function initSignalMenu() {
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
buildLayoutMenu();
|
||||
applyLayout('l1x1');
|
||||
buildSidebar(); // show "Add Source" section even before WS connection
|
||||
initSignalMenu();
|
||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||
connectWS();
|
||||
|
||||
+146
-156
@@ -1,163 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MARTe2 UDP Streamer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/uPlot.min.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/uPlot.iife.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Top bar ───────────────────────────────────────────────── -->
|
||||
<div id="topbar">
|
||||
<button id="btn-sidebar" class="ctrl-btn active" title="Toggle sidebar">☰</button>
|
||||
<span id="app-title">MARTe2 UDP Streamer</span>
|
||||
<div class="topbar-vsep"></div>
|
||||
<button id="btn-layout" class="ctrl-btn layout-toggle" title="Select layout">⊞ 1×1 ▾</button>
|
||||
<div class="topbar-sep"></div>
|
||||
<div id="cursor-readout">
|
||||
<span id="cur-ta">A: —</span><span class="cur-sep">│</span>
|
||||
<span id="cur-tb">B: —</span><span class="cur-sep">│</span>
|
||||
<span id="cur-dt">ΔT: —</span>
|
||||
</div>
|
||||
<span class="ctrl-label" id="lbl-window">Window:</span>
|
||||
<select id="window-select" class="ctrl-select">
|
||||
<option value="1">1 s</option><option value="5" selected>5 s</option>
|
||||
<option value="10">10 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
</select>
|
||||
<button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button>
|
||||
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
||||
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
||||
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
|
||||
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
|
||||
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
||||
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
||||
<div id="trigbar">
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Signal</span>
|
||||
<select id="trig-signal" class="trig-select"><option value="">— none —</option></select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Edge</span>
|
||||
<select id="trig-edge" class="trig-select">
|
||||
<option value="rising">Rising ↑</option>
|
||||
<option value="falling">Falling ↓</option>
|
||||
<option value="both">Both ↕</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Threshold</span>
|
||||
<input id="trig-threshold" class="trig-input" type="number" value="0" step="any">
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Window</span>
|
||||
<select id="trig-window" class="trig-select">
|
||||
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
|
||||
<option value="5">5 s</option><option value="10">10 s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Pre</span>
|
||||
<input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20">
|
||||
<span class="trig-range-val" id="trig-pre-val">20%</span>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Mode</span>
|
||||
<select id="trig-mode" class="trig-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="single">Single</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group" style="gap:8px">
|
||||
<span id="trig-status-badge">IDLE</span>
|
||||
<button id="btn-trig-stop" style="display:none">Stop</button>
|
||||
<button id="btn-trig-rearm">Rearm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Body ─────────────────────────────────────────────────── -->
|
||||
<div id="body">
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">Signals</div>
|
||||
<div id="signal-list">
|
||||
<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">
|
||||
Waiting for streamer…
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UDP Scope</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/uPlot.min.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/uPlot.iife.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ── Top bar ───────────────────────────────────────────────── -->
|
||||
<div id="topbar">
|
||||
<button id="btn-sidebar" class="ctrl-btn active" title="Toggle sidebar">☰</button>
|
||||
<span id="app-title">UDP Scope</span>
|
||||
<div class="topbar-vsep"></div>
|
||||
<button id="btn-layout" class="ctrl-btn layout-toggle" title="Select layout">⊞ 1×1 ▾</button>
|
||||
<div class="topbar-sep"></div>
|
||||
<div id="cursor-readout">
|
||||
<span id="cur-ta">A: —</span><span class="cur-sep">│</span>
|
||||
<span id="cur-tb">B: —</span><span class="cur-sep">│</span>
|
||||
<span id="cur-dt">ΔT: —</span>
|
||||
</div>
|
||||
<span class="ctrl-label" id="lbl-window">Window:</span>
|
||||
<select id="window-select" class="ctrl-select">
|
||||
<option value="1">1 s</option><option value="5" selected>5 s</option>
|
||||
<option value="10">10 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
</select>
|
||||
<button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button>
|
||||
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
||||
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
||||
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
|
||||
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
|
||||
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
||||
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||
</div>
|
||||
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
||||
<div id="trigbar">
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Signal</span>
|
||||
<select id="trig-signal" class="trig-select"><option value="">— none —</option></select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Edge</span>
|
||||
<select id="trig-edge" class="trig-select">
|
||||
<option value="rising">Rising ↑</option>
|
||||
<option value="falling">Falling ↓</option>
|
||||
<option value="both">Both ↕</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Threshold</span>
|
||||
<input id="trig-threshold" class="trig-input" type="number" value="0" step="any">
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Window</span>
|
||||
<select id="trig-window" class="trig-select">
|
||||
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
|
||||
<option value="5">5 s</option><option value="10">10 s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Pre</span>
|
||||
<input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20">
|
||||
<span class="trig-range-val" id="trig-pre-val">20%</span>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Mode</span>
|
||||
<select id="trig-mode" class="trig-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="single">Single</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group" style="gap:8px">
|
||||
<span id="trig-status-badge">IDLE</span>
|
||||
<button id="btn-trig-stop" style="display:none">Stop</button>
|
||||
<button id="btn-trig-rearm">Rearm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="plot-grid" class="l1x1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Status bar ─────────────────────────────────────────────── -->
|
||||
<div id="statusbar">
|
||||
<div id="sb-left">
|
||||
<div id="status-led"></div>
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="sb-tsage"></span>
|
||||
</div>
|
||||
<span id="build-version"></span>
|
||||
</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>
|
||||
<!-- ── Body ─────────────────────────────────────────────────── -->
|
||||
<div id="body">
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">Signals</div>
|
||||
<div id="signal-list"></div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="plot-grid" class="l1x1"></div>
|
||||
</div>
|
||||
</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>
|
||||
<!-- ── Status bar ─────────────────────────────────────────────── -->
|
||||
<div id="statusbar">
|
||||
<div id="sb-left">
|
||||
<div id="status-led"></div>
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="sb-tsage"></span>
|
||||
</div>
|
||||
<span id="build-version"></span>
|
||||
</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 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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -312,6 +312,71 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
.ctx-range { width:80px; }
|
||||
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
|
||||
|
||||
/* ── Source groups ────────────────────────────────────────────── */
|
||||
.source-group { margin-bottom:2px; }
|
||||
.source-group-header {
|
||||
display:flex; align-items:center; gap:5px;
|
||||
padding:5px 8px 5px 10px; margin:4px 6px 2px;
|
||||
background:var(--surface0); border-radius:6px;
|
||||
}
|
||||
.source-state-dot {
|
||||
width:7px; height:7px; border-radius:50%; flex-shrink:0;
|
||||
background:var(--overlay0); transition:background var(--transition);
|
||||
}
|
||||
.source-state-dot.connected { background:var(--green); }
|
||||
.source-state-dot.connecting { background:var(--yellow); animation:pulse-orange 1.5s infinite; }
|
||||
.source-state-dot.disconnected { background:var(--red); }
|
||||
.source-name {
|
||||
font-size:11px; font-weight:600; color:var(--subtext1); flex:1;
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
||||
}
|
||||
.source-addr {
|
||||
font-size:10px; color:var(--overlay0); font-family:monospace;
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:90px;
|
||||
}
|
||||
.source-remove-btn {
|
||||
background:none; border:none; color:var(--overlay0);
|
||||
cursor:pointer; font-size:15px; line-height:1; padding:0 2px; flex-shrink:0;
|
||||
transition:color var(--transition);
|
||||
}
|
||||
.source-remove-btn:hover { color:var(--red); }
|
||||
|
||||
/* ── Add source section ───────────────────────────────────────── */
|
||||
.add-source-section {
|
||||
border-top:1px solid var(--surface0); margin-top:4px;
|
||||
}
|
||||
.add-source-title {
|
||||
display:flex; align-items:center; gap:5px;
|
||||
padding:6px 10px; cursor:pointer; user-select:none;
|
||||
font-size:11px; color:var(--overlay0);
|
||||
transition:color var(--transition);
|
||||
}
|
||||
.add-source-title:hover { color:var(--subtext1); }
|
||||
.add-src-arrow {
|
||||
font-size:9px; display:inline-block;
|
||||
transition:transform var(--transition);
|
||||
}
|
||||
.add-source-body {
|
||||
display:none; flex-direction:column; gap:5px;
|
||||
padding:0 10px 10px;
|
||||
}
|
||||
.add-source-section.open .add-source-body { display:flex; }
|
||||
.add-src-input {
|
||||
background:var(--surface0); color:var(--text);
|
||||
border:1px solid var(--surface1); border-radius:5px;
|
||||
padding:4px 8px; font-size:12px; outline:none; width:100%;
|
||||
}
|
||||
.add-src-input:focus { border-color:var(--accent); }
|
||||
.add-src-btn {
|
||||
background:var(--surface0); color:var(--accent);
|
||||
border:1px solid var(--surface1); border-radius:5px;
|
||||
padding:4px 10px; font-size:12px; cursor:pointer; width:100%;
|
||||
transition:background var(--transition),border-color var(--transition);
|
||||
}
|
||||
.add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); }
|
||||
.save-src-btn { color:var(--green); }
|
||||
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────── */
|
||||
#empty-state {
|
||||
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||
|
||||
Reference in New Issue
Block a user