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