Files
seismo-relay/sfm/event_browser.html
T
serversdown c14a8c54db event_browser: Instantel-printout-style polish
Apply the cheap visual wins from the BW Event Report layout:

  1. Channel order reversed → MicL (top), Long, Vert, Tran (bottom)
     to match the Instantel printout.
  2. Shared bottom time axis — x-axis ticks only render on the
     bottom-most data channel; other channels hide ticks so all four
     visually share one time scale.
  3. Triangle trigger markers above and below the t=0 dashed line.
  4. Horizontal zero-baseline (dotted) per channel with "0.0" label
     on the right edge — Instantel convention.
  5. "Print view" toggle that flips dark→light theme (white panels,
     light grids, dark text) so the viewer can render usefully on
     paper-style output / @media print.
  6. Per-channel PPV stats table in the metadata header, with Peak
     Vector Sum displayed prominently.
  7. Colors adjusted to approximate BW trace colors (magenta MicL,
     blue Long, green Vert, red Tran).

Future PDF-export work will reproduce the same layout server-side
once you upload a real example PDF and we pick a rendering pipeline
(weasyprint / chromium --print-to-pdf / etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:09:12 +00:00

730 lines
23 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 30px 8px 12px; /* right padding leaves room for the "0.0" baseline label */
}
.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;
}
/* Per-channel stats table in the metadata header */
.stats-table {
grid-column: 1 / -1;
border-collapse: collapse;
font-family: monospace;
font-size: 12px;
margin-top: 4px;
}
.stats-table th, .stats-table td {
padding: 3px 14px 3px 0;
text-align: left;
color: #c9d1d9;
}
.stats-table th {
color: #484f58;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
/* ── Print view (light theme matching the Instantel printout) ─── */
body.print-view {
background: #ffffff;
color: #000000;
}
body.print-view header,
body.print-view #event-list-wrap,
body.print-view #event-list-header,
body.print-view #event-meta,
body.print-view #status-bar,
body.print-view .chart-wrap {
background: #ffffff;
border-color: #cccccc;
color: #000000;
}
body.print-view .event-row { color: #000; border-bottom-color: #eee; }
body.print-view .event-row:hover { background: #f4f4f4; }
body.print-view .event-row.active {
background: #e6f0ff;
border-left-color: #1f6feb;
}
body.print-view .er-ts { color: #000; }
body.print-view .er-pvs { color: #003a8c; }
body.print-view .er-meta,
body.print-view #event-list-header,
body.print-view .meta-field .mf-label,
body.print-view .stats-table th {
color: #666;
}
body.print-view .mf-value { color: #000; }
body.print-view .mf-value.highlight { color: #003a8c; }
body.print-view label { color: #444; }
body.print-view input, body.print-view select {
background: #fff; color: #000; border-color: #ccc;
}
/* In print theme, the channel-label colors stay (they identify
the trace). Only the chart panel background flips. */
@media print {
header, #event-list-wrap, #status-bar, button { display: none !important; }
body { overflow: visible; height: auto; }
#main, #viewer { overflow: visible; }
#charts { overflow: visible; }
}
</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="print-btn" onclick="togglePrintView()" style="margin-left:auto;background:#21262d">Print view</button>
<button id="reload-btn" onclick="loadSerials()">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>
// Channel colors and rendering order mirror Instantel's BW Event Report
// printout: MicL at the top, Tran at the bottom. Colors approximate
// what BW renders (magenta mic, blue long, green vert, red tran).
const CHANNEL_COLORS = {
MicL: '#e066ff',
Long: '#3a80ff',
Vert: '#3fb950',
Tran: '#f85149',
};
const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
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 || '—'],
['Peak Vector Sum',
ev?.peak_vector_sum != null ? `${ev.peak_vector_sum.toFixed(4)} in/s` : '—'],
];
// Per-channel stats table mirroring the printout's middle block.
// Pulls per-channel PPV from the events row (DB columns) and additional
// details (peak time, peak accel, peak displacement, sensor check) from
// bw_report when present.
const fmt = v => (v == null ? '—' : (typeof v === 'number' ? v.toFixed(3) : v));
const rows = [
['Tran', ev?.tran_ppv],
['Vert', ev?.vert_ppv],
['Long', ev?.long_ppv],
];
const statsHtml = `
<table class="stats-table">
<thead>
<tr><th>Channel</th><th>PPV (in/s)</th></tr>
</thead>
<tbody>
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
<tr><td>MicL</td><td>${fmt(ev?.mic_ppv)} psi</td></tr>
</tbody>
</table>
`;
metaDiv.innerHTML =
fields.map(([l, v]) =>
`<div class="meta-field"><span class="mf-label">${l}</span><span class="mf-value${l === 'Peak Vector Sum' ? ' highlight' : ''}">${v}</span></div>`
).join('') + statsHtml;
metaDiv.style.display = 'grid';
}
function togglePrintView() {
document.body.classList.toggle('print-view');
// Force chart redraw so axis/grid colors are re-evaluated against the
// new background. Easiest: re-render the current event.
if (currentEventId) {
loadEvent(currentEventId);
}
}
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;
const isPrintMode = document.body.classList.contains('print-view');
// Which channels actually have data → determines which one renders the
// shared x-axis at the bottom (Instantel printout has the time scale
// only on the bottom-most chart).
const channelsWithData = CHANNEL_ORDER.filter(ch =>
channels[ch] && (channels[ch].values || []).length > 0
);
const lastDataCh = channelsWithData[channelsWithData.length - 1];
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` : '')
: '';
// Hide x-axis on every chart except the bottom-most data channel —
// gives the "single shared time axis" feel of the BW printout.
const showXAxis = (ch === lastDataCh);
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',
display: showXAxis,
ticks: {
color: isPrintMode ? '#666' : '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => rT[i] + ' ms',
},
grid: { color: isPrintMode ? '#e0e0e0' : '#21262d', drawTicks: showXAxis },
},
y: {
ticks: { color: isPrintMode ? '#666' : '#484f58', maxTicksLimit: 5 },
grid: { color: isPrintMode ? '#e0e0e0' : '#21262d' },
title: { display: true, text: unit,
color: isPrintMode ? '#666' : '#484f58', font: { size: 10 } },
},
},
},
plugins: [{
// Trigger line @ t=0 + triangle markers above/below + "0.0"
// baseline label on the right edge. Matches the Instantel
// BW Event Report printout style.
id: 'instantelOverlays',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
const fgPrim = isPrintMode ? '#000' : '#c9d1d9';
const fgTrigger = '#f85149';
// Dashed vertical trigger line at t=0
const zeroIdx = rT.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx >= 0) {
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = isPrintMode ? '#cc0000' : 'rgba(248, 81, 73, 0.8)';
ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
// Triangles above and below the chart at the trigger column
ctx.save();
ctx.fillStyle = fgTrigger;
ctx.beginPath(); // top triangle pointing down
ctx.moveTo(x - 5, yAxis.top - 8);
ctx.lineTo(x + 5, yAxis.top - 8);
ctx.lineTo(x, yAxis.top - 1);
ctx.closePath();
ctx.fill();
ctx.beginPath(); // bottom triangle pointing up
ctx.moveTo(x - 5, yAxis.bottom + 8);
ctx.lineTo(x + 5, yAxis.bottom + 8);
ctx.lineTo(x, yAxis.bottom + 1);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// "0.0" baseline label on the right edge — printout convention.
// Position vertically at the zero-amplitude level.
const zeroY = yAxis.getPixelForValue(0);
if (zeroY >= yAxis.top && zeroY <= yAxis.bottom) {
ctx.save();
ctx.strokeStyle = isPrintMode ? '#aaa' : '#30363d';
ctx.lineWidth = 0.8;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(xAxis.left, zeroY);
ctx.lineTo(xAxis.right, zeroY);
ctx.stroke();
ctx.restore();
ctx.save();
ctx.fillStyle = fgPrim;
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('0.0', xAxis.right + 6, zeroY);
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>