/* event-modal.js — shared event-detail modal. * * Used by: * - /sfm (admin Events tab) * - /projects/{p}/nrl/{l} (project-location Events tab) * - /unit/{id} (unit-detail SFM Events table) * * Pages must include partials/event_detail_modal.html in the body * before this script is loaded. * * Public API: * showEventDetail(eventId) * Open the modal and fetch /api/sfm/db/events/{id}/sidecar to * populate the rich BW report fields (peaks, ZC freq, sensor * self-check, device info, etc.) into a tabbed/sectioned view. * * closeEventDetailModal() * Close the modal. * * Notes: * - Fetches sidecar live from SFM via terra-view's /api/sfm proxy. * - Renders gracefully when the sidecar lacks a bw_report block * (older events forwarded before the _ASCII.TXT pairing fix). * - All functions are global on window so inline onclick handlers * can reach them across all three host pages. */ (function () { const MODAL_ID = 'event-detail-modal'; function _esc(s) { if (s == null) return ''; return String(s).replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function _fmt(v, digits = 4, suffix = '') { if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—'; if (typeof v === 'number') { return v.toFixed(digits) + (suffix ? ` ${suffix}` : ''); } return _esc(v) + (suffix ? ` ${suffix}` : ''); } function _ppvClass(v) { if (v == null) return 'text-gray-400'; if (v < 0.5) return 'text-green-600 dark:text-green-400'; if (v < 2.0) return 'text-amber-600 dark:text-amber-400'; return 'text-red-600 dark:text-red-400 font-semibold'; } function _kvCard(label, value, options = {}) { // Single key-value tile. `value` is pre-rendered HTML (or text). const colorCls = options.colorCls || ''; const valCls = `font-mono font-semibold ${colorCls}`; return `
${_esc(label)}
${value}
${options.sub ? `
${options.sub}
` : ''}
`; } function _sectionHeader(title, sub) { return `

${_esc(title)}${sub ? ` ${_esc(sub)}` : ''}

`; } // ── Section renderers ──────────────────────────────────────────── function _renderEventHeader(s) { const ev = s.event || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; return `
Serial ${_esc(ev.serial)}
Timestamp ${ts}
Record Type ${_esc(ev.record_type || '—')}
Sample Rate ${ev.sample_rate ?? '—'} sps
Rec Time ${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}
Waveform Key ${_esc(ev.waveform_key || '—')}
`; } function _renderProjectInfo(s) { // The "user notes" metadata the operator typed into the BW device. // These are the strings the future metadata-driven parser will use. const p = s.project_info || {}; return `
Project ${_esc(p.project || '—')}
Client ${_esc(p.client || '—')}
Operator ${_esc(p.operator || '—')}
Sensor Location ${_esc(p.sensor_location || '—')}

Values are as typed into the seismograph at session start — not the terra-view project/location assignment.

`; } function _renderPeakValues(s) { // Prefer bw_report.peaks for richer per-channel data; fall back to peak_values. const bwPeaks = (s.bw_report && s.bw_report.peaks) || null; const pv = s.peak_values || {}; const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse; const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical; const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal; const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum; const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null; return `
${_kvCard('Transverse', `${_fmt(tran, 4)}`, { sub: 'in/s' })} ${_kvCard('Vertical', `${_fmt(vert, 4)}`, { sub: 'in/s' })} ${_kvCard('Longitudinal', `${_fmt(lng, 4)}`, { sub: 'in/s' })} ${_kvCard('Peak Vector Sum', `${_fmt(pvs, 4)}`, { sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s', })}
`; } function _renderMic(s) { const mic = (s.bw_report && s.bw_report.mic) || null; const pv = s.peak_values || {}; if (!mic && pv.mic_psi == null) return ''; const dbl = mic?.pspl_dbl; const psi = pv.mic_psi; const zcHz = mic?.zc_freq_hz; const tPk = mic?.time_of_peak_s; const wt = mic?.weighting; return `
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })} ${_kvCard('Peak Mic psi', _fmt(psi, 4))} ${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))} ${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
`; } function _sensorRow(label, ch) { if (!ch) { return `${_esc(label)} —`; } const result = ch.result || '—'; const resultCls = result === 'Passed' ? 'text-green-600 dark:text-green-400' : (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500'); // Geo channels have freq + ratio; mic has freq + amplitude. const rightCol = (ch.amplitude_mv != null) ? `${_fmt(ch.amplitude_mv, 1, 'mV')}` : `${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}`; return ` ${_esc(label)} ${_fmt(ch.freq_hz, 1, 'Hz')} ${rightCol} ${_esc(result)} `; } function _renderSensorCheck(s) { const sc = s.bw_report && s.bw_report.sensor_check; if (!sc) return ''; return ` ${_sensorRow('Transverse', sc.tran)} ${_sensorRow('Vertical', sc.vert)} ${_sensorRow('Longitudinal', sc.long)} ${_sensorRow('Microphone', sc.mic)}
Channel Frequency Amplitude/Ratio Result
`; } function _renderDeviceMetadata(s) { const bw = s.bw_report || {}; const dev = bw.device || {}; const rec = bw.recording || {}; return `
Firmware ${_esc(bw.version || '—')}
Battery ${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}
Calibrated ${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}
Geo Range ${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}
Stop Mode ${_esc(rec.stop_mode || '—')}
Units ${_esc(rec.units || '—')}
`; } function _renderFileInfo(s) { const bw = s.blastware || {}; const src = s.source || {}; return `
Blastware file ${_esc(bw.filename || '—')} ${bw.filesize ? `(${(bw.filesize / 1024).toFixed(1)} KB)` : ''}
SHA-256 ${_esc(bw.sha256 || '—')}
Captured at ${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}
Tool version ${_esc(src.tool_version || '—')}
`; } // ── Public API ─────────────────────────────────────────────────── window.showEventDetail = async function (eventId) { const modal = document.getElementById(MODAL_ID); if (!modal) { console.warn('event-modal: include event_detail_modal.html partial on this page.'); return; } modal.classList.remove('hidden'); document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail'; document.getElementById(MODAL_ID + '-content').innerHTML = `
Loading event detail…
`; let s; try { const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`); if (!r.ok) { throw new Error('HTTP ' + r.status + ' fetching sidecar'); } s = await r.json(); } catch (e) { document.getElementById(MODAL_ID + '-content').innerHTML = `
Failed to load event detail: ${_esc(e.message)}
`; return; } const ev = s.event || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : ''; document.getElementById(MODAL_ID + '-title').textContent = `Event — ${ev.serial || '?'} @ ${ts}`; const hasReport = !!s.bw_report; const reportNote = hasReport ? '' : `
No BW ASCII report paired with this event. Older events forwarded before the watcher's _ASCII.TXT pairing fix landed lack this data. PPV is still available from the binary event file.
`; document.getElementById(MODAL_ID + '-content').innerHTML = ` ${reportNote} ${_sectionHeader('Event')} ${_renderEventHeader(s)} ${_sectionHeader('Project Info', '(operator-typed at session start)')} ${_renderProjectInfo(s)} ${_sectionHeader('Peak Particle Velocity')} ${_renderPeakValues(s)} ${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? ` ${_sectionHeader('Microphone')} ${_renderMic(s)} ` : ''} ${hasReport ? ` ${_sectionHeader('Sensor Self-Check')} ${_renderSensorCheck(s)} ${_sectionHeader('Device & Recording Metadata')} ${_renderDeviceMetadata(s)} ` : ''} ${_sectionHeader('Source File')} ${_renderFileInfo(s)} `; }; window.closeEventDetailModal = function () { const modal = document.getElementById(MODAL_ID); if (modal) modal.classList.add('hidden'); }; // Close on Escape. document.addEventListener('keydown', function (e) { if (e.key === 'Escape') window.closeEventDetailModal(); }); })();