event-modal: port 4-channel Chart.js waveform/histogram panels
Adds inline waveform plots to the shared event-detail modal, ported from sfm/sfm_webapp.html:2555-2880. The standalone SFM webapp's plot logic moves into event-modal.js with Tailwind-friendly grid + tick colors (theme-aware via the `dark` class on <html>). Channels render in BW Event Report order — MicL on top, Tran on bottom. Mic channel auto-converts psi → dB(L) when the operator's mic_unit_pref is "dBL" (the default), using _psiToDblForChart with a MIC_DBL_FLOOR=60 floor so the chart shows an SPL-vs-time curve instead of a sparse pattern of "moments above floor". Histograms render as bars with HH:MM:SS x-axis labels when the sidecar carries time_axis.interval_times (events ingested with the v0.20 parser); falls back to interval index for older events. Geo + mic histogram channels enforce minimum Y ranges (0.05 in/s and 0.001 psi respectively) so quiet events don't fill the panel. Waveform events get the trigger-line + zero-baseline overlay; the histogram branch suppresses it (no trigger concept). Downsampling kicks in at >3000 samples to keep render time bounded. Modal partial widened max-w-3xl → max-w-5xl to fit the chart panels without horizontal clipping. Chart.js 4.4.1 loaded from cdn.jsdelivr at the bottom of the partial, matching the standalone webapp's reference version pin. Side-yard: docker-compose bind-mounts ../seismo-relay-prod-snap into the SFM container so the symlinked DB + waveform store inside bridges/captures resolve. Without it SFM 500s on every /db/* call because the symlink target was outside the container's filesystem view. Read-write (not :ro) because SFM opens the DB in WAL mode which requires creating -wal and -shm sidecar files even for reads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 <html> 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 = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
|
||||
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
|
||||
</div>`;
|
||||
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 = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
|
||||
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')}
|
||||
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform…</div>
|
||||
<div id="event-waveform-charts" class="space-y-0.5"></div>
|
||||
|
||||
${(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 () {
|
||||
|
||||
Reference in New Issue
Block a user