export const THROW_MIN_DISTANCE = 14; export const THROW_GRACE_MS = 24; export const THROW_STALE_MS = 90; export function freshness(idleMs: number): number { if (idleMs <= THROW_GRACE_MS) return 1; return Math.max(0, 1 - (idleMs - THROW_GRACE_MS) / (THROW_STALE_MS - THROW_GRACE_MS)); } export type Vec = { x: number; y: number }; export function createVelocityTracker() { let last: { x: number; y: number; t: number } | null = null; let vx = 0; let vy = 0; return { reset(x: number, y: number) { last = { x, y, t: performance.now() }; vx = 0; vy = 0; }, sample(x: number, y: number) { const now = performance.now(); if (last) { const dt = now - last.t; if (dt > 0) { vx = vx * 0.7 + ((x - last.x) / dt) * 0.3; vy = vy * 0.7 + ((y - last.y) / dt) * 0.3; } } last = { x, y, t: now }; }, release(): Vec { const idle = last ? performance.now() - last.t : Infinity; const f = freshness(idle); return { x: vx * f, y: vy * f }; }, }; } export function runInertia( velocity: Vec, onStep: (dx: number, dy: number) => void, opts: { minSpeed?: number; stopSpeed?: number; decayPerFrame?: number } = {}, ): () => void { const minSpeed = opts.minSpeed ?? 0.04; const stopSpeed = opts.stopSpeed ?? 0.012; const decayPerFrame = opts.decayPerFrame ?? 0.94; let { x: vx, y: vy } = velocity; if (Math.hypot(vx, vy) < minSpeed) return () => { /* nothing started */ }; let raf = 0; let last = performance.now(); const tick = (now: number) => { const dt = Math.min(40, now - last); last = now; onStep(vx * dt, vy * dt); const decay = Math.pow(decayPerFrame, dt / 16); vx *= decay; vy *= decay; if (Math.hypot(vx, vy) > stopSpeed) { raf = requestAnimationFrame(tick); } else { raf = 0; } }; raf = requestAnimationFrame(tick); return () => { if (raf) cancelAnimationFrame(raf); }; }