1407 lines
54 KiB
JavaScript
1407 lines
54 KiB
JavaScript
/* global uPlot */
|
||
'use strict';
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// State
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const MAX_TRACE_PTS = 100000;
|
||
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
|
||
let forcedSignals = {};
|
||
// 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;
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 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_clear': onTreeClear(msg.total); break;
|
||
case 'tree_batch': onTreeBatch(msg); break;
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// Incremental tree rendering (server-side pre-processed batches)
|
||
//
|
||
// Batches are stored purely in memory — no DOM work during receipt.
|
||
// DOM nodes are created lazily: only the top-level containers are
|
||
// built immediately; everything inside a closed <details> is deferred
|
||
// until the user opens it. This keeps the initial render O(1) in the
|
||
// number of signals regardless of tree size.
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
let _treeBuild = null; // { threads:[], objects:[] } — null when idle
|
||
let _flatNodes = null; // Map<path, FlatNode>
|
||
let _childrenOf = null; // Map<parentPath|'', FlatNode[]>
|
||
|
||
function onTreeClear(total) {
|
||
_treeBuild = { threads: [], objects: [] };
|
||
_flatNodes = new Map();
|
||
_childrenOf = new Map();
|
||
document.getElementById('tree-body').innerHTML = '';
|
||
appendLog('sys', 'INFO', `Receiving tree (${total} nodes)…`);
|
||
}
|
||
|
||
function onTreeBatch(msg) {
|
||
if (!_treeBuild) return;
|
||
// Pure memory work — no DOM touched here.
|
||
for (const node of (msg.nodes || [])) {
|
||
_flatNodes.set(node.p, node);
|
||
const pk = node.par || '';
|
||
let ch = _childrenOf.get(pk);
|
||
if (!ch) { ch = []; _childrenOf.set(pk, ch); }
|
||
ch.push(node);
|
||
if (node.c === 'RealTimeThread' && node.n) _treeBuild.threads.push(node.n);
|
||
if (node.c && node.c !== 'Signal' && node.n) _treeBuild.objects.push(node.p);
|
||
}
|
||
|
||
if (!msg.done) return;
|
||
|
||
// All batches received — build only top-level nodes now.
|
||
const body = document.getElementById('tree-body');
|
||
const frag = document.createDocumentFragment();
|
||
for (const node of (_childrenOf.get('') || [])) {
|
||
frag.appendChild(_buildFlatNode(node));
|
||
}
|
||
body.appendChild(frag);
|
||
|
||
// Finalise autocompletes.
|
||
threads = _treeBuild.threads;
|
||
allObjects = _treeBuild.objects;
|
||
['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('');
|
||
|
||
_treeBuild = null;
|
||
_treeReady = true;
|
||
appendLog('sys', 'INFO', `Tree ready — ${allObjects.length} objects, ${threads.length} threads`);
|
||
startStepPoll();
|
||
}
|
||
|
||
// Build one flat node into a DOM element. Containers install a toggle
|
||
// listener that builds their children on first open (lazy load).
|
||
function _buildFlatNode(node) {
|
||
if (node.ch) {
|
||
const details = document.createElement('details');
|
||
const summary = document.createElement('summary');
|
||
summary.innerHTML =
|
||
`<span class="tree-name">${esc(node.n)}</span>` +
|
||
`<span class="tree-class">[${esc(node.c)}]</span>`;
|
||
details.appendChild(summary);
|
||
details.addEventListener('toggle', function() {
|
||
if (!details.open || details._childrenBuilt) return;
|
||
details._childrenBuilt = true;
|
||
const childDiv = document.createElement('div');
|
||
childDiv.className = 'children';
|
||
const infoBtn = makeLeafButtons(node.p);
|
||
if (infoBtn) childDiv.appendChild(infoBtn);
|
||
const f = document.createDocumentFragment();
|
||
for (const child of (_childrenOf.get(node.p) || [])) {
|
||
f.appendChild(_buildFlatNode(child));
|
||
}
|
||
childDiv.appendChild(f);
|
||
details.appendChild(childDiv);
|
||
});
|
||
return details;
|
||
}
|
||
|
||
// Leaf node.
|
||
const isSig = node.c === 'Signal' || node.tr;
|
||
const item = { Name: node.n, Class: node.c, IsTraceable: node.tr, IsForcable: node.fo };
|
||
const row = document.createElement('div');
|
||
row.className = 'tree-node';
|
||
row.appendChild(makeLeafRow(node.p, item, isSig));
|
||
if (isSig && node.el > 1) {
|
||
const sub = document.createElement('div');
|
||
sub.style.paddingLeft = '14px';
|
||
for (let i = 0; i < node.el; i++) {
|
||
const en = `${node.p}[${i}]`;
|
||
sub.appendChild(makeLeafRow(en,
|
||
{ Name:`[${i}]`, Class:'Signal', IsTraceable:true, IsForcable:node.fo }, true));
|
||
}
|
||
row.appendChild(sub);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
// Force-build all lazy containers (used before a DOM-wide filter search).
|
||
function _buildAllTreeNodes(root) {
|
||
let dirty = true;
|
||
while (dirty) {
|
||
dirty = false;
|
||
root.querySelectorAll('details').forEach(d => {
|
||
if (!d._childrenBuilt) { d.open = true; dirty = true; }
|
||
});
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 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;
|
||
stopStepPoll(); // always kill any running poll when state is reset
|
||
}
|
||
|
||
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) processTree(data); break; // fallback if Go parse 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
updateTracedValues();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// Discovery
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
function processDiscovery(sigs) {
|
||
signalsById = {};
|
||
signalsByName = {};
|
||
for (const s of sigs) {
|
||
const el = s.elements || 1;
|
||
const meta = {id:s.id, name:s.name, type:s.type||'', elements:el, dimensions:s.dimensions||0, names:[s.name]};
|
||
signalsById[s.id] = meta;
|
||
signalsByName[s.name] = meta;
|
||
// Array element names
|
||
if (el > 1) {
|
||
for (let i=0; i<el; i++) signalsByName[`${s.name}[${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
|
||
const row = document.createElement('div');
|
||
row.className = 'tree-node';
|
||
const leaf = makeLeafRow(path, item, isSig);
|
||
row.appendChild(leaf);
|
||
|
||
// Array expansion
|
||
if (isSig && item.Elements > 1) {
|
||
const sub = document.createElement('div');
|
||
sub.style.paddingLeft = '14px';
|
||
for (let i = 0; i < item.Elements; i++) {
|
||
const en = `${path}[${i}]`;
|
||
sub.appendChild(makeLeafRow(en, {Name:`[${i}]`, Class:'Signal', IsTraceable:true, IsForcable:item.IsForcable}, true));
|
||
}
|
||
row.appendChild(sub);
|
||
}
|
||
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);
|
||
|
||
// Info
|
||
const btnI = mkBtn('i', '', () => showNodeInfo(path));
|
||
div.appendChild(btnI);
|
||
|
||
if (isSig || item.IsTraceable) {
|
||
const btnT = mkBtn('T', 't', () => traceSignal(path, true));
|
||
btnT.title = 'Trace';
|
||
div.appendChild(btnT);
|
||
}
|
||
if (item.IsForcable || isSig) {
|
||
const btnF = mkBtn('F', 'f', () => openForceDialogFor(path));
|
||
btnF.title = 'Force';
|
||
div.appendChild(btnF);
|
||
}
|
||
if (isSig || item.IsTraceable) {
|
||
const btnB = mkBtn('B', 'b', () => openBreakDialogFor(path));
|
||
btnB.title = 'Break';
|
||
div.appendChild(btnB);
|
||
}
|
||
return div;
|
||
}
|
||
|
||
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) {
|
||
// Build all lazy nodes so the querySelectorAll below sees everything.
|
||
_buildAllTreeNodes(body);
|
||
body.querySelectorAll('details').forEach(d => 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) {
|
||
if (enable) {
|
||
tracedSet.add(name);
|
||
if (!traceData[name]) traceData[name] = {ts:[], vals:[], lastVal:0, color: nextColor()};
|
||
sendCmd(`TRACE ${name} 1`);
|
||
} else {
|
||
tracedSet.delete(name);
|
||
sendCmd(`TRACE ${name} 0`);
|
||
}
|
||
renderTracedTab();
|
||
}
|
||
|
||
function renderTracedTab() {
|
||
const tab = document.getElementById('tab-traced');
|
||
const hint = document.getElementById('no-traced-hint');
|
||
hint.style.display = tracedSet.size ? 'none' : 'block';
|
||
// Remove old sig-items
|
||
tab.querySelectorAll('.sig-item').forEach(e=>e.remove());
|
||
let colorIdx = 0;
|
||
for (const name of tracedSet) {
|
||
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';
|
||
// Stop drag from starting when clicking the button
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 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 renderForcedTab() {
|
||
const tab = document.getElementById('tab-forced');
|
||
tab.querySelectorAll('.sig-item').forEach(e=>e.remove());
|
||
const hint = document.getElementById('no-forced-hint');
|
||
const keys = Object.keys(forcedSignals);
|
||
hint.style.display = keys.length ? 'none' : 'block';
|
||
for (const name of keys) {
|
||
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) 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;
|
||
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);
|
||
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 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(s.name))}</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);
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildPlotData(p) {
|
||
const maxPts = p.maxPts;
|
||
// Find common time range: union of all series timestamps
|
||
// uPlot requires aligned data — we use each series' own timestamps
|
||
// For simplicity use the first non-empty series timestamps as X axis,
|
||
// and for other series linearly interpolate.
|
||
// Simpler approach: collect all unique timestamps (sorted) and use NaN for missing.
|
||
|
||
// Per-series data
|
||
const seriesTs = p.series.map(s => {
|
||
const td = traceData[s.name];
|
||
return td ? td.ts : [];
|
||
});
|
||
const seriesVs = p.series.map(s => {
|
||
const td = traceData[s.name];
|
||
if (!td) return [];
|
||
return td.vals.map(v => v * s.gain + s.offset);
|
||
});
|
||
|
||
// Use first series with data as the X reference, or merge all
|
||
let xRef = [];
|
||
for (const ts of seriesTs) if (ts.length > 0) { xRef = ts; break; }
|
||
if (xRef.length === 0) return [[]];
|
||
|
||
// Trim to maxPts
|
||
const start = Math.max(0, xRef.length - maxPts);
|
||
const times = xRef.slice(start);
|
||
|
||
const data = [times];
|
||
for (let i = 0; i < p.series.length; i++) {
|
||
const ts = seriesTs[i];
|
||
const vs = seriesVs[i];
|
||
if (ts.length === 0) {
|
||
data.push(new Array(times.length).fill(NaN));
|
||
continue;
|
||
}
|
||
const s = Math.max(0, ts.length - maxPts);
|
||
data.push(vs.slice(s));
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 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;
|
||
}
|
||
}
|
||
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 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==='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();
|