Files
measurement/src/editor/smoothing-sparkline.js

159 lines
5.8 KiB
JavaScript
Raw Normal View History

// 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`;
}
},
};
})();