diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js
index b41d961..ec210cf 100644
--- a/backend/static/event-modal.js
+++ b/backend/static/event-modal.js
@@ -28,6 +28,27 @@
(function () {
const MODAL_ID = 'event-detail-modal';
+ // ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
+ const _CHANNEL_COLORS = {
+ MicL: '#e066ff', // purple — distinct from the geo channels
+ Long: '#3b82f6', // blue
+ Vert: '#22c55e', // green
+ Tran: '#ef4444', // red
+ };
+ const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
+
+ // dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
+ const DBL_REF = 2.9e-9;
+ // Mic display floor — sound-pressure AC samples sit at the digitisation
+ // noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
+ // a floor, the chart looks like a sparse pattern of "moments when sound
+ // briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
+ const MIC_DBL_FLOOR = 60;
+
+ let _charts = {}; // ch → Chart instance
+ let _micUnitPref = 'dBL'; // refreshed via fetch on first chart render
+ let _micUnitPrefLoaded = false; // one-shot fetch guard
+
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&')
@@ -224,6 +245,283 @@
`;
}
+ // ── Waveform / histogram chart helpers ──────────────────────────
+
+ async function _loadMicUnitPref() {
+ if (_micUnitPrefLoaded) return _micUnitPref;
+ try {
+ const r = await fetch('/api/settings/preferences');
+ if (r.ok) {
+ const prefs = await r.json();
+ _micUnitPref = prefs.mic_unit_pref === 'psi' ? 'psi' : 'dBL';
+ }
+ } catch (e) {
+ // Network error → silent fall back to default 'dBL'.
+ }
+ _micUnitPrefLoaded = true;
+ return _micUnitPref;
+ }
+
+ function _psiToDbl(psi) {
+ if (psi == null || !(psi > 0)) return null;
+ return 20 * Math.log10(psi / DBL_REF);
+ }
+
+ // Rectifying psi→dBL converter for per-sample values — see comments in
+ // sfm_webapp.html:2592-2607 for the floor rationale.
+ 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;
+ }
+
+ // Adaptive decimal formatter — sensible precision in the normal range,
+ // scientific notation only at the extremes.
+ 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;
+ }
+
+ function _destroyCharts() {
+ Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
+ _charts = {};
+ }
+
+ // Returns true when Tailwind dark mode is active (the `dark` class is
+ // toggled on by Terra-View's theme handler). Drives chart grid
+ // + tick colors so they have contrast on both backgrounds.
+ function _isDark() {
+ return document.documentElement.classList.contains('dark');
+ }
+
+ function _renderWaveformInto(containerId, data, micUnit) {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+ container.innerHTML = '';
+ _destroyCharts();
+
+ const channels = data.channels || {};
+ 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 isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
+
+ const withData = _CHANNEL_ORDER.filter(ch =>
+ channels[ch] && (channels[ch].values || []).length > 0
+ );
+ const lastCh = withData[withData.length - 1];
+
+ // Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
+ // light is white. Grids + ticks need contrast on both.
+ const dark = _isDark();
+ const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
+ const tickColor = dark ? '#94a3b8' : '#64748b';
+
+ if (withData.length === 0) {
+ container.innerHTML = `
+ No waveform samples decoded — codec walker returned 0 valid blocks for this event.
+
`;
+ return;
+ }
+
+ for (const ch of _CHANNEL_ORDER) {
+ const chData = channels[ch];
+ if (!chData) continue;
+ let values = chData.values || [];
+ let chUnit = chData.unit || '';
+ let chPeak = chData.peak;
+
+ // Mic: convert psi → dBL when the user pref is dBL (default).
+ if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
+ values = values.map(_psiToDblForChart);
+ chPeak = _psiToDbl(chPeak);
+ chUnit = 'dB(L)';
+ }
+
+ const wrap = document.createElement('div');
+ wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
+
+ const lbl = document.createElement('div');
+ lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
+ lbl.style.color = _CHANNEL_COLORS[ch];
+ const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
+ lbl.innerHTML = `${ch} ${peakStr} `;
+ wrap.appendChild(lbl);
+
+ if (values.length === 0) {
+ const e = document.createElement('div');
+ e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
+ e.textContent = 'no samples decoded';
+ wrap.appendChild(e);
+ container.appendChild(wrap);
+ continue;
+ }
+
+ const canvasWrap = document.createElement('div');
+ canvasWrap.className = 'relative';
+ canvasWrap.style.height = '100px';
+ const canvas = document.createElement('canvas');
+ canvasWrap.appendChild(canvas);
+ wrap.appendChild(canvasWrap);
+ container.appendChild(wrap);
+
+ // X-axis: waveforms use ms-relative-to-trigger; histograms use
+ // the BW-reported interval timestamps (HH:MM:SS) when the server
+ // aggregated to BW intervals, else 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 when very long.
+ const MAX = 3000;
+ let rT = times, rV = values;
+ if (values.length > MAX) {
+ const step = Math.ceil(values.length / MAX);
+ rT = times.filter((_, i) => i % step === 0);
+ rV = values.filter((_, i) => i % step === 0);
+ }
+ const showX = (ch === lastCh);
+
+ const xAxisLabel = isHistogram ? '' : ' ms';
+ const fmtTick = i => {
+ const v = rT[i];
+ if (typeof v === 'number') {
+ const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
+ return s + xAxisLabel;
+ }
+ return String(v) + xAxisLabel;
+ };
+
+ // Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
+ let yBounds = {};
+ const isGeo = ch !== 'MicL';
+ if (isGeo && !isHistogram) {
+ 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) {
+ const HIST_GEO_MIN_INS = 0.05;
+ let peak = 0;
+ for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
+ yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
+ } else if (ch === 'MicL' && micUnit === 'dBL') {
+ const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
+ ? chPeak + 5 : 100;
+ yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
+ } else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
+ const HIST_MIC_MIN_PSI = 0.001;
+ let peak = 0;
+ for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
+ yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
+ }
+
+ _charts[ch] = 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,
+ }] : [{
+ 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, chUnit)}`,
+ },
+ },
+ },
+ scales: {
+ x: {
+ type: 'category', display: showX,
+ ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
+ grid: { color: gridColor, drawTicks: showX },
+ },
+ y: {
+ ...yBounds,
+ ticks: { color: tickColor, maxTicksLimit: 4 },
+ grid: { color: gridColor },
+ title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
+ },
+ },
+ },
+ plugins: isHistogram ? [] : [{
+ id: 'overlays',
+ afterDraw(chart) {
+ const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
+ const zi = rT.findIndex(t => parseFloat(t) >= 0);
+ if (zi >= 0) {
+ const px = x.getPixelForValue(zi);
+ ctx.save();
+ ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
+ ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
+ ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
+ ctx.save();
+ ctx.fillStyle = '#ef4444';
+ ctx.beginPath();
+ ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
+ ctx.closePath(); ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
+ ctx.closePath(); ctx.fill();
+ ctx.restore();
+ }
+ const zy = y.getPixelForValue(0);
+ if (zy >= y.top && zy <= y.bottom) {
+ ctx.save();
+ ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
+ ctx.restore();
+ ctx.save();
+ ctx.fillStyle = tickColor; ctx.font = '10px monospace';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+ ctx.fillText('0.0', x.right + 6, zy);
+ ctx.restore();
+ }
+ },
+ }],
+ });
+ }
+ }
+
function _renderFileInfo(s, eventId) {
const bw = s.blastware || {};
const src = s.source || {};
@@ -345,6 +643,10 @@
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
+ ${_sectionHeader('Waveform')}
+ Loading waveform…
+
+
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
@@ -361,11 +663,37 @@
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
+
+ // Waveform load runs after the sidecar content is in the DOM, in
+ // parallel with the mic-unit-pref fetch. Either may complete first.
+ try {
+ const [wfRes, micUnit] = await Promise.all([
+ fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
+ _loadMicUnitPref(),
+ ]);
+ if (wfRes.status === 404) {
+ document.getElementById('event-waveform-status').textContent =
+ 'No waveform data — codec returned 0 valid blocks for this event.';
+ return;
+ }
+ if (!wfRes.ok) {
+ document.getElementById('event-waveform-status').textContent =
+ 'Failed to load waveform: HTTP ' + wfRes.status;
+ return;
+ }
+ const wfData = await wfRes.json();
+ document.getElementById('event-waveform-status').textContent = '';
+ _renderWaveformInto('event-waveform-charts', wfData, micUnit);
+ } catch (e) {
+ const st = document.getElementById('event-waveform-status');
+ if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
+ }
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
+ _destroyCharts();
};
window.toggleEventJsonViewer = function () {
diff --git a/docker-compose.yml b/docker-compose.yml
index 77f682e..dddde41 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -60,6 +60,11 @@ services:
volumes:
- ../seismo-relay/sfm/data:/app/sfm/data
- ../seismo-relay/bridges/captures:/app/bridges/captures
+ # The DB + waveform store inside bridges/captures are symlinks
+ # pointing at the prod-snap directory. Mount its host path at
+ # the same absolute path inside the container so the symlinks
+ # resolve. Needed for SFM to query the events DB.
+ - ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
environment:
- PYTHONUNBUFFERED=1
- PORT=8200
diff --git a/templates/partials/event_detail_modal.html b/templates/partials/event_detail_modal.html
index 68dc574..667a801 100644
--- a/templates/partials/event_detail_modal.html
+++ b/templates/partials/event_detail_modal.html
@@ -10,8 +10,8 @@ Usage:
#}
-
-
+
+
Event Detail
@@ -23,3 +23,7 @@ Usage:
+{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
+ (v4 chart API; differs from v3). Loaded once globally; safe if other
+ pages on the same template tree also load it. #}
+