diff --git a/sfm/event_browser.html b/sfm/event_browser.html index c3b7516..357e718 100644 --- a/sfm/event_browser.html +++ b/sfm/event_browser.html @@ -328,6 +328,23 @@ const CHANNEL_COLORS = { }; const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; +// Adaptive decimal formatter — scientific notation only for truly extreme +// values. Normal-range peaks render as plain decimals with sensible +// precision (was previously forcing toExponential(3) which produced ugly +// "2.500E-2 IN/S" labels). +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; +} + let allEvents = []; let filteredEvents = []; let currentEventId = null; @@ -530,6 +547,10 @@ function renderWaveform(data) { const dtMs = ta.dt_ms || (1000.0 / sr); const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0; const isPrintMode = document.body.classList.contains('print-view'); + // Histograms record per-interval peaks (typically 1 per minute/5-min), + // not per-sample waveforms. Render as a tight bar graph instead of a + // line plot — matches the BW Event Report's histogram presentation. + const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram'); // Which channels actually have data → determines which one renders the // shared x-axis at the bottom (Instantel printout has the time scale @@ -562,8 +583,8 @@ function renderWaveform(data) { const peak = chData.peak; const peakT = chData.peak_t_ms; const peakLabel = peak != null - ? `peak ${(typeof peak === 'number' ? peak.toExponential(3) : peak)} ${unit}` - + (peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '') + ? `peak ${_fmtPeak(peak, unit)}` + + (!isHistogram && peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '') : ''; // Hide x-axis on every chart except the bottom-most data channel — // gives the "single shared time axis" feel of the BW printout. @@ -583,8 +604,12 @@ function renderWaveform(data) { wrap.appendChild(canvasWrap); chartsDiv.appendChild(wrap); - // Per-sample time in ms relative to trigger. Negative for pre-trigger samples. - const times = values.map((_, i) => t0Ms + i * dtMs); + // Waveform: per-sample time in ms relative to trigger (negative for pretrig). + // Histogram: interval index (1..N); sample_rate-based time math doesn't + // apply to per-interval peaks. + const times = isHistogram + ? values.map((_, i) => i + 1) + : values.map((_, i) => t0Ms + i * dtMs); // Downsample for rendering const MAX_POINTS = 4000; @@ -595,11 +620,26 @@ function renderWaveform(data) { rV = values.filter((_, i) => i % step === 0); } + // Tick formatter — round to 1 decimal so we don't get + // "11.7187040000000002 ms" garbage from floating-point accumulation. + const xAxisUnit = isHistogram ? '' : ' ms'; + const fmtTick = i => { + const v = rT[i]; + if (typeof v !== 'number') return String(v) + xAxisUnit; + return (Number.isInteger(v) ? String(v) : v.toFixed(1)) + xAxisUnit; + }; + const chart = new Chart(canvas, { - type: 'line', + type: isHistogram ? 'bar' : 'line', data: { - labels: rT.map(t => (typeof t === 'number' ? t.toFixed(2) : t)), - datasets: [{ + 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, // bars touch — tight bargraph + }] : [{ data: rV, borderColor: CHANNEL_COLORS[ch], borderWidth: 1, @@ -617,8 +657,10 @@ function renderWaveform(data) { mode: 'index', intersect: false, callbacks: { - title: items => `t = ${items[0].label} ms`, - label: item => `${ch}: ${item.raw} ${unit}`, + title: items => isHistogram + ? `interval ${items[0].label}` + : `t = ${items[0].label} ms`, + label: item => `${ch}: ${_fmtPeak(item.raw, unit)}`, }, }, }, @@ -630,7 +672,7 @@ function renderWaveform(data) { color: isPrintMode ? '#666' : '#484f58', maxTicksLimit: 10, maxRotation: 0, - callback: (val, i) => rT[i] + ' ms', + callback: (val, i) => fmtTick(i), }, grid: { color: isPrintMode ? '#e0e0e0' : '#21262d', drawTicks: showXAxis }, }, @@ -642,10 +684,11 @@ function renderWaveform(data) { }, }, }, - plugins: [{ + plugins: isHistogram ? [] : [{ // Trigger line @ t=0 + triangle markers above/below + "0.0" // baseline label on the right edge. Matches the Instantel - // BW Event Report printout style. + // BW Event Report printout style. Skipped for histograms — + // they have no trigger event. id: 'instantelOverlays', afterDraw(chart) { const ctx = chart.ctx; diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index df23b3a..e072566 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -2560,6 +2560,23 @@ const _SC_CHANNEL_COLORS = { const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; let _scCharts = {}; +// Adaptive decimal formatter — scientific notation is reserved for truly +// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks +// fall here) render as decimals with sensible precision. Replaces the +// previous .toExponential(3) call that turned every peak into ugly "2.500E-2". +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 _destroyScCharts() { Object.values(_scCharts).forEach(c => { try { c.destroy(); } catch {} }); _scCharts = {}; @@ -2578,8 +2595,14 @@ function _renderScWaveform(data) { // t=0 by convention. 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 dtMs = ta.dt_ms || (1000.0 / sr); + const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0; + // Histogram events have per-interval peaks, not per-sample data. + // Render as bars (one per interval) instead of a connected line, and + // suppress trigger/zero overlays which don't apply. X-axis becomes + // interval index since the sample_rate-based time math is meaningless + // here (each "sample" is one interval, typically 1-5 minutes long). + const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram'); // Which channels have data — determines which one renders the shared bottom axis. const withData = _SC_CHANNEL_ORDER.filter(ch => @@ -2597,7 +2620,7 @@ function _renderScWaveform(data) { 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`; const peakStr = chData.peak != null - ? `peak ${(typeof chData.peak === 'number' ? chData.peak.toExponential(3) : chData.peak)} ${chData.unit || ''}` + ? `peak ${_fmtPeak(chData.peak, chData.unit)}` : ''; lbl.innerHTML = `${ch}${peakStr}`; wrap.appendChild(lbl); @@ -2618,8 +2641,11 @@ function _renderScWaveform(data) { wrap.appendChild(canvasWrap); chartsDiv.appendChild(wrap); - // Per-sample time in ms relative to trigger. Negative for pre-trigger samples. - const times = values.map((_, i) => t0Ms + i * dtMs); + // Waveform: per-sample time in ms relative to trigger (negative for pretrig). + // Histogram: interval index (1..N); time math doesn't apply to per-interval peaks. + const times = isHistogram + ? values.map((_, i) => i + 1) + : values.map((_, i) => t0Ms + i * dtMs); // Downsample for rendering when very long. const MAX = 3000; @@ -2631,11 +2657,30 @@ function _renderScWaveform(data) { } const showX = (ch === lastCh); + // Tick label formatter: snap floats to 1 decimal place so we don't get + // "11.7187040000000002 ms" garbage from accumulated floating-point error. + const xAxisLabel = isHistogram ? '' : ' ms'; + const fmtTick = i => { + const v = rT[i]; + if (typeof v === 'number') { + // Whole numbers (intervals) → no decimals. Sub-integer ms → 1 decimal. + const s = Number.isInteger(v) ? String(v) : v.toFixed(1); + return s + xAxisLabel; + } + return String(v) + xAxisLabel; + }; + _scCharts[ch] = new Chart(canvas, { - type: 'line', + type: isHistogram ? 'bar' : 'line', data: { - labels: rT.map(t => (typeof t === 'number' ? t.toFixed(2) : t)), - datasets: [{ + labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)), + datasets: isHistogram ? [{ + data: rV, + backgroundColor: _SC_CHANNEL_COLORS[ch], + borderWidth: 0, + barPercentage: 1.0, + categoryPercentage: 1.0, // bars touch — "tight bargraph" look + }] : [{ data: rV, borderColor: _SC_CHANNEL_COLORS[ch], borderWidth: 1, @@ -2650,15 +2695,17 @@ function _renderScWaveform(data) { tooltip: { mode: 'index', intersect: false, callbacks: { - title: items => `t = ${items[0].label} ms`, - label: item => `${ch}: ${item.raw} ${chData.unit || ''}`, + title: items => isHistogram + ? `interval ${items[0].label}` + : `t = ${items[0].label} ms`, + label: item => `${ch}: ${_fmtPeak(item.raw, chData.unit)}`, }, }, }, scales: { x: { type: 'category', display: showX, - ticks: { color: '#484f58', maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => rT[i] + ' ms' }, + ticks: { color: '#484f58', maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) }, grid: { color: '#21262d', drawTicks: showX }, }, y: { @@ -2668,7 +2715,9 @@ function _renderScWaveform(data) { }, }, }, - plugins: [{ + plugins: isHistogram ? [] : [{ + // Trigger line + triangle markers + zero baseline — only meaningful + // for waveform-mode events. Histograms have no trigger. id: 'overlays', afterDraw(chart) { const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;