/* global React, TimelineContext, clamp, Easing */ // ScrollStage — sticky scroll-driven video. The page scroll within the section // drives the timeline 0 → duration, instead of a RAF playhead. // Reuses TimelineContext from animations.jsx so Sprite/useTime still work. const _sc = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); function useIsMobile() { const [isMobile, setIsMobile] = React.useState(typeof window !== 'undefined' ? window.innerWidth < 1024 : false); React.useEffect(() => { const check = () => setIsMobile(window.innerWidth < 1024); window.addEventListener('resize', check); return () => window.removeEventListener('resize', check); }, []); return isMobile; } function ScrollStage({ width = 1920, height = 1080, duration = 60, background, scrollVH = 700, children, label, fit = 0.94, // fraction of viewport to occupy }) { const isMobile = useIsMobile(); const cWidth = isMobile ? 1080 : width; const cHeight = isMobile ? 1920 : height; const outerRef = React.useRef(null); const [time, setTime] = React.useState(0); const [scale, setScale] = React.useState(1); const [progress, setProgress] = React.useState(0); React.useEffect(() => { const onScroll = () => { const el = outerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const vh = window.innerHeight; const total = el.offsetHeight - vh; if (total <= 0) return; const scrolled = _sc(-rect.top, 0, total); const p = scrolled / total; setProgress(p); setTime(p * duration); }; window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => window.removeEventListener('scroll', onScroll); }, [duration]); React.useEffect(() => { const fit_ = () => { const vw = window.innerWidth; const vh = window.innerHeight; const s = Math.min(vw / cWidth, vh / cHeight) * fit; setScale(s); }; fit_(); window.addEventListener('resize', fit_); return () => window.removeEventListener('resize', fit_); }, [cWidth, cHeight, fit]); const ctxValue = React.useMemo(() => ({ time, duration, playing: false }), [time, duration]); return (
{/* Clean solid background */}
); } /* ────────────────────────────────────────────────────────────── Animated cursor — used in product scenes to show interactions. Provide keyframes [{t, x, y, click?}]; cursor lerps between them. ────────────────────────────────────────────────────────────── */ function CursorTrail({ keyframes, color = 'var(--bataj-ink)' }) { const { localTime } = useSprite(); // find current segment let kfA = keyframes[0]; let kfB = keyframes[keyframes.length - 1]; let local = 1; for (let i = 0; i < keyframes.length - 1; i++) { if (localTime >= keyframes[i].t && localTime <= keyframes[i + 1].t) { kfA = keyframes[i]; kfB = keyframes[i + 1]; const span = kfB.t - kfA.t || 0.0001; local = (localTime - kfA.t) / span; break; } } if (localTime <= keyframes[0].t) { kfA = keyframes[0]; kfB = keyframes[0]; local = 0; } if (localTime >= keyframes[keyframes.length - 1].t) { kfA = keyframes[keyframes.length - 1]; kfB = kfA; local = 1; } const eased = Easing.easeInOutCubic(local); const x = kfA.x + (kfB.x - kfA.x) * eased; const y = kfA.y + (kfB.y - kfA.y) * eased; // click pulse: if the segment ENDS in a click, pulse near local=1 const click = kfB.click ? Math.max(0, 1 - Math.abs(local - 0.95) / 0.18) : 0; return (
{/* click ripple */} {click > 0 && ( )}
); } /* Chrome banner along the canvas top: section number, product name, counter */ function CanvasChrome({ idx, total, code, title, sub, accent }) { const isMobile = useIsMobile(); return ( <>
┌── BATAJ.REEL · SEC.03
● {code || ''} {title || ''} {!isMobile && {sub || ''}} ──┐
{idx != null && (
{String(idx).padStart(2, '0')} /{String(total).padStart(2, '0')}
)} ); } /* Simple side panel renderer used by product scenes */ function SidePanel({ name, code, kicker, tagline, blurb, stats, stack, accent, dark = false }) { const isMobile = useIsMobile(); const ink = dark ? '#F4EFE3' : 'var(--bataj-ink)'; const mute = dark ? 'rgba(244,239,227,.55)' : 'color-mix(in srgb, var(--bataj-ink) 55%, transparent)'; return (
● {kicker}
{code}

{name}

{tagline}

{blurb}

{stats && (
{stats.map((s, i) => (
{s.l} {s.v}
))}
)} {stack && (
{stack.map((s) => ( {s} ))}
)}
); } Object.assign(window, { ScrollStage, CursorTrail, CanvasChrome, SidePanel, useIsMobile });