/* 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 `
| Channel |
Frequency |
Amplitude/Ratio |
Result |
${_sensorRow('Transverse', sc.tran)}
${_sensorRow('Vertical', sc.vert)}
${_sensorRow('Longitudinal', sc.long)}
${_sensorRow('Microphone', sc.mic)}
`;
}
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 = `
`;
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 = `
`;
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, eventId)}
`;
};
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();
});
})();