diff --git a/web/src/lib/motion/value.svelte.ts b/web/src/lib/motion/value.svelte.ts new file mode 100644 index 000000000..4c5afc38d --- /dev/null +++ b/web/src/lib/motion/value.svelte.ts @@ -0,0 +1,90 @@ +export interface SpringConfig { + stiffness: number; + damping: number; + mass: number; +} + +export const CELL: SpringConfig = { stiffness: 2080, damping: 68, mass: 0.45 }; +export const HOME: SpringConfig = { stiffness: 600, damping: 54, mass: 1 }; +export const VEIL: SpringConfig = { stiffness: 1040, damping: 68, mass: 0.8 }; + +const SAMPLE = 0.005; + +const solve = (config: SpringConfig, x0: number, v0: number): ((t: number) => number) => { + const omega = Math.sqrt(config.stiffness / config.mass); + const zeta = config.damping / (2 * Math.sqrt(config.stiffness * config.mass)); + + if (Math.abs(zeta - 1) < 1e-6) + return (t) => Math.exp(-omega * t) * (x0 + (v0 + omega * x0) * t); + + if (zeta < 1) { + const wd = omega * Math.sqrt(1 - zeta * zeta); + return (t) => + Math.exp(-zeta * omega * t) * + (x0 * Math.cos(wd * t) + ((v0 + zeta * omega * x0) / wd) * Math.sin(wd * t)); + } + + const spread = omega * Math.sqrt(zeta * zeta - 1); + const fast = -zeta * omega + spread; + const slow = -zeta * omega - spread; + const a = (v0 - slow * x0) / (fast - slow); + const b = x0 - a; + return (t) => a * Math.exp(fast * t) + b * Math.exp(slow * t); +}; + +export class Motion { + current = $state(0); + #velocity = 0; + #frame: number | null = null; + + constructor(initial: number) { + this.current = initial; + } + + stop(): void { + if (this.#frame === null) return; + cancelAnimationFrame(this.#frame); + this.#frame = null; + } + + set(value: number): void { + this.stop(); + this.#velocity = 0; + this.current = value; + } + + animate(target: number, config: SpringConfig, done?: () => void): void { + this.stop(); + + const x0 = this.current - target; + if (x0 === 0 && this.#velocity === 0) { + done?.(); + return; + } + + const at = solve(config, x0, this.#velocity); + const restDelta = Math.max(Math.abs(x0), 1e-3) * 1e-3; + const restSpeed = restDelta * 30; + const start = performance.now(); + + const step = (now: number): void => { + const t = (now - start) / 1000; + const x = at(t); + const velocity = (at(t + SAMPLE) - x) / SAMPLE; + + if (Math.abs(x) < restDelta && Math.abs(velocity) < restSpeed) { + this.#frame = null; + this.#velocity = 0; + this.current = target; + done?.(); + return; + } + + this.#velocity = velocity; + this.current = target + x; + this.#frame = requestAnimationFrame(step); + }; + + this.#frame = requestAnimationFrame(step); + } +}