const { useState: useStateCU, useRef: useRefCU, useEffect: useEffectCU } = React; /* Scroll-triggered count-up. Animates 0 → end when it scrolls into view. Honors prefers-reduced-motion (snaps to final value). */ function CountUp({ end, decimals = 0, duration = 1500 }) { const [val, setVal] = useStateCU(0); const ref = useRefCU(null); const done = useRefCU(false); useEffectCU(() => { const node = ref.current; if (!node) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce) { setVal(end); return; } const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !done.current) { done.current = true; const t0 = performance.now(); const tick = (now) => { const t = Math.min(1, (now - t0) / duration); const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic setVal(end * eased); if (t < 1) requestAnimationFrame(tick); else setVal(end); }; requestAnimationFrame(tick); // Safety net: if rAF is throttled (e.g. background tab), still land on the final value. setTimeout(() => setVal(end), duration + 250); } }); }, { threshold: 0.5 }); io.observe(node); return () => io.disconnect(); }, [end, duration]); const display = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString(); return {display}; } window.CountUp = CountUp;