// Times are seconds from the reference video's 05:00 frame (25 fps). export const smooth = (t: number) => { t = Math.max(0, Math.min(1, t)); return t * t * t * (10 + t * (-15 + 6 * t)); }; const bell = (x: number, width: number) => Math.exp(-0.5 * (x / width) ** 2); export function archiveWave(row: number, lane: number, time: number) { const t = time - 22; const phase = row + (lane - 2) * 0.65; const enter = smooth(t / 0.32); const first = 3 + t * 19; const returning = 32 - (t - 2.3) * 24; // A crest has shoulders and a trailing trough: neighboring files describe // one continuous surface, rather than independent staggered tweens. const packet = (distance: number) => 2.5 * bell(distance, 3.8) - 0.58 * bell(distance - 6, 3.5); return ( enter * (packet(phase - first) * (1 - smooth((t - 2.15) / 0.65)) + packet(phase - returning) * smooth((t - 2.17) / 0.32) * (1 - smooth((t - 3.5) / 0.85))) ); } export function extraction(time: number) { return ( 0.4 * smooth((time - 25.58) / 0.82) + 2.95 * smooth((time - 27.55) / 1.3) ); } // The returning scan leaves two moving shoulders around the selected file. // Their delay grows with distance, so the neighboring files keep moving during // the first extraction and settle before the second extraction. export function settlingWave(distance: number, time: number) { const age = time - 25.05 - Math.abs(distance) * 0.065; const envelope = Math.max( -0.42, 2.15 - 0.17 * (Math.sqrt(distance * distance + 1) - 1), ); const rise = smooth(age / 0.62); const ring = age > 0 ? Math.sin(age * 5.1) * Math.exp(-age * 1.3) : 0; return envelope * (rise + 0.18 * ring * smooth(age / 0.16)); } export function selectionWave(distance: number, age: number) { if (age < 0 || age > 3.2) return 0; return ( 0.8 * smooth(age / 0.2) * Math.exp(-age * 1.15) * Math.cos((distance - age * 8) * 0.58) * bell(distance - age * 8, 3.4) ); } export function columnStrength(lane: number, focus: number, progress = 1) { const selected = 0.25 + 0.75 * bell(lane - focus, 0.55); return 1 + (selected - 1) * smooth(progress); } // A quiet idle drift, with neighboring cards slightly out of phase. // Maximum displacement is 0.102, under 3% of a card's height. export function idleWave(row: number, lane: number, time: number) { return ( 0.075 * Math.sin((time * Math.PI * 2) / 8 + row * 0.3 - lane * 0.45) + 0.027 * Math.sin((time * Math.PI * 2) / 13 - row * 0.17 + lane * 0.3) ); } export function cinematicField( row: number, lane: number, time: number, center = 12, focus = 2, ) { const handoff = smooth((time - 24.95) / 0.45); const selection = smooth((time - 25.4) / 0.95); const shoulderTime = time + 0.3 * handoff * (1 - selection); return ( archiveWave(row, lane, time) * (1 - handoff) + settlingWave(row - center, shoulderTime) * columnStrength(lane, focus, (time - 25.4) / 0.95) ); } export const INSPECTION_LIFT = 4.05; export const ALIGNMENT_EPSILON = 0.001; // Hold altitude while facing back into the slot. Descent begins only once // alignment is complete; this also applies to independently returning copies. export function returnStep(angle: number, dt: number, reduced = false) { const next = angle * Math.exp(-dt * (reduced ? 35 : 7)); return Math.abs(next) <= ALIGNMENT_EPSILON ? 0 : next; } export interface Spring { value: number; velocity: number; } export function damp(s: Spring, target: number, rate: number, dt: number) { const delta = s.value - target; const impulse = s.velocity + rate * delta; const decay = Math.exp(-rate * dt); s.value = target + (delta + impulse * dt) * decay; s.velocity = (s.velocity - rate * impulse * dt) * decay; }