/* 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, 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, 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 returns the immediate children of that node. // Each
element sends TREE 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
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
that fires TREE 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 = `${esc(child.Name)}` + `[${esc(child.Class)}]`; 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 = '' + threads.map(t => ``).join(''); } else if (el.tagName === 'DATALIST') { el.innerHTML = threads.map(t => `