mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
97 lines
2.9 KiB
TypeScript
97 lines
2.9 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|