Files
MARTe_IO_Components/Client/WebUI/static/index.html
T
Martino Ferrari 82005ec5d3 Fix WebUI rolling-window and buffer-wrap display bugs
Replace fragile zoomGuard boolean with userInteracting flag so that
programmatic scale updates (rolling window, resize, fit) never lock
p.xRange.  Only genuine mouse drag or scroll-wheel events on the uPlot
canvas set userInteracting=true and allow onZoom to freeze the view.

Also move stale-xRange detection out of the needsRedraw gate so that a
plot whose circular buffer has scrolled past a frozen zoom range
automatically returns to rolling-window mode every frame, fixing the
second bug where data disappeared as the buffer wrapped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:48:31 +02:00

1542 lines
71 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MARTe2 UDP Streamer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<style>
/* ── Catppuccin Mocha palette ──────────────────────────────── */
:root {
--bg: #1e1e2e; --mantle: #181825; --crust: #11111b;
--surface0: #313244; --surface1: #45475a; --surface2: #585b70;
--overlay0: #6c7086; --overlay1: #7f849c; --text: #cdd6f4;
--subtext0: #a6adc8; --subtext1: #bac2de;
--accent: #89b4fa; --green: #a6e3a1; --red: #f38ba8;
--yellow: #f9e2af; --peach: #fab387; --mauve: #cba6f7;
--teal: #94e2d5; --sky: #89dceb; --lavender: #b4befe;
--pink: #f5c2e7;
--radius: 8px; --sidebar-w: 280px; --topbar-h: 52px;
--trigbar-h: 0px; --transition: 0.18s ease;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height:100%; background:var(--bg); color:var(--text);
font-family:'Segoe UI',system-ui,sans-serif; font-size:14px; overflow:hidden; }
::-webkit-scrollbar { width:6px; }
::-webkit-scrollbar-track { background:var(--mantle); }
::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:3px; }
/* ── uPlot overrides ─────────────────────────────────────────── */
.uplot { background:transparent !important; }
.uplot .u-over { cursor: crosshair; }
.uplot .u-cursor-x, .uplot .u-cursor-y { border-color: #585b70 !important; }
.uplot .u-select { background: rgba(137,180,250,0.1) !important; border: 1px solid rgba(137,180,250,0.4) !important; }
/* ── Top bar ─────────────────────────────────────────────────── */
#topbar {
position:fixed; top:0; left:0; right:0; height:var(--topbar-h);
background:var(--mantle); border-bottom:1px solid var(--surface0);
display:flex; align-items:center; gap:10px; padding:0 14px;
z-index:100; box-shadow:0 2px 8px rgba(0,0,0,0.4); overflow:hidden;
}
#app-title { font-weight:700; font-size:15px; color:var(--accent);
white-space:nowrap; letter-spacing:0.3px; flex-shrink:0; }
.topbar-sep { flex:1; min-width:4px; }
#status-led { width:12px; height:12px; border-radius:50%;
background:var(--red); flex-shrink:0; transition:background var(--transition); }
#status-led.green { background:var(--green); animation:pulse-green 2s infinite; }
#status-led.orange { background:var(--yellow); animation:pulse-orange 1.5s infinite; }
@keyframes pulse-green { 0%,100%{box-shadow:0 0 0 0 rgba(166,227,161,0.4)} 50%{box-shadow:0 0 0 5px rgba(166,227,161,0)} }
@keyframes pulse-orange { 0%,100%{box-shadow:0 0 0 0 rgba(249,226,175,0.4)} 50%{box-shadow:0 0 0 5px rgba(249,226,175,0)} }
#status-text { font-size:12px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
#cursor-readout {
display:none; align-items:center; gap:8px;
font-size:11px; font-family:monospace;
background:var(--surface0); border:1px solid var(--surface1);
border-radius:5px; padding:3px 8px; white-space:nowrap; flex-shrink:0;
}
#cursor-readout.visible { display:flex; }
#cur-ta { color:var(--sky); } #cur-tb { color:var(--yellow); }
#cur-dt { color:var(--subtext1); } .cur-sep { color:var(--surface2); }
.ctrl-label { font-size:12px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
select.ctrl-select {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:var(--radius);
padding:4px 6px; font-size:12px; cursor:pointer; outline:none; flex-shrink:0;
}
select.ctrl-select:hover { border-color:var(--accent); }
button.ctrl-btn {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:var(--radius);
padding:4px 12px; font-size:12px; cursor:pointer; white-space:nowrap; flex-shrink:0;
transition:background var(--transition),border-color var(--transition);
}
button.ctrl-btn:hover { background:var(--surface1); border-color:var(--accent); }
button.ctrl-btn.active { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
button.ctrl-btn.trig-active { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
button.ctrl-btn.cursor-a { border-color:var(--sky); color:var(--sky); }
button.ctrl-btn.cursor-b { border-color:var(--yellow); color:var(--yellow); }
button.ctrl-btn.resume-btn { border-color:var(--teal); color:var(--teal); }
/* ── Trigger bar ──────────────────────────────────────────────── */
#trigbar {
position:fixed; top:var(--topbar-h); left:0; right:0;
background:var(--crust); border-bottom:1px solid var(--surface0);
display:flex; align-items:center; flex-wrap:wrap; gap:10px;
padding:0 16px; z-index:99;
height:0; overflow:hidden;
transition:height var(--transition),padding var(--transition);
}
#trigbar.open { height:48px; padding:0 16px; }
.trig-group { display:flex; align-items:center; gap:6px; }
.trig-sep { width:1px; height:24px; background:var(--surface0); flex-shrink:0; }
.trig-label { font-size:11px; color:var(--subtext0); white-space:nowrap; }
select.trig-select {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:3px 6px; font-size:12px; cursor:pointer; outline:none;
}
select.trig-select:hover { border-color:var(--mauve); }
input.trig-input {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:3px 6px; font-size:12px; outline:none; width:80px;
}
input.trig-input:focus { border-color:var(--mauve); }
input[type=range].trig-range {
-webkit-appearance:none; width:90px; height:4px;
background:var(--surface1); border-radius:2px; outline:none; cursor:pointer;
}
input[type=range].trig-range::-webkit-slider-thumb {
-webkit-appearance:none; width:12px; height:12px;
border-radius:50%; background:var(--mauve); cursor:pointer;
}
.trig-range-val { font-size:11px; color:var(--mauve); min-width:28px; }
#trig-status-badge {
font-size:11px; font-weight:700; letter-spacing:0.8px;
padding:3px 10px; border-radius:12px;
border:1px solid var(--surface1); background:var(--surface0); color:var(--subtext0);
min-width:80px; text-align:center; white-space:nowrap;
}
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm {
background:var(--mauve); color:var(--crust); border:none; border-radius:5px;
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none;
}
#btn-trig-rearm:hover { opacity:0.85; }
/* ── Body ─────────────────────────────────────────────────────── */
#body {
position:fixed; top:calc(var(--topbar-h) + var(--trigbar-h));
left:0; right:0; bottom:0; display:flex; overflow:hidden;
transition:top var(--transition);
}
/* ── Sidebar ──────────────────────────────────────────────────── */
#sidebar {
width:var(--sidebar-w); min-width:var(--sidebar-w);
background:var(--mantle); border-right:1px solid var(--surface0);
display:flex; flex-direction:column;
transition:width var(--transition),min-width var(--transition); overflow:hidden;
}
#sidebar.collapsed { width:0; min-width:0; }
#sidebar-header {
display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:1px solid var(--surface0);
font-weight:600; color:var(--subtext1); font-size:12px;
text-transform:uppercase; letter-spacing:0.8px; flex-shrink:0;
}
#signal-list { flex:1; overflow-y:auto; padding:8px 0; }
.sig-item {
padding:6px 14px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none;
}
.sig-item:hover { background:var(--surface0); }
.sig-item:active { cursor:grabbing; }
.sig-item.dragging { opacity:0.4; }
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
.array-group {}
.array-header {
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
}
.array-header:hover { background:var(--surface0); }
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
.array-header.open .array-arrow { transform:rotate(90deg); }
.array-children { display:none; padding-left:16px; }
.array-header.open + .array-children { display:block; }
.array-child {
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none; color:var(--subtext1); font-size:12px;
}
.array-child:hover { background:var(--surface0); }
.array-child:active { cursor:grabbing; }
/* ── Main area ────────────────────────────────────────────────── */
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
#toolbar {
display:flex; align-items:center; gap:8px;
padding:6px 12px; border-bottom:1px solid var(--surface0);
background:var(--mantle); flex-shrink:0;
}
#toolbar-title { font-size:12px; color:var(--subtext0); text-transform:uppercase; letter-spacing:0.7px; font-weight:600; }
.layout-btn {
background:var(--surface0); border:1px solid var(--surface1);
border-radius:5px; padding:3px 7px; cursor:pointer; font-size:10px; line-height:1.1;
color:var(--subtext1); white-space:pre; font-family:monospace;
transition:background var(--transition),border-color var(--transition),color var(--transition);
}
.layout-btn:hover { background:var(--surface1); border-color:var(--accent); color:var(--accent); }
.layout-btn.active { border-color:var(--accent); color:var(--accent); background:var(--surface1); }
#btn-add-plot {
background:var(--accent); color:var(--crust); border:none; border-radius:var(--radius);
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; flex-shrink:0;
}
#btn-add-plot:hover { opacity:0.85; }
/* ── Plot grid ────────────────────────────────────────────────── */
#plot-grid {
flex:1; min-height:0; display:grid; gap:8px; padding:8px; overflow:hidden;
}
#plot-grid.l1x1 { grid-template-columns:1fr; grid-template-rows:1fr; }
#plot-grid.l2x1 { grid-template-columns:1fr 1fr; grid-template-rows:1fr; }
#plot-grid.l1x2 { grid-template-columns:1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l2x2 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l3x1 { grid-template-columns:1fr 1fr 1fr; grid-template-rows:1fr; }
#plot-grid.l1x3 { grid-template-columns:1fr; grid-template-rows:1fr 1fr 1fr; }
#plot-grid.l3x2 { grid-template-columns:1fr 1fr 1fr; grid-template-rows:1fr 1fr; }
#plot-grid.l2x3 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr 1fr; }
/* ── Plot card ────────────────────────────────────────────────── */
.plot-card {
background:var(--surface0); border:1px solid var(--surface1);
border-radius:var(--radius); display:flex; flex-direction:column;
min-height:0; position:relative; overflow:hidden;
transition:border-color var(--transition);
}
.plot-card.drag-over { border-color:var(--accent); box-shadow:0 0 0 2px rgba(137,180,250,0.25); }
.plot-card-header {
display:flex; align-items:center; gap:5px;
padding:4px 8px; border-bottom:1px solid var(--surface1);
flex-shrink:0; min-height:30px; max-height:30px; overflow:hidden;
}
.plot-title {
font-size:11px; color:var(--subtext1); font-weight:600;
cursor:text; outline:none; border:1px solid transparent; border-radius:3px;
padding:1px 3px; background:transparent; white-space:nowrap;
flex-shrink:0; max-width:80px; overflow:hidden; text-overflow:ellipsis;
}
.plot-title:focus { border-color:var(--accent); background:var(--surface1); color:var(--text); }
.sig-badges { display:flex; flex-wrap:nowrap; gap:3px; flex:1; overflow:hidden; min-width:0; }
.sig-badge {
display:inline-flex; align-items:center; gap:3px;
background:rgba(69,71,90,0.6); color:var(--subtext1);
border-radius:10px; padding:1px 6px 1px 4px;
font-size:10px; white-space:nowrap; flex-shrink:0;
}
.trace-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; display:inline-block; }
.sig-badge-x {
cursor:pointer; color:var(--overlay0); font-size:11px; line-height:1; margin-left:1px;
transition:color var(--transition);
}
.sig-badge-x:hover { color:var(--red); }
.plot-icons { display:flex; gap:2px; flex-shrink:0; }
.plot-icon-btn {
background:none; border:none; cursor:pointer; color:var(--subtext0);
font-size:13px; line-height:1; padding:2px 4px; border-radius:4px;
transition:color var(--transition),background var(--transition);
}
.plot-icon-btn:hover { color:var(--accent); background:var(--surface1); }
.plot-icon-btn.danger:hover { color:var(--red); }
.plot-body { flex:1; position:relative; min-height:0; overflow:hidden; }
.drop-hint {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:var(--overlay0); font-size:13px; pointer-events:none;
}
.pause-overlay {
position:absolute; inset:0; display:none;
align-items:center; justify-content:center; pointer-events:none; z-index:10;
}
.plot-card.paused .pause-overlay { display:flex; }
.pause-overlay-text {
background:rgba(49,50,68,0.82); color:var(--yellow);
font-size:11px; font-weight:600; padding:4px 12px; border-radius:20px;
border:1px solid var(--yellow);
}
.trig-collect-overlay {
position:absolute; inset:0; display:none;
align-items:center; justify-content:center; pointer-events:none; z-index:10;
}
.plot-card.trig-collecting .trig-collect-overlay { display:flex; }
.trig-collect-text {
background:rgba(49,50,68,0.82); color:var(--mauve);
font-size:11px; font-weight:600; padding:4px 12px; border-radius:20px;
border:1px solid var(--mauve);
}
/* ── Empty state ──────────────────────────────────────────────── */
#empty-state {
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
text-align:center; color:var(--subtext0); pointer-events:none; display:none;
}
#empty-state.visible { display:block; }
#empty-state h2 { font-size:20px; margin-bottom:8px; color:var(--surface2); }
#empty-state p { font-size:13px; }
@media (max-width:700px) { #sidebar { width:0; min-width:0; } :root { --sidebar-w:240px; } }
</style>
</head>
<body>
<!-- ── Top bar ───────────────────────────────────────────────── -->
<div id="topbar">
<span id="app-title">MARTe2 UDP Streamer</span>
<div id="status-led"></div>
<span id="status-text">Disconnected</span>
<div class="topbar-sep"></div>
<div id="cursor-readout">
<span id="cur-ta">A: —</span><span class="cur-sep"></span>
<span id="cur-tb">B: —</span><span class="cur-sep"></span>
<span id="cur-dt">ΔT: —</span>
</div>
<span class="ctrl-label">Window:</span>
<select id="window-select" class="ctrl-select">
<option value="1">1 s</option><option value="5" selected>5 s</option>
<option value="10">10 s</option><option value="30">30 s</option>
<option value="60">60 s</option>
</select>
<button id="btn-cursor" class="ctrl-btn">⊢ Cursor</button>
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
<button id="btn-zoom-fit" class="ctrl-btn">⊞ Fit</button>
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause All</button>
</div>
<!-- ── Trigger bar ───────────────────────────────────────────── -->
<div id="trigbar">
<div class="trig-group">
<span class="trig-label">Signal</span>
<select id="trig-signal" class="trig-select"><option value="">— none —</option></select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Edge</span>
<select id="trig-edge" class="trig-select">
<option value="rising">Rising ↑</option>
<option value="falling">Falling ↓</option>
<option value="both">Both ↕</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Threshold</span>
<input id="trig-threshold" class="trig-input" type="number" value="0" step="any">
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Window</span>
<select id="trig-window" class="trig-select">
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
<option value="5">5 s</option><option value="10">10 s</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Pre</span>
<input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20">
<span class="trig-range-val" id="trig-pre-val">20%</span>
</div>
<div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label">Mode</span>
<select id="trig-mode" class="trig-select">
<option value="normal">Normal</option>
<option value="single">Single</option>
</select>
</div>
<div class="trig-sep"></div>
<div class="trig-group" style="gap:8px">
<span id="trig-status-badge">IDLE</span>
<button id="btn-trig-rearm">Rearm</button>
</div>
</div>
<!-- ── Body ─────────────────────────────────────────────────── -->
<div id="body">
<div id="sidebar">
<div id="sidebar-header">Signals</div>
<div id="signal-list">
<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">
Waiting for streamer&hellip;
</div>
</div>
</div>
<div id="main">
<div id="toolbar">
<button id="btn-sidebar" class="layout-btn active" style="font-size:13px;padding:3px 8px;"></button>
<span id="toolbar-title">Plots</span>
<button id="btn-add-plot">+ Add Plot</button>
<div style="flex:1"></div>
<div id="layout-btns" style="display:flex;gap:4px;align-items:center;"></div>
</div>
<div id="plot-grid" class="l1x1"></div>
<div id="empty-state">
<h2>No plots yet</h2>
<p>Click <strong>+ Add Plot</strong> then drag signals from the sidebar.</p>
</div>
</div>
</div>
<script>
'use strict';
/* ════════════════════════════════════════════════════════════════
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000;
const TEMPORAL_CAP = 600_000;
const MAX_PTS = 5000; // LTTB threshold; uPlot renders 5k pts easily
const LTTB_MIN = 400;
const TRACE_COLORS = [
'#89b4fa','#a6e3a1','#f38ba8','#fab387','#cba6f7',
'#94e2d5','#89dceb','#b4befe','#f9e2af','#f5c2e7',
];
/* ════════════════════════════════════════════════════════════════
Globals
════════════════════════════════════════════════════════════════ */
let signals = [];
let buffers = {};
let plots = [];
let nextPlotId = 1;
let windowSec = 5;
let globalPause = false;
let lastDataAt = 0;
const traceColorMap = {};
let colorIdx = 0;
function getTraceColor(key) {
if (!traceColorMap[key]) traceColorMap[key] = TRACE_COLORS[colorIdx++ % TRACE_COLORS.length];
return traceColorMap[key];
}
// Sync: shared uPlot cursor crosshair across all live plots
const LIVE_SYNC = uPlot.sync('live');
const TRIG_SYNC = uPlot.sync('trig');
// Zoom guard: prevents echo on cross-plot sync calls inside onZoom.
// NOTE: this is NOT used to block rolling-window renders — userInteracting
// handles that more reliably.
let zoomGuard = false;
// Track genuine user interaction with a uPlot canvas area.
// The setScale hook only locks p.xRange when userInteracting=true so that
// programmatic scale updates (rolling window, resize, fit) never freeze the plot.
let userInteracting = false;
let _uiTimer = null;
function _setUserInteracting(on) {
clearTimeout(_uiTimer);
if (on) {
userInteracting = true;
} else {
// 80 ms grace so events fired just after mouseup (uPlot drag-zoom
// commits its setScale on mouseup) are still captured.
_uiTimer = setTimeout(() => { userInteracting = false; }, 80);
}
}
document.addEventListener('mousedown', e => {
if (e.target && e.target.closest('.uplot')) _setUserInteracting(true);
}, true);
document.addEventListener('mouseup', () => _setUserInteracting(false), true);
let _wheelTimer = null;
document.addEventListener('wheel', e => {
if (e.target && e.target.closest('.uplot')) {
userInteracting = true;
clearTimeout(_wheelTimer);
_wheelTimer = setTimeout(() => { userInteracting = false; }, 200);
}
}, { passive: true, capture: true });
// Zoom history for Back button (global since plots are zoom-synced)
const zoomHistory = [];
// Cursors A/B — stored in x-axis units of the current mode:
// live mode → Unix seconds
// trig mode → relative seconds from trigger
const cursors = { mode: 'off', tA: null, tB: null };
let cursorsDirty = false; // if true, redraw all plots to update cursor lines
// Layout
const LAYOUTS = [
['1×1','l1x1'],['2×1','l2x1'],['1×2','l1x2'],['2×2','l2x2'],
['3×1','l3x1'],['1×3','l1x3'],['3×2','l3x2'],['2×3','l2x3'],
];
let currentLayout = 'l1x1';
/* ════════════════════════════════════════════════════════════════
Trigger state
════════════════════════════════════════════════════════════════ */
const trig = {
enabled:false, signal:'', edge:'rising', threshold:0, windowSec:1,
prePercent:20, mode:'normal',
armed:false, prevVal:null, collecting:false, trigTime:null, snapshot:null,
};
function trigPreSec() { return trig.windowSec * trig.prePercent / 100; }
function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; }
/* ════════════════════════════════════════════════════════════════
WebSocket
════════════════════════════════════════════════════════════════ */
let ws = null, wsBackoff = 1000;
function connectWS() {
ws = new WebSocket('ws://' + location.host + '/ws');
ws.onopen = () => { wsBackoff = 1000; setStatus('orange','Connected waiting for data'); };
ws.onclose = () => {
setStatus('red','Disconnected (reconnecting…)');
setTimeout(connectWS, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 30000);
};
ws.onerror = () => {};
ws.onmessage = evt => {
let msg; try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'config') onConfig(msg);
else if (msg.type === 'data') onData(msg);
};
}
/* ════════════════════════════════════════════════════════════════
Status LED
════════════════════════════════════════════════════════════════ */
function setStatus(s, t) {
document.getElementById('status-led').className = s;
document.getElementById('status-text').textContent = t;
}
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN && lastDataAt > 0) {
const age = performance.now() - lastDataAt;
// Compute how far behind the newest data timestamp is from the browser clock.
// Large positive values mean clock skew or very slow data.
let tsAge = null;
const wallNow = Date.now() / 1000;
Object.values(buffers).forEach(buf => {
if (buf.size === 0) return;
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
const a = wallNow - newest;
if (tsAge === null || a < tsAge) tsAge = a;
});
const tsStr = tsAge !== null ? ` | ts-age: ${tsAge.toFixed(1)}s` : '';
if (age > 1000) setStatus('orange', 'No data for ' + (age/1000).toFixed(1) + 's' + tsStr);
else setStatus('green', 'Streaming' + tsStr);
}
}, 500);
/* ════════════════════════════════════════════════════════════════
Config handler
════════════════════════════════════════════════════════════════ */
function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }
function onConfig(msg) {
const newSigs = msg.signals || [];
const fp = s => s.name+':'+s.typeCode+':'+(s.numRows||1)+':'+(s.numCols||1)+':'+(s.timeMode||0);
const changed = newSigs.length !== signals.length || newSigs.some((s,i) => fp(s) !== fp(signals[i]));
signals = newSigs;
if (changed) {
buffers = {};
signals.forEach(sig => {
const n = numElements(sig);
if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); }
else if (n === 1) { buffers[sig.name] = makeBuffer(); }
else { for (let i = 0; i < n; i++) buffers[sig.name+'['+i+']'] = makeBuffer(); }
});
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
}
buildSidebar();
buildTrigSignalSelect();
}
/* ════════════════════════════════════════════════════════════════
Data handler
════════════════════════════════════════════════════════════════ */
function onData(msg) {
lastDataAt = performance.now();
const sigs = msg.signals; if (!sigs) return;
Object.keys(sigs).forEach(key => {
const buf = buffers[key]; if (!buf) return;
const sd = sigs[key]; if (!sd || !sd.t || !sd.v) return;
const len = Math.min(sd.t.length, sd.v.length);
for (let i = 0; i < len; i++) pushBuffer(buf, sd.t[i], sd.v[i]);
});
if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs);
if (trig.enabled && trig.collecting && (Date.now()/1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.snapshot) {
plots.forEach(p => {
if (p.paused || globalPause) return;
if (p.traces.some(t => buffers[t] !== undefined)) p.needsRedraw = true;
});
}
}
/* ════════════════════════════════════════════════════════════════
Trigger logic
════════════════════════════════════════════════════════════════ */
function checkTrigger(sigs) {
const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return;
for (let i = 0; i < sd.v.length; i++) {
const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur;
if (prev === null) continue;
const e = trig.edge, thr = trig.threshold;
if ((e==='rising' &&prev<thr&&cur>=thr) ||
(e==='falling'&&prev>thr&&cur<=thr) ||
(e==='both'&&((prev<thr&&cur>=thr)||(prev>thr&&cur<=thr)))) {
fireTrigger(sd.t ? sd.t[i] : Date.now()/1000); break;
}
}
}
function fireTrigger(t) {
trig.armed=false; trig.collecting=true; trig.trigTime=t; trig.snapshot=null;
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
}
function finaliseTriggerCapture() {
trig.collecting = false;
const t0 = trig.trigTime - trigPreSec(), t1 = trig.trigTime + trigPostSec();
const snap = {};
Object.keys(buffers).forEach(key => {
const sl = getBufferSliceRange(buffers[key], t0, t1);
if (sl.t.length > 0) snap[key] = sl;
});
// Tag snapshot with the window parameters captured at trigger time so that
// the render loop uses the correct bounds even if UI controls are changed later.
snap._preS = trigPreSec();
snap._postS = trigPostSec();
trig.snapshot = snap;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
// Reset cursor positions (mode changed)
cursors.tA = null; cursors.tB = null; updateCursorReadout();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
if (trig.mode === 'normal') setTimeout(() => { if (trig.enabled && trig.mode==='normal') trigArm(); }, 200);
}
function trigArm() {
trig.snapshot=null; trig.armed=true; trig.collecting=false; trig.prevVal=null;
showRearmBtn(false); updateTrigStatusBadge('armed');
cursors.tA = null; cursors.tB = null; updateCursorReadout();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
}
function trigDisarm() {
trig.armed=false; trig.collecting=false; trig.trigTime=null;
setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('idle');
}
function setAllCardsCollecting(on) {
document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on));
}
function updateTrigStatusBadge(state) {
const el = document.getElementById('trig-status-badge');
el.className = state;
el.textContent = {idle:'IDLE',armed:'ARMED',waiting:'COLLECTING',triggered:'TRIGGERED'}[state]||'IDLE';
}
function showRearmBtn(v) { document.getElementById('btn-trig-rearm').style.display = v?'inline-block':'none'; }
/* ════════════════════════════════════════════════════════════════
Circular buffer
════════════════════════════════════════════════════════════════ */
function makeBuffer(cap) {
cap = cap || DEFAULT_CAP;
return { t: new Float64Array(cap), v: new Float64Array(cap), head:0, size:0, cap };
}
function pushBuffer(buf, t, v) {
buf.t[buf.head] = t; buf.v[buf.head] = v;
buf.head = (buf.head + 1) % buf.cap;
if (buf.size < buf.cap) buf.size++;
}
// Binary-search range slice of circular buffer — O(log n + window_size)
function getBufferSliceRange(buf, t0, t1) {
if (buf.size === 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const { cap, size, head } = buf;
const start = (size === cap) ? head : 0;
const physAt = k => (start + k) % cap;
let lo = 0, hi = size;
while (lo < hi) { const m = (lo+hi)>>>1; if (buf.t[physAt(m)] < t0) lo=m+1; else hi=m; }
const kStart = lo;
lo = kStart; hi = size;
while (lo < hi) { const m = (lo+hi)>>>1; if (buf.t[physAt(m)] <= t1) lo=m+1; else hi=m; }
const kEnd = lo, len = kEnd - kStart;
if (len <= 0) return { t: new Float64Array(0), v: new Float64Array(0) };
const outT = new Float64Array(len), outV = new Float64Array(len);
const physStart = physAt(kStart), tail = cap - physStart;
if (tail >= len) {
outT.set(buf.t.subarray(physStart, physStart + len));
outV.set(buf.v.subarray(physStart, physStart + len));
} else {
outT.set(buf.t.subarray(physStart, physStart + tail));
outT.set(buf.t.subarray(0, len - tail), tail);
outV.set(buf.v.subarray(physStart, physStart + tail));
outV.set(buf.v.subarray(0, len - tail), tail);
}
return { t: outT, v: outV };
}
// getGlobalNow returns the reference "now" for the rolling window.
// Always anchors to the newest timestamp found in any buffer so the rolling
// window tracks real data regardless of any clock skew between the Go server
// and the browser. Falls back to Date.now()/1000 only when all buffers are
// empty (no data yet received).
function getGlobalNow() {
let latest = -Infinity;
Object.values(buffers).forEach(buf => {
if (buf.size === 0) return;
const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (newestT > latest) latest = newestT;
});
return isFinite(latest) ? latest : Date.now() / 1000;
}
// getBufferNow returns the "now" anchor for a single buffer — the buffer's own
// newest timestamp. This avoids cross-signal interference when signals have
// different timescales or update rates.
function getBufferNow(buf) {
if (buf.size === 0) return Date.now() / 1000;
return buf.t[(buf.head - 1 + buf.cap) % buf.cap];
}
function getBufferSlice(buf) {
const now = getBufferNow(buf);
return getBufferSliceRange(buf, now - windowSec, now);
}
// Binary-search slice of a sorted contiguous Float64Array pair
function sliceTypedArrayRange(t, v, t0, t1) {
let lo = 0, hi = t.length;
while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] < t0) lo=m+1; else hi=m; }
const s = lo; lo = s; hi = t.length;
while (lo < hi) { const m = (lo+hi)>>>1; if (t[m] <= t1) lo=m+1; else hi=m; }
return { t: t.subarray(s, lo), v: v.subarray(s, lo) };
}
// LTTB decimation
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold) return { t, v };
const outT = new Float64Array(threshold), outV = new Float64Array(threshold);
outT[0] = t[0]; outV[0] = v[0];
const every = (len - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
const avgS = Math.floor((i+1)*every)+1, avgE = Math.min(Math.floor((i+2)*every)+1, len);
let avgT = 0, avgV = 0;
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; }
const avgN = avgE - avgS; avgT /= avgN; avgV /= avgN;
const rangeS = Math.floor(i*every)+1, rangeE = Math.min(Math.floor((i+1)*every)+1, len);
let maxArea = -1, nextA = rangeS;
const aT = t[a], aV = v[a];
for (let j = rangeS; j < rangeE; j++) {
const area = Math.abs((aT-avgT)*(v[j]-aV) - (aT-t[j])*(avgV-aV));
if (area > maxArea) { maxArea = area; nextA = j; }
}
outT[i+1] = t[nextA]; outV[i+1] = v[nextA]; a = nextA;
}
outT[threshold-1] = t[len-1]; outV[threshold-1] = v[len-1];
return { t: outT, v: outV };
}
/* ════════════════════════════════════════════════════════════════
uPlot helpers
════════════════════════════════════════════════════════════════ */
// Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks)
function fmtLiveTick(u, vals) {
return vals.map(v => {
if (v == null) return '';
const d = new Date(v * 1000);
const hh = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
const ss = String(d.getSeconds()).padStart(2,'0');
const ms = String(d.getMilliseconds()).padStart(3,'0');
return hh+':'+mm+':'+ss+'.'+ms;
});
}
// Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks)
function fmtTrigTick(u, vals) {
return vals.map(v => {
if (v == null) return '';
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
if (abs < 1) return sign + (abs*1000).toFixed(1) + 'ms';
return sign + abs.toFixed(3) + 's';
});
}
// Draw custom cursor A/B lines onto the canvas (called from draw hook)
function drawCursorLines(u) {
const { ctx, bbox } = u;
if (!bbox) return;
const drawLine = (val, color) => {
if (val === null) return;
const x = u.valToPos(val, 'x', true); // canvas pixels
if (x < bbox.left || x > bbox.left + bbox.width) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(Math.round(x), bbox.top);
ctx.lineTo(Math.round(x), bbox.top + bbox.height);
ctx.stroke();
ctx.restore();
};
drawLine(cursors.tA, 'rgba(137,220,235,0.85)');
drawLine(cursors.tB, 'rgba(249,226,175,0.85)');
}
// Build uPlot opts for a given plot object
function makeUPlotOpts(p, inTrigMode) {
const seriesArr = [{}]; // time (index 0)
p.traces.forEach(key => {
seriesArr.push({
label: key,
stroke: getTraceColor(key),
width: 1.5,
points: { show: false },
spanGaps: true,
});
});
const xVals = inTrigMode ? (u, vals) => fmtTrigTick(u, vals)
: (u, vals) => fmtLiveTick(u, vals);
return {
width: Math.max(p.div.clientWidth || 100, 50),
height: Math.max(p.div.clientHeight || 100, 50),
cursor: {
sync: { key: inTrigMode ? 'trig' : 'live', setSeries: false },
drag: { x: true, y: false, setScale: true, uni: 20 },
lock: false,
},
select: { show: true },
scales: {
x: { time: false, auto: !p.xRange,
min: p.xRange ? p.xRange[0] : undefined,
max: p.xRange ? p.xRange[1] : undefined },
y: { auto: true },
},
series: seriesArr,
axes: [
{ stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 },
values: xVals, size: 40 },
{ stroke:'#7f849c', grid:{ stroke:'#313244', width:1 }, ticks:{ stroke:'#313244', width:1 }, size: 50 },
],
legend: { show: false },
padding: [4, 8, 0, 0],
hooks: {
draw: [ u => drawCursorLines(u) ],
setScale: [(u, key) => {
// Only lock xRange when the user is physically interacting with the
// chart (drag-zoom or scroll). Programmatic scale changes from the
// rolling-window render loop, resize, or fit never set userInteracting,
// so they pass through without freezing the view.
// zoomGuard blocks the recursive echo from cross-plot sync inside onZoom.
if (key !== 'x' || zoomGuard || !u._ready || !userInteracting) return;
const { min, max } = u.scales.x;
if (min == null || max == null) return;
onZoom(p.id, min, max);
}],
ready: [ u => { u._ready = true; } ],
},
};
}
// Create (or recreate) the uPlot instance for a plot, mounting into p.div
function createUPlot(p) {
// Destroy previous instance
if (p.uplot) { p.uplot.destroy(); p.uplot = null; }
const inTrigMode = trig.enabled && trig.snapshot !== null;
const opts = makeUPlotOpts(p, inTrigMode);
const data = buildUPlotData(p, inTrigMode);
// Mount into the plot body div (clear previous uPlot DOM)
p.div.querySelectorAll('.uplot').forEach(el => el.remove());
p.uplot = new uPlot(opts, data, p.div);
// Cursor placement: click on the over-layer to place A/B cursors
p.uplot.over.addEventListener('click', e => {
if (cursors.mode === 'off') return;
const rect = p.uplot.over.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const { min, max } = p.uplot.scales.x;
const val = min + pct * (max - min); // in x-axis units (seconds)
if (cursors.mode === 'A') cursors.tA = val;
else cursors.tB = val;
updateCursorReadout();
cursorsDirty = true;
});
// Resize observer so the uPlot fills its container.
// userInteracting is false during resize, so setSize → setScale → hook will
// not call onZoom and will not freeze p.xRange.
if (p.ro) { p.ro.disconnect(); }
p.ro = new ResizeObserver(() => {
if (!p.uplot) return;
const w = Math.max(p.div.clientWidth || 50, 50);
const h = Math.max(p.div.clientHeight || 50, 50);
// userInteracting is false during resize → setSize will not trigger onZoom
p.uplot.setSize({ width: w, height: h });
});
p.ro.observe(p.div);
}
// Build the uPlot data array from buffers / trigger snapshot
function buildUPlotData(p, inTrigMode) {
if (p.traces.length === 0) return [new Float64Array(0)];
const targetPts = Math.max(LTTB_MIN, (p.uplot ? p.uplot.width : p.div.clientWidth) || MAX_PTS, MAX_PTS);
if (inTrigMode && trig.snapshot) {
return buildTrigData(p, targetPts);
}
return buildLiveData(p, targetPts);
}
function buildLiveData(p, targetPts) {
// All series share the first trace's time array (aligned by index)
let sharedT = null;
const yArrays = [];
const sliceFor = key => {
const buf = buffers[key];
if (!buf) return { t: new Float64Array(0), v: new Float64Array(0) };
return p.xRange
? getBufferSliceRange(buf, p.xRange[0], p.xRange[1])
: getBufferSlice(buf);
};
p.traces.forEach(key => {
const raw = sliceFor(key);
const dec = lttb(raw.t, raw.v, targetPts);
if (!sharedT || dec.t.length > sharedT.length) sharedT = dec.t;
yArrays.push(dec.v);
});
return [sharedT || new Float64Array(0), ...yArrays];
}
function buildTrigData(p, targetPts) {
const trigT = trig.trigTime;
// Use bounds stored at capture time so the data never exceeds the trigger window
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
const winT0 = trigT - preS;
const winT1 = trigT + postS;
let sharedT = null;
const yArrays = [];
p.traces.forEach(key => {
let raw = trig.snapshot[key];
if (!raw) { yArrays.push(new Float64Array(0)); return; }
// Clamp to capture window first (prevents overflow of fast burst signals)
const t0 = p.xRange ? trigT + p.xRange[0] : winT0;
const t1 = p.xRange ? trigT + p.xRange[1] : winT1;
raw = sliceTypedArrayRange(raw.t, raw.v, t0, t1);
const dec = lttb(raw.t, raw.v, targetPts);
const relT = dec.t.map(ts => ts - trigT); // absolute → relative seconds
if (!sharedT || relT.length > sharedT.length) sharedT = relT;
yArrays.push(dec.v);
});
return [sharedT || new Float64Array(0), ...yArrays];
}
/* ════════════════════════════════════════════════════════════════
Zoom sync
════════════════════════════════════════════════════════════════ */
let syncLocked = false;
function onZoom(sourcePlotId, min, max) {
// Push current range to history before applying new zoom
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
zoomHistory.push(prevRange);
if (zoomHistory.length > 30) zoomHistory.shift();
document.getElementById('btn-zoom-back').style.display = '';
// Store zoom on source plot
const src = plots.find(p => p.id === sourcePlotId);
if (src) src.xRange = [min, max];
// Show Auto button in live mode
if (!trig.enabled && !syncLocked) {
syncLocked = true;
document.getElementById('btn-sync-resume').style.display = '';
}
// Propagate to other plots
zoomGuard = true;
plots.forEach(p => {
if (p.id === sourcePlotId) return;
p.xRange = [min, max];
if (p.uplot) p.uplot.setScale('x', { min, max });
});
zoomGuard = false;
// Mark all plots dirty (re-slice data to the new range for full resolution)
plots.forEach(p => { p.needsRedraw = true; });
}
// Undo last zoom/pan action
function zoomBack() {
if (!zoomHistory.length) return;
const prev = zoomHistory.pop();
if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none';
if (prev === null) {
// Was at auto/rolling state before the zoom
resetZoom();
} else {
plots.forEach(p => {
p.xRange = [...prev];
if (p.uplot) p.uplot.setScale('x', { min: prev[0], max: prev[1] });
p.needsRedraw = true;
});
}
}
// Reset to auto/rolling window (clears all zoom)
function resetZoom() {
syncLocked = false;
document.getElementById('btn-sync-resume').style.display = 'none';
if (trig.enabled && trig.snapshot) {
const preS = trig.snapshot._preS || trigPreSec();
const postS = trig.snapshot._postS || trigPostSec();
plots.forEach(p => {
p.xRange = null;
if (p.uplot) p.uplot.setScale('x', { min: -preS, max: postS });
p.needsRedraw = true;
});
} else {
// Back to rolling window — setScale to current window, render loop keeps it moving
plots.forEach(p => {
p.xRange = null;
if (!p.paused && !globalPause) p.needsRedraw = true;
});
}
}
// Fit x-axis to all data currently in buffers (or full trigger snapshot)
function zoomFit() {
if (trig.enabled && trig.snapshot) {
resetZoom(); // "Fit" in trigger mode = show full trigger window
return;
}
// Find oldest/newest timestamps across all visible signals
let gMin = Infinity, gMax = -Infinity;
plots.forEach(p => {
p.traces.forEach(key => {
const buf = buffers[key]; if (!buf || buf.size === 0) return;
const startIdx = (buf.size === buf.cap) ? buf.head : 0;
const oldestT = buf.t[startIdx];
const newestT = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (oldestT < gMin) gMin = oldestT;
if (newestT > gMax) gMax = newestT;
});
});
if (!isFinite(gMin) || gMin >= gMax) return;
// Push to history
const prevRange = plots[0] && plots[0].xRange ? [...plots[0].xRange] : null;
zoomHistory.push(prevRange);
document.getElementById('btn-zoom-back').style.display = '';
if (!syncLocked) { syncLocked = true; document.getElementById('btn-sync-resume').style.display = ''; }
plots.forEach(p => {
p.xRange = [gMin, gMax];
if (p.uplot) p.uplot.setScale('x', { min: gMin, max: gMax });
p.needsRedraw = true;
});
}
// Auto = return to rolling window / full trigger window
function exitSyncLock() { resetZoom(); }
document.getElementById('btn-sync-resume').addEventListener('click', resetZoom);
document.getElementById('btn-zoom-back').addEventListener('click', zoomBack);
document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
/* ════════════════════════════════════════════════════════════════
Cursor controls
════════════════════════════════════════════════════════════════ */
const CURSOR_LABELS = { off:'⊢ Cursor', A:'⊢ A', B:'⊢ B' };
const CURSOR_MODES = ['off','A','B'];
document.getElementById('btn-cursor').addEventListener('click', () => {
cursors.mode = CURSOR_MODES[(CURSOR_MODES.indexOf(cursors.mode)+1) % 3];
const btn = document.getElementById('btn-cursor');
btn.textContent = CURSOR_LABELS[cursors.mode];
btn.className = 'ctrl-btn'
+ (cursors.mode==='A' ? ' cursor-a' : cursors.mode==='B' ? ' cursor-b' : '');
if (cursors.mode === 'off') {
cursors.tA = null; cursors.tB = null;
document.getElementById('cursor-readout').classList.remove('visible');
cursorsDirty = true;
} else {
document.getElementById('cursor-readout').classList.add('visible');
}
});
function updateCursorReadout() {
const ro = document.getElementById('cursor-readout');
const active = cursors.mode !== 'off' || cursors.tA !== null || cursors.tB !== null;
ro.classList.toggle('visible', active);
if (!active) return;
// Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms
const fmt = v => {
if (v === null) return '—';
if (trig.enabled && trig.snapshot) {
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
return abs < 1 ? sign+(abs*1000).toFixed(3)+'ms' : sign+abs.toFixed(6)+'s';
}
const d = new Date(v * 1000);
return String(d.getHours()).padStart(2,'0') + ':'
+ String(d.getMinutes()).padStart(2,'0') + ':'
+ String(d.getSeconds()).padStart(2,'0') + '.'
+ String(d.getMilliseconds()).padStart(3,'0');
};
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
if (cursors.tA !== null && cursors.tB !== null) {
const dt = cursors.tB - cursors.tA, abs = Math.abs(dt);
const s = dt >= 0 ? '+' : '-';
const str = abs < 1 ? s+(abs*1000).toFixed(3)+'ms' : s+abs.toFixed(6)+'s';
document.getElementById('cur-dt').textContent = 'ΔT: ' + str;
} else {
document.getElementById('cur-dt').textContent = 'ΔT: —';
}
}
/* ════════════════════════════════════════════════════════════════
Trigger bar controls
════════════════════════════════════════════════════════════════ */
function openTrigBar(open) {
trig.enabled = open;
document.getElementById('trigbar').classList.toggle('open', open);
document.getElementById('btn-trigger').classList.toggle('trig-active', open);
document.documentElement.style.setProperty('--trigbar-h', open ? '48px' : '0px');
if (!open) {
trigDisarm(); trig.snapshot = null;
cursors.tA = null; cursors.tB = null; updateCursorReadout();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
} else {
cursors.tA = null; cursors.tB = null; updateCursorReadout();
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
resetZoom();
if (trig.signal) trigArm(); else updateTrigStatusBadge('idle');
}
// Resize uPlot instances after trigbar height changes
setTimeout(() => { plots.forEach(p => {
if (!p.uplot) return;
p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
}); }, 220);
}
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
document.getElementById('trig-signal').addEventListener('change', e => {
trig.signal = e.target.value; trig.prevVal = null;
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value)||0; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); });
document.getElementById('trig-pre').addEventListener('input', e => {
trig.prePercent = parseInt(e.target.value, 10);
document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
});
document.getElementById('trig-mode').addEventListener('change', e => { trig.mode = e.target.value; });
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
/* ════════════════════════════════════════════════════════════════
Trigger signal selector
════════════════════════════════════════════════════════════════ */
function buildTrigSignalSelect() {
const sel = document.getElementById('trig-signal'), cur = sel.value;
sel.innerHTML = '<option value="">— none —</option>';
signals.forEach(sig => {
const n = numElements(sig);
if (isTemporal(sig) || n === 1) {
const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o);
} else {
for (let i = 0; i < n; i++) {
const key = sig.name+'['+i+']', o = document.createElement('option');
o.value = key; o.textContent = key; sel.appendChild(o);
}
}
});
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
trig.signal = sel.value;
}
/* ════════════════════════════════════════════════════════════════
Sidebar
════════════════════════════════════════════════════════════════ */
function buildSidebar() {
const list = document.getElementById('signal-list');
list.innerHTML = '';
if (!signals.length) {
list.innerHTML = '<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">No signals</div>';
return;
}
const typeNames = ['u8','i8','u16','i16','u32','i32','u64','i64','f32','f64'];
signals.forEach(sig => {
const n = numElements(sig), temporal = isTemporal(sig);
const typeName = typeNames[sig.typeCode] || '?';
if (n === 1 || temporal) {
list.appendChild(makeDraggable(sig.name, sig.name, temporal?'['+n+'] '+typeName:typeName, sig.unit||''));
} else {
const group = document.createElement('div'); group.className = 'array-group';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">'+escHtml(sig.name)+'</span>'
+ (sig.unit?'<span class="sig-unit">'+escHtml(sig.unit)+'</span>':'')
+ '<span class="type-badge">['+n+'] '+typeName+'</span>';
header.addEventListener('click', () => header.classList.toggle('open'));
const children = document.createElement('div'); children.className = 'array-children';
for (let i = 0; i < n; i++) {
const key = sig.name+'['+i+']', child = makeDraggable(key, key, typeName, sig.unit||'');
child.className = 'array-child'; children.appendChild(child);
}
group.appendChild(header); group.appendChild(children); list.appendChild(group);
}
});
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
item.className = 'sig-item'; item.draggable = true;
item.innerHTML = '<span class="sig-name">'+escHtml(label)+'</span>'
+ (unit?'<span class="sig-unit">'+escHtml(unit)+'</span>':'')
+ '<span class="type-badge">'+escHtml(typeName)+'</span>';
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('signal', key); e.dataTransfer.effectAllowed = 'copy';
requestAnimationFrame(() => item.classList.add('dragging'));
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
return item;
}
/* ════════════════════════════════════════════════════════════════
Layout buttons
════════════════════════════════════════════════════════════════ */
function buildLayoutBtns() {
const container = document.getElementById('layout-btns');
LAYOUTS.forEach(([label, cls]) => {
const btn = document.createElement('button');
btn.className = 'layout-btn' + (cls === currentLayout ? ' active' : '');
btn.textContent = label; btn.title = label;
btn.addEventListener('click', () => {
currentLayout = cls;
document.getElementById('plot-grid').className = cls;
container.querySelectorAll('.layout-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
setTimeout(() => { plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
}); }, 120);
});
container.appendChild(btn);
});
}
/* ════════════════════════════════════════════════════════════════
Plot management
════════════════════════════════════════════════════════════════ */
function addPlot() {
const id = nextPlotId++;
const card = document.createElement('div');
card.className = 'plot-card'; card.dataset.plotId = id;
card.innerHTML = `
<div class="plot-card-header">
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
<div class="sig-badges" id="badges-${id}"></div>
<div class="plot-icons">
<button class="plot-icon-btn" id="btn-pause-${id}" title="Pause/Resume">⏸</button>
<button class="plot-icon-btn" id="btn-csv-${id}" title="Export CSV">⬇</button>
<button class="plot-icon-btn danger" id="btn-del-${id}" title="Delete">🗑</button>
</div>
</div>
<div class="plot-body" id="pbody-${id}">
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
<div class="pause-overlay"><span class="pause-overlay-text">PAUSED</span></div>
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
</div>`;
card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='copy'; card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', e => {
e.preventDefault(); card.classList.remove('drag-over');
const key = e.dataTransfer.getData('signal'); if (key) addTraceTo(id, key);
});
card.querySelector('#btn-pause-'+id).addEventListener('click', () => togglePlotPause(id));
card.querySelector('#btn-csv-'+id).addEventListener('click', () => exportCSV(id));
card.querySelector('#btn-del-'+id).addEventListener('click', () => deletePlot(id));
document.getElementById('plot-grid').appendChild(card);
const plotBody = card.querySelector('#pbody-'+id);
const p = { id, traces:[], paused:false, div: plotBody, needsRedraw:false, xRange:null, uplot:null, ro:null };
plots.push(p);
// Create the uPlot instance (empty) — deferred so DOM has settled
requestAnimationFrame(() => { createUPlot(p); });
updateEmptyState();
return id;
}
function addTraceTo(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
if (p.traces.includes(signalKey)) return;
p.traces.push(signalKey);
document.querySelector('#hint-'+plotId).style.display = 'none';
addBadge(plotId, signalKey);
// Recreate uPlot with new series list
createUPlot(p);
p.needsRedraw = true;
}
function removeTraceFrom(plotId, signalKey) {
const p = plots.find(p => p.id === plotId); if (!p) return;
p.traces = p.traces.filter(t => t !== signalKey);
removeBadge(plotId, signalKey);
createUPlot(p);
p.needsRedraw = true;
if (!p.traces.length) document.querySelector('#hint-'+plotId).style.display = '';
}
function addBadge(plotId, key) {
const c = document.getElementById('badges-'+plotId); if (!c) return;
if (c.querySelector('[data-key="'+CSS.escape(key)+'"]')) return;
const color = getTraceColor(key);
const badge = document.createElement('span');
badge.className = 'sig-badge'; badge.dataset.key = key;
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
x.addEventListener('click', () => removeTraceFrom(plotId, key));
badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x);
c.appendChild(badge);
}
function removeBadge(plotId, key) {
const c = document.getElementById('badges-'+plotId); if (!c) return;
const b = c.querySelector('[data-key="'+CSS.escape(key)+'"]'); if (b) b.remove();
}
function togglePlotPause(plotId) {
const p = plots.find(p => p.id === plotId); if (!p) return;
p.paused = !p.paused;
const card = document.querySelector('[data-plot-id="'+plotId+'"]');
const btn = document.getElementById('btn-pause-'+plotId);
if (p.paused) { card.classList.add('paused'); btn.textContent='▶'; btn.title='Resume'; }
else { card.classList.remove('paused'); btn.textContent='⏸'; btn.title='Pause'; p.needsRedraw=true; }
}
function deletePlot(plotId) {
const idx = plots.findIndex(p => p.id === plotId); if (idx === -1) return;
const p = plots[idx];
if (p.ro) p.ro.disconnect();
if (p.uplot) p.uplot.destroy();
plots.splice(idx, 1);
document.querySelector('[data-plot-id="'+plotId+'"]').remove();
updateEmptyState();
}
function updateEmptyState() {
document.getElementById('empty-state').classList.toggle('visible', plots.length === 0);
}
/* ════════════════════════════════════════════════════════════════
Render loop
════════════════════════════════════════════════════════════════ */
let _dbgTick = 0;
function renderDirtyPlots() {
const inTrigMode = trig.enabled && trig.snapshot !== null;
// Diagnostic: every ~5 s print buffer state to the browser console.
// Open DevTools → Console to see timestamps and sizes.
if (++_dbgTick % 300 === 0) {
const wallNow = Date.now() / 1000;
let anyData = false;
Object.entries(buffers).forEach(([k, b]) => {
if (b.size === 0) return;
anyData = true;
const newest = b.t[(b.head - 1 + b.cap) % b.cap];
const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap];
const sliceLen = getBufferSlice(b).t.length;
console.log(`[buf] ${k}: size=${b.size} oldest=${oldest.toFixed(3)} newest=${newest.toFixed(3)} wallNow=${wallNow.toFixed(3)} ts-age=${(wallNow-newest).toFixed(3)}s slice(${windowSec}s)=${sliceLen}pts`);
});
if (!anyData) console.log('[buf] all buffers empty — no data received yet');
plots.forEach(p => console.log(`[plot${p.id}] traces=${JSON.stringify(p.traces)} xRange=${JSON.stringify(p.xRange)} needsRedraw=${p.needsRedraw}`));
}
// Rolling-window mode: detect stale xRange and mark dirty.
// Stale-detection MUST run before the mark-dirty decision: a plot with a
// stale xRange (circular buffer has scrolled past the zoomed window) will
// never be marked dirty by the !p.xRange guard, so we clear it here first.
if (!inTrigMode && !globalPause) {
plots.forEach(p => {
if (!p.uplot || p.paused) return;
if (p.xRange) {
// Check if the zoomed window is entirely before all buffer data.
const hasData = p.traces.some(k => buffers[k] && buffers[k].size > 0);
if (hasData) {
const stale = p.traces.every(key => {
const buf = buffers[key];
if (!buf || buf.size === 0) return true;
const oldest = buf.t[buf.size === buf.cap ? buf.head : 0];
return p.xRange[1] < oldest;
});
if (stale) {
p.xRange = null;
syncLocked = false;
const btnR = document.getElementById('btn-sync-resume');
if (btnR) btnR.style.display = 'none';
}
}
}
if (!p.xRange) p.needsRedraw = true;
});
}
// Fast path: cursor-only redraw (no data rebuild needed)
if (cursorsDirty) {
cursorsDirty = false;
plots.forEach(p => { if (p.uplot) p.uplot.redraw(false); });
}
plots.forEach(p => {
if (!p.needsRedraw || !p.uplot) return;
p.needsRedraw = false;
const data = buildUPlotData(p, inTrigMode);
// userInteracting is false during all programmatic renders, so the
// setScale hook will not call onZoom for any of the calls below.
p.uplot.setData(data);
// Always set the x-scale explicitly so uPlot renders the correct viewport.
if (inTrigMode) {
const preS = trig.snapshot._preS !== undefined ? trig.snapshot._preS : trigPreSec();
const postS = trig.snapshot._postS !== undefined ? trig.snapshot._postS : trigPostSec();
p.uplot.setScale('x', { min: p.xRange ? p.xRange[0] : -preS,
max: p.xRange ? p.xRange[1] : postS });
} else if (p.xRange) {
// Zoomed: re-apply so scale is correct after setData
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
} else {
// Rolling window: anchor to the newest timestamp among this plot's own
// traces. Using a per-plot "now" avoids cross-signal interference when
// signals have different timescales or update rates.
let plotNow = -Infinity;
p.traces.forEach(key => {
const buf = buffers[key];
if (!buf || buf.size === 0) return;
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > plotNow) plotNow = t;
});
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
}
});
requestAnimationFrame(renderDirtyPlots);
}
/* ════════════════════════════════════════════════════════════════
Export CSV
════════════════════════════════════════════════════════════════ */
function exportCSV(plotId) {
const p = plots.find(p => p.id === plotId); if (!p || !p.traces.length) return;
const inTrigMode = trig.enabled && trig.snapshot !== null;
const slices = p.traces.map(key => {
if (inTrigMode) {
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
return { t: Array.from(raw.t).map(ts => ts - trig.trigTime), v: Array.from(raw.v) };
}
const buf = buffers[key]; if (!buf) return { t:[], v:[] };
const sl = getBufferSlice(buf);
return { t: Array.from(sl.t), v: Array.from(sl.v) };
});
const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t)));
const sortedT = Array.from(allT).sort((a,b) => a-b);
const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t,i) => m.set(t, s.v[i])); return m; });
const hdr = [(inTrigMode?'time_rel_s':'time_s'), ...p.traces].join(',');
const rows = sortedT.map(t => [t.toFixed(9), ...lookups.map(lk => lk.has(t)?lk.get(t):'')].join(','));
const blob = new Blob([hdr+'\n'+rows.join('\n')], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'plot_'+plotId+'_'+Date.now()+'.csv';
a.click(); URL.revokeObjectURL(a.href);
}
/* ════════════════════════════════════════════════════════════════
Global controls
════════════════════════════════════════════════════════════════ */
document.getElementById('btn-pause-global').addEventListener('click', () => {
globalPause = !globalPause;
const btn = document.getElementById('btn-pause-global');
btn.textContent = globalPause ? '▶ Resume All' : '⏸ Pause All';
btn.classList.toggle('active', globalPause);
if (!globalPause) plots.forEach(p => { if (!p.paused) p.needsRedraw = true; });
});
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
plots.forEach(p => { if (!p.paused && !p.xRange) p.needsRedraw = true; });
});
/* ════════════════════════════════════════════════════════════════
Sidebar toggle
════════════════════════════════════════════════════════════════ */
let sidebarOpen = true;
function setSidebar(open) {
sidebarOpen = open;
document.getElementById('sidebar').classList.toggle('collapsed', !open);
document.getElementById('btn-sidebar').classList.toggle('active', open);
setTimeout(() => { plots.forEach(p => {
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
}); }, 200);
}
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
document.getElementById('btn-add-plot').addEventListener('click', addPlot);
/* ════════════════════════════════════════════════════════════════
Utility
════════════════════════════════════════════════════════════════ */
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ════════════════════════════════════════════════════════════════
Init
════════════════════════════════════════════════════════════════ */
buildLayoutBtns();
addPlot();
connectWS();
requestAnimationFrame(renderDirtyPlots);
</script>
</body>
</html>