6a73523e4d
The PDF report shows per-channel ZC Freq alongside PPV in the stats block, but neither modal exposed it. Now that the sidecar projection carries zc_freq_hz + zc_freq_above_range, plumb them through: - sfm_webapp.html: inline suffix on existing Peaks cells, e.g. "Tran 0.04500 in/s · >100 Hz". Empty suffix when no ZC is available (legacy events without a preserved .TXT). - event_browser.html: new ZC Freq column on the per-channel stats table. Required adding a parallel sidecar fetch in loadEvent() (waveform.json alone doesn't carry bw_report). Fetch failure is non-fatal — falls back to "—" in the new column. Above-range ZC peaks (BW ">100 Hz") render with a literal ">" prefix mirroring the PDF, so operators don't have to generate the PDF to see when a channel hit the zero-crossing ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
910 lines
31 KiB
HTML
910 lines
31 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="mic-unit-toggle" style="margin-left:auto;background:#21262d"
|
|
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
|
|
title="Toggle mic display unit (dBL ↔ psi). Persists across page loads.">
|
|
Mic: dBL
|
|
</button>
|
|
<button id="print-btn" onclick="togglePrintView()" style="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'];
|
|
|
|
// Reference pressure for dB(L) — 20 µPa expressed in psi (≈ 2.9e-9 psi).
|
|
const DBL_REF = 2.9e-9;
|
|
|
|
// User-toggleable mic display unit: 'dBL' (default, matches BW printout
|
|
// + the rest of SFM) or 'psi' (raw sample unit).
|
|
function _getMicUnit() {
|
|
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
|
|
}
|
|
function _setMicUnit(u) {
|
|
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
|
|
_refreshMicUnitToggle();
|
|
if (currentEventId) loadEvent(currentEventId);
|
|
}
|
|
function _refreshMicUnitToggle() {
|
|
const b = document.getElementById('mic-unit-toggle');
|
|
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
|
|
}
|
|
// psi → dB(L). Null for non-positive (log undefined; Chart.js renders as a gap).
|
|
function _psiToDbl(psi) {
|
|
if (psi == null || !(psi > 0)) return null;
|
|
return 20 * Math.log10(psi / DBL_REF);
|
|
}
|
|
|
|
// Per-sample mic chart conversion — rectify the AC waveform, dBL,
|
|
// floor below the noise-floor minimum. Gives a continuous baseline
|
|
// instead of the spikey/discontinuous look you get from raw _psiToDbl.
|
|
const MIC_DBL_FLOOR = 60;
|
|
function _psiToDblForChart(psi) {
|
|
if (psi == null) return MIC_DBL_FLOOR;
|
|
const a = Math.abs(psi);
|
|
if (a === 0) return MIC_DBL_FLOOR;
|
|
const dbl = 20 * Math.log10(a / DBL_REF);
|
|
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
|
|
}
|
|
|
|
// Format an ISO timestamp in the browser's local timezone — UTC values
|
|
// (with 'Z' suffix) convert; naive values are interpreted as local clock.
|
|
// Returns '—' for null/empty/unparseable.
|
|
function _fmtTsLocal(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return iso;
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
// Adaptive decimal formatter — scientific notation only for truly extreme
|
|
// values. Normal-range peaks render as plain decimals with sensible
|
|
// precision (was previously forcing toExponential(3) which produced ugly
|
|
// "2.500E-2 IN/S" labels).
|
|
function _fmtPeak(v, unit) {
|
|
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
|
|
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
|
|
if (v === 0) return '0' + (unit ? ' ' + unit : '');
|
|
const a = Math.abs(v);
|
|
const u = unit ? ' ' + unit : '';
|
|
if (a >= 0.0001 && a < 10000) {
|
|
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
|
|
return v.toFixed(d) + u;
|
|
}
|
|
return v.toExponential(2) + u;
|
|
}
|
|
|
|
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 = _fmtTsLocal(ev.timestamp);
|
|
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 {
|
|
// Sidecar fetch runs in parallel — its bw_report block carries ZC
|
|
// Freq + above-range flags + sensor-check results that the per-
|
|
// channel stats table surfaces. Failures are non-fatal (legacy
|
|
// events without a preserved .TXT have no sidecar bw_report).
|
|
const sidecarP = fetch(`${apiBase}/db/events/${eventId}/sidecar`)
|
|
.then(r => r.ok ? r.json() : null)
|
|
.catch(() => null);
|
|
|
|
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);
|
|
const sidecar = await sidecarP;
|
|
renderMeta(data, ev, sidecar);
|
|
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, sidecar) {
|
|
const metaDiv = document.getElementById('event-meta');
|
|
const fields = [
|
|
['Serial', data.serial || ev?.serial || '—'],
|
|
['Timestamp', _fmtTsLocal(data.timestamp || ev?.timestamp)],
|
|
['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.
|
|
// PPV from the events DB row; ZC Freq + saturation flags from the
|
|
// sidecar's bw_report block (when a .TXT was preserved on ingest).
|
|
const bwrPeaks = (sidecar?.bw_report || {}).peaks || {};
|
|
const bwrMic = (sidecar?.bw_report || {}).mic || {};
|
|
const fmt = v => (v == null ? '—' : (typeof v === 'number' ? v.toFixed(3) : v));
|
|
const fmtZc = bwr => {
|
|
if (!bwr || bwr.zc_freq_hz == null) return '—';
|
|
const prefix = bwr.zc_freq_above_range ? '>' : '';
|
|
return `${prefix}${Math.round(bwr.zc_freq_hz)} Hz`;
|
|
};
|
|
const rows = [
|
|
['Tran', ev?.tran_ppv, fmtZc(bwrPeaks.tran)],
|
|
['Vert', ev?.vert_ppv, fmtZc(bwrPeaks.vert)],
|
|
['Long', ev?.long_ppv, fmtZc(bwrPeaks.long)],
|
|
];
|
|
// Mic display honors the current user preference (dBL default).
|
|
// mic_ppv is stored as raw psi on series3 events; convert when needed.
|
|
const micPsi = ev?.mic_ppv;
|
|
const micUnitDisplay = _getMicUnit();
|
|
let micStr;
|
|
if (micPsi == null) {
|
|
micStr = '—';
|
|
} else if (micUnitDisplay === 'dBL') {
|
|
const d = _psiToDbl(Number(micPsi));
|
|
micStr = (d != null ? d.toFixed(1) : '—') + ' dBL';
|
|
} else {
|
|
micStr = Number(micPsi).toExponential(2) + ' psi';
|
|
}
|
|
const statsHtml = `
|
|
<table class="stats-table">
|
|
<thead>
|
|
<tr><th>Channel</th><th>PPV (in/s)</th><th>ZC Freq</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows.map(([ch, ppv, zc]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td><td>${zc}</td></tr>`).join('')}
|
|
<tr><td>MicL</td><td>${micStr}</td><td>${fmtZc(bwrMic)}</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 || {};
|
|
// time_axis is METADATA from sfm.plot.v1 — sample_rate, pretrig_samples,
|
|
// t0_ms (first-sample time relative to trigger; negative when pretrig
|
|
// exists), dt_ms. Trigger is at t=0 by convention.
|
|
const ta = data.time_axis || {};
|
|
const sr = ta.sample_rate || 1024;
|
|
const dtMs = ta.dt_ms || (1000.0 / sr);
|
|
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
|
|
const isPrintMode = document.body.classList.contains('print-view');
|
|
// Histograms record per-interval peaks (typically 1 per minute/5-min),
|
|
// not per-sample waveforms. Render as a tight bar graph instead of a
|
|
// line plot — matches the BW Event Report's histogram presentation.
|
|
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
|
|
|
|
// 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];
|
|
|
|
const micUnit = _getMicUnit();
|
|
for (const ch of CHANNEL_ORDER) {
|
|
const chData = channels[ch];
|
|
if (!chData) continue;
|
|
if ((chData.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;
|
|
}
|
|
|
|
// Mic channel: convert from raw psi to dB(L) when the user prefers dBL
|
|
// (the default). We mutate `values`, `peak`, and `unit` locally so the
|
|
// chart datasets + axis title + tooltip + peak label all stay aligned.
|
|
let values = chData.values || [];
|
|
let unit = chData.unit || 'unit';
|
|
let peak = chData.peak;
|
|
const peakT = chData.peak_t_ms;
|
|
if (ch === 'MicL' && unit === 'psi' && micUnit === 'dBL') {
|
|
// Per-sample chart uses rectified-and-floored conversion so the
|
|
// baseline is continuous; the peak label uses the unrectified
|
|
// converter to preserve the true measurement.
|
|
values = values.map(_psiToDblForChart);
|
|
peak = _psiToDbl(peak);
|
|
unit = 'dB(L)';
|
|
}
|
|
|
|
const peakLabel = peak != null
|
|
? `peak ${_fmtPeak(peak, unit)}`
|
|
+ (!isHistogram && 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);
|
|
|
|
// Waveform: per-sample time in ms relative to trigger (negative for pretrig).
|
|
// Histogram: when the server has aggregated to BW-reported intervals AND
|
|
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
|
|
// Falls back to interval index.
|
|
let times;
|
|
if (isHistogram) {
|
|
const intervalTimes = ta.interval_times || [];
|
|
times = (intervalTimes.length === values.length)
|
|
? intervalTimes
|
|
: values.map((_, i) => i + 1);
|
|
} else {
|
|
times = values.map((_, i) => t0Ms + i * dtMs);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Tick formatter — round to 1 decimal so we don't get
|
|
// "11.7187040000000002 ms" garbage from floating-point accumulation.
|
|
const xAxisUnit = isHistogram ? '' : ' ms';
|
|
const fmtTick = i => {
|
|
const v = rT[i];
|
|
if (typeof v !== 'number') return String(v) + xAxisUnit;
|
|
return (Number.isInteger(v) ? String(v) : v.toFixed(1)) + xAxisUnit;
|
|
};
|
|
|
|
// Y-axis bounds. Geophone waveforms render symmetric around zero
|
|
// (seismograph convention — zero line in the middle, signal goes
|
|
// up AND down). Mic + histograms keep default auto-scale (always
|
|
// positive values; zero at the bottom).
|
|
let yBounds = {};
|
|
const isGeo = ch !== 'MicL';
|
|
if (isGeo && !isHistogram) {
|
|
// Waveform geo: symmetric around zero for full shape detail.
|
|
let absMax = 0;
|
|
for (const v of values) {
|
|
const a = Math.abs(v);
|
|
if (a > absMax) absMax = a;
|
|
}
|
|
const padded = (absMax || 1) * 1.10;
|
|
yBounds = { min: -padded, max: padded };
|
|
} else if (isGeo && isHistogram) {
|
|
// Histogram geo: enforce minimum chart range so quiet events
|
|
// look quiet (matches BW's near-fixed-scale convention).
|
|
const HIST_GEO_MIN_INS = 0.05;
|
|
let p = 0;
|
|
for (const v of values) { const a = Math.abs(v); if (a > p) p = a; }
|
|
yBounds = { min: 0, max: Math.max(p * 1.10, HIST_GEO_MIN_INS) };
|
|
} else if (ch === 'MicL' && micUnit === 'dBL') {
|
|
// Mic dBL: baseline at noise-floor minimum, top at peak + 5 dB.
|
|
const peakDbl = (typeof peak === 'number' && isFinite(peak))
|
|
? peak + 5
|
|
: 100;
|
|
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
|
|
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
|
|
// Mic histogram in psi: same minimum-range treatment as geo.
|
|
const HIST_MIC_MIN_PSI = 0.001;
|
|
let p = 0;
|
|
for (const v of values) { const a = Math.abs(v); if (a > p) p = a; }
|
|
yBounds = { min: 0, max: Math.max(p * 1.10, HIST_MIC_MIN_PSI) };
|
|
}
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: isHistogram ? 'bar' : 'line',
|
|
data: {
|
|
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
|
|
datasets: isHistogram ? [{
|
|
data: rV,
|
|
backgroundColor: CHANNEL_COLORS[ch],
|
|
borderWidth: 0,
|
|
barPercentage: 1.0,
|
|
categoryPercentage: 1.0, // bars touch — tight bargraph
|
|
}] : [{
|
|
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 => isHistogram
|
|
? `interval ${items[0].label}`
|
|
: `t = ${items[0].label} ms`,
|
|
label: item => `${ch}: ${_fmtPeak(item.raw, unit)}`,
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: 'category',
|
|
display: showXAxis,
|
|
ticks: {
|
|
color: isPrintMode ? '#666' : '#484f58',
|
|
maxTicksLimit: 10,
|
|
maxRotation: 0,
|
|
callback: (val, i) => fmtTick(i),
|
|
},
|
|
grid: { color: isPrintMode ? '#e0e0e0' : '#21262d', drawTicks: showXAxis },
|
|
},
|
|
y: {
|
|
...yBounds,
|
|
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: isHistogram ? [] : [{
|
|
// Trigger line @ t=0 + triangle markers above/below + "0.0"
|
|
// baseline label on the right edge. Matches the Instantel
|
|
// BW Event Report printout style. Skipped for histograms —
|
|
// they have no trigger event.
|
|
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);
|
|
|
|
// Reflect any persisted mic-unit preference in the header pill on load
|
|
_refreshMicUnitToggle();
|
|
|
|
// Initial load
|
|
loadSerials();
|
|
</script>
|
|
</body>
|
|
</html>
|