feat: add deterministic scroll drive controller

This commit is contained in:
2026-09-02 04:10:42 +03:00
parent 8dabd9a6df
commit 9bbe4652ac
4 changed files with 500 additions and 0 deletions

View File

@@ -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<Ref<ScrollDriveSnapshot>> {
const controller = createScrollDriveController();
const snapshot = shallowRef<ScrollDriveSnapshot>(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);
}

View File

@@ -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';

18
src/journey/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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('<output>0.18</output>');
});
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<number, FrameRequestCallback>();
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: '<output>{{ drive.routeProgress }}</output>',
}));
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);
});
});