interface ThreadCameraMotionProfile { /** * Time constant for the ease-out chase. Smaller values react faster; the * camera closes roughly 95% of an uncapped distance in three time constants. */ responseTimeMs: number; /** Prevents a long explicit navigation from turning into a one-frame jump. */ maxSpeedPxPerSecond: number; /** Avoids spending frames chasing sub-pixel layout noise. */ settleDistancePx: number; /** Limits catch-up after a throttled or backgrounded animation frame. */ maxFrameDeltaMs: number; } const THREAD_CAMERA_NAVIGATION_MOTION: Readonly = { responseTimeMs: 110, maxSpeedPxPerSecond: 12_000, settleDistancePx: 0.5, maxFrameDeltaMs: 50, }; const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly = { responseTimeMs: 45, maxSpeedPxPerSecond: 24_000, settleDistancePx: 0.5, maxFrameDeltaMs: 50, }; export interface ThreadCameraViewport { scrollTop: number; scrollTo?: (options?: ScrollToOptions) => void; } export interface ThreadCameraScheduler { request: (callback: FrameRequestCallback) => number; cancel: (id: number) => void; now: () => number; } export type ThreadCameraFollowResult = "started" | "retargeted" | "settled"; interface ThreadCameraOptions { scheduler?: ThreadCameraScheduler; prefersReducedMotion?: () => boolean; } /** * A time-based ease-out chase rather than a start/end tween. The target can * move during explicit history navigation without restarting a duration or * adding another frame loop. */ function easeOutChase( current: number, target: number, deltaSeconds: number, profile: Pick, ): number { const distance = target - current; const responseSeconds = Math.max(0.001, profile.responseTimeMs / 1000); const timeStep = Math.max(0.001, deltaSeconds); const easeOutFraction = 1 - Math.exp(-timeStep / responseSeconds); const uncappedStep = distance * easeOutFraction; const maxStep = Math.max(0, profile.maxSpeedPxPerSecond) * timeStep; const step = Math.max(-maxStep, Math.min(maxStep, uncappedStep)); return current + step; } function defaultScheduler(): ThreadCameraScheduler { return { request: (callback) => window.requestAnimationFrame(callback), cancel: (id) => window.cancelAnimationFrame(id), now: () => performance.now(), }; } function defaultPrefersReducedMotion(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } export class ThreadCameraController { private readonly getViewport: () => ThreadCameraViewport | null; private readonly scheduler: ThreadCameraScheduler; private readonly prefersReducedMotion: () => boolean; private frameId: number | null = null; private phase: "idle" | "following" = "idle"; private target = 0; private lastTimestamp: number | null = null; constructor( getViewport: () => ThreadCameraViewport | null, options: ThreadCameraOptions = {}, ) { this.getViewport = getViewport; this.scheduler = options.scheduler ?? defaultScheduler(); this.prefersReducedMotion = options.prefersReducedMotion ?? defaultPrefersReducedMotion; } isFollowing(): boolean { return this.phase === "following"; } jumpTo(top: number): void { const viewport = this.getViewport(); if (!viewport) return; this.cancel(); this.target = Math.max(0, top); this.write(viewport, this.target); } /** * Automatic follow is a layout constraint, not navigation. Resolve it in * the geometry frame so streamed content and viewport resizing cannot build * up hidden travel below the visible tail. */ followTo(top: number): ThreadCameraFollowResult | null { const viewport = this.getViewport(); if (!viewport) return null; this.cancel(); this.target = Math.max(0, top); this.write(viewport, this.target); return "settled"; } navigateTo(top: number): ThreadCameraFollowResult | null { return this.moveTo(top); } private moveTo(top: number): ThreadCameraFollowResult | null { const viewport = this.getViewport(); if (!viewport) return null; const current = viewport.scrollTop; this.target = Math.max(0, top); const motion = this.currentMotion(); if (this.phase === "following") { return "retargeted"; } if (Math.abs(this.target - current) <= motion.settleDistancePx) { this.write(viewport, this.target); return "settled"; } this.phase = "following"; this.lastTimestamp = this.scheduler.now(); this.frameId = this.scheduler.request(this.advance); return "started"; } cancel(): void { if (this.frameId !== null) { this.scheduler.cancel(this.frameId); this.frameId = null; } this.phase = "idle"; this.lastTimestamp = null; } dispose(): void { this.cancel(); } private readonly advance = (timestamp: number): void => { this.frameId = null; const viewport = this.getViewport(); if (!viewport || this.phase !== "following") { this.cancel(); return; } const motion = this.currentMotion(); const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60); const deltaMs = Math.min( motion.maxFrameDeltaMs, Math.max(1, timestamp - previousTimestamp), ); this.lastTimestamp = timestamp; const current = viewport.scrollTop; const remainingDistance = this.target - current; if (Math.abs(remainingDistance) <= motion.settleDistancePx) { this.write(viewport, this.target); this.phase = "idle"; this.lastTimestamp = null; return; } const deltaSeconds = deltaMs / 1000; const easedTop = easeOutChase( current, this.target, deltaSeconds, motion, ); // Some browsers quantize scrollTop writes to whole pixels. Keep the // ease-out curve, but never let its subpixel tail round back to the same // position forever. const minimumStep = Math.min(1, Math.abs(remainingDistance)); const nextTop = Math.abs(easedTop - current) < minimumStep ? current + Math.sign(remainingDistance) * minimumStep : easedTop; const settled = Math.abs(this.target - nextTop) <= motion.settleDistancePx; this.write(viewport, settled ? this.target : nextTop); if (settled) { this.phase = "idle"; this.lastTimestamp = null; return; } this.frameId = this.scheduler.request(this.advance); }; private currentMotion(): ThreadCameraMotionProfile { return this.prefersReducedMotion() ? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION : THREAD_CAMERA_NAVIGATION_MOTION; } private write(viewport: ThreadCameraViewport, top: number): void { try { viewport.scrollTop = top; } catch { try { viewport.scrollTo?.({ top, behavior: "auto" }); } catch { // Test DOMs can expose read-only scrollTop; browsers keep this writable. } } } }