viewers: decimal peak labels + bar chart for histograms + clean x-axis ticks

Three polish fixes spotted in the first prod screenshot of the inline
event-modal waveform plot:

1. Peak labels were rendering as "PEAK 2.500E-2 IN/S" because of a
   blanket toExponential(3) call.  New _fmtPeak() formatter picks
   decimal with adaptive precision for normal-range values (0.0001 to
   10000) and falls back to scientific only for truly extreme
   magnitudes.  Same value now reads "peak 0.0250 in/s".

2. Histogram events were being plotted as connected line charts, but
   histograms are per-INTERVAL peaks (one bar per minute, typically),
   not per-sample waveforms.  Now: detect histogram via record_type,
   render as a tight bar graph (bars touch), suppress the trigger line
   + zero baseline overlays (no trigger event on a histogram), and
   label the x-axis with interval number instead of milliseconds.

3. X-axis tick labels were displaying as "11.7187040000000002 ms"
   because the callback used the raw float, not the formatted label.
   Snap to 1 decimal place (or integer for whole-number values like
   histogram intervals).

Applied to both the inline modal plot in sfm_webapp.html and the
standalone /events viewer in event_browser.html — they share the same
data shape and presentation conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:54:04 +00:00
parent 6abfadae4f
commit 784f2cca36
2 changed files with 116 additions and 24 deletions
+55 -12
View File
@@ -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;