// Frame motion. Everything here writes to #frameMover's transform and nothing // else, so additional animation layers can be nested inside without conflict. import { FRAME_SCALE, BOUNCE_SPEED } from '/config.js'; const ASPECT = 9 / 16; /** * Frame size for a viewport: FRAME_SCALE of the width, but never taller than * FRAME_SCALE of the height, so a wide screen does not get a letterbox strip. */ export function frameSize(viewportWidth, viewportHeight) { let width = viewportWidth * FRAME_SCALE; let height = width * ASPECT; const maxHeight = viewportHeight * FRAME_SCALE; if (height > maxHeight) { height = maxHeight; width = height / ASPECT; } return { width, height }; } /** * Diagonal drift with reflection off the viewport edges. Pure state + step, so * the physics can be exercised without a browser and without waiting on rAF. */ export function createBouncer({ x = 0, y = 0 } = {}) { const bouncer = { x, y, // A shallow diagonal reads better than 45°, and never retraces its path. vx: BOUNCE_SPEED, vy: BOUNCE_SPEED * 0.62, step(dt, bounds) { const maxX = Math.max(0, bounds.width); const maxY = Math.max(0, bounds.height); bouncer.x += bouncer.vx * dt; bouncer.y += bouncer.vy * dt; // Reflect, mirroring the overshoot back inside the viewport. if (bouncer.x <= 0) { bouncer.x = Math.min(-bouncer.x, maxX); bouncer.vx = Math.abs(bouncer.vx); } else if (bouncer.x >= maxX) { bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0); bouncer.vx = -Math.abs(bouncer.vx); } if (bouncer.y <= 0) { bouncer.y = Math.min(-bouncer.y, maxY); bouncer.vy = Math.abs(bouncer.vy); } else if (bouncer.y >= maxY) { bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0); bouncer.vy = -Math.abs(bouncer.vy); } return bouncer; }, }; return bouncer; } export function startBounce(element) { let size = frameSize(window.innerWidth, window.innerHeight); const bouncer = createBouncer({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2, }); function bounds() { return { width: window.innerWidth - size.width, height: window.innerHeight - size.height, }; } function measure() { size = frameSize(window.innerWidth, window.innerHeight); element.style.width = `${size.width}px`; element.style.height = `${size.height}px`; const limit = bounds(); bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width)); bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height)); } measure(); window.addEventListener('resize', measure); let last = performance.now(); function frame(now) { // Clamped so a stalled tab does not teleport the frame on resume. const dt = Math.min((now - last) / 1000, 0.05); last = now; bouncer.step(dt, bounds()); element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`; requestAnimationFrame(frame); } requestAnimationFrame(frame); return bouncer; }