UNPKG

vevet

Version:

Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.

354 lines (268 loc) 8.89 kB
import { Raf } from '@/components/Raf'; import { isFiniteNumber } from '@/internal/isFiniteNumber'; import { clamp, lerp } from '@/utils'; import { Swipe } from '..'; import { SwipeCoords } from '../Coords'; import { ISwipeState, ISwipeVec3 } from '../global'; const IDLE_VEC3 = { x: 0, y: 0, angle: 0 }; const IDLE_STATE = { ...IDLE_VEC3, time: 0 }; const LERP_APPROX = 0.01; const BELOW_THRESHOLD = 0.1; interface IProps { props: typeof Swipe.prototype.props; coords: SwipeCoords; onStart: () => void; onFail: () => void; onCancel: () => void; onEnd: () => void; } export class SwipeInertia { constructor(private ctx: IProps) {} private _raf?: Raf; private _velocity: ISwipeState = { ...IDLE_STATE }; private _initialVelocity: ISwipeState = { ...IDLE_STATE }; private _modifiedDistance?: ISwipeVec3 | null; private _saveRawMovement: ISwipeVec3 = { ...IDLE_VEC3 }; private _rawMovement: ISwipeVec3 = { ...IDLE_VEC3 }; private _saveStep: ISwipeState = { ...IDLE_STATE }; private _saveCurrent: ISwipeState = { ...IDLE_STATE }; private _onUpdate?: (state: ISwipeVec3) => void; /** Check if inertia is active */ get has() { return !!this._raf; } /** Apply inertia-based movement */ public release(onUpdate: (state: ISwipeVec3) => void) { const { ctx } = this; const { props } = ctx; this._modifiedDistance = undefined; this._saveCurrent = { ...ctx.coords.current }; this._saveStep = { ...ctx.coords.step }; this._saveRawMovement = { ...ctx.coords.rawMovement }; this._rawMovement = { ...ctx.coords.rawMovement }; const data = this._calcVelocity(); if (!data || !isFiniteNumber(data.dt) || data.dt <= 0) { ctx.onFail(); return false; } const { linearSpeed, angularSpeed, vx, vy, va, threshold } = data; if ( !isFiniteNumber(linearSpeed) || !isFiniteNumber(angularSpeed) || (linearSpeed < threshold && angularSpeed < threshold) ) { ctx.onFail(); return false; } this._onUpdate = onUpdate; this._velocity = { x: vx, y: vy, angle: va, time: performance.now() }; this._initialVelocity = { ...this._velocity }; if (props.inertiaDistanceModifier) { this._modifiedDistance = props.inertiaDistanceModifier({ x: this._predictDistance(vx, props.inertiaDecay), y: this._predictDistance(vy, props.inertiaDecay), angle: this._predictDistance(va, props.inertiaDecay), }); } this._raf = new Raf({ enabled: true, onFrame: this._handleRaf.bind(this), }); this.ctx.onStart(); return true; } /** Calculate velocity */ private _calcVelocity() { const { _saveCurrent: current, _saveStep: step } = this; const { inertiaRatio, ratio, maxVelocity, ...props } = this.ctx.props; if (!current || !step) { return null; } const gap = performance.now() - current.time; const dt = Math.max(step.time, gap, 1); const iRatio = isFiniteNumber(inertiaRatio) ? inertiaRatio : 1; const sRatio = isFiniteNumber(ratio) ? ratio : 1; const finalRatio = sRatio * iRatio; const maxVX = maxVelocity.x ? Math.abs(maxVelocity.x) : 0; let vx = (step.x / dt) * finalRatio; vx = clamp(vx, -maxVX, maxVX); const maxVY = maxVelocity.y ? Math.abs(maxVelocity.y) : 0; let vy = (step.y / dt) * finalRatio; vy = clamp(vy, -maxVY, maxVY); const maxVA = maxVelocity.angle ? Math.abs(maxVelocity.angle) : 0; let va = (step.angle / dt) * finalRatio; va = clamp(va, -maxVA, maxVA); const linearSpeed = Math.hypot(vx, vy) * 1000; const angularSpeed = Math.abs(va) * 1000; const threshold = props.inertiaThreshold; return { dt, vx, vy, va, linearSpeed, angularSpeed, threshold }; } /** Handle RAF update */ private _handleRaf() { if (!this._raf) { return; } const { _raf: raf } = this; const duration = this._raf.duration; const { coords, props } = this.ctx; const { _velocity: velocity, _saveCurrent: startCurrent, _saveRawMovement: startRawMovement, _rawMovement: rawMovement, _modifiedDistance: distance, _initialVelocity: initial, } = this; const frameMs = duration; // Delta const dx = velocity.x * frameMs; const dy = velocity.y * frameMs; const dAngle = velocity.angle * frameMs; // Friction const frictionEase = raf.lerpFactor(props.inertiaDecay); velocity.x = lerp(velocity.x, 0, frictionEase); velocity.y = lerp(velocity.y, 0, frictionEase); velocity.angle = lerp(velocity.angle, 0, frictionEase); // Movement if (distance) { const xP = this._getVelocityProgress(velocity.x, initial.x); const yP = this._getVelocityProgress(velocity.y, initial.y); const aP = this._getVelocityProgress(velocity.angle, initial.angle); rawMovement.x = startRawMovement.x + distance.x * xP; rawMovement.y = startRawMovement.y + distance.y * yP; rawMovement.angle = startRawMovement.angle + distance.angle * aP; } else { rawMovement.x += dx; rawMovement.y += dy; rawMovement.angle += dAngle; } // Bounce let isBouncing = false; const rawBounceEase = props.inertiaBounceEase; const bounceEase = rawBounceEase >= 1 ? 1 : raf.lerpFactor(rawBounceEase); // Bounce within bounds const { bounds } = coords; if (bounds?.x) { const bx = this._applyAxisBounce( 'x', rawMovement.x, velocity.x, bounds.x, bounceEase, ); rawMovement.x = bx.value; velocity.x = bx.velocity; isBouncing = 'bounceFinished' in bx ? true : isBouncing; } if (bounds?.y) { const by = this._applyAxisBounce( 'y', rawMovement.y, velocity.y, bounds.y, bounceEase, ); rawMovement.y = by.value; velocity.y = by.velocity; isBouncing = 'bounceFinished' in by ? true : isBouncing; } if (bounds?.angle) { const ba = this._applyAxisBounce( 'angle', rawMovement.angle, velocity.angle, bounds.angle, bounceEase, ); rawMovement.angle = ba.value; velocity.angle = ba.velocity; isBouncing = 'bounceFinished' in ba ? true : isBouncing; } // Callbacks const totalX = rawMovement.x - startRawMovement.x; const totalY = rawMovement.y - startRawMovement.y; const totalA = rawMovement.angle - startRawMovement.angle; const x = startCurrent.x + totalX; const y = startCurrent.y + totalY; const angle = startCurrent.angle + totalA; this._onUpdate?.({ x, y, angle }); // Stop const linearStep = Math.hypot(dx, dy); const angularStep = Math.abs(dAngle); let shouldStop = linearStep < BELOW_THRESHOLD && angularStep < BELOW_THRESHOLD; if (distance) { shouldStop = Math.abs(totalX - distance.x) < LERP_APPROX && Math.abs(totalY - distance.y) < LERP_APPROX && Math.abs(totalA - distance.angle) < LERP_APPROX; } if (!isBouncing && shouldStop) { this.ctx.onEnd(); this._clear(); } } /** Calculate velocity progress */ private _getVelocityProgress(v: number, initial: number) { if (Math.abs(initial) < BELOW_THRESHOLD) { return 1; } const p = 1 - Math.abs(v / initial); if (Math.abs(1 - p) < LERP_APPROX / 10) { return 1; } return p; } private _predictDistance( velocity: number, decay: number, frameMs = 1000 / 60, ) { const k = (decay * 60) / 1000; const r = Math.exp(-k * frameMs); return (velocity * frameMs) / (1 - r); } /** Apply exponential axis bounce overflow */ private _applyAxisBounce( axis: 'x' | 'y' | 'angle', value: number, velocity: number, bounds: number[], ease: number, ) { if (!bounds.length) { return { value, velocity }; } const snappy = this.ctx.coords.snap[axis]; const lo = typeof snappy === 'number' ? snappy : Math.min(...bounds); const hi = typeof snappy === 'number' ? snappy : Math.max(...bounds); if (value < lo || value > hi) { const target = clamp(value, lo, hi); const val = lerp(value, target, ease, LERP_APPROX); const vel = lerp(velocity, 0, ease, LERP_APPROX); return { value: val, velocity: vel, bounceFinished: val === target && vel === 0, }; } return { value, velocity }; } /** Clear data and stop animation */ private _clear() { this._raf?.destroy(); this._raf = undefined; this._velocity = { ...IDLE_STATE }; } /** Stop inertia animation */ public cancel() { if (this._raf) { this.ctx.onCancel(); } this._clear(); } /** Destroy instance */ public destroy() { this._clear(); } }