// Measurement editor — smoothing sparkline. // // Renders a synthetic noisy signal (gray) and the same signal after the // selected smoothing method + window (green) so the user can see what each // method does before deploying. The smoothing math here is a small, // browser-side mirror of src/channel.js. Drift risk: if you add a method // there, add it here (and vice-versa). Keep parameters identical (e.g. the // 0.2 lowPass alpha) so the preview matches runtime behaviour. (function () { const ns = window.MeasEditor = window.MeasEditor || {}; // Plot box in viewBox coords (matches the inline SVG in measurement.html). const VB = { left: 10, right: 380, top: 8, bot: 92 }; const N = 80; // sample count // Deterministic noisy signal: low-freq sine + medium-freq sine + a small // pseudo-random component using a fixed seed so the preview never jitters // between renders. const buildSignal = () => { const out = new Array(N); let seed = 0xC0FFEE; const rand = () => { // mulberry32 seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; for (let i = 0; i < N; i++) { const base = 0.6 * Math.sin(i * 0.18) + 0.25 * Math.sin(i * 0.55); // Inject an outlier at sample 40 so the median/iqr cases look different const spike = (i === 40) ? 2.2 : 0; const noise = (rand() - 0.5) * 0.7; out[i] = base + noise + spike; } return out; }; // --- Smoothing math (mirror of src/channel.js, kept self-contained) --- const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length; const median = (a) => { const s = [...a].sort((x, y) => x - y); const mid = Math.floor(s.length / 2); return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; }; const stdDev = (a) => { if (a.length <= 1) return 0; const m = mean(a); return Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1)); }; const wma = (a) => { let num = 0, den = 0; for (let i = 0; i < a.length; i++) { num += a[i] * (i + 1); den += (i + 1); } return num / den; }; const lowPass = (a) => { let out = a[0]; for (let i = 1; i < a.length; i++) out = 0.2 * a[i] + 0.8 * out; return out; }; const highPass = (a) => { const f = [a[0]]; for (let i = 1; i < a.length; i++) f[i] = 0.8 * (f[i - 1] + a[i] - a[i - 1]); return f[f.length - 1]; }; const bandPass = (a) => { const lp = lowPass(a), hp = highPass(a); return a.map((v) => lp + hp - v).pop(); }; const kalman = (a) => { let e = a[0]; const gain = 0.1 / (0.1 + 1); for (let i = 1; i < a.length; i++) e = e + gain * (a[i] - e); return e; }; const savitzkyGolay = (a) => { const c = [-3, 12, 17, 12, -3]; const norm = c.reduce((s, v) => s + v, 0); if (a.length < c.length) return a[a.length - 1]; let s = 0; for (let i = 0; i < c.length; i++) s += a[a.length - c.length + i] * c[i]; return s / norm; }; const applyMethod = (window, method) => { const m = (method || '').toLowerCase(); switch (m) { case '': case 'none': return window[window.length - 1]; case 'mean': return mean(window); case 'min': return Math.min(...window); case 'max': return Math.max(...window); case 'sd': return stdDev(window); case 'median': return median(window); case 'weightedmovingaverage': return wma(window); case 'lowpass': return lowPass(window); case 'highpass': return highPass(window); case 'bandpass': return bandPass(window); case 'kalman': return kalman(window); case 'savitzkygolay': return savitzkyGolay(window); default: return window[window.length - 1]; } }; // --- Render --- // Cache the synthetic signal so we don't rebuild it on every keystroke. let _signalCache = null; const getSignal = () => { _signalCache = _signalCache || buildSignal(); return _signalCache; }; ns.smoothingSparkline = { redraw() { const wrap = document.getElementById('meas-smooth-wrap'); if (!wrap) return; const method = ns.fStr('smooth_method'); const win = Math.max(1, ns.fNum('count') || 1); const raw = getSignal(); const smoothed = new Array(raw.length); const buf = []; for (let i = 0; i < raw.length; i++) { buf.push(raw[i]); if (buf.length > win) buf.shift(); smoothed[i] = applyMethod(buf, method); } // Compute y range from BOTH series so neither line clips at the edges. let yMin = Infinity, yMax = -Infinity; for (const v of raw) { if (v < yMin) yMin = v; if (v > yMax) yMax = v; } for (const v of smoothed) { if (v < yMin) yMin = v; if (v > yMax) yMax = v; } if (!Number.isFinite(yMin) || !Number.isFinite(yMax) || yMin === yMax) { yMin = yMin - 1; yMax = yMax + 1; } const pad = (yMax - yMin) * 0.08; yMin -= pad; yMax += pad; const xPx = (i) => VB.left + (i / (N - 1)) * (VB.right - VB.left); const yPx = (v) => VB.bot - ((v - yMin) / (yMax - yMin)) * (VB.bot - VB.top); const toPoints = (arr) => arr.map((v, i) => `${xPx(i).toFixed(1)},${yPx(v).toFixed(1)}`).join(' '); const rawEl = document.getElementById('meas-smooth-raw'); const smEl = document.getElementById('meas-smooth-smoothed'); if (rawEl) rawEl.setAttribute('points', toPoints(raw)); if (smEl) smEl.setAttribute('points', toPoints(smoothed)); const label = document.getElementById('meas-smooth-label'); if (label) { const m = (method || 'none').toLowerCase(); if (m === '' || m === 'none') label.textContent = 'no smoothing — raw value passed through'; else label.textContent = `method: ${method} · window: ${win} samples`; } }, }; })();