/* global React */ const { useState, useEffect, useRef } = React; // ───────────────────────────────────────────────────────────────── // SCROLL STORE — single rAF-driven scrollY source for all parallax // ───────────────────────────────────────────────────────────────── const scrollListeners = new Set(); let _scrollY = typeof window !== "undefined" ? window.scrollY : 0; let _ticking = false; function _emit() { _scrollY = window.scrollY; scrollListeners.forEach((fn) => fn(_scrollY)); _ticking = false; } if (typeof window !== "undefined") { window.addEventListener("scroll", () => { if (!_ticking) { _ticking = true; requestAnimationFrame(_emit); } }, { passive: true }); } function useScrollY() { const [y, setY] = useState(_scrollY); useEffect(() => { const fn = (v) => setY(v); scrollListeners.add(fn); fn(window.scrollY); return () => scrollListeners.delete(fn); }, []); return y; } // ───────────────────────────────────────────────────────────────── // useInView — IntersectionObserver is unreliable in sandboxed iframes, // so we drive visibility off getBoundingClientRect + the scroll store. // ───────────────────────────────────────────────────────────────── function useInView(ref, { margin = 0.12, fallback = 1800 } = {}) { const [inView, setInView] = useState(false); useEffect(() => { let raf, settled = false; const reveal = () => { if (!settled) { settled = true; setInView(true); } }; const check = () => { if (settled) return; const el = ref.current; if (el) { const r = el.getBoundingClientRect(); const h = window.innerHeight || document.documentElement.clientHeight; if (r.top < h * (1 - margin) && r.bottom > 0) { reveal(); return; } } raf = requestAnimationFrame(check); }; raf = requestAnimationFrame(check); // scroll-driven check (works even if rAF is throttled) const onScroll = () => check(); window.addEventListener("scroll", onScroll, { passive: true }); // hard safety net: never leave content permanently hidden const t = setTimeout(reveal, fallback); return () => { cancelAnimationFrame(raf); clearTimeout(t); window.removeEventListener("scroll", onScroll); }; }, [ref, margin, fallback]); return inView; } // ───────────────────────────────────────────────────────────────── // REVEAL — fade/slide children in when they enter the viewport // ───────────────────────────────────────────────────────────────── function Reveal({ children, y = 28, delay = 0, className = "", as: Tag = "div" }) { const ref = useRef(null); const shown = useInView(ref); return ( {children} ); } // ───────────────────────────────────────────────────────────────── // GEAR — rotates continuously; scroll adds spin; hover boosts speed // ───────────────────────────────────────────────────────────────── function Gear({ src = "assets/gear-steel.png", size = 160, speed = 6, // deg/sec base dir = 1, // 1 cw, -1 ccw scrollFactor = 0.04, // deg per px scrolled style = {}, hoverBoost = 6, className = "", }) { const ref = useRef(null); const angle = useRef(Math.random() * 360); const last = useRef(performance.now()); const boost = useRef(1); useEffect(() => { let raf; const tick = (t) => { const dt = (t - last.current) / 1000; last.current = t; angle.current += dir * speed * boost.current * dt; const total = angle.current + dir * _scrollY * scrollFactor; if (ref.current) ref.current.style.transform = `rotate(${total}deg)`; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [speed, dir, scrollFactor]); return ( { boost.current = hoverBoost; }} onMouseLeave={() => { boost.current = 1; }} style={{ width: size, height: size, ...style }} /> ); } // ───────────────────────────────────────────────────────────────── // PARALLAX LAYER — translateY proportional to scroll // ───────────────────────────────────────────────────────────────── function ParallaxLayer({ speed = 0.2, children, className = "", style = {} }) { const ref = useRef(null); useEffect(() => { const fn = (y) => { if (ref.current) ref.current.style.transform = `translate3d(0, ${y * speed}px, 0)`; }; scrollListeners.add(fn); fn(window.scrollY); return () => scrollListeners.delete(fn); }, [speed]); return (
{children}
); } Object.assign(window, { useScrollY, useInView, Reveal, Gear, ParallaxLayer });