ui: surface per-channel ZC Freq (and ">100") in event modals

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>
This commit is contained in:
2026-05-28 18:47:37 +00:00
parent 780b45a371
commit 6a73523e4d
2 changed files with 44 additions and 15 deletions
+26 -11
View File
@@ -499,6 +499,14 @@ async function loadEvent(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) {
@@ -511,7 +519,8 @@ async function loadEvent(eventId) {
renderWaveform(data);
// Also fetch metadata from the events list for richer header
const ev = allEvents.find(e => e.id === eventId);
renderMeta(data, ev);
const sidecar = await sidecarP;
renderMeta(data, ev, sidecar);
setStatus(`Event loaded.`, 'ok');
} catch (e) {
setStatus(`Failed to load event: ${e.message}`, 'error');
@@ -528,7 +537,7 @@ function showEmpty(msg) {
charts = {};
}
function renderMeta(data, ev) {
function renderMeta(data, ev, sidecar) {
const metaDiv = document.getElementById('event-meta');
const fields = [
['Serial', data.serial || ev?.serial || '—'],
@@ -543,14 +552,20 @@ function renderMeta(data, ev) {
];
// 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.
// 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],
['Vert', ev?.vert_ppv],
['Long', ev?.long_ppv],
['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.
@@ -568,11 +583,11 @@ function renderMeta(data, ev) {
const statsHtml = `
<table class="stats-table">
<thead>
<tr><th>Channel</th><th>PPV (in/s)</th></tr>
<tr><th>Channel</th><th>PPV (in/s)</th><th>ZC Freq</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>
${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>
`;
+18 -4
View File
@@ -2886,6 +2886,12 @@ function _renderSidecar(data) {
const bw = data.blastware || {};
const src = data.source || {};
const rev = data.review || {};
// bw_report carries the per-channel ASCII-derived stats (ZC Freq,
// saturation flags, peak time, etc.). Only present on events
// ingested with a preserved .TXT (post-2026-05-27); falls back to
// empty for legacy events.
const bwrPeaks = (data.bw_report || {}).peaks || {};
const bwrMic = (data.bw_report || {}).mic || {};
document.getElementById('sc-title').textContent = `Event — ${bw.filename || ev.waveform_key || 'unknown'}`;
@@ -2918,11 +2924,19 @@ function _renderSidecar(data) {
document.getElementById('sc-f-sr').textContent = (ev.sample_rate ?? '—') + (ev.sample_rate ? ' sps' : '');
document.getElementById('sc-f-key').textContent = ev.waveform_key || '—';
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse);
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical);
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal);
// Suffix with " · {prefix}{N} Hz" when bw_report has a ZC Freq.
// Above-range ZC peaks (BW ">100 Hz") get a literal ">" prefix so
// operators see the same indicator the PDF shows.
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`;
};
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse) + fmtZc(bwrPeaks.tran);
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical) + fmtZc(bwrPeaks.vert);
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal) + fmtZc(bwrPeaks.long);
document.getElementById('sc-f-pvs').textContent = fmtPpv(pv.vector_sum);
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi);
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi) + fmtZc(bwrMic);
document.getElementById('sc-f-project').textContent = pi.project || '—';
document.getElementById('sc-f-client').textContent = pi.client || '—';