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
text/typescript
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();
}
}