Files
Martino Ferrari bceab8607c Improved all axpect of tracing / forcing arrays
Improved ui and tree export
2026-05-21 06:20:22 +02:00

2099 lines
81 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global uPlot */
'use strict';
// ════════════════════════════════════════════════════════════════════
// State
// ════════════════════════════════════════════════════════════════════
const MAX_TRACE_PTS = 500000; // per-signal storage limit; LTTB downsamples for display
const COLORS = [
'#89b4fa','#f38ba8','#a6e3a1','#fab387','#f9e2af',
'#cba6f7','#89dceb','#b4befe','#eba0ac','#94e2d5'
];
let ws = null;
let marteConnected = false;
// Signal registry: id(number) → {id, name, type, elements, dimensions, names:[]}
let signalsById = {};
// name → meta (same objects, multiple name entries per signal for array elems)
let signalsByName = {};
// Trace data: signalName → {ts:[], vals:[], lastVal:0, color:''}
let traceData = {};
// Set of names currently being traced
let tracedSet = new Set();
// Forced: name → value string (scalars and single-index array elements)
let forcedSignals = {};
// Forced array groups: baseName → {isAll, indices: Set<number>, elements, value, expanded}
const forcedGroups = new Map();
// Breakpoints: name → {op, threshold}
let breakpoints = {};
// Thread list from TREE
let threads = [];
// All object paths (for autocomplete)
let allObjects = [];
// Plots: [{id, container, chart, series:[{name, color, gain, offset}], maxPts, follow, dirty}]
let plots = [];
let plotSeq = 0;
// Logs
let logs = [];
const MAX_LOGS = 2000;
let logDirty = false;
// Step status
let stepStatus = null; // {paused, gam, remaining, thread}
let stepPollTimer = null;
// Pending unforce target
let unforceTarget = '';
// UDP stats
let udpPackets = 0, udpDropped = 0;
// Grouped array traces: baseName → {indices: Set<number>, elements: number, expanded: boolean}
// Created when user traces All/Range from the array dialog.
const tracedGroups = new Map();
// Sequential array series: baseName → {seqName, nEl, lastTs}
const seqState = {};
// Waterfall panels: wfId → wfObj
const waterfalls = new Map();
let wfSeq = 0;
let wfRO = null;
// Pending array-plot dialog state
let _arrpPlotId = -1, _arrpName = '', _arrpElem = 0;
// ════════════════════════════════════════════════════════════════════
// WebSocket
// ════════════════════════════════════════════════════════════════════
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws');
ws.onopen = () => { appendLog('sys','INFO','WebSocket connected to server'); };
ws.onclose = () => { appendLog('sys','WARNING','WebSocket disconnected — reconnecting…'); setTimeout(connectWS, 2000); };
ws.onerror = () => {};
ws.onmessage = (e) => handleMsg(JSON.parse(e.data));
}
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); }
// ════════════════════════════════════════════════════════════════════
// Message handlers
// ════════════════════════════════════════════════════════════════════
function handleMsg(msg) {
switch (msg.type) {
case 'connected': onMarteConnected(); break;
case 'disconnected': onMarteDisconnected(); break;
case 'signal_cache': onSignalCache(msg); break;
case 'response': onResponse(msg); break;
case 'text_line': onTextLine(msg.data); break;
case 'telemetry': onTelemetry(msg); break;
case 'log': onLog(msg); break;
case 'service_config': onServiceConfig(msg); break;
case 'udp_stats': onUdpStats(msg); break;
case 'tree_node': try { onTreeNode(JSON.parse(msg.data)); } catch(e) {} break;
}
}
// ════════════════════════════════════════════════════════════════════
// Per-node lazy tree loading
//
// TREE (no arg) returns only the root-level children.
// TREE <path> returns the immediate children of that node.
// Each <details> element sends TREE <path> on first open to load
// its children on demand, keeping initial render O(1).
// ════════════════════════════════════════════════════════════════════
// Called when a tree_node message arrives from the Go server.
function onTreeNode(data) {
const path = data.Path || '';
const children = data.Children || [];
if (path === '') {
// Root level: populate tree-body and mark tree ready.
const body = document.getElementById('tree-body');
body.innerHTML = '';
const frag = document.createDocumentFragment();
for (const child of children) {
frag.appendChild(_buildLazyNode(child, ''));
}
body.appendChild(frag);
_treeReady = true;
appendLog('sys', 'INFO', `Tree root loaded — ${children.length} top-level nodes`);
startStepPoll();
} else {
// Find the <details> element for this path and populate it.
const details = document.querySelector(`[data-tree-path="${CSS.escape(path)}"]`);
if (details) {
// Remove loading indicator.
const ld = details.querySelector('.tree-loading');
if (ld) ld.remove();
const childDiv = document.createElement('div');
childDiv.className = 'children';
const infoBtn = makeLeafButtons(path);
if (infoBtn) childDiv.appendChild(infoBtn);
const frag = document.createDocumentFragment();
for (const child of children) {
frag.appendChild(_buildLazyNode(child, path));
}
childDiv.appendChild(frag);
details.appendChild(childDiv);
}
}
// Collect metadata (threads, objects) from the received children.
for (const child of children) {
const fullPath = path ? path + '.' + child.Name : child.Name;
if (child.Class === 'RealTimeThread' && !threads.includes(child.Name)) {
threads.push(child.Name);
}
if (child.HasChildren) {
allObjects.push(fullPath);
}
}
_updateTreeMeta();
}
// Build one lazy tree node DOM element.
// Containers get a <details> that fires TREE <path> on first open.
// Leaves are rendered immediately as signal rows.
function _buildLazyNode(child, parentPath) {
const fullPath = parentPath ? parentPath + '.' + child.Name : child.Name;
if (child.HasChildren) {
const details = document.createElement('details');
details.setAttribute('data-tree-path', fullPath);
const summary = document.createElement('summary');
summary.innerHTML =
`<span class="tree-name">${esc(child.Name)}</span>` +
`<span class="tree-class">[${esc(child.Class)}]</span>`;
details.appendChild(summary);
details.addEventListener('toggle', function() {
if (!details.open || details._childrenBuilt) return;
details._childrenBuilt = true;
const ld = document.createElement('div');
ld.className = 'tree-loading';
ld.textContent = '⏳ Loading…';
details.appendChild(ld);
sendCmd('TREE ' + fullPath);
});
return details;
}
// Leaf node (signal).
const isSig = child.Class === 'Signal' ||
child.Class === 'InputSignal' ||
child.Class === 'OutputSignal' ||
child.IsTraceable;
const item = {
Name: child.Name, Class: child.Class,
IsTraceable: child.IsTraceable, IsForcable: child.IsForcable,
Elements: child.Elements || 1
};
const row = document.createElement('div');
row.className = 'tree-node';
row.appendChild(makeLeafRow(fullPath, item, isSig));
return row;
}
// Update thread selects and object autocomplete from current state.
function _updateTreeMeta() {
['step-thread','step-thread-list','step-thread-inp'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'SELECT') {
el.innerHTML = '<option value="">— all threads —</option>' +
threads.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
} else if (el.tagName === 'DATALIST') {
el.innerHTML = threads.map(t => `<option value="${esc(t)}">`).join('');
}
});
document.getElementById('msg-dest-list').innerHTML =
allObjects.map(n => `<option value="${esc(n)}">`).join('');
}
// ════════════════════════════════════════════════════════════════════
// Startup sequencing
//
// The server (DebugService) can be very busy during application init
// (1000+ signals, registry patching, broker setup). Sending DISCOVER
// or STEP_STATUS before it is ready just fills its receive buffer,
// delaying every subsequent response and triggering idle timeouts.
//
// Correct sequence:
// connect → SERVICE_INFO → DISCOVER → TREE → STEP_STATUS poll
//
// Each step is gated on the previous one completing. sendOnce()
// prevents the same command from being queued twice across reconnects.
// ════════════════════════════════════════════════════════════════════
const _cmdSent = new Set();
let _treeReady = false; // hard gate: STEP_STATUS never starts before tree is built
function sendOnce(cmd) {
if (_cmdSent.has(cmd)) return;
_cmdSent.add(cmd);
sendCmd(cmd);
}
function _clearStartupState() {
_cmdSent.clear();
_treeReady = false;
threads = [];
allObjects = [];
stopStepPoll();
document.getElementById('tree-body').innerHTML = '';
}
function onMarteConnected() {
marteConnected = true;
_clearStartupState();
document.getElementById('conn-status').classList.add('ok');
document.getElementById('btn-connect').textContent = 'Disconnect';
appendLog('sys','INFO','Connected to MARTe2');
// Send DISCOVER immediately so the user gets feedback without waiting for
// SERVICE_INFO. STEP_STATUS is gated by _treeReady so the server is not
// flooded with status polls during the (potentially long) init phase.
sendOnce('DISCOVER');
}
function onMarteDisconnected() {
marteConnected = false;
_clearStartupState();
document.getElementById('conn-status').classList.remove('ok');
document.getElementById('btn-connect').textContent = 'Connect';
appendLog('sys','WARNING','Disconnected from MARTe2');
stopStepPoll();
}
function onSignalCache(msg) {
// Go server has cached signals from a previous DISCOVER — skip DISCOVER,
// go straight to TREE (still need the object hierarchy).
if (msg.signals) {
processDiscovery(msg.signals);
_cmdSent.add('DISCOVER'); // mark as done so fallback timer doesn't re-send
sendOnce('TREE');
}
}
function onResponse(msg) {
try {
const data = msg.data ? JSON.parse(msg.data) : null;
switch (msg.tag) {
case 'DISCOVER':
if (data) {
processDiscovery(data.Signals || []);
sendOnce('TREE');
}
break;
case 'TREE': if (data) onTreeNode(data); break; // fallback if Go forwarding fails
case 'INFO': showInfo(msg.tag, msg.data); break;
case 'CONFIG': showInfo('CONFIG', msg.data); break;
case 'SERVICE_INFO': showInfo('Service Info', msg.data); break;
case 'STEP_STATUS': processStepStatus(data); break;
case 'VALUE': processValue(data); break;
}
} catch(e) {
// non-JSON responses (TRACE, FORCE, etc.) — silently ignore
}
}
function onTextLine(line) {
if (line.startsWith('OK ')) return; // handled by response handler
appendLog('sys','DEBUG', line);
}
function onLog(msg) {
// Normalise level names from MARTe2
let lv = msg.level;
if (lv === 'Information') lv = 'INFO';
else if (lv === 'Warning') lv = 'WARNING';
else if (lv === 'Debug') lv = 'DEBUG';
else if (lv === 'FatalError' || lv === 'ParametersError' || lv === 'OSError') lv = 'ERROR';
appendLog(msg.time, lv, msg.message);
}
function onServiceConfig(msg) {
const auto = document.getElementById('auto-ports')?.checked ?? true;
if (auto) {
if (msg.udp_port) document.getElementById('udp-port').value = msg.udp_port;
if (msg.log_port) document.getElementById('log-port').value = msg.log_port;
}
appendLog('sys','INFO',`ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}${auto ? '' : ' (ignored — manual ports)'}`);
}
function onUdpStats(msg) {
udpPackets = msg.packets;
udpDropped = msg.dropped;
document.getElementById('udp-stats').textContent =
`${udpPackets} pkts${udpDropped>0?' ⚠'+udpDropped+' drop':''}`;
}
function onTelemetry(msg) {
if (!msg.signals) return;
for (const s of msg.signals) {
const names = s.names || [];
const vals = s.values || [];
let plotDirty = false;
for (let e = 0; e < vals.length; e++) {
// A signal can have multiple names (canonical DataSource path + GAM
// aliases). Find whichever name the user actually traced.
let n = '';
for (const nm of names) {
const candidate = vals.length > 1 ? `${nm}[${e}]` : nm;
if (tracedSet.has(candidate)) { n = candidate; break; }
}
if (!n) continue;
let td = traceData[n];
if (!td) {
td = {ts:[], vals:[], lastVal:0, color: nextColor()};
traceData[n] = td;
}
td.ts.push(s.ts);
td.vals.push(vals[e]);
td.lastVal = vals[e];
if (td.ts.length > MAX_TRACE_PTS) {
const trim = td.ts.length - MAX_TRACE_PTS;
td.ts.splice(0, trim);
td.vals.splice(0, trim);
}
plotDirty = true;
}
// Mark plots dirty if any of this signal's names appear in a series.
if (plotDirty) {
for (const p of plots) {
for (const sr of p.series) {
if (names.some(nm => sr.name === nm ||
(vals.length > 1 && sr.name.startsWith(nm + '[')))) {
p.dirty = true;
break;
}
}
}
}
// Sequential and waterfall — handled per base-signal-name
for (const nm of names) {
if (seqState[nm]) {
_processSequential(nm, s.ts, vals);
const seqName = seqState[nm].seqName;
for (const p of plots) {
if (p.series.some(sr => sr.name === seqName)) p.dirty = true;
}
}
for (const [, wf] of waterfalls) {
if (wf.name === nm) _wfAddRow(wf, vals);
}
}
}
updateTracedValues();
}
// ════════════════════════════════════════════════════════════════════
// Discovery
// ════════════════════════════════════════════════════════════════════
function processDiscovery(sigs) {
signalsById = {};
signalsByName = {};
for (const s of sigs) {
const el = s.elements || 1;
// Go merges all aliases for the same signal ID into s.Names (capital N,
// no JSON tag → default Go serialisation key). Fall back to [s.name] for
// older server builds that don't populate Names.
const allNames = (s.Names && s.Names.length > 0) ? s.Names : [s.name];
const meta = {id:s.id, name:s.name, type:s.type||'', elements:el,
dimensions:s.dimensions||0, names: allNames};
signalsById[s.id] = meta;
// Register every alias (canonical DataSource path + all GAM In/Out aliases)
for (const n of allNames) {
signalsByName[n] = meta;
if (el > 1) {
for (let i=0; i<el; i++) signalsByName[`${n}[${i}]`] = meta;
}
}
}
updateSignalAutocomplete();
appendLog('sys','INFO',`Discovered ${sigs.length} signals`);
}
function updateSignalAutocomplete() {
const names = Object.keys(signalsByName).filter(n=>!n.includes('['));
['force-sig-list','break-sig-list'].forEach(id=>{
const dl = document.getElementById(id);
dl.innerHTML = names.map(n=>`<option value="${esc(n)}">`).join('');
});
}
// ════════════════════════════════════════════════════════════════════
// Tree
// ════════════════════════════════════════════════════════════════════
function processTree(root) {
threads = [];
allObjects = [];
// Fast metadata pass: traverse the raw JSON tree to collect threads and
// object names without touching the DOM at all.
function collectMeta(item, parentPath) {
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
if (item.Class === 'RealTimeThread' && item.Name) threads.push(item.Name);
if (item.Class && item.Class !== 'Signal' && item.Name) allObjects.push(path);
if (item.Children) for (const c of item.Children) collectMeta(c, path);
}
collectMeta(root, '');
// Build only the top-level nodes now; everything deeper is lazy.
const body = document.getElementById('tree-body');
body.innerHTML = '';
const frag = document.createDocumentFragment();
const topNodes = (root.Name === 'Root' && root.Children) ? root.Children : [root];
for (const child of topNodes) frag.appendChild(buildTreeNode(child, ''));
body.appendChild(frag);
// Populate thread selects
['step-thread','step-thread-list','step-thread-inp'].forEach(id=>{
const el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'SELECT') {
el.innerHTML = '<option value="">— all threads —</option>' +
threads.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
} else if (el.tagName === 'DATALIST') {
el.innerHTML = threads.map(t=>`<option value="${esc(t)}">`).join('');
}
});
// Populate msg dest autocomplete
const dl = document.getElementById('msg-dest-list');
dl.innerHTML = allObjects.map(n=>`<option value="${esc(n)}">`).join('');
_treeReady = true;
startStepPoll(); // fallback path: Go couldn't parse TREE, tree_batch never fires
}
function buildTreeNode(item, parentPath) {
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
const isSig = item.Class === 'Signal' || item.IsTraceable;
if (item.Children && item.Children.length > 0) {
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.innerHTML = `<span class="tree-name">${esc(item.Name)}</span><span class="tree-class">[${esc(item.Class)}]</span>`;
details.appendChild(summary);
// Children are built lazily on first open — avoids creating thousands of
// DOM nodes for a large app when the user may never open most branches.
details.addEventListener('toggle', function() {
if (!details.open || details._childrenBuilt) return;
details._childrenBuilt = true;
const children = document.createElement('div');
children.className = 'children';
const leaf = makeLeafButtons(path);
if (leaf) children.appendChild(leaf);
for (const c of item.Children) children.appendChild(buildTreeNode(c, path));
details.appendChild(children);
});
return details;
}
// Leaf node — arrays are not expanded inline; use T/F/B dialog for element selection.
const row = document.createElement('div');
row.className = 'tree-node';
row.appendChild(makeLeafRow(path, item, isSig));
return row;
}
function makeLeafRow(path, item, isSig) {
const div = document.createElement('div');
div.className = 'tree-leaf';
div.draggable = isSig;
if (isSig) {
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = 'copy';
});
}
const name = document.createElement('span');
name.className = 'tree-name';
name.title = path;
name.textContent = item.Name;
div.appendChild(name);
const cls = document.createElement('span');
cls.className = 'tree-class';
cls.textContent = `[${item.Class}]`;
div.appendChild(cls);
// Array element-count badge
const nEl = item.Elements || 1;
const isArr = isSig && nEl > 1;
if (isArr) {
const badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;color:#89b4fa;border:1px solid #45475a;border-radius:3px;padding:0 3px;flex-shrink:0;margin-left:2px';
badge.title = `${nEl} elements`;
badge.textContent = `×${nEl}`;
div.appendChild(badge);
}
// Info
const btnI = mkBtn('i', '', () => showNodeInfo(path));
div.appendChild(btnI);
if (isSig || item.IsTraceable) {
const btnT = mkBtn('T', 't', () => {
if (isArr) openArrayDialog(path, nEl, 'trace');
else traceSignal(path, true);
});
btnT.title = isArr ? 'Trace elements…' : 'Trace';
div.appendChild(btnT);
}
if (item.IsForcable || isSig) {
const btnF = mkBtn('F', 'f', () => {
if (isArr) openArrayDialog(path, nEl, 'force');
else openForceDialogFor(path);
});
btnF.title = isArr ? 'Force elements…' : 'Force';
div.appendChild(btnF);
}
if (isSig || item.IsTraceable) {
const btnB = mkBtn('B', 'b', () => {
if (isArr) openArrayDialog(path, nEl, 'break');
else openBreakDialogFor(path);
});
btnB.title = isArr ? 'Break on element…' : 'Break';
div.appendChild(btnB);
}
return div;
}
// ════════════════════════════════════════════════════════════════════
// Array signal dialog (trace / force / break with index/range selection)
// ════════════════════════════════════════════════════════════════════
let _arrPath = '', _arrElem = 0, _arrAction = '', _arrSel = 'all';
function setArrSel(s) {
_arrSel = s;
['all','idx','rng'].forEach(id => {
const btn = document.getElementById(`arr-sel-${id}`);
if (btn) btn.className = (id === s) ? 'active' : '';
});
const idxSect = document.getElementById('arr-idx-sect');
const rngSect = document.getElementById('arr-rng-sect');
if (idxSect) idxSect.style.display = s === 'idx' ? '' : 'none';
if (rngSect) rngSect.style.display = s === 'rng' ? '' : 'none';
}
function openArrayDialog(path, elements, action) {
_arrPath = path;
_arrElem = elements;
_arrAction = action;
document.getElementById('arr-dlg-title').textContent =
action === 'trace' ? '📊 Trace Array' :
action === 'force' ? '⚡ Force Array' : '🔴 Break Array';
document.getElementById('arr-dlg-sig').textContent = path;
document.getElementById('arr-dlg-n').textContent = elements;
document.getElementById('arr-idx-max').textContent = elements - 1;
document.getElementById('arr-idx').max = elements - 1;
document.getElementById('arr-r0').max = elements - 1;
document.getElementById('arr-r1').max = elements - 1;
document.getElementById('arr-r1').value = elements - 1;
document.getElementById('arr-force-sect').style.display = action === 'force' ? '' : 'none';
document.getElementById('arr-break-sect').style.display = action === 'break' ? '' : 'none';
document.getElementById('arr-ok-btn').textContent =
action === 'trace' ? 'Trace' : action === 'force' ? 'Force' : 'Set Break';
setArrSel('all');
openDlg('dlg-array');
}
function _arrIndices() {
if (_arrSel === 'idx') {
const v = Math.min(Math.max(0, parseInt(document.getElementById('arr-idx').value)||0), _arrElem-1);
return [v];
}
if (_arrSel === 'rng') {
const s = Math.min(Math.max(0, parseInt(document.getElementById('arr-r0').value)||0), _arrElem-1);
const e = Math.min(Math.max(s, parseInt(document.getElementById('arr-r1').value)||0), _arrElem-1);
return Array.from({length: e-s+1}, (_,i) => s+i);
}
// 'all'
return Array.from({length: _arrElem}, (_,i) => i);
}
function doArrayOp() {
const indices = _arrIndices();
const isAll = indices.length === _arrElem;
if (_arrAction === 'trace') {
// One TRACE command covers the whole array; browser filters per element.
sendCmd(`TRACE ${_arrPath} 1`);
for (const i of indices) {
const n = `${_arrPath}[${i}]`;
tracedSet.add(n);
if (!traceData[n]) traceData[n] = {ts:[], vals:[], lastVal:0, color: nextColor()};
}
// All / Range → group in the trace list (one collapsible row per base signal).
// Single-index → kept as an ungrouped individual entry.
if (_arrSel !== 'idx') {
const meta = signalsByName[_arrPath];
const nEl = meta ? meta.elements : _arrElem;
const grp = tracedGroups.get(_arrPath);
if (grp) {
for (const i of indices) grp.indices.add(i);
} else {
tracedGroups.set(_arrPath, {indices: new Set(indices), elements: nEl, expanded: false});
}
}
renderTracedTab();
} else if (_arrAction === 'force') {
const val = document.getElementById('arr-force-val').value.trim();
if (!val) { closeDlg('dlg-array'); return; }
if (_arrSel === 'idx') {
// Single index — kept as an ungrouped individual entry.
const i = indices[0];
sendCmd(`FORCE ${_arrPath}[${i}] ${val}`);
forcedSignals[`${_arrPath}[${i}]`] = val;
} else {
// All / Range — grouped display (one collapsible row).
const meta = signalsByName[_arrPath];
const nEl = meta ? meta.elements : _arrElem;
if (isAll) {
sendCmd(`FORCE ${_arrPath} ${val}`);
} else {
for (const i of indices) sendCmd(`FORCE ${_arrPath}[${i}] ${val}`);
}
const grp = forcedGroups.get(_arrPath);
if (grp) {
for (const i of indices) grp.indices.add(i);
grp.value = val;
grp.isAll = isAll || (grp.isAll && grp.indices.size === nEl);
} else {
forcedGroups.set(_arrPath, {isAll, indices: new Set(indices), elements: nEl, value: val, expanded: false});
}
}
renderForcedTab();
} else { // break
const op = document.getElementById('arr-break-op').value;
const thr = document.getElementById('arr-break-thr').value.trim();
if (!thr) { closeDlg('dlg-array'); return; }
if (isAll) {
sendCmd(`BREAK ${_arrPath} ${op} ${thr}`);
breakpoints[_arrPath] = {op, threshold: thr};
} else {
for (const i of indices) {
const n = `${_arrPath}[${i}]`;
sendCmd(`BREAK ${n} ${op} ${thr}`);
breakpoints[n] = {op, threshold: thr};
}
}
renderBreaksTab();
}
closeDlg('dlg-array');
}
function makeLeafButtons(path) {
// Returns a row of buttons for container nodes (no name/class display)
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:4px;padding:2px 4px 2px 0';
const btnI = mkBtn(' info', '', () => showNodeInfo(path));
div.appendChild(btnI);
return div;
}
function mkBtn(label, cls, fn) {
const b = document.createElement('button');
b.className = `tree-btn${cls?' '+cls:''}`;
b.textContent = label;
b.addEventListener('click', e => { e.stopPropagation(); fn(); });
return b;
}
function filterTree(q) {
const body = document.getElementById('tree-body');
q = q.toLowerCase();
if (q) {
// Open already-loaded containers so their leaves are visible.
body.querySelectorAll('details').forEach(d => { if (d._childrenBuilt) d.open = true; });
}
body.querySelectorAll('.tree-leaf').forEach(el => {
if (!q) { el.style.display = ''; return; }
const t = (el.querySelector('.tree-name')?.title || '').toLowerCase();
el.style.display = t.includes(q) ? '' : 'none';
});
}
// ════════════════════════════════════════════════════════════════════
// Tracing
// ════════════════════════════════════════════════════════════════════
function traceSignal(name, enable) {
// For element refs like "Signal[3]", the server only knows the base name.
const bracketMatch = name.match(/^(.+)\[\d+\]$/);
const baseName = bracketMatch ? bracketMatch[1] : name;
if (enable) {
tracedSet.add(name);
if (!traceData[name]) traceData[name] = {ts:[], vals:[], lastVal:0, color: nextColor()};
sendCmd(`TRACE ${baseName} 1`);
} else {
tracedSet.delete(name);
// For element refs: only disable server tracing when no other elements remain.
if (bracketMatch) {
const anyLeft = tracedSet.has(baseName) ||
[...tracedSet].some(n => n.startsWith(baseName + '['));
if (!anyLeft) sendCmd(`TRACE ${baseName} 0`);
} else {
sendCmd(`TRACE ${name} 0`);
}
}
renderTracedTab();
}
function untraceGroup(baseName) {
const grp = tracedGroups.get(baseName);
if (grp) {
for (const i of grp.indices) tracedSet.delete(`${baseName}[${i}]`);
tracedGroups.delete(baseName);
}
// Only send TRACE OFF if no ungrouped elements remain
const anyLeft = tracedSet.has(baseName) ||
[...tracedSet].some(n => n.startsWith(baseName + '['));
if (!anyLeft) sendCmd(`TRACE ${baseName} 0`);
renderTracedTab();
}
function renderTracedTab() {
const tab = document.getElementById('tab-traced');
const hint = document.getElementById('no-traced-hint');
hint.style.display = (tracedSet.size === 0 && tracedGroups.size === 0) ? 'block' : 'none';
tab.querySelectorAll('.sig-item,.sig-group').forEach(e => e.remove());
// Build set of entries already covered by a group
const grouped = new Set();
// ── Grouped array signals ─────────────────────────────────────────
for (const [baseName, grp] of tracedGroups) {
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
for (const i of sortedIdx) grouped.add(`${baseName}[${i}]`);
const firstN = `${baseName}[${sortedIdx[0]}]`;
const firstTd = traceData[firstN];
const color = firstTd?.color || COLORS[0];
// Header row (dragging it puts baseName so addSignalToPlot opens array mode)
const hdr = document.createElement('div');
hdr.className = 'sig-group';
hdr.draggable = true;
hdr.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', baseName);
e.dataTransfer.effectAllowed = 'copy';
});
const expBtn = document.createElement('span');
expBtn.className = 'sig-group-exp';
expBtn.textContent = grp.expanded ? '▾' : '▸';
expBtn.title = grp.expanded ? 'Collapse' : 'Expand elements';
expBtn.addEventListener('click', e => {
e.stopPropagation(); e.preventDefault();
grp.expanded = !grp.expanded;
renderTracedTab();
});
const dot = document.createElement('span');
dot.className = 'sig-dot'; dot.style.background = color; dot.draggable = false;
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = baseName;
nm.textContent = shortName(baseName); nm.draggable = false;
const badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;color:#89b4fa;border:1px solid #45475a;border-radius:3px;padding:0 3px;flex-shrink:0;margin-left:2px';
badge.title = `${grp.indices.size} element${grp.indices.size > 1 ? 's' : ''} traced`;
badge.textContent = `×${grp.indices.size}`;
const val = document.createElement('span');
val.className = 'sig-val';
val.textContent = firstTd ? fmtVal(firstTd.lastVal) : '—';
val.id = `tval-grp-${CSS.escape(baseName)}`;
const btnR = mkBtn('×', 'danger', () => untraceGroup(baseName));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
btnR.addEventListener('dragstart', e => e.preventDefault());
hdr.append(expBtn, dot, nm, badge, val, btnR);
tab.appendChild(hdr);
// Expanded: individual index sub-rows
if (grp.expanded) {
for (const i of sortedIdx) {
const n = `${baseName}[${i}]`;
const td = traceData[n];
const sub = document.createElement('div');
sub.className = 'sig-item sig-item-sub';
sub.draggable = true;
sub.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', n);
e.dataTransfer.effectAllowed = 'copy';
});
const sdot = document.createElement('span');
sdot.className = 'sig-dot'; sdot.style.background = td?.color || color; sdot.draggable = false;
const snm = document.createElement('span');
snm.className = 'sig-name'; snm.title = n;
snm.textContent = `[${i}]`; snm.draggable = false;
const sval = document.createElement('span');
sval.className = 'sig-val';
sval.textContent = td ? fmtVal(td.lastVal) : '—';
sval.id = `tval-${CSS.escape(n)}`; sval.draggable = false;
const sbtnR = mkBtn('×', 'danger', () => {
tracedSet.delete(n); grp.indices.delete(i);
if (grp.indices.size === 0) { tracedGroups.delete(baseName); sendCmd(`TRACE ${baseName} 0`); }
renderTracedTab();
});
sbtnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
sbtnR.addEventListener('dragstart', e => e.preventDefault());
sub.append(sdot, snm, sval, sbtnR);
tab.appendChild(sub);
}
}
}
// ── Individual (non-grouped) signals ────────────────────────────
let colorIdx = 0;
for (const name of tracedSet) {
if (grouped.has(name)) continue;
const td = traceData[name];
const color = td ? td.color : COLORS[colorIdx++ % COLORS.length];
const div = document.createElement('div');
div.className = 'sig-item'; div.draggable = true;
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', name);
e.dataTransfer.effectAllowed = 'copy';
});
const dot = document.createElement('span');
dot.className = 'sig-dot'; dot.style.background = color; dot.draggable = false;
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = name;
nm.textContent = shortName(name); nm.draggable = false;
const val = document.createElement('span');
val.className = 'sig-val';
val.textContent = td ? fmtVal(td.lastVal) : '—';
val.id = `tval-${CSS.escape(name)}`; val.draggable = false;
const btnR = mkBtn('×', 'danger', () => traceSignal(name, false));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
btnR.addEventListener('dragstart', e => e.preventDefault());
div.append(dot, nm, val, btnR);
tab.appendChild(div);
}
}
// Update only the value spans without touching the DOM structure.
// Called on every telemetry packet — must be fast and non-destructive.
function updateTracedValues() {
for (const name of tracedSet) {
const td = traceData[name];
if (!td) continue;
const el = document.getElementById(`tval-${CSS.escape(name)}`);
if (el) el.textContent = fmtVal(td.lastVal);
}
// Update group header values (shows lowest-index element's current value).
for (const [baseName, grp] of tracedGroups) {
const el = document.getElementById(`tval-grp-${CSS.escape(baseName)}`);
if (!el) continue;
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
if (sortedIdx.length === 0) continue;
const td = traceData[`${baseName}[${sortedIdx[0]}]`];
if (td) el.textContent = fmtVal(td.lastVal);
}
}
// ════════════════════════════════════════════════════════════════════
// Force
// ════════════════════════════════════════════════════════════════════
function openForceDialog() { openDlg('dlg-force'); }
function openForceDialogFor(name) {
document.getElementById('force-sig').value = name;
openDlg('dlg-force');
}
function doForce() {
const sig = document.getElementById('force-sig').value.trim();
const val = document.getElementById('force-val').value.trim();
if (!sig || !val) return;
sendCmd(`FORCE ${sig} ${val}`);
forcedSignals[sig] = val;
closeDlg('dlg-force');
renderForcedTab();
}
function doUnforce() {
if (unforceTarget) {
sendCmd(`UNFORCE ${unforceTarget}`);
delete forcedSignals[unforceTarget];
unforceTarget = '';
renderForcedTab();
}
closeDlg('dlg-unforce');
}
function confirmUnforce(name) {
unforceTarget = name;
document.getElementById('unforce-msg').textContent = `Remove force on "${name}"?`;
openDlg('dlg-unforce');
}
function unforceGroup(baseName) {
const grp = forcedGroups.get(baseName);
if (!grp) return;
if (grp.isAll) {
sendCmd(`UNFORCE ${baseName}`);
} else {
for (const i of grp.indices) sendCmd(`UNFORCE ${baseName}[${i}]`);
}
forcedGroups.delete(baseName);
renderForcedTab();
}
function renderForcedTab() {
const tab = document.getElementById('tab-forced');
tab.querySelectorAll('.sig-item,.sig-group').forEach(e => e.remove());
const hint = document.getElementById('no-forced-hint');
hint.style.display = (Object.keys(forcedSignals).length === 0 && forcedGroups.size === 0) ? 'block' : 'none';
// ── Grouped array forces ─────────────────────────────────────────
for (const [baseName, grp] of forcedGroups) {
const sortedIdx = [...grp.indices].sort((a, b) => a - b);
const hdr = document.createElement('div');
hdr.className = 'sig-group';
const expBtn = document.createElement('span');
expBtn.className = 'sig-group-exp';
expBtn.textContent = grp.expanded ? '▾' : '▸';
expBtn.title = grp.expanded ? 'Collapse' : 'Expand elements';
expBtn.addEventListener('click', e => {
e.stopPropagation(); e.preventDefault();
grp.expanded = !grp.expanded;
renderForcedTab();
});
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = baseName;
nm.textContent = shortName(baseName); nm.draggable = false;
const badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;color:#89b4fa;border:1px solid #45475a;border-radius:3px;padding:0 3px;flex-shrink:0;margin-left:2px';
badge.title = grp.isAll ? 'all elements forced' : `${grp.indices.size} element${grp.indices.size > 1 ? 's' : ''} forced`;
badge.textContent = grp.isAll ? '×all' : `×${grp.indices.size}`;
const val = document.createElement('span');
val.className = 'sig-val'; val.style.color = '#f9e2af';
val.textContent = grp.value;
const btnR = mkBtn('×', 'danger', () => unforceGroup(baseName));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
hdr.append(expBtn, nm, badge, val, btnR);
tab.appendChild(hdr);
if (grp.expanded) {
const dispIdx = grp.isAll ? Array.from({length: grp.elements}, (_, i) => i) : sortedIdx;
for (const i of dispIdx) {
const sub = document.createElement('div');
sub.className = 'sig-item sig-item-sub';
const snm = document.createElement('span');
snm.className = 'sig-name'; snm.title = `${baseName}[${i}]`;
snm.textContent = `[${i}]`; snm.draggable = false;
const sval = document.createElement('span');
sval.className = 'sig-val'; sval.style.color = '#f9e2af';
sval.textContent = grp.value; sval.draggable = false;
const sbtnR = mkBtn('×', 'danger', () => {
if (grp.isAll) {
// Re-force all other elements individually, then drop the group.
forcedGroups.delete(baseName);
sendCmd(`UNFORCE ${baseName}`);
for (const j of dispIdx) {
if (j !== i) {
sendCmd(`FORCE ${baseName}[${j}] ${grp.value}`);
forcedSignals[`${baseName}[${j}]`] = grp.value;
}
}
} else {
sendCmd(`UNFORCE ${baseName}[${i}]`);
grp.indices.delete(i);
if (grp.indices.size === 0) forcedGroups.delete(baseName);
}
renderForcedTab();
});
sbtnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
sub.append(snm, sval, sbtnR);
tab.appendChild(sub);
}
}
}
// ── Individual (scalar / single-index) forces ────────────────────
for (const name of Object.keys(forcedSignals)) {
const div = document.createElement('div');
div.className = 'sig-item';
const nm = document.createElement('span');
nm.className = 'sig-name'; nm.title = name;
nm.textContent = shortName(name);
const val = document.createElement('span');
val.className = 'sig-val'; val.style.color = '#f9e2af';
val.textContent = forcedSignals[name];
const btnR = mkBtn('×', '', () => confirmUnforce(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(nm, val, btnR);
tab.appendChild(div);
}
}
// ════════════════════════════════════════════════════════════════════
// Breakpoints
// ════════════════════════════════════════════════════════════════════
function openBreakDialog() { openDlg('dlg-break'); }
function openBreakDialogFor(name) {
document.getElementById('break-sig').value = name;
openDlg('dlg-break');
}
function doBreak() {
const sig = document.getElementById('break-sig').value.trim();
const op = document.getElementById('break-op').value;
const thresh = document.getElementById('break-thresh').value.trim();
if (!sig || !thresh) return;
sendCmd(`BREAK ${sig} ${op} ${thresh}`);
breakpoints[sig] = {op, threshold: thresh};
closeDlg('dlg-break');
renderBreaksTab();
}
function clearBreak(name) {
sendCmd(`BREAK ${name} OFF`);
delete breakpoints[name];
renderBreaksTab();
}
function renderBreaksTab() {
const tab = document.getElementById('tab-breaks');
tab.querySelectorAll('.break-item').forEach(e=>e.remove());
const hint = document.getElementById('no-breaks-hint');
const keys = Object.keys(breakpoints);
hint.style.display = keys.length ? 'none' : 'block';
for (const name of keys) {
const bp = breakpoints[name];
const div = document.createElement('div');
div.className = 'break-item';
const sig = document.createElement('span');
sig.className = 'break-sig';
sig.title = name;
sig.textContent = shortName(name);
const cond = document.createElement('span');
cond.style.color = '#fab387';
cond.textContent = `${bp.op} ${bp.threshold}`;
const btnR = mkBtn('×', '', () => clearBreak(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(sig, cond, btnR);
tab.appendChild(div);
}
}
// ════════════════════════════════════════════════════════════════════
// Messages
// ════════════════════════════════════════════════════════════════════
let msgHistory = [];
function openMsgDialog() { openDlg('dlg-msg'); }
function doSendMsg() {
const dest = document.getElementById('msg-dest').value.trim();
const func = document.getElementById('msg-func').value.trim();
const payload = document.getElementById('msg-payload').value.trim();
const wait = document.getElementById('msg-wait').checked ? 1 : 0;
if (!dest || !func) return;
// Build key=value pairs on one line: K=V K2=V2
const kvLine = payload.split('\n')
.map(l=>l.trim()).filter(Boolean)
.map(l=>l.replace(/\s*=\s*/,'=')).join(' ');
const cmd = `MSG ${dest} ${func} ${wait}${kvLine ? ' '+kvLine : ''}`;
sendCmd(cmd);
const entry = {time: now(), dest, func, payload, cmd, status:'pending'};
msgHistory.unshift(entry);
closeDlg('dlg-msg');
renderMsgsTab();
}
function renderMsgsTab() {
const tab = document.getElementById('tab-msgs');
tab.querySelectorAll('.msg-item').forEach(e=>e.remove());
const hint = document.getElementById('no-msgs-hint');
hint.style.display = msgHistory.length ? 'none' : 'block';
for (const m of msgHistory.slice(0,50)) {
const div = document.createElement('div');
div.className = 'msg-item';
const icon = m.status==='ok'?'✔':m.status==='err'?'✘':'…';
div.innerHTML = `<div style="display:flex;gap:6px;align-items:center">
<span style="color:${m.status==='ok'?'#a6e3a1':m.status==='err'?'#f38ba8':'#585b70'}">${icon}</span>
<span style="color:#585b70;flex-shrink:0">${m.time}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(m.cmd)}">${esc(m.dest)}.${esc(m.func)}</span>
<button class="tree-btn" onclick="resendMsg(${msgHistory.indexOf(m)})">↺</button>
</div>`;
tab.appendChild(div);
}
}
function resendMsg(idx) {
const m = msgHistory[idx];
if (!m) return;
sendCmd(m.cmd);
}
// ════════════════════════════════════════════════════════════════════
// Step / Pause
// ════════════════════════════════════════════════════════════════════
let isPaused = false;
function togglePause() {
if (isPaused) {
sendCmd('RESUME');
isPaused = false;
document.getElementById('step-bar').classList.remove('visible');
} else {
sendCmd('PAUSE');
isPaused = true;
}
document.getElementById('btn-pause').textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
function openStepDialog() { openDlg('dlg-step'); }
function doStep() {
const count = document.getElementById('step-count').value || 1;
const thread = document.getElementById('step-thread-inp').value.trim();
const cmd = thread ? `STEP ${count} ${thread}` : `STEP ${count}`;
sendCmd(cmd);
closeDlg('dlg-step');
}
function step(n) {
const thread = document.getElementById('step-thread').value;
const cmd = thread ? `STEP ${n} ${thread}` : `STEP ${n}`;
sendCmd(cmd);
}
function startStepPoll() {
if (!_treeReady) return; // hard gate: never poll before tree is fully built
stopStepPoll();
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
}
function stopStepPoll() {
if (stepPollTimer) { clearInterval(stepPollTimer); stepPollTimer = null; }
}
function processStepStatus(data) {
if (!data) return;
stepStatus = data;
const bar = document.getElementById('step-bar');
if (data.Paused) {
isPaused = true;
bar.classList.add('visible');
document.getElementById('paused-gam').textContent = data.PausedAtGam || '—';
document.getElementById('step-remaining').textContent =
data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
document.getElementById('btn-pause').textContent = '▶ Resume';
// Faster polling while paused
if (_treeReady) {
stopStepPoll();
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
}
} else {
if (isPaused) {
isPaused = false;
bar.classList.remove('visible');
document.getElementById('btn-pause').textContent = '⏸ Pause';
// Back to slow poll
startStepPoll();
}
}
}
function processValue(data) {
if (!data || data.Error) return;
const title = `Value: ${data.Name}`;
const body = `${data.Value}\n(${data.Elements} element${data.Elements>1?'s':''})`;
showInfo(title, body);
}
// ════════════════════════════════════════════════════════════════════
// Info dialog
// ════════════════════════════════════════════════════════════════════
function showNodeInfo(path) {
document.getElementById('info-title').textContent = path;
document.getElementById('info-body').textContent = 'Loading…';
openDlg('dlg-info');
sendCmd(`INFO ${path}`);
}
function showInfo(title, data) {
let pretty = data;
try { pretty = JSON.stringify(JSON.parse(data), null, 2); } catch(e){}
document.getElementById('info-title').textContent = title;
document.getElementById('info-body').textContent = pretty;
// If dialog not visible, open it
if (document.getElementById('dlg-info').style.display === 'none') openDlg('dlg-info');
}
// ════════════════════════════════════════════════════════════════════
// Connect / Disconnect
// ════════════════════════════════════════════════════════════════════
function requireConnected() {
if (!marteConnected) {
appendLog('sys', 'WARNING', 'Not connected to MARTe2');
return false;
}
return true;
}
function discoverCmd() { if (requireConnected()) { sendCmd('DISCOVER'); closeAllMenus(); } }
function treeCmd() { if (requireConnected()) { sendCmd('TREE'); closeAllMenus(); } }
function serviceInfoCmd() { if (requireConnected()) { sendCmd('SERVICE_INFO'); closeAllMenus(); } }
function toggleAutoPorts(auto) {
const row = document.getElementById('port-manual-row');
if (row) row.style.display = auto ? 'none' : 'flex';
}
function toggleConnect() {
if (marteConnected) {
sendWS({type:'disconnect'});
} else {
const host = document.getElementById('host').value.trim() || '127.0.0.1';
const port = parseInt(document.getElementById('port').value) || 8080;
const auto = document.getElementById('auto-ports').checked;
const udpPort = auto ? port + 1 : (parseInt(document.getElementById('udp-port').value) || port + 1);
const logPort = auto ? port + 2 : (parseInt(document.getElementById('log-port').value) || port + 2);
sendWS({type:'connect', data:{host, port, udp_port:udpPort, log_port:logPort}});
}
closeAllMenus();
}
// ════════════════════════════════════════════════════════════════════
// Plots
// ════════════════════════════════════════════════════════════════════
function addPlot() {
const id = ++plotSeq;
document.getElementById('no-plots-hint').style.display = 'none';
const card = document.createElement('div');
card.className = 'plot-card';
card.id = `plot-card-${id}`;
card.innerHTML = `
<div class="plot-header">
<span class="plot-title">Plot ${id}</span>
<label style="font-size:10px;color:#a6adc8">Max pts:
<input type="number" value="50000" min="100" max="100000" step="1000"
style="width:70px" onchange="setMaxPts(${id},this.value)">
</label>
<label style="font-size:10px"><input type="checkbox" checked onchange="setFollow(${id},this.checked)"> Follow</label>
<button onclick="fitPlot(${id})" style="font-size:10px;padding:1px 6px">Fit</button>
<button onclick="deletePlot(${id})" style="font-size:10px;padding:1px 6px;color:#f38ba8;border-color:#f38ba8">✕</button>
</div>
<div id="plot-series-${id}" class="plot-series-bar"></div>
<div id="plot-drop-${id}" class="plot-drop">Drop signals here to plot</div>
<div id="plot-wrap-${id}" class="uplot-wrap" style="display:none"></div>`;
document.getElementById('center-panel').appendChild(card);
// Counter to handle dragenter/dragleave across child elements reliably
let dragDepth = 0;
card.addEventListener('dragenter', e => {
e.preventDefault();
dragDepth++;
card.classList.add('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.add('over');
});
card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
card.addEventListener('dragleave', () => {
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
card.classList.remove('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.remove('over');
}
});
card.addEventListener('drop', e => {
e.preventDefault();
dragDepth = 0;
card.classList.remove('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.remove('over');
const name = e.dataTransfer.getData('text/plain');
if (name) addSignalToPlot(id, name);
});
const plotObj = {id, card, chart:null, series:[], maxPts:50000, follow:true, dirty:false};
plots.push(plotObj);
}
function findPlot(id) { return plots.find(p=>p.id===id); }
function deletePlot(id) {
const idx = plots.findIndex(p=>p.id===id);
if (idx>=0) { plots.splice(idx,1); }
document.getElementById(`plot-card-${id}`)?.remove();
if (plots.length === 0 && waterfalls.size === 0) document.getElementById('no-plots-hint').style.display = 'block';
}
function setMaxPts(id, v) { const p = findPlot(id); if(p) p.maxPts = parseInt(v)||50000; }
function setFollow(id, v) { const p = findPlot(id); if(p) { p.follow = v; if(v && p.chart) p.chart.setScale('x',{min:null,max:null}); } }
function fitPlot(id) { const p = findPlot(id); if(p&&p.chart) p.chart.setScale('x',{min:null,max:null}); }
function addSignalToPlot(id, name) {
const p = findPlot(id);
if (!p) return;
// Array signal (whole array, not a specific element like name[i]): show mode dialog
const meta = signalsByName[name];
const isElementRef = /\[\d+\]$/.test(name);
if (meta && meta.elements > 1 && !isElementRef) {
openArrayPlotDialog(id, name, meta.elements);
return;
}
if (p.series.find(s=>s.name===name)) return; // already added
// Ensure tracing
traceSignal(name, true);
const color = traceData[name]?.color || nextColor();
p.series.push({name, color, gain:1, offset:0});
updateSeriesBar(p);
rebuildChart(p);
p.dirty = true;
}
function removeSignalFromPlot(id, name) {
const p = findPlot(id);
if (!p) return;
p.series = p.series.filter(s=>s.name!==name);
// Clean up sequential state if no plot uses this seq series any more
if (name.endsWith('#seq')) {
const baseName = name.slice(0, -4);
const stillUsed = plots.some(pl => pl.series.some(s => s.name === name));
if (!stillUsed) delete seqState[baseName];
}
updateSeriesBar(p);
rebuildChart(p);
}
function updateSeriesBar(p) {
const bar = document.getElementById(`plot-series-${p.id}`);
if (!bar) return;
bar.innerHTML = '';
for (const s of p.series) {
const dispName = s.displayName || s.name;
const chip = document.createElement('span');
chip.className = 'series-chip';
chip.innerHTML =
`<span class="series-chip-dot" style="background:${s.color}"></span>` +
`<span class="series-chip-name" title="${esc(s.name)}">${esc(shortName(dispName))}</span>` +
`<button class="series-chip-rm" title="Remove signal">×</button>`;
chip.querySelector('.series-chip-rm').addEventListener('click', () => removeSignalFromPlot(p.id, s.name));
bar.appendChild(chip);
}
}
function rebuildChart(p) {
const wrap = document.getElementById(`plot-wrap-${p.id}`);
const drop = document.getElementById(`plot-drop-${p.id}`);
if (!wrap) return;
if (p.series.length === 0) {
if (p.chart) { p.chart.destroy(); p.chart = null; }
plotRO.unobserve(wrap);
wrap.innerHTML = '';
wrap.style.display = 'none';
if (drop) drop.style.display = 'flex';
return;
}
if (drop) drop.style.display = 'none';
wrap.style.display = 'flex';
if (p.chart) { p.chart.destroy(); p.chart = null; }
wrap.innerHTML = '';
// Build after the browser has laid out the flex container so clientHeight is real.
requestAnimationFrame(() => {
if (!document.getElementById(`plot-wrap-${p.id}`)) return; // deleted meanwhile
const w = Math.max(wrap.clientWidth - 8, 200);
const h = Math.max(wrap.clientHeight - 6, 120);
const seriesOpts = [{label:'time'}];
for (const s of p.series) {
seriesOpts.push({
label: shortName(s.name),
stroke: s.color,
width: 1.5,
points: {show: false},
});
}
const opts = {
width: w,
height: h,
series: seriesOpts,
axes: [
{
stroke: '#585b70',
ticks: {stroke:'#313244'},
grid: {stroke:'#313244'},
values: (_u, ts) => ts.map(t => fmtTime(t)),
},
{
stroke: '#585b70',
ticks: {stroke:'#313244'},
grid: {stroke:'#313244'},
size: 60,
}
],
legend: { show: true, live: false },
cursor: { drag: {x:true, y:false} },
hooks: {
ready: [uInst => {
const legend = uInst.root.querySelector('.u-legend');
if (legend) {
legend.querySelectorAll('.u-label').forEach((lbl, i) => {
if (i === 0) return;
lbl.title = 'Right-click to remove';
lbl.addEventListener('contextmenu', e => {
e.preventDefault();
const sName = p.series[i-1]?.name;
if (sName) removeSignalFromPlot(p.id, sName);
});
});
}
}]
},
};
const data = buildPlotData(p);
try {
p.chart = new uPlot(opts, data, wrap);
plotRO.observe(wrap);
} catch(e) {
console.error('uPlot error', e);
}
});
}
// ════════════════════════════════════════════════════════════════════
// LTTB (Largest-Triangle-Three-Buckets) downsampling
//
// Returns { xs, ys, indices } where indices[i] is the original array
// position chosen for output point i. Using indices lets us apply the
// same selection to co-plotted series without running LTTB on each one.
// ════════════════════════════════════════════════════════════════════
function lttbWithIndices(xs, ys, threshold) {
const n = xs.length;
if (n <= threshold) {
const idx = Array.from({length: n}, (_, i) => i);
return {xs, ys, indices: idx};
}
const outX = new Array(threshold);
const outY = new Array(threshold);
const outI = new Array(threshold);
outX[0] = xs[0]; outY[0] = ys[0]; outI[0] = 0;
outX[threshold - 1] = xs[n - 1];
outY[threshold - 1] = ys[n - 1];
outI[threshold - 1] = n - 1;
const bucketSize = (n - 2) / (threshold - 2);
let aIdx = 0;
for (let i = 0; i < threshold - 2; i++) {
// Average of the next bucket (used as the "future" anchor point).
const nextFrom = Math.floor((i + 1) * bucketSize) + 1;
const nextTo = Math.min(Math.floor((i + 2) * bucketSize) + 1, n);
let avgX = 0, avgY = 0;
for (let j = nextFrom; j < nextTo; j++) { avgX += xs[j]; avgY += ys[j]; }
const cnt = nextTo - nextFrom;
avgX /= cnt; avgY /= cnt;
// Pick the point in the current bucket that forms the largest triangle
// with point a (last selected) and the next-bucket average.
const from = Math.floor(i * bucketSize) + 1;
const to = Math.min(Math.floor((i + 1) * bucketSize) + 1, n);
const ax = xs[aIdx], ay = ys[aIdx];
let maxArea = -1, maxIdx = from;
for (let j = from; j < to; j++) {
const area = Math.abs((ax - avgX) * (ys[j] - ay) - (ax - xs[j]) * (avgY - ay));
if (area > maxArea) { maxArea = area; maxIdx = j; }
}
outX[i + 1] = xs[maxIdx];
outY[i + 1] = ys[maxIdx];
outI[i + 1] = maxIdx;
aIdx = maxIdx;
}
return {xs: outX, ys: outY, indices: outI};
}
function buildPlotData(p) {
const maxPts = p.maxPts;
// Step 1: LTTB each series independently so every series keeps its own
// timestamps. Sharing LTTB indices across series is only valid when all
// series share the same time base, which is not the case for signals from
// different RT threads running at different rates.
const dsTs = [];
const dsVs = [];
for (const s of p.series) {
const td = traceData[s.name];
const rawTs = td ? td.ts : [];
if (rawTs.length === 0) { dsTs.push([]); dsVs.push([]); continue; }
const scaled = td.vals.map(v => v * s.gain + s.offset);
if (rawTs.length > maxPts) {
const r = lttbWithIndices(rawTs, scaled, maxPts);
dsTs.push(r.xs);
dsVs.push(r.ys);
} else {
dsTs.push(rawTs.slice());
dsVs.push(scaled);
}
}
// Step 2: Build a sorted merged time axis from all downsampled series.
const allTsSet = new Set();
for (const ts of dsTs) for (const t of ts) allTsSet.add(t);
if (allTsSet.size === 0) return [[]];
const times = [...allTsSet].sort((a, b) => a - b);
// Step 3: For each series, place values only at the timestamps that belong
// to that series; all other merged times stay NaN. uPlot then draws straight
// lines between consecutive known points — no staircase artefact for signals
// at different sample rates (e.g. a 1 Hz scalar alongside a 100-pt/s
// sequential array series).
const data = [times];
for (let i = 0; i < p.series.length; i++) {
const ts = dsTs[i];
const vs = dsVs[i];
if (ts.length === 0) { data.push(new Array(times.length).fill(NaN)); continue; }
const tsMap = new Map();
for (let j = 0; j < ts.length; j++) tsMap.set(ts[j], vs[j]);
data.push(times.map(t => { const v = tsMap.get(t); return v !== undefined ? v : NaN; }));
}
return data;
}
// ════════════════════════════════════════════════════════════════════
// Array → plot: dialog + sequential + waterfall
// ════════════════════════════════════════════════════════════════════
const _arrpDescs = {
sequential: 'Unroll elements as a dense time series — timestamps interpolated between consecutive samples.',
waterfall: '2D heatmap (index × time) — array index on X-axis, newest sample at bottom.',
};
let _arrpMode = 'sequential';
function setArrpMode(mode) {
_arrpMode = mode;
['sequential', 'waterfall'].forEach(id => {
const btn = document.getElementById(`arrp-sel-${id}`);
if (btn) btn.className = (id === mode) ? 'active' : '';
});
const desc = document.getElementById('arrp-desc');
if (desc) desc.textContent = _arrpDescs[mode] || '';
}
function openArrayPlotDialog(plotId, name, nEl) {
_arrpPlotId = plotId;
_arrpName = name;
_arrpElem = nEl;
document.getElementById('arrp-name').textContent = name;
document.getElementById('arrp-n').textContent = nEl;
setArrpMode('sequential');
document.getElementById('dlg-arr-plot').style.display = 'flex';
}
function doArrayPlotMode() {
closeDlg('dlg-arr-plot');
if (_arrpMode === 'sequential') {
_addSequentialToPlot(_arrpPlotId, _arrpName, _arrpElem);
} else {
_addWaterfall(_arrpName, _arrpElem);
}
}
// ── Sequential ──────────────────────────────────────────────────────
function _addSequentialToPlot(plotId, name, nEl) {
const p = findPlot(plotId);
if (!p) return;
const seqName = name + '#seq';
if (p.series.find(s => s.name === seqName)) return;
// Ensure base signal is traced (we receive all elements as a bundle)
sendCmd(`TRACE ${name} 1`);
tracedSet.add(name);
if (!traceData[seqName]) traceData[seqName] = {ts:[], vals:[], lastVal:0, color: nextColor()};
if (!seqState[name]) seqState[name] = {seqName, nEl, lastTs: null};
const color = traceData[seqName].color;
// displayName shown in the series chip
p.series.push({name: seqName, color, gain:1, offset:0,
displayName: shortName(name) + ' [seq]'});
updateSeriesBar(p);
rebuildChart(p);
p.dirty = true;
}
function _processSequential(baseName, ts, vals) {
const st = seqState[baseName];
if (!st) return;
const td = traceData[st.seqName];
if (!td) return;
const prevTs = st.lastTs;
if (prevTs !== null) {
const n = Math.min(st.nEl, vals.length);
const dt = ts - prevTs;
for (let e = 0; e < n; e++) {
td.ts.push(prevTs + (e + 1) / n * dt);
td.vals.push(vals[e]);
}
td.lastVal = vals[n - 1];
if (td.ts.length > MAX_TRACE_PTS) {
const trim = td.ts.length - MAX_TRACE_PTS;
td.ts.splice(0, trim);
td.vals.splice(0, trim);
}
}
st.lastTs = ts;
}
// ── Waterfall ───────────────────────────────────────────────────────
function _wfViridis(t) {
const stops = [[68,1,84],[58,82,139],[32,144,141],[94,201,98],[253,231,37]];
t = Math.max(0, Math.min(1, t));
const i = t * (stops.length - 1);
const lo = Math.floor(i);
const hi = Math.min(lo + 1, stops.length - 1);
const f = i - lo;
return [
(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f) | 0,
(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f) | 0,
(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f) | 0,
];
}
function _addWaterfall(name, nEl) {
wfSeq++;
const id = wfSeq;
const nRows = 200;
const card = document.createElement('div');
card.className = 'plot-card';
card.id = `wf-card-${id}`;
card.innerHTML =
`<div class="plot-header">` +
`<span class="plot-title">Waterfall: ${esc(shortName(name))}</span>` +
`<span style="display:flex;align-items:center;gap:6px;font-size:10px;color:#a6adc8">` +
`Min:<input type="number" id="wf-min-${id}" value="0" style="width:56px" onchange="setWfRange(${id})">` +
`Max:<input type="number" id="wf-max-${id}" value="1" style="width:56px" onchange="setWfRange(${id})">` +
`<label style="display:flex;align-items:center;gap:4px">` +
`<input type="checkbox" id="wf-auto-${id}" checked onchange="setWfRange(${id})"> Auto` +
`</label>` +
`</span>` +
`<button onclick="deleteWaterfall(${id})" style="font-size:10px;padding:1px 6px;color:#f38ba8;border-color:#f38ba8">✕</button>` +
`</div>` +
`<div id="wf-wrap-${id}" style="flex:1;min-height:0;overflow:hidden">` +
`<canvas id="wf-canvas-${id}" style="width:100%;height:100%;display:block;image-rendering:pixelated"></canvas>` +
`</div>`;
document.getElementById('center-panel').appendChild(card);
document.getElementById('no-plots-hint').style.display = 'none';
const canvas = document.getElementById(`wf-canvas-${id}`);
const ctx = canvas.getContext('2d');
// Circular row buffer
const rows = [];
const rowMin = [];
const rowMax = [];
for (let r = 0; r < nRows; r++) {
rows.push(new Float32Array(nEl));
rowMin.push(0); rowMax.push(0);
}
const wf = {id, card, canvas, ctx, name, nEl, nRows,
rows, rowMin, rowMax,
colorMin: 0, colorMax: 1, autoRange: true, dirty: false};
waterfalls.set(id, wf);
_wfResize(wf);
// Lazy-init the shared ResizeObserver
if (!wfRO) {
wfRO = new ResizeObserver(entries => {
for (const en of entries) {
const m = en.target.id.match(/^wf-wrap-(\d+)$/);
if (m) { const w = waterfalls.get(parseInt(m[1])); if (w) _wfResize(w); }
}
});
}
wfRO.observe(document.getElementById(`wf-wrap-${id}`));
// Ensure base signal is traced (all elements in each packet)
sendCmd(`TRACE ${name} 1`);
tracedSet.add(name);
}
function _wfResize(wf) {
const wrap = document.getElementById(`wf-wrap-${wf.id}`);
if (!wrap) return;
wf.canvas.width = wf.nEl;
wf.canvas.height = wf.nRows;
_wfRedraw(wf);
}
function _wfRedraw(wf) {
const {ctx, nEl, nRows, rows, rowMin, rowMax} = wf;
let mn = wf.colorMin, mx = wf.colorMax;
if (wf.autoRange) {
mn = Infinity; mx = -Infinity;
for (let r = 0; r < nRows; r++) { if (rowMin[r] < mn) mn = rowMin[r]; if (rowMax[r] > mx) mx = rowMax[r]; }
if (!isFinite(mn) || mn === mx) { mn = 0; mx = 1; }
}
const img = ctx.createImageData(nEl, nRows);
const d = img.data;
const rng = mx - mn || 1;
for (let r = 0; r < nRows; r++) {
const row = rows[r];
for (let c = 0; c < nEl; c++) {
const t = (row[c] - mn) / rng;
const [R, G, B] = _wfViridis(t);
const i = (r * nEl + c) * 4;
d[i] = R; d[i+1] = G; d[i+2] = B; d[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function _wfAddRow(wf, vals) {
// Shift rows up, append new row at bottom
wf.rows.shift(); wf.rowMin.shift(); wf.rowMax.shift();
const row = new Float32Array(wf.nEl);
let mn = Infinity, mx = -Infinity;
const n = Math.min(wf.nEl, vals.length);
for (let c = 0; c < n; c++) {
row[c] = vals[c];
if (vals[c] < mn) mn = vals[c];
if (vals[c] > mx) mx = vals[c];
}
wf.rows.push(row); wf.rowMin.push(mn); wf.rowMax.push(mx);
wf.dirty = true;
}
function setWfRange(id) {
const wf = waterfalls.get(id);
if (!wf) return;
wf.autoRange = document.getElementById(`wf-auto-${id}`).checked;
if (!wf.autoRange) {
wf.colorMin = parseFloat(document.getElementById(`wf-min-${id}`).value) || 0;
wf.colorMax = parseFloat(document.getElementById(`wf-max-${id}`).value) || 1;
}
_wfRedraw(wf);
}
function deleteWaterfall(id) {
const wf = waterfalls.get(id);
if (!wf) return;
if (wfRO) { const w = document.getElementById(`wf-wrap-${id}`); if (w) wfRO.unobserve(w); }
waterfalls.delete(id);
wf.card.remove();
if (plots.length === 0 && waterfalls.size === 0)
document.getElementById('no-plots-hint').style.display = 'block';
}
// ════════════════════════════════════════════════════════════════════
// Render loop
// ════════════════════════════════════════════════════════════════════
function renderLoop() {
for (const p of plots) {
if (p.dirty && p.chart && p.series.length > 0) {
const data = buildPlotData(p);
try { p.chart.setData(data, p.follow); } catch(e){}
p.dirty = false;
}
}
for (const [, wf] of waterfalls) {
if (wf.dirty) { _wfRedraw(wf); wf.dirty = false; }
}
if (logDirty) { flushLogs(); logDirty = false; }
requestAnimationFrame(renderLoop);
}
// ════════════════════════════════════════════════════════════════════
// Logs
// ════════════════════════════════════════════════════════════════════
function appendLog(time, level, message) {
logs.push({time: time==='sys'?now():time, level, message});
if (logs.length > MAX_LOGS) logs.shift();
logDirty = true;
}
function flushLogs() {
renderLogs();
}
function renderLogs() {
logDirty = false;
const body = document.getElementById('log-body');
const showService = document.getElementById('lf-service').checked;
const showDebug = document.getElementById('lf-debug').checked;
const showInfo = document.getElementById('lf-info').checked;
const showWarn = document.getElementById('lf-warn').checked;
const showError = document.getElementById('lf-error').checked;
const filter = document.getElementById('log-filter').value.toLowerCase();
const visible = logs.filter(l => {
const lv = l.level;
if ((lv==='CMD' || lv==='RESP') && !showService) return false;
if (lv==='DEBUG' && !showDebug) return false;
if ((lv==='INFO'||lv==='Information') && !showInfo) return false;
if ((lv==='WARNING'||lv==='Warning') && !showWarn) return false;
if ((lv==='ERROR'||lv==='FatalError'||lv==='FATAL'||lv==='WARN') && !showError) return false;
if (filter && !l.message.toLowerCase().includes(filter)) return false;
return true;
}).slice(-500);
body.innerHTML = visible.map(l =>
`<div class="log-line ${esc(l.level)}">` +
`<span class="log-time">${esc(l.time)}</span>` +
`<span class="log-lvl">${esc(l.level)}</span>` +
`<span class="log-msg" title="${esc(l.message)}">${esc(l.message)}</span>` +
`</div>`
).join('');
if (document.getElementById('log-autoscroll').checked) {
body.scrollTop = body.scrollHeight;
}
}
// ════════════════════════════════════════════════════════════════════
// Tab switching
// ════════════════════════════════════════════════════════════════════
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t,i)=>{
const names=['traced','forced','breaks','msgs'];
t.classList.toggle('active', names[i]===name);
});
document.querySelectorAll('.tab-content').forEach(t=>{
t.classList.remove('active');
});
document.getElementById(`tab-${name}`)?.classList.add('active');
}
// ════════════════════════════════════════════════════════════════════
// Dialogs
// ════════════════════════════════════════════════════════════════════
function openDlg(id) { document.getElementById(id).style.display='flex'; }
function closeDlg(id) { document.getElementById(id).style.display='none'; }
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.dialog-overlay').forEach(d => d.style.display='none');
}
});
// ════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════
let _colorIdx = 0;
function nextColor() { return COLORS[_colorIdx++ % COLORS.length]; }
function shortName(name) {
const parts = name.split('.');
return parts.length > 2 ? '…'+parts.slice(-2).join('.') : name;
}
function fmtVal(v) {
if (v === undefined || v === null || isNaN(v)) return '—';
if (Math.abs(v) < 1e-3 && v !== 0) return v.toExponential(3);
if (Math.abs(v) >= 1e6) return v.toExponential(3);
return v.toPrecision(6).replace(/\.?0+$/, '');
}
function fmtTime(t) {
if (t == null) return '';
const s = t;
const h = Math.floor(s/3600);
const m = Math.floor((s%3600)/60);
const sec = (s%60).toFixed(2);
if (h>0) return `${h}:${String(m).padStart(2,'0')}:${sec.padStart(5,'0')}`;
if (m>0) return `${m}:${sec.padStart(5,'0')}`;
return `${sec}s`;
}
function now() {
return new Date().toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
}
function esc(s) {
if (s==null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ════════════════════════════════════════════════════════════════════
// Resize observer — per plot-wrap, keeps uPlot size in sync
// ════════════════════════════════════════════════════════════════════
const plotRO = new ResizeObserver(entries => {
for (const entry of entries) {
const wrap = entry.target;
const m = wrap.id.match(/^plot-wrap-(\d+)$/);
if (!m) continue;
const p = findPlot(parseInt(m[1]));
if (!p || !p.chart) continue;
const w = Math.max(wrap.clientWidth - 8, 200);
const h = Math.max(wrap.clientHeight - 6, 120);
try { p.chart.setSize({width: w, height: h}); } catch(e) {}
}
});
// ════════════════════════════════════════════════════════════════════
// Dropdown menus
// ════════════════════════════════════════════════════════════════════
function toggleMenu(id) {
const el = document.getElementById(id);
if (!el) return;
const isOpen = el.classList.contains('open');
closeAllMenus();
if (!isOpen) el.classList.add('open');
}
function closeAllMenus() {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
}
// Close menus when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.menu-wrap')) closeAllMenus();
});
// ════════════════════════════════════════════════════════════════════
// Panel collapse
// ════════════════════════════════════════════════════════════════════
function togglePanelStrip(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const wasCollapsed = panel.classList.toggle('collapsed');
if (!wasCollapsed) {
// Expanding: restore the last manual width if one was saved
const saved = panel.dataset.savedWidth;
if (saved) panel.style.width = saved;
}
}
function togglePanel(panelId, collapseIcon, expandIcon, btn) {
const panel = document.getElementById(panelId);
if (!panel) return;
const collapsed = panel.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? expandIcon : collapseIcon;
}
function setupPanelResize(stripId, panelId, growDir) {
// growDir: 'right' means dragging right widens the panel (left panel),
// 'left' means dragging left widens the panel (right panel).
const strip = document.getElementById(stripId);
const panel = document.getElementById(panelId);
if (!strip || !panel) return;
strip.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startW = panel.offsetWidth;
let moved = false;
const onMove = ev => {
const dx = ev.clientX - startX;
if (!moved && Math.abs(dx) < 4) return;
moved = true;
// Expand collapsed panel as soon as drag begins
if (panel.classList.contains('collapsed')) {
panel.classList.remove('collapsed');
}
panel.style.transition = 'none';
document.body.style.cursor = 'col-resize';
const newW = Math.max(120, Math.min(700,
growDir === 'right' ? startW + dx : startW - dx));
panel.style.width = newW + 'px';
panel.dataset.savedWidth = panel.style.width;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
panel.style.transition = '';
document.body.style.cursor = '';
if (!moved) {
// Plain click — toggle collapse
togglePanelStrip(panelId);
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ════════════════════════════════════════════════════════════════════
// Boot
// ════════════════════════════════════════════════════════════════════
connectWS();
requestAnimationFrame(renderLoop);
setupPanelResize('left-strip', 'left-panel', 'right');
setupPanelResize('right-strip', 'right-panel', 'left');
addPlot();