feat: add deterministic scroll drive controller
This commit is contained in:
113
src/journey/scroll-drive-controller.ts
Normal file
113
src/journey/scroll-drive-controller.ts
Normal 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
18
src/journey/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user