// Animation utilities const { useEffect: useAnimEffect, useRef: useAnimRef, useState: useAnimState } = React; // Reveal: fade up on scroll into view function Reveal({ children, delay = 0, className = '', as: As = 'div', threshold = 0.18, once = true, style = {} }){ const ref = useAnimRef(null); const [shown, setShown] = useAnimState(false); useAnimEffect(() => { const el = ref.current; if(!el) return; const obs = new IntersectionObserver(([e]) => { if(e.isIntersecting){ setShown(true); if(once) obs.disconnect(); } else if(!once) setShown(false); }, { threshold }); obs.observe(el); return () => obs.disconnect(); }, []); const dCls = delay ? ` d${Math.min(4, Math.max(1, Math.round(delay)))}` : ''; return ( {children} ); } // SplitText: char-by-char rise function SplitText({ text, className = '', tag: Tag = 'h1', delay = 0, threshold = 0.2 }){ const ref = useAnimRef(null); const [shown, setShown] = useAnimState(false); useAnimEffect(() => { const el = ref.current; if(!el) return; const obs = new IntersectionObserver(([e]) => { if(e.isIntersecting){ setShown(true); obs.disconnect(); } }, { threshold }); obs.observe(el); return () => obs.disconnect(); }, []); // Split by characters, preserving newlines as
const lines = String(text).split('\n'); return ( {lines.map((line, li) => ( {[...line].map((ch, ci) => { const totalIndex = li*1000 + ci; return ( {ch === ' ' ? '\u00A0' : ch} ); })} {li < lines.length - 1 &&
}
))}
); } // HoverFigure: zoom + caption function HoverFigure({ src, alt, caption, no, aspect = '4/5', style = {} }){ return (
{alt {(caption || no) && (
{caption && {caption}} {no && {no}}
)}
); } // HeroSlideshow: crossfading figure (reuses .figure frame) function HeroSlideshow({ images, aspect = '4/5', interval = 5000, style = {} }){ const [i, setI] = useAnimState(0); useAnimEffect(() => { if(images.length <= 1) return; const t = setInterval(() => setI(p => (p+1) % images.length), interval); return () => clearInterval(t); }, [images.length, interval]); const cur = images[i] || {}; return (
{images.map((im, n) => ( {im.caption ))} {(cur.caption || cur.no) && (
{cur.caption && {cur.caption}} {cur.no && {cur.no}}
)}
); } // EditorialBand: marquee/ticker function EditorialBand({ items }){ const all = [...items, ...items, ...items]; return (
{all.map((m, i) => ( {m} ))}
); } // Parallax wrapper — translateY based on scroll function Parallax({ children, speed = 0.2, className = '', style = {} }){ const ref = useAnimRef(null); useAnimEffect(() => { const el = ref.current; if(!el) return; let ticking = false; const onScroll = () => { if(ticking) return; ticking = true; requestAnimationFrame(() => { const r = el.getBoundingClientRect(); const cy = window.innerHeight / 2; const offset = (r.top + r.height/2 - cy) * speed; el.style.transform = `translate3d(0, ${-offset}px, 0)`; ticking = false; }); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, [speed]); return
{children}
; } Object.assign(window, { Reveal, SplitText, HoverFigure, HeroSlideshow, EditorialBand, Parallax });