From 9bbe4652ac969c69764ffb039336b0852b333907 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 04:10:42 +0300 Subject: [PATCH] feat: add deterministic scroll drive controller --- src/composables/useScrollDrive.ts | 87 +++++++ src/journey/scroll-drive-controller.ts | 113 +++++++++ src/journey/types.ts | 18 ++ tests/unit/scroll-drive-controller.test.ts | 282 +++++++++++++++++++++ 4 files changed, 500 insertions(+) create mode 100644 src/composables/useScrollDrive.ts create mode 100644 src/journey/scroll-drive-controller.ts create mode 100644 src/journey/types.ts create mode 100644 tests/unit/scroll-drive-controller.test.ts diff --git a/src/composables/useScrollDrive.ts b/src/composables/useScrollDrive.ts new file mode 100644 index 0000000..7639628 --- /dev/null +++ b/src/composables/useScrollDrive.ts @@ -0,0 +1,87 @@ +import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue'; +import { createScrollDriveController } from '@/journey/scroll-drive-controller'; +import type { ScrollDriveSample, ScrollDriveSnapshot } from '@/journey/types'; + +const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; + +const initialSnapshot = (): ScrollDriveSnapshot => ({ + scrollY: 0, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, +}); + +export function useScrollDrive(): Readonly> { + const controller = createScrollDriveController(); + const snapshot = shallowRef(initialSnapshot()); + let teardown = (): void => {}; + + onMounted(() => { + let frameId: number | undefined; + let resetOnNextFrame = false; + const visualViewport = window.visualViewport; + + const readSample = (now: number): ScrollDriveSample => { + const scrollingElement = document.scrollingElement ?? document.documentElement; + const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight; + return { + scrollY: scrollingElement.scrollTop, + now, + maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight), + }; + }; + + const runFrame = (now: number): void => { + frameId = undefined; + const input = readSample(now); + snapshot.value = resetOnNextFrame + ? controller.reset(input) + : controller.sample(input); + resetOnNextFrame = false; + }; + + const scheduleFrame = (): void => { + if (frameId === undefined && document.visibilityState !== 'hidden') { + frameId = requestAnimationFrame(runFrame); + } + }; + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'hidden') { + resetOnNextFrame = true; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + snapshot.value = controller.reset(); + return; + } + resetOnNextFrame = true; + scheduleFrame(); + }; + + snapshot.value = controller.reset(readSample(performance.now())); + window.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER); + window.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER); + visualViewport?.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER); + visualViewport?.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER); + document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER); + + teardown = (): void => { + window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER); + window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER); + visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER); + visualViewport?.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER); + document.removeEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER); + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + }; + }); + + onUnmounted(() => teardown()); + + return readonly(snapshot); +} diff --git a/src/journey/scroll-drive-controller.ts b/src/journey/scroll-drive-controller.ts new file mode 100644 index 0000000..c775956 --- /dev/null +++ b/src/journey/scroll-drive-controller.ts @@ -0,0 +1,113 @@ +import type { + ScrollDriveController, + ScrollDriveSample, + ScrollDriveSnapshot, +} from './types'; + +const MAX_RAW_SPEED = 2.5; +const RISE_TIME_CONSTANT_MS = 90; +const DECAY_TIME_CONSTANT_MS = 420; +const CRUISE_SPEED = 0.18; +const ACCELERATION_SPEED_RANGE = 0.82; +const BACKGROUND_GAP_MS = 250; + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(maximum, Math.max(minimum, value)); + +const isFiniteSample = (input: ScrollDriveSample): boolean => + Number.isFinite(input.scrollY) && + Number.isFinite(input.now) && + Number.isFinite(input.maxScrollY); + +const initialSnapshot = (): ScrollDriveSnapshot => ({ + scrollY: 0, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: CRUISE_SPEED, +}); + +const routeProgress = (input: ScrollDriveSample): number => + input.maxScrollY > 0 + ? clamp(input.scrollY / Math.max(1, input.maxScrollY), 0, 1) + : 0; + +const cruiseSnapshot = (input: ScrollDriveSample): ScrollDriveSnapshot => ({ + scrollY: input.scrollY, + routeProgress: routeProgress(input), + direction: 0, + acceleration: 0, + driveSpeed: CRUISE_SPEED, +}); + +export function createScrollDriveController(): ScrollDriveController { + let previousSample: ScrollDriveSample | undefined; + let smoothedAcceleration = 0; + + const reset = (input?: ScrollDriveSample): ScrollDriveSnapshot => { + smoothedAcceleration = 0; + previousSample = input !== undefined && isFiniteSample(input) ? { ...input } : undefined; + return previousSample === undefined ? initialSnapshot() : cruiseSnapshot(previousSample); + }; + + const sample = (input: ScrollDriveSample): ScrollDriveSnapshot => { + if (!isFiniteSample(input)) { + smoothedAcceleration = 0; + return initialSnapshot(); + } + + if (input.maxScrollY <= 0) { + smoothedAcceleration = 0; + previousSample = { ...input }; + return cruiseSnapshot(input); + } + + if (previousSample === undefined) { + previousSample = { ...input }; + return cruiseSnapshot(input); + } + + const elapsedMs = input.now - previousSample.now; + if (elapsedMs <= 0 || !Number.isFinite(elapsedMs)) { + smoothedAcceleration = 0; + return cruiseSnapshot(input); + } + + if (elapsedMs > BACKGROUND_GAP_MS) { + previousSample = { ...input }; + smoothedAcceleration = 0; + return cruiseSnapshot(input); + } + + const deltaY = input.scrollY - previousSample.scrollY; + const rawSpeed = Math.abs(deltaY) / elapsedMs; + const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1); + const timeConstantMs = impulse > smoothedAcceleration + ? RISE_TIME_CONSTANT_MS + : DECAY_TIME_CONSTANT_MS; + const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs); + smoothedAcceleration += (impulse - smoothedAcceleration) * alpha; + previousSample = { ...input }; + + const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0; + return { + scrollY: input.scrollY, + routeProgress: routeProgress(input), + direction, + acceleration: smoothedAcceleration, + driveSpeed: clamp( + CRUISE_SPEED + ACCELERATION_SPEED_RANGE * smoothedAcceleration, + CRUISE_SPEED, + 1, + ), + }; + }; + + return { sample, reset }; +} + +export type { + ScrollDriveController, + ScrollDriveSample, + ScrollDriveSnapshot, +} from './types'; diff --git a/src/journey/types.ts b/src/journey/types.ts new file mode 100644 index 0000000..be2abac --- /dev/null +++ b/src/journey/types.ts @@ -0,0 +1,18 @@ +export interface ScrollDriveSample { + scrollY: number; + now: number; + maxScrollY: number; +} + +export interface ScrollDriveSnapshot { + scrollY: number; + routeProgress: number; + direction: -1 | 0 | 1; + acceleration: number; + driveSpeed: number; +} + +export interface ScrollDriveController { + sample(input: ScrollDriveSample): ScrollDriveSnapshot; + reset(input?: ScrollDriveSample): ScrollDriveSnapshot; +} diff --git a/tests/unit/scroll-drive-controller.test.ts b/tests/unit/scroll-drive-controller.test.ts new file mode 100644 index 0000000..65c7259 --- /dev/null +++ b/tests/unit/scroll-drive-controller.test.ts @@ -0,0 +1,282 @@ +import { mount } from '@vue/test-utils'; +import { createSSRApp, defineComponent, h, nextTick } from 'vue'; +import { renderToString } from '@vue/server-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createScrollDriveController } from '@/journey/scroll-drive-controller'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('scroll drive controller', () => { + it('starts and resets at a deterministic cruise snapshot', () => { + const controller = createScrollDriveController(); + + expect(controller.reset()).toEqual({ + scrollY: 0, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + expect(controller.reset({ scrollY: 250, now: 10, maxScrollY: 1000 })).toEqual({ + scrollY: 250, + routeProgress: 0.25, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + }); + + it('uses the literal rise formula for a fixed finite sample', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + + const snapshot = controller.sample({ scrollY: 50, now: 100, maxScrollY: 1000 }); + + expect(snapshot.scrollY).toBe(50); + expect(snapshot.routeProgress).toBe(0.05); + expect(snapshot.direction).toBe(1); + expect(snapshot.acceleration).toBeCloseTo(0.13416140243841887, 14); + expect(snapshot.driveSpeed).toBeCloseTo(0.2900123499995034, 14); + }); + + it('uses the literal 420 ms decay formula when scrolling stops', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 }); + + const snapshot = controller.sample({ scrollY: 500, now: 200, maxScrollY: 1000 }); + + expect(snapshot.direction).toBe(0); + expect(snapshot.acceleration).toBeCloseTo(0.5286815391938753, 14); + expect(snapshot.driveSpeed).toBeCloseTo(0.6135188621389778, 14); + }); + + it.each([ + ['NaN scroll position', { scrollY: Number.NaN, now: 10, maxScrollY: 1000 }], + ['infinite scroll position', { scrollY: Number.POSITIVE_INFINITY, now: 10, maxScrollY: 1000 }], + ['NaN timestamp', { scrollY: 10, now: Number.NaN, maxScrollY: 1000 }], + ['infinite timestamp', { scrollY: 10, now: Number.POSITIVE_INFINITY, maxScrollY: 1000 }], + ['NaN range', { scrollY: 10, now: 10, maxScrollY: Number.NaN }], + ['infinite range', { scrollY: 10, now: 10, maxScrollY: Number.POSITIVE_INFINITY }], + ])('falls back to finite cruise for an invalid sample: %s', (_label, input) => { + const controller = createScrollDriveController(); + + expect(controller.sample(input)).toEqual({ + scrollY: 0, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + }); + + it.each([ + ['zero', 100], + ['negative', 99], + ])('emits cruise and does not integrate a %s dt', (_label, now) => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 100, maxScrollY: 1000 }); + + expect(controller.sample({ scrollY: 500, now, maxScrollY: 1000 })).toEqual({ + scrollY: 500, + routeProgress: 0.5, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + }); + + it('integrates an exact 250 ms interval', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + + const snapshot = controller.sample({ scrollY: 625, now: 250, maxScrollY: 1000 }); + + expect(snapshot.direction).toBe(1); + expect(snapshot.acceleration).toBeCloseTo(0.9378234759778836, 14); + expect(snapshot.driveSpeed).toBeCloseTo(0.9490152503018645, 14); + }); + + it('resets transient velocity and establishes a baseline after a gap over 250 ms', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 }); + + expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 })).toEqual({ + scrollY: 500, + routeProgress: 0.5, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + expect(controller.sample({ scrollY: 600, now: 451, maxScrollY: 1000 }).direction).toBe(1); + }); + + it.each([ + ['downward', 0, 1000, 1], + ['upward', 1000, 0, -1], + ] as const)('clamps a maximum %s delta while preserving signed direction', (_label, start, end, direction) => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: start, now: 0, maxScrollY: 1000 }); + + const snapshot = controller.sample({ scrollY: end, now: 100, maxScrollY: 1000 }); + + expect(snapshot.direction).toBe(direction); + expect(snapshot.acceleration).toBeCloseTo(0.6708070121920944, 14); + expect(snapshot.driveSpeed).toBeCloseTo(0.7300617499975173, 14); + }); + + it.each([0, -1])('emits zero progress cruise for a non-positive range of %s', (maxScrollY) => { + const controller = createScrollDriveController(); + + expect(controller.sample({ scrollY: 500, now: 100, maxScrollY })).toEqual({ + scrollY: 500, + routeProgress: 0, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + }); + + it('uses a denominator of at least one CSS pixel for a fractional scroll range', () => { + const controller = createScrollDriveController(); + + expect(controller.reset({ scrollY: 0.25, now: 100, maxScrollY: 0.5 }).routeProgress).toBe(0.25); + }); + + it('clamps route progress and recomputes it when the range changes during smoothing', () => { + const controller = createScrollDriveController(); + controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 }); + controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 }); + + expect(controller.sample({ scrollY: 500, now: 200, maxScrollY: 2000 })).toMatchObject({ + routeProgress: 0.25, + acceleration: expect.closeTo(0.5286815391938753, 14), + }); + expect(controller.reset({ scrollY: -10, now: 300, maxScrollY: 1000 }).routeProgress).toBe(0); + expect(controller.reset({ scrollY: 1010, now: 400, maxScrollY: 1000 }).routeProgress).toBe(1); + }); +}); + +describe('useScrollDrive', () => { + it('imports and renders on the server without browser globals', async () => { + vi.resetModules(); + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + const { useScrollDrive } = await import('@/composables/useScrollDrive'); + const app = createSSRApp(defineComponent({ + setup() { + const drive = useScrollDrive(); + return () => h('output', drive.value.driveSpeed.toString()); + }, + })); + + await expect(renderToString(app)).resolves.toBe('0.18'); + }); + + it('initializes deterministically, batches passive events, resets visibility gaps, and tears down', async () => { + const { useScrollDrive } = await import('@/composables/useScrollDrive'); + let visibility: DocumentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => visibility, + }); + Object.defineProperties(document.documentElement, { + scrollTop: { configurable: true, writable: true, value: 250 }, + scrollHeight: { configurable: true, value: 1500 }, + clientHeight: { configurable: true, value: 500 }, + }); + + const visualViewport = new EventTarget() as VisualViewport; + Object.defineProperty(visualViewport, 'height', { configurable: true, value: 500 }); + Object.defineProperty(window, 'visualViewport', { configurable: true, value: visualViewport }); + + const callbacks = new Map(); + let nextFrameId = 1; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = nextFrameId++; + callbacks.set(id, callback); + return id; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id))); + vi.spyOn(performance, 'now').mockReturnValue(100); + const windowAdd = vi.spyOn(window, 'addEventListener'); + const documentAdd = vi.spyOn(document, 'addEventListener'); + const viewportAdd = vi.spyOn(visualViewport, 'addEventListener'); + + const wrapper = mount(defineComponent({ + setup() { + return { drive: useScrollDrive() }; + }, + template: '{{ drive.routeProgress }}', + })); + const drive = () => wrapper.vm.drive; + + expect(drive()).toMatchObject({ + scrollY: 250, + routeProgress: 0.25, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + expect(callbacks).toHaveLength(0); + expect(windowAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }); + expect(windowAdd).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }); + expect(documentAdd).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true }); + expect(viewportAdd).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }); + expect(viewportAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }); + + document.documentElement.scrollTop = 750; + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + visualViewport.dispatchEvent(new Event('resize')); + expect(callbacks).toHaveLength(1); + + const firstFrame = [...callbacks.entries()][0]!; + callbacks.delete(firstFrame[0]); + firstFrame[1](200); + await nextTick(); + expect(drive()).toMatchObject({ + scrollY: 750, + routeProgress: 0.75, + direction: 1, + acceleration: expect.closeTo(0.6708070121920944, 14), + driveSpeed: expect.closeTo(0.7300617499975173, 14), + }); + + window.dispatchEvent(new Event('scroll')); + expect(callbacks).toHaveLength(1); + visibility = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + expect(callbacks).toHaveLength(0); + expect(drive()).toMatchObject({ acceleration: 0, driveSpeed: 0.18 }); + + visibility = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + expect(callbacks).toHaveLength(1); + const visibleFrame = [...callbacks.entries()][0]!; + callbacks.delete(visibleFrame[0]); + visibleFrame[1](1000); + await nextTick(); + expect(drive()).toMatchObject({ + scrollY: 750, + routeProgress: 0.75, + direction: 0, + acceleration: 0, + driveSpeed: 0.18, + }); + + window.dispatchEvent(new Event('scroll')); + expect(callbacks).toHaveLength(1); + wrapper.unmount(); + expect(callbacks).toHaveLength(0); + window.dispatchEvent(new Event('scroll')); + visualViewport.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('visibilitychange')); + expect(callbacks).toHaveLength(0); + }); +});