v0.20.0 -- Full s3 event parse and PDF creation. #28

Merged
serversdown merged 46 commits from dev into main 2026-05-28 17:54:34 -04:00
2 changed files with 110 additions and 11 deletions
Showing only changes of commit ed926de3f4 - Show all commits
+60 -6
View File
@@ -289,7 +289,12 @@
</select> </select>
<input type="search" id="event-filter" placeholder="filter events…" /> <input type="search" id="event-filter" placeholder="filter events…" />
<span class="pill" id="count-pill"></span> <span class="pill" id="count-pill"></span>
<button id="print-btn" onclick="togglePrintView()" style="margin-left:auto;background:#21262d">Print view</button> <button id="mic-unit-toggle" style="margin-left:auto;background:#21262d"
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
title="Toggle mic display unit (dBL ↔ psi). Persists across page loads.">
Mic: dBL
</button>
<button id="print-btn" onclick="togglePrintView()" style="background:#21262d">Print view</button>
<button id="reload-btn" onclick="loadSerials()">Reload</button> <button id="reload-btn" onclick="loadSerials()">Reload</button>
</header> </header>
@@ -328,6 +333,29 @@ const CHANNEL_COLORS = {
}; };
const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
// Reference pressure for dB(L) — 20 µPa expressed in psi (≈ 2.9e-9 psi).
const DBL_REF = 2.9e-9;
// User-toggleable mic display unit: 'dBL' (default, matches BW printout
// + the rest of SFM) or 'psi' (raw sample unit).
function _getMicUnit() {
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
}
function _setMicUnit(u) {
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
_refreshMicUnitToggle();
if (currentEventId) loadEvent(currentEventId);
}
function _refreshMicUnitToggle() {
const b = document.getElementById('mic-unit-toggle');
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
}
// psi → dB(L). Null for non-positive (log undefined; Chart.js renders as a gap).
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Adaptive decimal formatter — scientific notation only for truly extreme // Adaptive decimal formatter — scientific notation only for truly extreme
// values. Normal-range peaks render as plain decimals with sensible // values. Normal-range peaks render as plain decimals with sensible
// precision (was previously forcing toExponential(3) which produced ugly // precision (was previously forcing toExponential(3) which produced ugly
@@ -502,6 +530,19 @@ function renderMeta(data, ev) {
['Vert', ev?.vert_ppv], ['Vert', ev?.vert_ppv],
['Long', ev?.long_ppv], ['Long', ev?.long_ppv],
]; ];
// Mic display honors the current user preference (dBL default).
// mic_ppv is stored as raw psi on series3 events; convert when needed.
const micPsi = ev?.mic_ppv;
const micUnitDisplay = _getMicUnit();
let micStr;
if (micPsi == null) {
micStr = '—';
} else if (micUnitDisplay === 'dBL') {
const d = _psiToDbl(Number(micPsi));
micStr = (d != null ? d.toFixed(1) : '—') + ' dBL';
} else {
micStr = Number(micPsi).toExponential(2) + ' psi';
}
const statsHtml = ` const statsHtml = `
<table class="stats-table"> <table class="stats-table">
<thead> <thead>
@@ -509,7 +550,7 @@ function renderMeta(data, ev) {
</thead> </thead>
<tbody> <tbody>
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')} ${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
<tr><td>MicL</td><td>${fmt(ev?.mic_ppv)} psi</td></tr> <tr><td>MicL</td><td>${micStr}</td></tr>
</tbody> </tbody>
</table> </table>
`; `;
@@ -560,11 +601,11 @@ function renderWaveform(data) {
); );
const lastDataCh = channelsWithData[channelsWithData.length - 1]; const lastDataCh = channelsWithData[channelsWithData.length - 1];
const micUnit = _getMicUnit();
for (const ch of CHANNEL_ORDER) { for (const ch of CHANNEL_ORDER) {
const chData = channels[ch]; const chData = channels[ch];
if (!chData) continue; if (!chData) continue;
const values = chData.values || []; if ((chData.values || []).length === 0) {
if (values.length === 0) {
// Render an empty card so user sees the channel exists but is missing // Render an empty card so user sees the channel exists but is missing
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chart-wrap'; wrap.className = 'chart-wrap';
@@ -579,9 +620,19 @@ function renderWaveform(data) {
continue; continue;
} }
const unit = chData.unit || 'unit'; // Mic channel: convert from raw psi to dB(L) when the user prefers dBL
const peak = chData.peak; // (the default). We mutate `values`, `peak`, and `unit` locally so the
// chart datasets + axis title + tooltip + peak label all stay aligned.
let values = chData.values || [];
let unit = chData.unit || 'unit';
let peak = chData.peak;
const peakT = chData.peak_t_ms; const peakT = chData.peak_t_ms;
if (ch === 'MicL' && unit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
peak = _psiToDbl(peak);
unit = 'dB(L)';
}
const peakLabel = peak != null const peakLabel = peak != null
? `peak ${_fmtPeak(peak, unit)}` ? `peak ${_fmtPeak(peak, unit)}`
+ (!isHistogram && peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '') + (!isHistogram && peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '')
@@ -781,6 +832,9 @@ document.getElementById('serial-select').addEventListener('change', e => {
}); });
document.getElementById('event-filter').addEventListener('input', applyFilter); document.getElementById('event-filter').addEventListener('input', applyFilter);
// Reflect any persisted mic-unit preference in the header pill on load
_refreshMicUnitToggle();
// Initial load // Initial load
loadSerials(); loadSerials();
</script> </script>
+50 -5
View File
@@ -818,6 +818,12 @@
<span class="ft-dot"></span> <span class="ft-dot"></span>
<span>Force refresh</span> <span>Force refresh</span>
</label> </label>
<div class="hdr-sep"></div>
<button id="mic-unit-toggle" class="section-btn"
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
title="Toggle microphone display unit (dBL ↔ psi) for waveform plots. Affects all mic charts; persists across page loads.">
Mic: dBL
</button>
</header> </header>
<!-- ════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════
@@ -2560,6 +2566,29 @@ const _SC_CHANNEL_COLORS = {
const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
let _scCharts = {}; let _scCharts = {};
// User preference for how mic is displayed in plots — dBL (default,
// matches BW printout convention + the rest of SFM) or psi (the raw
// sample unit). Toggleable via the header pill; persists in localStorage.
function _getMicUnit() {
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
}
function _setMicUnit(u) {
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
_refreshMicUnitToggleLabel();
// Re-render the open modal so the change is immediately visible.
if (_scCurrentEventId) openSidecarModal(_scCurrentEventId);
}
function _refreshMicUnitToggleLabel() {
const b = document.getElementById('mic-unit-toggle');
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
}
// Convert a psi value to dB(L). Returns null for non-positive values
// (log of zero is undefined) — Chart.js handles null as a gap in the line.
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Adaptive decimal formatter — scientific notation is reserved for truly // Adaptive decimal formatter — scientific notation is reserved for truly
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks // extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
// fall here) render as decimals with sensible precision. Replaces the // fall here) render as decimals with sensible precision. Replaces the
@@ -2610,17 +2639,30 @@ function _renderScWaveform(data) {
); );
const lastCh = withData[withData.length - 1]; const lastCh = withData[withData.length - 1];
const micUnit = _getMicUnit(); // user preference: 'dBL' or 'psi'
for (const ch of _SC_CHANNEL_ORDER) { for (const ch of _SC_CHANNEL_ORDER) {
const chData = channels[ch]; const chData = channels[ch];
if (!chData) continue; if (!chData) continue;
const values = chData.values || []; let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
// (default). Mic samples that are zero/negative become null (Chart.js
// renders them as gaps in line mode, zero-height bars in histogram mode).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px'; wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px';
const lbl = document.createElement('div'); const lbl = document.createElement('div');
lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`; lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`;
const peakStr = chData.peak != null const peakStr = chPeak != null
? `peak ${_fmtPeak(chData.peak, chData.unit)}` ? `peak ${_fmtPeak(chPeak, chUnit)}`
: ''; : '';
lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`; lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`;
wrap.appendChild(lbl); wrap.appendChild(lbl);
@@ -2716,7 +2758,7 @@ function _renderScWaveform(data) {
title: items => isHistogram title: items => isHistogram
? `interval ${items[0].label}` ? `interval ${items[0].label}`
: `t = ${items[0].label} ms`, : `t = ${items[0].label} ms`,
label: item => `${ch}: ${_fmtPeak(item.raw, chData.unit)}`, label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
}, },
}, },
}, },
@@ -2730,7 +2772,7 @@ function _renderScWaveform(data) {
...yBounds, ...yBounds,
ticks: { color: '#484f58', maxTicksLimit: 4 }, ticks: { color: '#484f58', maxTicksLimit: 4 },
grid: { color: '#21262d' }, grid: { color: '#21262d' },
title: { display: true, text: chData.unit || '', color: '#484f58', font: { size: 9 } }, title: { display: true, text: chUnit, color: '#484f58', font: { size: 9 } },
}, },
}, },
}, },
@@ -3058,6 +3100,9 @@ document.addEventListener('keydown', e => {
// hit localhost:8200, 10.0.0.44:8200, or anything else. // hit localhost:8200, 10.0.0.44:8200, or anything else.
document.getElementById('api-base').value = window.location.origin; document.getElementById('api-base').value = window.location.origin;
// Reflect any persisted mic-unit preference in the header pill on load
_refreshMicUnitToggleLabel();
// We default to Database view → trigger initial history + units load // We default to Database view → trigger initial history + units load
// (switchSection handles this when clicked, but we never click on first paint). // (switchSection handles this when clicked, but we never click on first paint).
if (currentSection === 'db') { if (currentSection === 'db') {