multi source

This commit is contained in:
Martino Ferrari
2026-05-19 13:14:26 +02:00
parent e6ab50e90e
commit b4d6e2443c
8 changed files with 862 additions and 436 deletions
+211 -48
View File
@@ -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">&#x25B6;</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">&#x25B6;</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">&#x25B6;</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();