3
Wavelet Decomposition: Multiresolution
drag X to scrub detail scales ยท click to swap signal
idle
400 lines ยท vanilla
view source
// 1D Haar wavelet decomposition + progressive reconstruction.
// One concept, limited palette, short loop.
const N = 256; // signal length (power of 2)
const LEVELS = 8; // log2(N)
const BG = '#0c1014';
const ORIG_COLOR = 'rgba(180, 188, 196, 0.35)';
const RECON_COLOR = '#5fa8d3'; // muted blue
const RECON_FADE = 'rgba(95, 168, 211, 0.18)';
const BAR_COLOR = '#3fa39a'; // muted teal
const BAR_FADE = 'rgba(63, 163, 154, 0.18)';
const TEXT_COLOR = 'rgba(220, 226, 232, 0.85)';
const HUD_DIM = 'rgba(150, 158, 168, 0.55)';
const GRID_COLOR = 'rgba(60, 70, 80, 0.4)';
const T_ADD = 8.0; // seconds to add all bands
const T_HOLD = 2.0; // hold full reconstruction
const T_SNAP = 0.35; // brief snap-back blink
const T_TOTAL = T_ADD + T_HOLD + T_SNAP;
const IDLE_THRESHOLD = 3.0; // seconds of mouse inactivity before auto-anim resumes
let W = 0;
let H = 0;
let signal; // Float32Array(N) โ original
let approx; // scaling coefficient (single scalar c0)
let details; // array of Float32Array, details[j] length 2^j, j=0..LEVELS-1
let recon; // Float32Array(N) โ current reconstruction
let bandAdded; // Float32Array(LEVELS+1) โ fade-in [0..1] per band
// index 0 = approx, 1..LEVELS = details[0..LEVELS-1]
let elapsed = 0;
let signalMin = 0;
let signalMax = 1;
// Interaction state
let signalKind = 0; // 0 = piecewise, 1 = fbm-with-edge, 2 = sinusoid sum
let lastMouseX = -1;
let lastMouseY = -1;
let idleTime = IDLE_THRESHOLD; // start in idle/auto mode
let userActive = false; // true while scrubbing
function buildSignal() {
const s = new Float32Array(N);
for (let i = 0; i < N; i++) {
const x = i / N;
let y = 0;
// two smooth bumps
y += 0.55 * Math.exp(-((x - 0.18) * (x - 0.18)) / (2 * 0.04 * 0.04));
y += 0.7 * Math.exp(-((x - 0.78) * (x - 0.78)) / (2 * 0.05 * 0.05));
// gentle ramp underlay
y += 0.15 * Math.sin(2 * Math.PI * x * 1.0);
// three jumps (piecewise offsets)
if (x > 0.32) y += 0.45;
if (x > 0.48) y -= 0.6;
if (x > 0.62) y += 0.35;
s[i] = y;
}
return s;
}
// Small deterministic LCG so each rebuild of the fbm signal stays stable
// for the duration it's on screen (we re-seed once per swap).
function makeRng(seed) {
let state = (seed >>> 0) || 1;
return function () {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
}
// Fractional Brownian motion via midpoint-displacement-ish summed-octave
// noise, plus one sharp edge so wavelet detail bursts at every scale.
function buildFbmWithEdge(seed) {
const rng = makeRng(seed);
const s = new Float32Array(N);
// Sample value-noise at multiple octaves: pick a small number of random
// anchor points per octave, lerp between them.
const octaves = 6;
for (let o = 0; o < octaves; o++) {
const samples = 1 << (o + 1); // 2,4,8,16,32,64 anchors
const amp = Math.pow(0.55, o);
const anchors = new Float32Array(samples + 1);
for (let k = 0; k <= samples; k++) anchors[k] = rng() * 2 - 1;
for (let i = 0; i < N; i++) {
const t = (i / N) * samples;
const k = Math.floor(t);
const f = t - k;
const a = anchors[k];
const b = anchors[k + 1];
// smoothstep
const w = f * f * (3 - 2 * f);
s[i] += amp * (a * (1 - w) + b * w);
}
}
// one big edge somewhere in the middle third
const edgePos = 0.35 + rng() * 0.3;
const edgeAmp = (rng() > 0.5 ? 1 : -1) * (0.7 + rng() * 0.3);
const edgeIdx = Math.floor(edgePos * N);
for (let i = edgeIdx; i < N; i++) s[i] += edgeAmp;
return s;
}
// Clean sinusoid sum: smooth, no jumps. Wavelets concentrate the energy
// at coarse scales; finer detail bands are nearly empty.
function buildSinusoidSum() {
const s = new Float32Array(N);
for (let i = 0; i < N; i++) {
const x = i / N;
let y = 0;
y += 0.55 * Math.sin(2 * Math.PI * x * 1.0 + 0.4);
y += 0.35 * Math.sin(2 * Math.PI * x * 2.0 + 1.2);
y += 0.22 * Math.sin(2 * Math.PI * x * 4.0 - 0.6);
s[i] = y;
}
return s;
}
function buildSignalByKind(kind) {
if (kind === 0) return buildSignal();
if (kind === 1) return buildFbmWithEdge(0x9e3779b9 ^ Math.floor(Date.now() & 0xffffff));
return buildSinusoidSum();
}
function signalLabel(kind) {
if (kind === 0) return 'jumps + bumps';
if (kind === 1) return 'fbm + edge';
return 'sinusoid sum';
}
// Recompute coefficients, y-range, and reset reconstruction for the
// currently selected signalKind.
function rebuildForSignal() {
signal = buildSignalByKind(signalKind);
let mn = Infinity, mx = -Infinity;
for (let i = 0; i < N; i++) {
if (signal[i] < mn) mn = signal[i];
if (signal[i] > mx) mx = signal[i];
}
const pad = (mx - mn) * 0.15 + 0.05;
signalMin = mn - pad;
signalMax = mx + pad;
const dec = haarDecompose(signal);
approx = dec.approxScalar;
details = dec.details;
recon = reconstructUpTo(approx, details, 0);
bandAdded = new Float32Array(LEVELS + 1);
bandAdded[0] = 1;
elapsed = 0;
}
// In-place Haar DWT on a copy. Returns { approx (scalar), details: Float32Array[] }.
// Convention: at each level k (length L -> L/2), pairs (a,b) become avg=(a+b)/sqrt2,
// diff=(a-b)/sqrt2. We keep the "diff" (detail) arrays per level; the final
// surviving scalar is the coarsest approximation.
function haarDecompose(sig) {
const buf = new Float32Array(sig);
const detailArrays = []; // detailArrays[k] has length (N >> (k+1))
const SQRT2 = Math.SQRT2;
let len = N;
while (len > 1) {
const half = len >> 1;
const d = new Float32Array(half);
for (let i = 0; i < half; i++) {
const a = buf[2 * i];
const b = buf[2 * i + 1];
buf[i] = (a + b) / SQRT2;
d[i] = (a - b) / SQRT2;
}
detailArrays.push(d);
len = half;
}
// detailArrays[0] is finest (length N/2), detailArrays[LEVELS-1] coarsest (length 1)
// For "progressive add coarsest-first" we'll iterate in reverse.
return { approxScalar: buf[0], details: detailArrays };
}
// Reconstruct using approx + a subset of detail levels.
// `useUpTo` = how many detail levels (counted from coarsest) to include.
// useUpTo = 0 -> approx only. useUpTo = LEVELS -> full.
function reconstructUpTo(approxScalar, detailArrays, useUpTo) {
const SQRT2 = Math.SQRT2;
// Start from coarsest length-1 array.
let cur = new Float32Array(1);
cur[0] = approxScalar;
// The coarsest detail is detailArrays[LEVELS-1] (length 1).
// To rebuild upward, at each step we need either the real detail (if added)
// or zeros.
for (let step = 0; step < LEVELS; step++) {
// Index into detailArrays from coarsest: detailArrays[LEVELS-1-step]
const dIdx = LEVELS - 1 - step;
const includeThis = step < useUpTo;
const d = detailArrays[dIdx];
const halfLen = cur.length; // before doubling
const next = new Float32Array(halfLen * 2);
for (let i = 0; i < halfLen; i++) {
const a = cur[i];
const b = includeThis ? d[i] : 0;
next[2 * i] = (a + b) / SQRT2;
next[2 * i + 1] = (a - b) / SQRT2;
}
cur = next;
}
return cur;
}
// Same but with FRACTIONAL inclusion for the band currently fading in.
// `fullBands` are fully added, `partialBand` is the next one with weight w in [0..1].
function reconstructPartial(approxScalar, detailArrays, fullBands, partialBand, w) {
const SQRT2 = Math.SQRT2;
let cur = new Float32Array(1);
cur[0] = approxScalar;
for (let step = 0; step < LEVELS; step++) {
const dIdx = LEVELS - 1 - step;
let weight = 0;
if (step < fullBands) weight = 1;
else if (step === fullBands && partialBand) weight = w;
const d = detailArrays[dIdx];
const halfLen = cur.length;
const next = new Float32Array(halfLen * 2);
for (let i = 0; i < halfLen; i++) {
const a = cur[i];
const b = weight === 0 ? 0 : weight * d[i];
next[2 * i] = (a + b) / SQRT2;
next[2 * i + 1] = (a - b) / SQRT2;
}
cur = next;
}
return cur;
}
function init({ ctx, width, height }) {
W = width;
H = height;
signalKind = 0;
rebuildForSignal();
lastMouseX = -1;
lastMouseY = -1;
idleTime = IDLE_THRESHOLD;
userActive = false;
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
}
function easeInOut(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) * 0.5;
}
function mapY(yVal, top, bot) {
const t = (yVal - signalMin) / (signalMax - signalMin);
return bot - t * (bot - top);
}
function drawTopPanel(ctx, top, bot) {
// background
ctx.fillStyle = BG;
ctx.fillRect(0, top, W, bot - top);
// zero-line / mid grid
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, (top + bot) * 0.5);
ctx.lineTo(W, (top + bot) * 0.5);
ctx.stroke();
// Original signal โ faint gray
ctx.strokeStyle = ORIG_COLOR;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < N; i++) {
const x = (i / (N - 1)) * W;
const y = mapY(signal[i], top + 6, bot - 6);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Reconstruction โ solid blue
ctx.strokeStyle = RECON_COLOR;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i < N; i++) {
const x = (i / (N - 1)) * W;
const y = mapY(recon[i], top + 6, bot - 6);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawBottomPanel(ctx, top, bot) {
ctx.fillStyle = BG;
ctx.fillRect(0, top, W, bot - top);
const innerTop = top + 14;
const innerBot = bot - 8;
const totalH = innerBot - innerTop;
// LEVELS rows of detail bars; coarsest at top, finest at bottom
const rowH = totalH / LEVELS;
for (let row = 0; row < LEVELS; row++) {
// row=0 -> coarsest band visually on top
const dIdx = LEVELS - 1 - row; // coarsest first
const d = details[dIdx];
const fadeIdx = row + 1; // bandAdded index
const fade = bandAdded[fadeIdx];
if (fade <= 0.001) continue;
// find local max for this band (for normalization within band)
let amp = 1e-6;
for (let i = 0; i < d.length; i++) {
const v = Math.abs(d[i]);
if (v > amp) amp = v;
}
const yMid = innerTop + (row + 0.5) * rowH;
const halfH = rowH * 0.42;
const barW = W / d.length;
// baseline
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, yMid);
ctx.lineTo(W, yMid);
ctx.stroke();
const alpha = 0.25 + 0.7 * fade;
ctx.fillStyle = `rgba(63, 163, 154, ${alpha.toFixed(3)})`;
for (let i = 0; i < d.length; i++) {
const v = d[i] / amp;
const h = v * halfH;
const x = i * barW;
// small inset for visible separation when bars are wide
const inset = d.length <= 16 ? 1.5 : (d.length <= 64 ? 0.6 : 0);
const drawW = Math.max(0.5, barW - inset);
if (h >= 0) {
ctx.fillRect(x, yMid - h, drawW, h);
} else {
ctx.fillRect(x, yMid, drawW, -h);
}
}
// tiny scale label on the left
ctx.fillStyle = HUD_DIM;
ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const jLabel = LEVELS - 1 - dIdx; // coarsest = 0 in display
ctx.fillText(`j=${jLabel} (${d.length})`, 6, yMid);
}
}
function drawHUD(ctx, currentLevelLabel, addedCount, modeLabel) {
ctx.fillStyle = TEXT_COLOR;
ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('Haar wavelet reconstruction', 10, 8);
ctx.fillStyle = HUD_DIM;
ctx.font = '11px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(`signal: ${signalLabel(signalKind)} ยท ${modeLabel}`, 10, 24);
ctx.textAlign = 'right';
ctx.fillText(currentLevelLabel, W - 10, 8);
ctx.fillText(`bands: ${addedCount}/${LEVELS}`, W - 10, 22);
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) {
W = width;
H = height;
}
// ---- Input handling ----
// Click cycles through source signals.
if (input && typeof input.consumeClicks === 'function') {
const clicks = input.consumeClicks();
if (clicks && clicks.length > 0) {
signalKind = (signalKind + 1) % 3;
rebuildForSignal();
// Treat the click itself as user activity โ stay in scrub mode if mouse is over canvas.
idleTime = 0;
userActive = true;
}
}
// Detect mouse movement (any change in mouseX/Y counts as activity).
const mx = input ? input.mouseX : -1;
const my = input ? input.mouseY : -1;
if (mx !== lastMouseX || my !== lastMouseY) {
if (lastMouseX !== -1 || lastMouseY !== -1) {
// genuine movement, not the very first sample
idleTime = 0;
userActive = true;
}
lastMouseX = mx;
lastMouseY = my;
} else {
idleTime += dt;
if (idleTime > IDLE_THRESHOLD) userActive = false;
}
// ---- Determine band activation ----
let fullBands = 0;
let partialW = 0;
let inSnap = false;
let hudLabel = '';
let modeLabel = '';
if (userActive && mx >= 0 && mx <= W) {
// Mouse-scrub mode: scale = floor((mouseX / W) * LEVELS).
// We allow 0..LEVELS inclusive so the user can reach "full" at the right edge.
const ratio = Math.max(0, Math.min(1, mx / Math.max(1, W)));
const scale = Math.min(LEVELS, Math.floor(ratio * (LEVELS + 1)));
fullBands = scale;
partialW = 0;
bandAdded[0] = 1;
for (let k = 1; k <= LEVELS; k++) {
bandAdded[k] = (k - 1) < fullBands ? 1 : 0;
}
hudLabel = scale === 0
? 'approx only'
: scale >= LEVELS
? 'full reconstruction'
: `up to j = ${scale - 1} (${1 << (scale - 1)} coeffs)`;
modeLabel = 'mode: scrub';
// freeze the auto-anim timer so we resume cleanly when user disengages
elapsed = 0;
} else {
// Auto-anim fallback (original behavior).
elapsed += dt;
if (elapsed > T_TOTAL) elapsed -= T_TOTAL;
if (elapsed < T_ADD) {
const t = elapsed / T_ADD;
const eased = easeInOut(t);
const f = eased * LEVELS;
fullBands = Math.floor(f);
partialW = f - fullBands;
if (fullBands >= LEVELS) {
fullBands = LEVELS;
partialW = 0;
}
bandAdded[0] = 1;
for (let k = 1; k <= LEVELS; k++) {
const step = k - 1;
if (step < fullBands) bandAdded[k] = 1;
else if (step === fullBands) bandAdded[k] = partialW;
else bandAdded[k] = 0;
}
const curStep = Math.min(fullBands, LEVELS - 1);
hudLabel = `adding j = ${curStep} (${1 << curStep} coeffs)`;
} else if (elapsed < T_ADD + T_HOLD) {
fullBands = LEVELS;
partialW = 0;
for (let k = 0; k <= LEVELS; k++) bandAdded[k] = 1;
hudLabel = 'full reconstruction';
} else {
inSnap = true;
fullBands = 0;
partialW = 0;
for (let k = 1; k <= LEVELS; k++) bandAdded[k] = 0;
bandAdded[0] = 1;
hudLabel = 'reset';
}
modeLabel = 'mode: auto';
}
// recompute reconstruction
recon = reconstructPartial(approx, details, fullBands, partialW > 0, partialW);
// layout
const topH = Math.round(H * 0.5);
drawTopPanel(ctx, 0, topH);
drawBottomPanel(ctx, topH, H);
// separator
ctx.strokeStyle = 'rgba(60, 70, 80, 0.55)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, topH + 0.5);
ctx.lineTo(W, topH + 0.5);
ctx.stroke();
// Scrub indicator: vertical line at mouseX while in scrub mode.
if (userActive && mx >= 0 && mx <= W) {
ctx.strokeStyle = 'rgba(95, 168, 211, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx + 0.5, 0);
ctx.lineTo(mx + 0.5, H);
ctx.stroke();
}
const addedCount = Math.min(LEVELS, fullBands + (partialW > 0 ? 1 : 0));
drawHUD(ctx, hudLabel, addedCount, modeLabel);
if (inSnap) {
// gentle dim overlay during snap
ctx.fillStyle = 'rgba(12, 16, 20, 0.35)';
ctx.fillRect(0, 0, W, H);
}
}
Comments (0)
Log in to comment.