Files
seismo-relay/sfm/waveform_viewer.html

539 lines
16 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; }
#unit-bar {
background: #0d1117;
border-bottom: 1px solid #21262d;
padding: 8px 20px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
}
.unit-field { display: flex; flex-direction: column; gap: 1px; }
.unit-field .uf-label { color: #484f58; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.unit-field .uf-value { color: #c9d1d9; font-family: monospace; font-size: 13px; }
.unit-field .uf-value.highlight { color: #58a6ff; font-weight: 600; }
.event-chips {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-left: 8px;
}
.event-chip {
background: #21262d;
border: 1px solid #30363d;
border-radius: 5px;
color: #8b949e;
cursor: pointer;
font-size: 12px;
padding: 3px 10px;
transition: all 0.12s;
}
.event-chip:hover { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.event-chip.active { background: #1f6feb; border-color: #388bfd; color: #fff; font-weight: 600; }
#connect-btn {
background: #238636;
margin-left: auto;
}
#connect-btn:hover { background: #2ea043; }
#connect-btn:disabled { background: #21262d; color: #484f58; }
</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" />
</div>
<button id="connect-btn" onclick="connectUnit()">Connect</button>
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
</header>
<!-- Unit info bar — hidden until connected -->
<div id="unit-bar" style="display:none">
<div class="unit-field">
<span class="uf-label">Serial</span>
<span class="uf-value" id="u-serial"></span>
</div>
<div class="unit-field">
<span class="uf-label">Firmware</span>
<span class="uf-value" id="u-fw"></span>
</div>
<div class="unit-field">
<span class="uf-label">Sample rate</span>
<span class="uf-value" id="u-sr"></span>
</div>
<div class="unit-field">
<span class="uf-label">Events</span>
<span class="uf-value highlight" id="u-count"></span>
</div>
<div class="event-chips" id="event-chips"></div>
</div>
<div id="status-bar">Ready — enter device host and click Connect.</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;
let unitInfo = null;
let currentEventIndex = 0;
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 connectUnit() {
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
const devHost = document.getElementById('dev-host').value.trim();
const tcpPort = document.getElementById('dev-tcp-port').value;
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
const btn = document.getElementById('connect-btn');
btn.disabled = true;
btn.textContent = 'Connecting…';
setStatus('Connecting to unit…', 'loading');
const url = `${apiBase}/device/info?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
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);
}
unitInfo = await resp.json();
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
btn.disabled = false;
btn.textContent = 'Connect';
return;
}
// Populate unit bar
document.getElementById('u-serial').textContent = unitInfo.serial || '—';
document.getElementById('u-fw').textContent = unitInfo.firmware_version || '—';
const sr = unitInfo.compliance_config?.sample_rate;
document.getElementById('u-sr').textContent = sr ? `${sr} sps` : '—';
const count = unitInfo.event_count ?? 0;
document.getElementById('u-count').textContent = count;
// Build event chips
const chipsEl = document.getElementById('event-chips');
chipsEl.innerHTML = '';
for (let i = 0; i < count; i++) {
const chip = document.createElement('button');
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
chip.textContent = `Event ${i}`;
chip.onclick = () => selectEvent(i);
chipsEl.appendChild(chip);
}
document.getElementById('unit-bar').style.display = 'flex';
document.getElementById('load-btn').disabled = count === 0;
document.getElementById('prev-btn').disabled = true;
document.getElementById('next-btn').disabled = count <= 1;
btn.disabled = false;
btn.textContent = 'Reconnect';
if (count === 0) {
setStatus('Connected — no events stored on device.', 'ok');
} else {
setStatus(`Connected — ${count} event${count !== 1 ? 's' : ''} stored. Select an event or click Load Waveform.`, 'ok');
}
}
function selectEvent(idx) {
currentEventIndex = idx;
// Update chip highlight
document.querySelectorAll('.event-chip').forEach((c, i) => {
c.classList.toggle('active', i === idx);
});
document.getElementById('prev-btn').disabled = idx <= 0;
const count = unitInfo?.event_count ?? 0;
document.getElementById('next-btn').disabled = idx >= count - 1;
loadWaveform();
}
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 = currentEventIndex;
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;
}
function stepEvent(delta) {
const count = unitInfo?.event_count ?? 0;
const next = Math.max(0, Math.min(count - 1, currentEventIndex + delta));
selectEvent(next);
}
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 || {};
const recType = data.record_type || 'Unknown';
// 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('type', recType);
appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
appendMeta('pretrig', pretrig);
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
// No waveform data — show a clear reason instead of empty charts
if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('empty-state').querySelector('p').textContent =
recType === 'Waveform'
? 'Waveform decode returned no samples — check server logs'
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
document.getElementById('charts').style.display = 'none';
Object.values(charts).forEach(c => c.destroy());
charts = {};
return;
}
// Build time axis (ms)
const times = Array.from({ length: decoded }, (_, i) =>
((i - pretrig) / sr * 1000).toFixed(2)
);
// 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 connection inputs to trigger connect
['api-base', 'dev-host', 'dev-tcp-port'].forEach(id => {
document.getElementById(id).addEventListener('keydown', e => {
if (e.key === 'Enter') connectUnit();
});
});
</script>
</body>
</html>