82005ec5d3
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>
1542 lines
71 KiB
HTML
1542 lines
71 KiB
HTML
<!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…
|
||
</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">▶</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Init
|
||
════════════════════════════════════════════════════════════════ */
|
||
buildLayoutBtns();
|
||
addPlot();
|
||
connectWS();
|
||
requestAnimationFrame(renderDirtyPlots);
|
||
</script>
|
||
</body>
|
||
</html>
|