Files
seismo-relay/sfm/event_browser.html
T
serversdown b9f8bbb220 viewers: enforce minimum Y-range on histogram channels
Quiet histogram events were filling the chart panel even though the
peak was tiny (0.005 in/s rendered as 90% of chart height because
Chart.js auto-scaled to peak * 1.1).  Made everything look uniformly
loud regardless of actual amplitude.

BW's solution: a near-fixed scale per channel ("Geo: 0.002 in/s/div"
from the footer).  Quiet events render small, loud events render
proportionally tall.

Match the intent without copying BW's "no Y-axis labels at all"
convention.  For histogram channels:

  Geo (in/s):       min Y range 0.05 in/s
  Mic in psi:       min Y range 0.001 psi
  Mic in dBL:       unchanged (the 60 dBL floor + peak+5 top already
                    gives quiet events a sensible baseline)

So a 0.005 in/s geo event renders as ~10% of chart height; a 0.05
event fills it; a 5.0 event still fills it (max(peak*1.1, 0.05) ==
peak*1.1 for any peak > 0.045).

Waveform charts unchanged — they should zoom for shape detail.
Applied to both the modal in sfm_webapp.html and the standalone
/events page in event_browser.html.

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

895 lines
30 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 {
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', _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.
// 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],
];
// 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></tr>
</thead>
<tbody>
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
<tr><td>MicL</td><td>${micStr}</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>