389 lines
11 KiB
HTML
389 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>SFM Waveform Viewer</title>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 13px;
|
|
}
|
|
|
|
header {
|
|
background: #161b22;
|
|
border-bottom: 1px solid #30363d;
|
|
padding: 12px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
white-space: nowrap;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.conn-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
label { color: #8b949e; font-size: 12px; }
|
|
|
|
input[type="text"], input[type="number"] {
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
color: #c9d1d9;
|
|
padding: 5px 8px;
|
|
font-size: 13px;
|
|
width: 100px;
|
|
}
|
|
input[type="number"] { width: 70px; }
|
|
input:focus { outline: none; border-color: #388bfd; }
|
|
|
|
button {
|
|
background: #1f6feb;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
padding: 5px 14px;
|
|
transition: background 0.15s;
|
|
}
|
|
button:hover { background: #388bfd; }
|
|
button:active { background: #1158c7; }
|
|
button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
|
|
|
|
#status-bar {
|
|
background: #161b22;
|
|
border-bottom: 1px solid #21262d;
|
|
padding: 5px 20px;
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
min-height: 26px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
#status-bar.error { color: #f85149; }
|
|
#status-bar.ok { color: #3fb950; }
|
|
#status-bar.loading { color: #d29922; }
|
|
|
|
.meta-pill {
|
|
background: #21262d;
|
|
border-radius: 4px;
|
|
padding: 2px 8px;
|
|
color: #c9d1d9;
|
|
font-family: monospace;
|
|
}
|
|
|
|
#charts {
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.chart-wrap {
|
|
background: #161b22;
|
|
border: 1px solid #21262d;
|
|
border-radius: 8px;
|
|
padding: 10px 12px 8px;
|
|
}
|
|
|
|
.chart-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.chart-canvas-wrap { position: relative; height: 130px; }
|
|
|
|
#empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 60vh;
|
|
color: #484f58;
|
|
gap: 8px;
|
|
}
|
|
#empty-state svg { opacity: 0.3; }
|
|
#empty-state p { font-size: 14px; }
|
|
|
|
.ch-tran { color: #58a6ff; }
|
|
.ch-vert { color: #3fb950; }
|
|
.ch-long { color: #d29922; }
|
|
.ch-mic { color: #bc8cff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>SFM Waveform Viewer</h1>
|
|
<div class="conn-group">
|
|
<label>API</label>
|
|
<input type="text" id="api-base" value="http://localhost:8200" style="width:180px" />
|
|
</div>
|
|
<div class="conn-group">
|
|
<label>Device host</label>
|
|
<input type="text" id="dev-host" value="" placeholder="e.g. 10.0.0.5" />
|
|
<label>TCP port</label>
|
|
<input type="number" id="dev-tcp-port" value="9034" />
|
|
<label>Event #</label>
|
|
<input type="number" id="event-index" value="0" min="0" style="width:55px" />
|
|
</div>
|
|
<button id="load-btn" onclick="loadWaveform()">Load Waveform</button>
|
|
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
|
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
|
</header>
|
|
|
|
<div id="status-bar">Ready — enter device host and click Load Waveform.</div>
|
|
|
|
<div id="empty-state">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
</svg>
|
|
<p>No waveform loaded</p>
|
|
</div>
|
|
|
|
<div id="charts" style="display:none"></div>
|
|
|
|
<script>
|
|
const CHANNEL_COLORS = {
|
|
Tran: '#58a6ff',
|
|
Vert: '#3fb950',
|
|
Long: '#d29922',
|
|
Mic: '#bc8cff',
|
|
};
|
|
|
|
let charts = {};
|
|
let lastData = null;
|
|
|
|
function setStatus(msg, cls = '') {
|
|
const bar = document.getElementById('status-bar');
|
|
bar.textContent = msg;
|
|
bar.className = cls;
|
|
}
|
|
|
|
function appendMeta(label, value) {
|
|
const bar = document.getElementById('status-bar');
|
|
const pill = document.createElement('span');
|
|
pill.className = 'meta-pill';
|
|
pill.textContent = `${label}: ${value}`;
|
|
bar.appendChild(pill);
|
|
}
|
|
|
|
async function loadWaveform() {
|
|
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
|
|
const devHost = document.getElementById('dev-host').value.trim();
|
|
const tcpPort = document.getElementById('dev-tcp-port').value;
|
|
const evIndex = parseInt(document.getElementById('event-index').value, 10);
|
|
|
|
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
|
|
|
|
const btn = document.getElementById('load-btn');
|
|
btn.disabled = true;
|
|
setStatus('Fetching waveform…', 'loading');
|
|
|
|
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
|
|
|
let data;
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
|
throw new Error(err.detail || resp.statusText);
|
|
}
|
|
data = await resp.json();
|
|
} catch (e) {
|
|
setStatus(`Error: ${e.message}`, 'error');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
lastData = data;
|
|
renderWaveform(data);
|
|
btn.disabled = false;
|
|
document.getElementById('prev-btn').disabled = evIndex <= 0;
|
|
document.getElementById('next-btn').disabled = false;
|
|
}
|
|
|
|
function stepEvent(delta) {
|
|
const el = document.getElementById('event-index');
|
|
const next = Math.max(0, parseInt(el.value, 10) + delta);
|
|
el.value = next;
|
|
loadWaveform();
|
|
}
|
|
|
|
function renderWaveform(data) {
|
|
const sr = data.sample_rate || 1024;
|
|
const pretrig = data.pretrig_samples || 0;
|
|
const decoded = data.samples_decoded || 0;
|
|
const total = data.total_samples || decoded;
|
|
const channels = data.channels || {};
|
|
|
|
// Build time axis (ms)
|
|
const times = Array.from({ length: decoded }, (_, i) =>
|
|
((i - pretrig) / sr * 1000).toFixed(2)
|
|
);
|
|
|
|
const triggerMs = 0; // t=0 is trigger by construction
|
|
|
|
// Status bar
|
|
const bar = document.getElementById('status-bar');
|
|
bar.innerHTML = '';
|
|
bar.className = 'ok';
|
|
const ts = data.timestamp;
|
|
if (ts) {
|
|
bar.textContent = `Event #${data.index} — ${ts.display} `;
|
|
} else {
|
|
bar.textContent = `Event #${data.index} `;
|
|
}
|
|
appendMeta('sr', `${sr} sps`);
|
|
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
|
appendMeta('pretrig', pretrig);
|
|
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
|
|
|
|
// Show charts area
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
const chartsDiv = document.getElementById('charts');
|
|
chartsDiv.style.display = 'flex';
|
|
chartsDiv.innerHTML = '';
|
|
|
|
// Destroy old Chart instances
|
|
Object.values(charts).forEach(c => c.destroy());
|
|
charts = {};
|
|
|
|
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
|
const samples = channels[ch];
|
|
if (!samples || samples.length === 0) continue;
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chart-wrap';
|
|
|
|
const lbl = document.createElement('div');
|
|
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
|
|
|
|
// Compute peak for label
|
|
const peak = Math.max(...samples.map(Math.abs));
|
|
lbl.textContent = `${ch} — peak ${peak.toLocaleString()} counts`;
|
|
wrap.appendChild(lbl);
|
|
|
|
const canvasWrap = document.createElement('div');
|
|
canvasWrap.className = 'chart-canvas-wrap';
|
|
const canvas = document.createElement('canvas');
|
|
canvasWrap.appendChild(canvas);
|
|
wrap.appendChild(canvasWrap);
|
|
chartsDiv.appendChild(wrap);
|
|
|
|
// Downsample for rendering if very long (keep chart responsive)
|
|
const MAX_POINTS = 4000;
|
|
let renderTimes = times;
|
|
let renderData = samples;
|
|
if (samples.length > MAX_POINTS) {
|
|
const step = Math.ceil(samples.length / MAX_POINTS);
|
|
renderTimes = times.filter((_, i) => i % step === 0);
|
|
renderData = samples.filter((_, i) => i % step === 0);
|
|
}
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: renderTimes,
|
|
datasets: [{
|
|
data: renderData,
|
|
borderColor: color,
|
|
borderWidth: 1,
|
|
pointRadius: 0,
|
|
tension: 0,
|
|
}],
|
|
},
|
|
options: {
|
|
animation: false,
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
callbacks: {
|
|
title: items => `t = ${items[0].label} ms`,
|
|
label: item => `${ch}: ${item.raw.toLocaleString()} counts`,
|
|
},
|
|
},
|
|
// Trigger line annotation (drawn manually via afterDraw)
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: 'category',
|
|
ticks: {
|
|
color: '#484f58',
|
|
maxTicksLimit: 10,
|
|
maxRotation: 0,
|
|
callback: (val, i) => renderTimes[i] + ' ms',
|
|
},
|
|
grid: { color: '#21262d' },
|
|
},
|
|
y: {
|
|
ticks: { color: '#484f58', maxTicksLimit: 5 },
|
|
grid: { color: '#21262d' },
|
|
},
|
|
},
|
|
},
|
|
plugins: [{
|
|
// Draw trigger line at t=0
|
|
id: 'triggerLine',
|
|
afterDraw(chart) {
|
|
const ctx = chart.ctx;
|
|
const xAxis = chart.scales.x;
|
|
const yAxis = chart.scales.y;
|
|
|
|
// Find index of t=0
|
|
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
|
|
if (zeroIdx < 0) return;
|
|
|
|
const x = xAxis.getPixelForValue(zeroIdx);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yAxis.top);
|
|
ctx.lineTo(x, yAxis.bottom);
|
|
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
},
|
|
}],
|
|
});
|
|
|
|
charts[ch] = chart;
|
|
}
|
|
}
|
|
|
|
// Allow Enter key on inputs to trigger load
|
|
document.querySelectorAll('input').forEach(el => {
|
|
el.addEventListener('keydown', e => { if (e.key === 'Enter') loadWaveform(); });
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|