Files
Psychotoxical-psysonic/src/utils/springScroll.ts
T

97 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Spring-based scroll animation — iOS / Apple Music feel.
*
* Uses a critically-damped spring model driven by rAF:
* velocity += (target position) × stiffness
* velocity *= damping
* position += velocity
*
* Tuning:
* stiffness 0.04 0.10 → lower = slower / more fluid
* damping 0.80 0.88 → lower = more bounce; higher = overdamped / snappy
* maxVelocity → caps initial lurch when target is far away
*
* A single SpringScroller instance per container avoids fighting rAF loops
* when the target changes before the previous animation finishes — calling
* scrollTo() mid-flight just updates the target and the running loop picks it up.
*/
export class SpringScroller {
private container : HTMLElement;
private target = 0;
private velocity = 0;
private rafId: number | null = null;
private readonly stiffness : number;
private readonly damping : number;
private readonly maxVelocity: number;
constructor(
container : HTMLElement,
stiffness = 0.065, // gentle pull
damping = 0.84, // smooth settle, no oscillation
maxVelocity = 28, // px/frame cap — prevents jarring lurch on large jumps
) {
this.container = container;
this.target = container.scrollTop;
this.stiffness = stiffness;
this.damping = damping;
this.maxVelocity = maxVelocity;
}
scrollTo(targetY: number) {
this.target = Math.max(0, targetY);
if (this.rafId === null) this.tick();
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.velocity = 0;
}
/** Teleport without animation (e.g. on track reset). */
jump(y: number) {
this.stop();
this.target = y;
this.container.scrollTop = y;
}
private tick = () => {
const pos = this.container.scrollTop;
const delta = this.target - pos;
let v = (this.velocity + delta * this.stiffness) * this.damping;
// Cap velocity so large distances don't start with a hard jerk.
if (v > this.maxVelocity) v = this.maxVelocity;
if (v < -this.maxVelocity) v = -this.maxVelocity;
this.velocity = v;
this.container.scrollTop += v;
const settled = Math.abs(v) < 0.12 && Math.abs(delta) < 0.5;
if (settled) {
this.container.scrollTop = this.target;
this.rafId = null;
this.velocity = 0;
} else {
this.rafId = requestAnimationFrame(this.tick);
}
};
}
/**
* Convenience: compute the scroll position that places `el` at `fraction`
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style).
*/
export function targetForFraction(
container: HTMLElement,
el : HTMLElement,
fraction = 0.35,
): number {
const cRect = container.getBoundingClientRect();
const eRect = el.getBoundingClientRect();
return container.scrollTop + (eRect.top - cRect.top) - cRect.height * fraction + eRect.height / 2;
}