Files
MARTe_IO_Components/Client/WebUI/static/index.html
T
Martino Ferrari e3389f932b FIrs
2026-05-15 17:42:14 +02:00

1085 lines
40 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>
<script src="https://cdn.plot.ly/plotly-2.35.2.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;
--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;
}
/* ── Scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--mantle); }
::-webkit-scrollbar-thumb { background: var(--surface1); border-radius: 3px; }
/* ── 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: 14px;
padding: 0 16px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
#app-title {
font-weight: 700; font-size: 15px;
color: var(--accent);
white-space: nowrap;
letter-spacing: 0.3px;
}
.topbar-sep { flex: 1; }
#status-led {
width: 12px; height: 12px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 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;
}
.ctrl-label {
font-size: 12px; color: var(--subtext0);
white-space: nowrap;
}
select.ctrl-select {
background: var(--surface0); color: var(--text);
border: 1px solid var(--surface1); border-radius: var(--radius);
padding: 4px 8px; font-size: 13px; cursor: pointer;
outline: none;
transition: border-color var(--transition);
}
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: 5px 14px; font-size: 13px; cursor: pointer;
transition: background var(--transition), border-color var(--transition);
white-space: nowrap;
}
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);
}
/* ── Body layout ────────────────────────────────────────────── */
#body {
position: fixed;
top: var(--topbar-h); left: 0; right: 0; bottom: 0;
display: flex;
overflow: hidden;
}
/* ── 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: 12px 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;
}
#sidebar-toggle {
background: none; border: none; color: var(--subtext0);
cursor: pointer; font-size: 16px; line-height: 1;
padding: 2px 4px;
border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
#sidebar-toggle:hover { color: var(--accent); background: var(--surface0); }
#signal-list {
flex: 1; overflow-y: auto; padding: 8px 0;
}
.sig-item {
padding: 7px 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: 7px 14px 7px 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: 5px 14px 5px 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;
}
#toolbar {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px;
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: 4px 7px;
cursor: pointer; font-size: 10px; line-height: 1.1;
color: var(--subtext1);
transition: background var(--transition), border-color var(--transition), color var(--transition);
white-space: pre;
font-family: monospace;
}
.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: 5px 14px; font-size: 13px; font-weight: 600;
cursor: pointer;
transition: opacity var(--transition);
}
#btn-add-plot:hover { opacity: 0.85; }
/* ── Plot grid ──────────────────────────────────────────────── */
#plot-grid {
flex: 1;
min-height: 0; /* allow shrinking inside flex parent */
display: grid;
gap: 10px;
padding: 10px;
overflow: hidden; /* plots fill viewport, no scroll */
}
/* Layout grid templates rows use 1fr so plots share the full height */
#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; /* allow shrinking inside grid cell */
position: relative;
transition: border-color var(--transition);
overflow: hidden;
}
.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: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--surface1);
flex-shrink: 0;
min-height: 36px;
}
.plot-title {
flex: 1; font-size: 12px; color: var(--subtext1);
font-weight: 500;
cursor: text;
outline: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 1px 4px;
background: transparent;
transition: border-color var(--transition);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.plot-title:focus {
border-color: var(--accent); background: var(--surface1); color: var(--text);
}
.sig-badges {
display: flex; flex-wrap: wrap; gap: 4px;
max-width: 55%;
}
.sig-badge {
display: inline-flex; align-items: center; gap: 3px;
background: var(--surface1); color: var(--text);
border-radius: 12px; padding: 1px 8px 1px 8px;
font-size: 11px; white-space: nowrap;
}
.sig-badge-x {
cursor: pointer; color: var(--subtext0);
font-size: 12px; line-height: 1;
margin-left: 2px;
transition: color var(--transition);
}
.sig-badge-x:hover { color: var(--red); }
.plot-icons { display: flex; gap: 4px; }
.plot-icon-btn {
background: none; border: none; cursor: pointer;
color: var(--subtext0); font-size: 15px; line-height: 1;
padding: 3px 5px; border-radius: 5px;
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;
}
.plotly-div {
width: 100%; height: 100%;
}
.pause-overlay {
position: absolute; inset: 0;
display: none;
align-items: center; justify-content: center;
pointer-events: none;
}
.plot-card.paused .pause-overlay { display: flex; }
.pause-overlay-text {
background: rgba(49,50,68,0.82);
color: var(--yellow);
font-size: 12px; font-weight: 600;
padding: 6px 14px; border-radius: 20px;
border: 1px solid var(--yellow);
letter-spacing: 0.3px;
}
.drop-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--overlay0); font-size: 13px; pointer-events: none;
}
/* ── 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; }
/* ── Responsive ─────────────────────────────────────────────── */
@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>
<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-pause-global" class="ctrl-btn">⏸ Pause All</button>
</div>
<!-- ── Body ──────────────────────────────────────────────── -->
<div id="body">
<!-- Sidebar -->
<div id="sidebar">
<div id="sidebar-header">
Signals
<button id="sidebar-toggle" title="Collapse sidebar">&#x2190;</button>
</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>
<!-- Main -->
<div id="main">
<div id="toolbar">
<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:5px;align-items:center;">
<!-- populated by JS -->
</div>
</div>
<div id="plot-grid" class="l2x2"></div>
<div id="empty-state" class="visible">
<h2>No plots yet</h2>
<p>Click <strong>+ Add Plot</strong> then drag signals from the sidebar.</p>
</div>
</div>
</div>
<!-- ── Sidebar collapse button (shown when collapsed) ────── -->
<button id="sidebar-open-btn"
style="position:fixed;top:calc(var(--topbar-h)+8px);left:4px;
background:var(--surface0);border:1px solid var(--surface1);
color:var(--subtext0);border-radius:6px;padding:4px 7px;
cursor:pointer;z-index:99;display:none;font-size:14px;"
title="Expand sidebar">&#x2192;</button>
<script>
'use strict';
/* ════════════════════════════════════════════════════════════════
State
════════════════════════════════════════════════════════════════ */
let signals = []; // SignalInfo[] from server
let buffers = {}; // key → {t: Float64Array, v: Float64Array, head, size, cap}
// Temporal-array signals (packed bursts with SamplingRate) use a larger ring buffer
// so the rolling window stays populated despite high decimation on the server side.
let plots = []; // {id, title, traces:[], paused, div, needsRedraw}
let nextPlotId = 1;
let windowSec = 5;
let globalPause = false;
let lastDataAt = 0; // performance.now() of last data frame
// Layout definitions: [label, cols, rows-hint (for display icon)]
const LAYOUTS = [
['1×1', 'l1x1', '▪', 1],
['2×1', 'l2x1', '▪▪', 2],
['1×2', 'l1x2', '▪\n▪', 1],
['2×2', 'l2x2', '▪▪\n▪▪', 2],
['3×1', 'l3x1', '▪▪▪', 3],
['1×3', 'l1x3', '▪\n▪\n▪', 1],
['3×2', 'l3x2', '▪▪▪\n▪▪▪',3],
['2×3', 'l2x3', '▪▪\n▪▪\n▪▪',2],
];
let currentLayout = 'l2x2';
/* ════════════════════════════════════════════════════════════════
WebSocket + reconnect
════════════════════════════════════════════════════════════════ */
let ws = null;
let wsBackoff = 1000;
function connectWS() {
const url = 'ws://' + location.host + '/ws';
ws = new WebSocket(url);
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);
else if (msg.type === 'pong') {}
};
}
/* ════════════════════════════════════════════════════════════════
Status LED
════════════════════════════════════════════════════════════════ */
function setStatus(state, text) {
const led = document.getElementById('status-led');
led.className = state; // 'green' | 'orange' | 'red'
document.getElementById('status-text').textContent = text;
}
// Watch for data silence (>1s → orange)
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
const age = performance.now() - lastDataAt;
if (lastDataAt === 0) return; // haven't received any data yet
if (age > 1000) setStatus('orange', 'No data for ' + (age/1000).toFixed(1) + 's');
else setStatus('green', 'Streaming');
}
}, 500);
/* ════════════════════════════════════════════════════════════════
Config handler
════════════════════════════════════════════════════════════════ */
// isTemporal returns true for signals that carry packed sample bursts.
// These signals have NumElements > 1 and a non-zero TimeMode (i.e. the
// server expands them into a dense time series using SamplingRate).
function isTemporal(sig) {
return numElements(sig) > 1 && (sig.timeMode || 0) !== 0;
}
function numElements(sig) {
const r = sig.numRows || 1;
const c = sig.numCols || 1;
return r * c;
}
function onConfig(msg) {
const newSigs = msg.signals || [];
// Only wipe buffers when the signal layout changes (name, type, dimensions).
// A reconnect with identical config (e.g. WebSocket drop/rejoin) must not
// clear accumulated data.
const sigFingerprint = s =>
s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0);
const changed =
newSigs.length !== signals.length ||
newSigs.some((s, i) => sigFingerprint(s) !== sigFingerprint(signals[i]));
signals = newSigs;
if (changed) {
buffers = {};
signals.forEach(sig => {
const n = numElements(sig);
if (isTemporal(sig)) {
// Temporal arrays carry up to maxBatchPoints (2000) decimated samples per
// 30 Hz server tick. TEMPORAL_CAP covers ≈10 s of data at that rate.
buffers[sig.name] = makeBuffer(TEMPORAL_CAP);
} else if (n === 1) {
buffers[sig.name] = makeBuffer();
} else {
// Spatial / PacketTime array: one ring buffer per element.
for (let i = 0; i < n; i++) {
buffers[sig.name + '[' + i + ']'] = makeBuffer();
}
}
});
}
buildSidebar();
}
/* ════════════════════════════════════════════════════════════════
Data handler
════════════════════════════════════════════════════════════════ */
function onData(msg) {
lastDataAt = performance.now();
setStatus('green', 'Streaming');
// New format: msg.signals[key] = { t: [...], v: [...] }
// Each signal carries its own time axis, so scalar and temporal-array
// signals can coexist in the same message without alignment issues.
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]);
}
});
// Mark plots dirty
plots.forEach(p => {
if (p.paused || globalPause) return;
const affected = p.traces.some(t => buffers[t] !== undefined);
if (affected) p.needsRedraw = true;
});
}
/* ════════════════════════════════════════════════════════════════
Circular buffer
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10000; // scalar / spatial-array signals
const TEMPORAL_CAP = 600000; // temporal arrays: ~10 s at 2000 pts/tick × 30 Hz
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++;
}
function getBufferSlice(buf) {
// Return {t, v} as plain arrays, trimmed to windowSec.
const now = Date.now() / 1000;
const oldest = now - windowSec;
const outT = [];
const outV = [];
const start = (buf.size === buf.cap) ? buf.head : 0;
for (let k = 0; k < buf.size; k++) {
const i = (start + k) % buf.cap;
if (buf.t[i] >= oldest) {
outT.push(buf.t[i]);
outV.push(buf.v[i]);
}
}
return { t: outT, v: outV };
}
/* ════════════════════════════════════════════════════════════════
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);
const temporal = isTemporal(sig);
const typeName = typeNames[sig.typeCode] || '?';
if (n === 1 || temporal) {
// Scalar or temporal array: single draggable item.
// For temporal arrays the badge shows the element count to hint at the
// high-sample-rate nature of the signal.
const badge = temporal ? '[' + n + '] ' + typeName : typeName;
const item = makeDraggable(sig.name, sig.name, badge, sig.unit || '');
list.appendChild(item);
} else {
// Spatial / PacketTime array: expandable group with per-element children.
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 + ']';
const child = makeDraggable(key, sig.name + '[' + i + ']', 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';
// Delay opacity change so the browser captures the ghost image before fading
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');
// Trigger Plotly resize for all plots
setTimeout(() => plots.forEach(p => Plotly.Plots.resize(p.div)), 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 plot">🗑</button>
</div>
</div>
<div class="plot-body">
<div class="plotly-div" id="pldiv-${id}"></div>
<div class="pause-overlay"><span class="pause-overlay-text">PAUSED Zoom/Pan active</span></div>
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
</div>
`;
// Drop zone
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);
});
// Pause button
card.querySelector('#btn-pause-' + id).addEventListener('click', () => togglePlotPause(id));
// CSV
card.querySelector('#btn-csv-' + id).addEventListener('click', () => exportCSV(id));
// Delete
card.querySelector('#btn-del-' + id).addEventListener('click', () => deletePlot(id));
document.getElementById('plot-grid').appendChild(card);
const plotObj = {
id,
title: 'Plot ' + id,
traces: [],
paused: false,
div: null,
needsRedraw: false,
};
plots.push(plotObj);
// Init Plotly
const pldiv = card.querySelector('#pldiv-' + id);
plotObj.div = pldiv;
Plotly.newPlot(pldiv, [], makePlotLayout(), {
responsive: true,
displaylogo: false,
modeBarButtonsToRemove: ['toImage'],
});
updateEmptyState();
return id;
}
function makePlotLayout(nowMs) {
const t = nowMs || Date.now(); // milliseconds — Plotly date axis uses ms
return {
uirevision: 'lock',
xaxis: {
range: [t - windowSec * 1000, t],
type: 'date',
tickformat: '%H:%M:%S',
color: '#7f849c',
gridcolor: '#313244',
zeroline: false,
},
yaxis: {
autorange: true,
color: '#7f849c',
gridcolor: '#313244',
zeroline: false,
},
margin: { t: 10, b: 40, l: 50, r: 10 },
showlegend: true,
legend: { font: { color: '#cdd6f4', size: 11 }, bgcolor: 'rgba(0,0,0,0)' },
paper_bgcolor: '#1e1e2e',
plot_bgcolor: '#1e1e2e',
font: { color: '#cdd6f4' },
};
}
function addTraceTo(plotId, signalKey) {
const p = plots.find(p => p.id === plotId);
if (!p) return;
if (p.traces.includes(signalKey)) return; // already present
p.traces.push(signalKey);
// Remove drop hint
const hint = document.querySelector('#hint-' + plotId);
if (hint) hint.style.display = 'none';
// Add badge
addBadge(plotId, signalKey);
// Force redraw
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);
p.needsRedraw = true;
if (p.traces.length === 0) {
const hint = document.querySelector('#hint-' + plotId);
if (hint) hint.style.display = '';
}
}
function addBadge(plotId, key) {
const container = document.getElementById('badges-' + plotId);
if (!container) return;
if (container.querySelector('[data-key="' + CSS.escape(key) + '"]')) return;
const badge = document.createElement('span');
badge.className = 'sig-badge';
badge.dataset.key = key;
badge.innerHTML = escHtml(key) + '<span class="sig-badge-x" title="Remove">&#x2715;</span>';
badge.querySelector('.sig-badge-x').addEventListener('click', () => removeTraceFrom(plotId, key));
container.appendChild(badge);
}
function removeBadge(plotId, key) {
const container = document.getElementById('badges-' + plotId);
if (!container) return;
const badge = container.querySelector('[data-key="' + CSS.escape(key) + '"]');
if (badge) badge.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';
// Enable zoom/pan by switching to pan mode
Plotly.relayout(p.div, { 'dragmode': 'pan' });
} 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;
Plotly.purge(plots[idx].div);
plots.splice(idx, 1);
const card = document.querySelector('[data-plot-id="' + plotId + '"]');
if (card) card.remove();
updateEmptyState();
}
function updateEmptyState() {
const es = document.getElementById('empty-state');
es.classList.toggle('visible', plots.length === 0);
}
/* ════════════════════════════════════════════════════════════════
Render loop
════════════════════════════════════════════════════════════════ */
const TRACE_COLORS = [
'#89b4fa','#a6e3a1','#f38ba8','#fab387','#cba6f7',
'#94e2d5','#89dceb','#b4befe','#f9e2af','#f5c2e7',
];
function renderDirtyPlots() {
const now = Date.now(); // ms
plots.forEach(p => {
if (!p.needsRedraw) return;
if (p.paused || globalPause) return;
p.needsRedraw = false;
const traces = p.traces.map((key, ci) => {
const buf = buffers[key];
let xArr = [], yArr = [];
if (buf) {
const slice = getBufferSlice(buf);
// Plotly date axis with range in ms expects x data in ms too
xArr = slice.t.map(ts => ts * 1000);
yArr = slice.v;
}
return {
x: xArr,
y: yArr,
name: key,
mode: 'lines',
line: { width: 1.5, color: TRACE_COLORS[ci % TRACE_COLORS.length] },
type: 'scatter',
};
});
const t = now / 1000;
const layout = makePlotLayout(now);
// Preserve uirevision so user zoom/pan not reset when paused
layout.uirevision = p.id;
Plotly.react(p.div, traces, layout, {
responsive: true,
displaylogo: false,
modeBarButtonsToRemove: ['toImage'],
});
});
requestAnimationFrame(renderDirtyPlots);
}
/* ════════════════════════════════════════════════════════════════
Export CSV
════════════════════════════════════════════════════════════════ */
function exportCSV(plotId) {
const p = plots.find(p => p.id === plotId);
if (!p || p.traces.length === 0) return;
// Collect all time points
const slices = p.traces.map(key => {
const buf = buffers[key];
return buf ? getBufferSlice(buf) : { t: [], v: [] };
});
// Build merged time set
const allT = new Set();
slices.forEach(s => s.t.forEach(t => allT.add(t)));
const sortedT = Array.from(allT).sort((a, b) => a - b);
// For each key, build a lookup
const lookups = slices.map(s => {
const m = new Map();
s.t.forEach((t, i) => m.set(t, s.v[i]));
return m;
});
const header = ['time_s', ...p.traces].join(',');
const rows = sortedT.map(t => {
const row = [t.toFixed(6)];
lookups.forEach(lk => row.push(lk.has(t) ? lk.get(t) : ''));
return row.join(',');
});
const csv = header + '\n' + rows.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plot_' + plotId + '_' + Date.now() + '.csv';
a.click();
URL.revokeObjectURL(url);
}
/* ════════════════════════════════════════════════════════════════
Global pause
════════════════════════════════════════════════════════════════ */
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; });
}
});
/* ════════════════════════════════════════════════════════════════
Window selector
════════════════════════════════════════════════════════════════ */
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
plots.forEach(p => { if (!p.paused) p.needsRedraw = true; });
});
/* ════════════════════════════════════════════════════════════════
Sidebar toggle
════════════════════════════════════════════════════════════════ */
document.getElementById('sidebar-toggle').addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebar-open-btn').style.display = 'block';
setTimeout(() => plots.forEach(p => Plotly.Plots.resize(p.div)), 200);
});
document.getElementById('sidebar-open-btn').addEventListener('click', () => {
document.getElementById('sidebar').classList.remove('collapsed');
document.getElementById('sidebar-open-btn').style.display = 'none';
setTimeout(() => plots.forEach(p => Plotly.Plots.resize(p.div)), 200);
});
/* ════════════════════════════════════════════════════════════════
Add plot button
════════════════════════════════════════════════════════════════ */
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();
connectWS();
requestAnimationFrame(renderDirtyPlots);
</script>
</body>
</html>