Files
marte-debug/Tools/go_web_client/static/app.js
T
2026-05-19 16:39:12 +02:00

1128 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global uPlot */
'use strict';
// ════════════════════════════════════════════════════════════════════
// State
// ════════════════════════════════════════════════════════════════════
const MAX_TRACE_PTS = 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;
}
}
function onMarteConnected() {
marteConnected = true;
document.getElementById('conn-status').classList.add('ok');
document.getElementById('btn-connect').textContent = 'Disconnect';
appendLog('sys','INFO','Connected to MARTe2');
// Auto-discover
sendCmd('DISCOVER');
sendCmd('TREE');
// Start polling step status
startStepPoll();
}
function onMarteDisconnected() {
marteConnected = false;
document.getElementById('conn-status').classList.remove('ok');
document.getElementById('btn-connect').textContent = 'Connect';
appendLog('sys','WARNING','Disconnected from MARTe2');
stopStepPoll();
}
function onSignalCache(msg) {
// Server sends cached signal list when a new browser connects mid-session
if (msg.signals) processDiscovery(msg.signals);
}
function onResponse(msg) {
try {
const data = msg.data ? JSON.parse(msg.data) : null;
switch (msg.tag) {
case 'DISCOVER': if (data) processDiscovery(data.Signals || []); break;
case 'TREE': if (data) processTree(data); break;
case 'INFO': showInfo(msg.tag, msg.data); break;
case 'CONFIG': showInfo('CONFIG', 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) {
appendLog('sys','INFO',`ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}`);
}
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 || [];
for (let e = 0; e < vals.length; e++) {
const n = vals.length > 1 ? `${names[0]}[${e}]` : (names[0] || '');
if (!n || !tracedSet.has(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];
// Trim to max points
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);
}
}
// Mark plots dirty
for (const p of plots) {
for (const sr of p.series) {
if (names.includes(sr.name) || (vals.length>1 && names.length>0 &&
p.series.some(x => x.name.startsWith(names[0])))) {
p.dirty = true;
break;
}
}
}
}
// Update only the value spans — never rebuild the DOM here
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 = [];
const body = document.getElementById('tree-body');
body.innerHTML = '';
if (root.Name === 'Root' && root.Children) {
for (const child of root.Children) body.appendChild(buildTreeNode(child, ''));
} else {
body.appendChild(buildTreeNode(root, ''));
}
// 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('');
}
function buildTreeNode(item, parentPath) {
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
const isSig = item.Class === 'Signal' || item.IsTraceable;
const isThread = item.Class === 'RealTimeThread';
if (isThread && item.Name) threads.push(item.Name);
if (item.Class && item.Class !== 'Signal' && item.Name) allObjects.push(path);
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);
const children = document.createElement('div');
children.className = 'children';
// Info button for container nodes
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();
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';
});
if (q) {
body.querySelectorAll('details').forEach(d => d.open = true);
}
}
// ════════════════════════════════════════════════════════════════════
// 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() {
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
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 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;
sendWS({type:'connect', data:{host, port, udp_port:port+1, log_port:port+2}});
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ════════════════════════════════════════════════════════════════════
// Resize observer — per plot-wrap, keeps uPlot size in sync
// ════════════════════════════════════════════════════════════════════
const plotRO = new ResizeObserver(entries => {
for (const entry of entries) {
const wrap = entry.target;
const m = wrap.id.match(/^plot-wrap-(\d+)$/);
if (!m) continue;
const p = findPlot(parseInt(m[1]));
if (!p || !p.chart) continue;
const w = Math.max(wrap.clientWidth - 8, 200);
const h = Math.max(wrap.clientHeight - 6, 120);
try { p.chart.setSize({width: w, height: h}); } catch(e) {}
}
});
// ════════════════════════════════════════════════════════════════════
// Dropdown menus
// ════════════════════════════════════════════════════════════════════
function toggleMenu(id) {
const el = document.getElementById(id);
if (!el) return;
const isOpen = el.classList.contains('open');
closeAllMenus();
if (!isOpen) el.classList.add('open');
}
function closeAllMenus() {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
}
// Close menus when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.menu-wrap')) closeAllMenus();
});
// ════════════════════════════════════════════════════════════════════
// Panel collapse
// ════════════════════════════════════════════════════════════════════
function togglePanelStrip(panelId, stripId, collapseIcon, expandIcon) {
const panel = document.getElementById(panelId);
const strip = document.getElementById(stripId);
if (!panel) return;
const collapsed = panel.classList.toggle('collapsed');
if (strip) strip.textContent = collapsed ? expandIcon : collapseIcon;
}
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;
}
// ════════════════════════════════════════════════════════════════════
// Boot
// ════════════════════════════════════════════════════════════════════
connectWS();
requestAnimationFrame(renderLoop);