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