600 lines
19 KiB
HTML
600 lines
19 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 geoRange = 10.0; // in/s full-scale for geo channels; updated on connect
|
||
let eventList = []; // populated from /device/events after connect
|
||
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();
|
||
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
|
||
} catch (e) {
|
||
setStatus(`Error: ${e.message}`, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Connect';
|
||
return;
|
||
}
|
||
|
||
// Populate unit bar from /device/info
|
||
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` : '—';
|
||
|
||
// Fetch real event list from /device/events — SUB 08 count is unreliable
|
||
setStatus('Fetching event list…', 'loading');
|
||
const eventsUrl = `${apiBase}/device/events?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||
try {
|
||
const evResp = await fetch(eventsUrl);
|
||
if (!evResp.ok) {
|
||
const err = await evResp.json().catch(() => ({ detail: evResp.statusText }));
|
||
throw new Error(err.detail || evResp.statusText);
|
||
}
|
||
const evData = await evResp.json();
|
||
eventList = evData.events || [];
|
||
} catch (e) {
|
||
setStatus(`Error fetching events: ${e.message}`, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Reconnect';
|
||
return;
|
||
}
|
||
|
||
const count = eventList.length;
|
||
document.getElementById('u-count').textContent = count;
|
||
|
||
// Build event chips with timestamps
|
||
const chipsEl = document.getElementById('event-chips');
|
||
chipsEl.innerHTML = '';
|
||
eventList.forEach((ev, i) => {
|
||
const chip = document.createElement('button');
|
||
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
|
||
const label = ev.timestamp?.display ?? `Event ${ev.index}`;
|
||
chip.textContent = label;
|
||
chip.title = ev.record_type || '';
|
||
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;
|
||
document.getElementById('next-btn').disabled = idx >= eventList.length - 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 = eventList.length;
|
||
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 = {};
|
||
|
||
// Mic peak PSI from 0C waveform record — used to scale raw mic counts
|
||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
|
||
|
||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||
const samples = channels[ch];
|
||
if (!samples || samples.length === 0) continue;
|
||
|
||
// Convert raw ADC counts to physical units
|
||
const isGeo = ch !== 'Mic';
|
||
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
|
||
|
||
if (isGeo) {
|
||
// Geo channels: counts × (range / 32767) → in/s
|
||
const scale = geoRange / 32767;
|
||
plotSamples = samples.map(c => c * scale);
|
||
const peakIns = Math.max(...plotSamples.map(Math.abs));
|
||
peakLabel = `${peakIns.toFixed(5)} in/s`;
|
||
yUnit = 'in/s';
|
||
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
||
tickFmt = v => v.toFixed(4);
|
||
} else {
|
||
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
|
||
const peakCounts = Math.max(...samples.map(Math.abs));
|
||
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
||
? Math.abs(micPeakPsi) / peakCounts
|
||
: 1.0;
|
||
plotSamples = samples.map(c => c * micScale);
|
||
const peakPsi = Math.max(...plotSamples.map(Math.abs));
|
||
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
|
||
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
|
||
yUnit = 'psi';
|
||
tooltipFmt = v => `${ch}: ${v.toExponential(3)} psi`;
|
||
tickFmt = v => v.toExponential(1);
|
||
}
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'chart-wrap';
|
||
|
||
const lbl = document.createElement('div');
|
||
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
|
||
lbl.textContent = `${ch} — peak ${peakLabel}`;
|
||
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 = plotSamples;
|
||
if (plotSamples.length > MAX_POINTS) {
|
||
const step = Math.ceil(plotSamples.length / MAX_POINTS);
|
||
renderTimes = times.filter((_, i) => i % step === 0);
|
||
renderData = plotSamples.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 => tooltipFmt(item.raw),
|
||
},
|
||
},
|
||
},
|
||
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,
|
||
callback: v => tickFmt(v),
|
||
},
|
||
grid: { color: '#21262d' },
|
||
title: {
|
||
display: true,
|
||
text: yUnit,
|
||
color: '#484f58',
|
||
font: { size: 10 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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>
|