'use client'; import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; /** * THE marquee animation core (single source of truth) — extracted verbatim * from so every endless-scroll surface (card strips, logo strips, * quick-action walls, chip marquees) shares ONE rAF position engine ("unify * means delete variations"). * * The engine is axis- and driver-agnostic: it owns a FLOAT position ref, a * velocity envelope, seam wrapping, and an optional chevron-style glide; the * caller decides what a position "means" via `apply` — writing * `scroller.scrollLeft` (CardsStrip: a real scroller the user can also drag) * or a `translate` transform (MarqueeWall: decorative tracks, incl. vertical — * mandatory inside surfaces that forbid inner scrollers, e.g. the deck's * mobile single-fold panels). * * Engine semantics (do not "simplify"): * - Position lives in a FLOAT ref, never read-modify-write from the DOM: * scrollLeft reads back rounded values, so sub-pixel increments * (speed/fps < 1px) get eaten by rounding — stutter + measurably slower * than the configured speed. * - Velocity envelope (~250ms time constant) eases between 0 and `speed` * instead of binary stop/start — pause decelerates, resume accelerates from * the current position (GSAP-marquee behavior; hard cuts read as jank). * - Glide consumes a signed remaining-distance, wrapping modulo the copy size * each frame, so seam warps are invisible to it (browser smooth-scroll * animates toward an ABSOLUTE target and lunges after a warp). * - `isPaused` is evaluated every frame — the caller owns the pause-reason * set (hover, viewport, tab visibility, suppress windows); the engine only * turns it into a smooth velocity target. */ export interface MarqueeEngineOptions { /** Master switch — the rAF runs only while true. On (re)activation the * position resyncs from `readBack` when provided. */ active: boolean; /** Cruise speed in px/s. */ speed: number; /** Per-frame pause verdict (caller-owned reason set). `now` is the rAF * timestamp, for suppress-until comparisons. */ isPaused: (now: number) => boolean; /** Single-copy size in px (the wrap modulus). Return <= 0 to disable * wrapping (position advances unwrapped — callers should size tracks so * this never happens while active). */ getWrapSize: () => number; /** Lower bound of the wrap range: positions stay in * [wrapMin, wrapMin + wrapSize). Default 0. Scroller drivers with USER * scrolling pass their seam-warp buffer here so engine-written positions * never enter the re-center zones — an exact-edge warp check is racy * against the rAF (the engine nudges the scroller off the edge before the * scroll handler samples it → "stuck at the left edge"). */ getWrapMin?: () => number; /** Write the position to the DOM (scrollLeft assignment or transform). */ apply: (pos: number) => void; /** Read the externally-mutated position back (real scrollers: user drag / * native smooth scroll move scrollLeft under the engine). Omit for * transform drivers — nothing external can move them. */ readBack?: () => number; /** Called at the end of every frame (e.g. hover re-resolution after the * track moved under a stationary pointer). */ onAfterFrame?: () => void; } export interface MarqueeEngineHandle { /** The engine's float position (px along the axis, 0 ≤ pos < wrap while * wrapping). Callers may write it together with their own DOM write (seam * warp on manual scroll). */ posRef: MutableRefObject; /** Accumulate a signed glide distance (chevron navigation). Runs through * the engine's own tween — never native smooth scroll while wrapping. */ glideBy: (px: number) => void; /** Wrap an arbitrary position into [0, wrapSize). */ wrap: (pos: number) => number; } // Resync threshold (px): external movement beyond this adopts the DOM value. const RESYNC_EPSILON = 1.5; export function useMarqueeEngine(options: MarqueeEngineOptions): MarqueeEngineHandle { const posRef = useRef(0); const glideRemainingRef = useRef(0); const speedEnvRef = useRef(0); // Latest-options ref: the rAF loop always reads current values without the // effect restarting on every render. Refreshed in an unconditional effect, // declared before every effect that starts the loop, rather than in the // render body — a render attempt React discards must not be able to hand the // loop options that were never committed. const optsRef = useRef(options); useEffect(() => { optsRef.current = options; }); const wrap = useCallback((pos: number) => { const half = optsRef.current.getWrapSize(); if (half <= 0) return pos; const min = optsRef.current.getWrapMin?.() ?? 0; let wrapped = pos; while (wrapped >= min + half) wrapped -= half; while (wrapped < min) wrapped += half; return wrapped; }, []); const glideBy = useCallback((px: number) => { glideRemainingRef.current += px; }, []); const active = options.active; useEffect(() => { if (!active) return undefined; let raf = 0; let last = performance.now(); const readBack = optsRef.current.readBack; if (readBack) posRef.current = readBack(); const tick = (now: number) => { const opts = optsRef.current; const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps last = now; const paused = opts.isPaused(now); // Ease the velocity toward its target (0 when paused) — smooth decel on // pause, smooth accel on resume, always from the CURRENT position. const targetSpeed = paused ? 0 : opts.speed; speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25); if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0; const external = opts.readBack ? opts.readBack() : null; const resync = () => { if (external !== null && Math.abs(external - posRef.current) > RESYNC_EPSILON) { posRef.current = external; } }; const glide = glideRemainingRef.current; if (glide !== 0) { // Ease-out toward the glide target; runs even while "paused" (the // interaction that queued it sets its own suppress window). resync(); const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt); glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step; posRef.current = wrap(posRef.current + step); opts.apply(posRef.current); } else if (speedEnvRef.current > 0) { // Resync after external movement (user scroll, seam warp). resync(); posRef.current = wrap(posRef.current + speedEnvRef.current * dt); opts.apply(posRef.current); } else if (external !== null) { // Idle: follow external movement so resumes start from where the // user left the scroller. posRef.current = external; } opts.onAfterFrame?.(); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [active, wrap]); return { posRef, glideBy, wrap }; }