/* 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 _deriveRecordType(filename, fallback) { // SFM currently hardcodes record_type="Waveform" for every event. // The actual type is encoded in the LAST character of the Blastware // filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram). // We derive it client-side until SFM is fixed; if the suffix isn't // a known code we fall back to whatever SFM reported. if (!filename) return fallback || '—'; const dotIdx = filename.lastIndexOf('.'); if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—'; const ext = filename.slice(dotIdx + 1); const lastChar = ext.slice(-1).toUpperCase(); const typeMap = { 'H': 'Histogram', 'W': 'Waveform', 'M': 'Manual', 'E': 'Event', 'C': 'Combo', }; return typeMap[lastChar] || (fallback || '—'); } function _sectionHeader(title, sub) { return `

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

`; } // ── Section renderers ──────────────────────────────────────────── function _renderEventHeader(s) { const ev = s.event || {}; const bw = s.blastware || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type); return `
Serial ${_esc(ev.serial)}
Timestamp ${ts}
Record Type ${_esc(recType)}
Sample Rate ${ev.sample_rate ?? '—'} sps
Rec Time ${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}
Waveform Key ${_esc(ev.waveform_key || '—')}
`; } function _renderUserNotes(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.user_notes || {}; 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) { // Operators only care about dB(L); PSI tile was dropped 2026-05. // We still render the row if any mic data is present so ZC freq / // time-of-peak stay visible even when bw_report.mic is missing. 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 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('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, eventId) { const bw = s.blastware || {}; const src = s.source || {}; const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null; const canDownloadBinary = !!(bw.available && bw.filename && eventId); const downloadButtons = `
${canDownloadBinary ? ` Download Blastware file (${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''}) ` : ` Blastware file unavailable `} Download sidecar JSON
`; return `${downloadButtons}
Blastware file ${_esc(bw.filename || '—')} ${sizeKb ? `(${sizeKb} 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('User Notes')} ${_renderUserNotes(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, eventId)} `; }; window.closeEventDetailModal = function () { const modal = document.getElementById(MODAL_ID); if (modal) modal.classList.add('hidden'); }; window.toggleEventJsonViewer = function () { const viewer = document.getElementById('event-json-viewer'); const label = document.getElementById('event-json-toggle-label'); if (!viewer) return; const isHidden = viewer.classList.toggle('hidden'); if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON'; }; window.copyEventJson = function () { const pre = document.getElementById('event-json-pre'); const label = document.getElementById('event-json-copy-label'); if (!pre) return; navigator.clipboard.writeText(pre.textContent).then(() => { if (label) { label.textContent = 'Copied!'; setTimeout(() => { label.textContent = 'Copy'; }, 1500); } }).catch(err => { console.error('clipboard write failed', err); if (label) { label.textContent = 'Failed'; setTimeout(() => { label.textContent = 'Copy'; }, 1500); } }); }; // Close on Escape. document.addEventListener('keydown', function (e) { if (e.key === 'Escape') window.closeEventDetailModal(); }); })();