From a2b6ab054609441965dbbf9feca3037bc31a91ab Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 1 Mar 2026 11:59:54 -0500 Subject: [PATCH] Add split-flap animation engine with staggered character stepping and sound --- index.html | 138 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 15 deletions(-) diff --git a/index.html b/index.html index 6d3d5d6..437077c 100644 --- a/index.html +++ b/index.html @@ -196,8 +196,8 @@ body { (function() { 'use strict'; - // ─── Config ───────────────────────────────────────────────────────── - // Column definitions - customize per use case + // ─── Constants ────────────────────────────────────────────────────── + const CHARSET = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/,&()+\u2713\u2717'; const COLS = [ { key: 'time', width: 7, header: 'TIME' }, { key: 'type', width: 10, header: 'TYPE' }, @@ -210,14 +210,19 @@ body { ]; const TOTAL_CHARS = COLS.reduce((s, c) => s + c.width, 0); const MAX_ROWS = 20; + const FLIP_STEP_MS = 200; + const STAGGER_MS = 20; const CHAR_WIDTH = 18; + // ─── State ────────────────────────────────────────────────────────── + const flapChars = new Map(); + // ─── Flap Element Creation ───────────────────────────────────────── function createFlap(ch) { ch = ch || ' '; const el = document.createElement('div'); el.className = 'flap'; - el.dataset.char = ch; + flapChars.set(el, ch); const top = document.createElement('div'); top.className = 'flap-top'; @@ -246,12 +251,115 @@ body { else if (ch === '\u2717') flapEl.classList.add('flap-red'); } - // ─── Set flap character (no animation yet) ───────────────────────── - function setFlapChar(flapEl, ch) { - flapEl.dataset.char = ch; - flapEl.querySelector('.flap-top .flap-char').textContent = ch; - flapEl.querySelector('.flap-bottom .flap-char').textContent = ch; - applyCharColor(flapEl, ch); + // ─── Sound ───────────────────────────────────────────────────────── + const flipAudio = new Audio('/split.mp3'); + flipAudio.loop = true; + flipAudio.volume = 0; + let fadeInterval = null; + + function startFlipSound() { + clearInterval(fadeInterval); + flipAudio.currentTime = 3; + flipAudio.volume = 0; + flipAudio.play().catch(() => {}); + const target = 0.5; + const step = target / 8; + fadeInterval = setInterval(() => { + flipAudio.volume = Math.min(flipAudio.volume + step, target); + if (flipAudio.volume >= target) clearInterval(fadeInterval); + }, 10); + } + + function stopFlipSound() { + clearInterval(fadeInterval); + const step = flipAudio.volume / 12; + fadeInterval = setInterval(() => { + flipAudio.volume = Math.max(flipAudio.volume - step, 0); + if (flipAudio.volume <= 0) { + clearInterval(fadeInterval); + flipAudio.pause(); + } + }, 10); + } + + // ─── Batch Flip Animation Engine ──────────────────────────────────── + const activeFlips = []; + let animRunning = false; + + function buildSteps(oldChar, newChar) { + const oldIdx = CHARSET.indexOf(oldChar); + const newIdx = CHARSET.indexOf(newChar); + if (oldIdx === -1 || newIdx === -1) return [newChar]; + + const steps = []; + if (newIdx >= oldIdx) { + for (let i = oldIdx + 1; i <= newIdx; i++) steps.push(CHARSET[i]); + } else { + for (let i = oldIdx + 1; i < CHARSET.length; i++) steps.push(CHARSET[i]); + for (let i = 0; i <= newIdx; i++) steps.push(CHARSET[i]); + } + if (steps.length === 0) return [newChar]; + + const maxSteps = 12; + if (steps.length <= maxSteps) return steps; + const sampled = []; + for (let i = 0; i < maxSteps - 1; i++) { + sampled.push(steps[Math.floor(i * (steps.length - 1) / (maxSteps - 1))]); + } + sampled.push(steps[steps.length - 1]); + return sampled; + } + + function scheduleFlip(flapEl, newChar, delay) { + const oldChar = flapChars.get(flapEl) || ' '; + if (oldChar === newChar) return; + + const topSpan = flapEl.querySelector('.flap-top .flap-char'); + const bottomSpan = flapEl.querySelector('.flap-bottom .flap-char'); + const steps = buildSteps(oldChar, newChar); + const startTime = performance.now() + delay; + + activeFlips.push({ + flapEl, topSpan, bottomSpan, steps, + stepIdx: 0, + nextTime: startTime + }); + + if (!animRunning) { + animRunning = true; + startFlipSound(); + requestAnimationFrame(animLoop); + } + } + + function animLoop(timestamp) { + let stillActive = false; + + for (let i = 0; i < activeFlips.length; i++) { + const flip = activeFlips[i]; + if (flip.stepIdx >= flip.steps.length) continue; + + stillActive = true; + if (timestamp >= flip.nextTime) { + const ch = flip.steps[flip.stepIdx]; + flip.topSpan.textContent = ch; + flip.bottomSpan.textContent = ch; + flapChars.set(flip.flapEl, ch); + flip.stepIdx++; + flip.nextTime = timestamp + FLIP_STEP_MS; + if (flip.stepIdx >= flip.steps.length) { + applyCharColor(flip.flapEl, ch); + } + } + } + + if (stillActive) { + requestAnimationFrame(animLoop); + } else { + activeFlips.length = 0; + animRunning = false; + stopFlipSound(); + } } // ─── Row helpers ─────────────────────────────────────────────────── @@ -269,7 +377,7 @@ body { const flaps = rowEl.children; const padded = text.padEnd(flaps.length); for (let i = 0; i < flaps.length; i++) { - setFlapChar(flaps[i], padded[i]); + scheduleFlip(flaps[i], padded[i], i * STAGGER_MS); } } @@ -296,13 +404,13 @@ body { if (container.children.length !== upper.length) { container.innerHTML = ''; for (let i = 0; i < upper.length; i++) { - container.appendChild(createFlap(upper[i])); - } - } else { - for (let i = 0; i < upper.length; i++) { - setFlapChar(container.children[i], upper[i]); + container.appendChild(createFlap(' ')); } } + + for (let i = 0; i < upper.length; i++) { + scheduleFlip(container.children[i], upper[i], i * STAGGER_MS); + } } // ─── Format Row ───────────────────────────────────────────────────