const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); let uid = 0; let cleanups = []; let frames = new Set(); function build(svg) { const i = ++uid; svg.innerHTML = ` ${[0,1].map(k=>` `).join('')} `; const beam = svg.querySelector('.beam'); const pans = svg.querySelectorAll('.pan'); return function tilt(deg) { const r = deg * Math.PI / 180; const c = Math.cos(r) * 142; const s = Math.sin(r) * 142; beam.setAttribute('transform', `rotate(${deg} 200 110)`); pans[0].setAttribute('transform', `translate(${200 - c} ${110 - s})`); pans[1].setAttribute('transform', `translate(${200 + c} ${110 + s})`); }; } /* a damped spring, so the beam swings and settles like real brass */ function spring(tilt, start) { const st = { a: start, v: 0, target: 0 }; let raf = 0; const step = () => { frames.delete(raf); st.v += (st.target - st.a) * 0.028; st.v *= 0.935; st.a += st.v; tilt(st.a); if (Math.abs(st.v) > 0.002 || Math.abs(st.target - st.a) > 0.01) { raf = requestAnimationFrame(step); frames.add(raf); } else { raf = 0; } }; return { set(target) { st.target = target; if (!raf) { raf = requestAnimationFrame(step); frames.add(raf); } }, }; } function on(target, type, handler, options) { target.addEventListener(type, handler, options); cleanups.push(() => target.removeEventListener(type, handler, options)); } function hero(svg, tilt) { tilt(14); const sp = spring(tilt, 14); const timer = setTimeout(() => sp.set(0), 450); cleanups.push(() => clearTimeout(timer)); const frame = svg.closest('.frame'); if (!frame || !matchMedia('(pointer:fine)').matches) return; const glow = frame.querySelector('.glow'); const img = frame.querySelector('img'); on(frame, 'pointermove', (e) => { const fr = frame.getBoundingClientRect(); const x = (e.clientX - fr.left) / fr.width; sp.set(clamp((x - 0.5) * 2.4, -1, 1) * 9); if (glow) { glow.style.left = (e.clientX - fr.left) + 'px'; glow.style.top = (e.clientY - fr.top) + 'px'; } if (img) { img.style.transform = `scale(1.08) translate(${(e.clientX - fr.left - fr.width / 2) / -40}px, ${(e.clientY - fr.top - fr.height / 2) / -40}px)`; } }); on(frame, 'pointerleave', () => { sp.set(0); if (img) img.style.transform = ''; }); } function finale(tilt) { tilt(-12); const sp = spring(tilt, -12); const onScroll = () => { const max = document.documentElement.scrollHeight - window.innerHeight; const p = max > 0 ? window.scrollY / max : 1; const q = clamp((p - 0.7) / 0.25, 0, 1); sp.set(-12 * (1 - q)); }; on(window, 'scroll', onScroll, { passive: true }); onScroll(); } export function init() { const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; document.querySelectorAll('svg[data-balance]').forEach((svg) => { const tilt = build(svg); const mode = svg.dataset.balance; if (reduced || mode === 'static') { tilt(0); } else if (mode === 'hero') { hero(svg, tilt); } else if (mode === 'finale') { finale(tilt); } }); } export function destroy() { cleanups.forEach((fn) => fn()); cleanups = []; frames.forEach((id) => cancelAnimationFrame(id)); frames.clear(); }