460006e5cd
New standalone HTML page (sfm/event_browser.html, ~470 lines, Chart.js)
that lets you browse persisted events from the SeismoDb + WaveformStore.
Companion to the existing live-device viewer at /waveform:
/waveform — connect to a unit and pull events in real time
/events — browse events already stored in the DB
Flow:
1. Page loads → GET /db/units → populate serial dropdown
2. Select serial → GET /db/events?serial=X&limit=500 → event list
3. Click event → GET /db/events/{id}/waveform.json → render
Layout is Instantel-printout-ready: channels stacked vertically in
Tran / Vert / Long / MicL order, trigger line at t=0, peak labels,
clean dark theme. Frames the future PDF-export feature without
needing extra layout work.
Smoke-tested against the dev prod-snapshot — 4 channels render with
correct peaks for K558 events (L=0.3 in/s = the offset-fault peak
we've been chasing all week).
CHANGELOG entry added under [Unreleased] per the v0.20.0 release plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
565 lines
17 KiB
HTML
565 lines
17 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 Event Browser</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;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
header {
|
|
background: #161b22;
|
|
border-bottom: 1px solid #30363d;
|
|
padding: 12px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
label { color: #8b949e; font-size: 12px; }
|
|
|
|
select, input[type="text"], input[type="search"] {
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
color: #c9d1d9;
|
|
padding: 5px 8px;
|
|
font-size: 13px;
|
|
}
|
|
select { min-width: 140px; }
|
|
input[type="search"] { width: 200px; }
|
|
select:focus, 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;
|
|
}
|
|
button:hover { background: #388bfd; }
|
|
button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
|
|
|
|
#main {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Event list (left sidebar) ────────────────────────────────── */
|
|
#event-list-wrap {
|
|
width: 320px;
|
|
flex-shrink: 0;
|
|
background: #0d1117;
|
|
border-right: 1px solid #21262d;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#event-list-header {
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid #21262d;
|
|
font-size: 11px;
|
|
color: #8b949e;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
#event-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.event-row {
|
|
padding: 8px 14px;
|
|
border-bottom: 1px solid #161b22;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.event-row:hover { background: #161b22; }
|
|
.event-row.active { background: #1f3a5f; border-left: 3px solid #58a6ff; padding-left: 11px; }
|
|
.event-row .er-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 2px;
|
|
}
|
|
.event-row .er-ts { font-family: monospace; font-size: 12px; color: #c9d1d9; }
|
|
.event-row .er-pvs { font-family: monospace; font-size: 12px; color: #58a6ff; font-weight: 600; }
|
|
.event-row .er-meta { font-size: 11px; color: #8b949e; }
|
|
.event-row.false_trigger .er-pvs { color: #f85149; text-decoration: line-through; }
|
|
|
|
/* ── Main viewer (right side) ─────────────────────────────────── */
|
|
#viewer {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#event-meta {
|
|
padding: 12px 20px;
|
|
background: #161b22;
|
|
border-bottom: 1px solid #21262d;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
gap: 8px 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
.meta-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
.meta-field .mf-label {
|
|
font-size: 10px;
|
|
color: #484f58;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.meta-field .mf-value {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
color: #c9d1d9;
|
|
}
|
|
.meta-field .mf-value.highlight { color: #58a6ff; font-weight: 600; }
|
|
|
|
#charts {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
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;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
.chart-canvas-wrap { position: relative; height: 130px; }
|
|
|
|
.ch-tran { color: #58a6ff; }
|
|
.ch-vert { color: #3fb950; }
|
|
.ch-long { color: #d29922; }
|
|
.ch-micl { color: #bc8cff; }
|
|
|
|
#status-bar {
|
|
background: #161b22;
|
|
border-top: 1px solid #21262d;
|
|
padding: 5px 20px;
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
min-height: 26px;
|
|
flex-shrink: 0;
|
|
}
|
|
#status-bar.error { color: #f85149; }
|
|
#status-bar.ok { color: #3fb950; }
|
|
|
|
#empty-state {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #484f58;
|
|
gap: 8px;
|
|
}
|
|
#empty-state svg { opacity: 0.3; }
|
|
|
|
.pill {
|
|
background: #21262d;
|
|
border-radius: 4px;
|
|
padding: 2px 8px;
|
|
color: #c9d1d9;
|
|
font-family: monospace;
|
|
font-size: 11px;
|
|
margin-left: 8px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>SFM Event Browser</h1>
|
|
<label>Serial</label>
|
|
<select id="serial-select">
|
|
<option value="">Loading…</option>
|
|
</select>
|
|
<input type="search" id="event-filter" placeholder="filter events…" />
|
|
<span class="pill" id="count-pill">—</span>
|
|
<button id="reload-btn" onclick="loadSerials()" style="margin-left:auto">Reload</button>
|
|
</header>
|
|
|
|
<div id="main">
|
|
<div id="event-list-wrap">
|
|
<div id="event-list-header">
|
|
<span>Events</span>
|
|
<span id="event-list-count">—</span>
|
|
</div>
|
|
<div id="event-list"></div>
|
|
</div>
|
|
|
|
<div id="viewer">
|
|
<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>Select a unit and event to view its waveform.</p>
|
|
</div>
|
|
<div id="event-meta" style="display:none"></div>
|
|
<div id="charts" style="display:none"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status-bar">Ready.</div>
|
|
|
|
<script>
|
|
const CHANNEL_COLORS = {
|
|
Tran: '#58a6ff',
|
|
Vert: '#3fb950',
|
|
Long: '#d29922',
|
|
MicL: '#bc8cff',
|
|
};
|
|
const CHANNEL_ORDER = ['Tran', 'Vert', 'Long', 'MicL'];
|
|
|
|
let allEvents = [];
|
|
let filteredEvents = [];
|
|
let currentEventId = null;
|
|
let charts = {};
|
|
|
|
const apiBase = window.location.origin;
|
|
|
|
function setStatus(msg, cls = '') {
|
|
const bar = document.getElementById('status-bar');
|
|
bar.textContent = msg;
|
|
bar.className = cls;
|
|
}
|
|
|
|
async function loadSerials() {
|
|
setStatus('Loading serials…');
|
|
try {
|
|
const r = await fetch(`${apiBase}/db/units`);
|
|
if (!r.ok) throw new Error(r.statusText);
|
|
// /db/units returns a bare list[dict], not {units:[...]}
|
|
const units = await r.json();
|
|
const sel = document.getElementById('serial-select');
|
|
sel.innerHTML = '';
|
|
if (!units || units.length === 0) {
|
|
sel.innerHTML = '<option value="">(no units found)</option>';
|
|
setStatus('No units in DB.', 'error');
|
|
return;
|
|
}
|
|
sel.innerHTML = '<option value="">— pick a unit —</option>' +
|
|
units.map(u => {
|
|
const n = u.total_events ?? 0;
|
|
return `<option value="${u.serial}">${u.serial} (${n} events)</option>`;
|
|
}).join('');
|
|
setStatus(`Loaded ${units.length} units.`, 'ok');
|
|
} catch (e) {
|
|
setStatus(`Failed to load units: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadEventsForSerial(serial) {
|
|
if (!serial) {
|
|
allEvents = [];
|
|
renderEventList();
|
|
return;
|
|
}
|
|
setStatus(`Loading events for ${serial}…`);
|
|
try {
|
|
const r = await fetch(`${apiBase}/db/events?serial=${encodeURIComponent(serial)}&limit=500`);
|
|
if (!r.ok) throw new Error(r.statusText);
|
|
const d = await r.json();
|
|
allEvents = d.events || [];
|
|
document.getElementById('count-pill').textContent = `${allEvents.length} events`;
|
|
applyFilter();
|
|
setStatus(`Loaded ${allEvents.length} events for ${serial}.`, 'ok');
|
|
} catch (e) {
|
|
setStatus(`Failed to load events: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function applyFilter() {
|
|
const q = document.getElementById('event-filter').value.toLowerCase().trim();
|
|
if (!q) {
|
|
filteredEvents = allEvents;
|
|
} else {
|
|
filteredEvents = allEvents.filter(ev =>
|
|
(ev.blastware_filename || '').toLowerCase().includes(q) ||
|
|
(ev.timestamp || '').toLowerCase().includes(q) ||
|
|
(ev.record_type || '').toLowerCase().includes(q) ||
|
|
(ev.project || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
document.getElementById('event-list-count').textContent = `${filteredEvents.length} / ${allEvents.length}`;
|
|
renderEventList();
|
|
}
|
|
|
|
function renderEventList() {
|
|
const list = document.getElementById('event-list');
|
|
list.innerHTML = '';
|
|
if (filteredEvents.length === 0) {
|
|
list.innerHTML = '<div style="padding:14px;color:#484f58;font-size:12px">No events.</div>';
|
|
return;
|
|
}
|
|
for (const ev of filteredEvents) {
|
|
const row = document.createElement('div');
|
|
row.className = 'event-row' + (ev.false_trigger ? ' false_trigger' : '');
|
|
if (ev.id === currentEventId) row.className += ' active';
|
|
const ts = (ev.timestamp || '').replace('T', ' ').replace('Z', '');
|
|
const pvs = ev.peak_vector_sum != null ? `${ev.peak_vector_sum.toFixed(3)} in/s` : '—';
|
|
row.innerHTML = `
|
|
<div class="er-top">
|
|
<span class="er-ts">${ts || '(no ts)'}</span>
|
|
<span class="er-pvs">${pvs}</span>
|
|
</div>
|
|
<div class="er-meta">${ev.record_type || '?'} · ${ev.blastware_filename || ev.id.slice(0,8)}</div>
|
|
`;
|
|
row.onclick = () => loadEvent(ev.id);
|
|
list.appendChild(row);
|
|
}
|
|
}
|
|
|
|
async function loadEvent(eventId) {
|
|
currentEventId = eventId;
|
|
renderEventList();
|
|
setStatus('Loading waveform…');
|
|
try {
|
|
const r = await fetch(`${apiBase}/db/events/${eventId}/waveform.json`);
|
|
if (!r.ok) {
|
|
if (r.status === 404) {
|
|
showEmpty('No waveform data for this event (codec returned no samples).');
|
|
return;
|
|
}
|
|
throw new Error(r.statusText);
|
|
}
|
|
const data = await r.json();
|
|
renderWaveform(data);
|
|
// Also fetch metadata from the events list for richer header
|
|
const ev = allEvents.find(e => e.id === eventId);
|
|
renderMeta(data, ev);
|
|
setStatus(`Event loaded.`, 'ok');
|
|
} catch (e) {
|
|
setStatus(`Failed to load event: ${e.message}`, 'error');
|
|
showEmpty(`Error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function showEmpty(msg) {
|
|
document.getElementById('empty-state').style.display = 'flex';
|
|
document.getElementById('empty-state').querySelector('p').textContent = msg;
|
|
document.getElementById('event-meta').style.display = 'none';
|
|
document.getElementById('charts').style.display = 'none';
|
|
Object.values(charts).forEach(c => c.destroy());
|
|
charts = {};
|
|
}
|
|
|
|
function renderMeta(data, ev) {
|
|
const metaDiv = document.getElementById('event-meta');
|
|
const fields = [
|
|
['Serial', data.serial || ev?.serial || '—'],
|
|
['Timestamp', (data.timestamp || ev?.timestamp || '—').replace('T', ' ').replace('Z', '')],
|
|
['Record', data.record_type || ev?.record_type || '—'],
|
|
['Sample rate', data.sample_rate ? `${data.sample_rate} sps` : '—'],
|
|
['Geo range', data.geo_range ? `${data.geo_range} (${data.geo_full_scale_ips} in/s FS)` : '—'],
|
|
['Project', ev?.project || '—'],
|
|
['Location', ev?.sensor_location || '—'],
|
|
['PVS', ev?.peak_vector_sum != null ? `${ev.peak_vector_sum.toFixed(4)} in/s` : '—'],
|
|
];
|
|
metaDiv.innerHTML = fields.map(([l, v]) =>
|
|
`<div class="meta-field"><span class="mf-label">${l}</span><span class="mf-value${l === 'PVS' ? ' highlight' : ''}">${v}</span></div>`
|
|
).join('');
|
|
metaDiv.style.display = 'grid';
|
|
}
|
|
|
|
function renderWaveform(data) {
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
const chartsDiv = document.getElementById('charts');
|
|
chartsDiv.style.display = 'flex';
|
|
chartsDiv.innerHTML = '';
|
|
Object.values(charts).forEach(c => c.destroy());
|
|
charts = {};
|
|
|
|
const channels = data.channels || {};
|
|
const timeAxis = data.time_axis || null; // ms relative to trigger
|
|
const triggerMs = data.trigger_ms ?? 0;
|
|
|
|
for (const ch of CHANNEL_ORDER) {
|
|
const chData = channels[ch];
|
|
if (!chData) continue;
|
|
const values = chData.values || [];
|
|
if (values.length === 0) {
|
|
// Render an empty card so user sees the channel exists but is missing
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chart-wrap';
|
|
wrap.innerHTML = `
|
|
<div class="chart-label ch-${ch.toLowerCase()}">
|
|
<span>${ch}</span>
|
|
<span style="color:#484f58">no samples decoded</span>
|
|
</div>
|
|
<div class="chart-canvas-wrap" style="display:flex;align-items:center;justify-content:center;color:#484f58;font-size:12px">empty</div>
|
|
`;
|
|
chartsDiv.appendChild(wrap);
|
|
continue;
|
|
}
|
|
|
|
const unit = chData.unit || 'unit';
|
|
const peak = chData.peak;
|
|
const peakT = chData.peak_t_ms;
|
|
const peakLabel = peak != null
|
|
? `peak ${(typeof peak === 'number' ? peak.toExponential(3) : peak)} ${unit}`
|
|
+ (peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '')
|
|
: '';
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chart-wrap';
|
|
const lbl = document.createElement('div');
|
|
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
|
|
lbl.innerHTML = `<span>${ch}</span><span style="color:#8b949e;font-weight:normal">${peakLabel}</span>`;
|
|
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);
|
|
|
|
// Build time labels — use server-provided time_axis if present, else derive from sample_rate
|
|
let times;
|
|
if (timeAxis && timeAxis.length === values.length) {
|
|
times = timeAxis;
|
|
} else {
|
|
const sr = data.sample_rate || 1024;
|
|
times = values.map((_, i) => (i / sr * 1000 - triggerMs));
|
|
}
|
|
|
|
// Downsample for rendering
|
|
const MAX_POINTS = 4000;
|
|
let rT = times, rV = values;
|
|
if (values.length > MAX_POINTS) {
|
|
const step = Math.ceil(values.length / MAX_POINTS);
|
|
rT = times.filter((_, i) => i % step === 0);
|
|
rV = values.filter((_, i) => i % step === 0);
|
|
}
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: rT.map(t => (typeof t === 'number' ? t.toFixed(2) : t)),
|
|
datasets: [{
|
|
data: rV,
|
|
borderColor: CHANNEL_COLORS[ch],
|
|
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} ${unit}`,
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: 'category',
|
|
ticks: {
|
|
color: '#484f58',
|
|
maxTicksLimit: 10,
|
|
maxRotation: 0,
|
|
callback: (val, i) => rT[i] + ' ms',
|
|
},
|
|
grid: { color: '#21262d' },
|
|
},
|
|
y: {
|
|
ticks: { color: '#484f58', maxTicksLimit: 5 },
|
|
grid: { color: '#21262d' },
|
|
title: { display: true, text: unit, color: '#484f58', font: { size: 10 } },
|
|
},
|
|
},
|
|
},
|
|
plugins: [{
|
|
// Vertical trigger line at t=0
|
|
id: 'triggerLine',
|
|
afterDraw(chart) {
|
|
const ctx = chart.ctx;
|
|
const xAxis = chart.scales.x;
|
|
const yAxis = chart.scales.y;
|
|
const zeroIdx = rT.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;
|
|
}
|
|
}
|
|
|
|
// Wire up handlers
|
|
document.getElementById('serial-select').addEventListener('change', e => {
|
|
loadEventsForSerial(e.target.value);
|
|
});
|
|
document.getElementById('event-filter').addEventListener('input', applyFilter);
|
|
|
|
// Initial load
|
|
loadSerials();
|
|
</script>
|
|
</body>
|
|
</html>
|