159 lines
5.8 KiB
JavaScript
159 lines
5.8 KiB
JavaScript
|
|
// 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`;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
})();
|