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();
}