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: #}