bceab8607c
Improved ui and tree export
2099 lines
81 KiB
JavaScript
2099 lines
81 KiB
JavaScript
/* 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 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();
|