motion-dom
Version:
398 lines (395 loc) • 17.4 kB
JavaScript
import { secondsToMilliseconds, warning, clamp, millisecondsToSeconds } from 'motion-utils';
import { generateLinearEasing } from '../waapi/utils/linear.mjs';
import { calcGeneratorDuration, maxGeneratorDuration } from './utils/calc-duration.mjs';
import { createGeneratorEasing } from './utils/create-generator-easing.mjs';
// Default spring physics
const defaultStiffness = 100;
const defaultDamping = 10;
const defaultMass = 1.0;
// Default duration/bounce-based options
const defaultDuration = 800; // in ms
const defaultBounce = 0.3;
const defaultVisualDuration = 0.3; // in seconds
// Rest thresholds
const restSpeedGranular = 0.01;
const restSpeedDefault = 2;
const restDeltaGranular = 0.005;
const restDeltaDefault = 0.5;
// Limits
const minDuration = 0.01; // in seconds
const maxDuration = 10.0; // in seconds
const minDamping = 0.05;
/**
* Maps bounce to a damping ratio as SwiftUI does: 0 is critically damped, and
* it approaches undamped towards 1 and infinitely overdamped towards -1. Both
* ends are limited so the spring still moves and settles.
*/
const bounceToDampingRatio = (bounce) => bounce < 0
? 1 / Math.max(1 + bounce, minDamping)
: Math.max(1 - bounce, minDamping);
/**
* Scales an overdamped spring's undamped frequency so that, at its defining
* time, it has as far left to go as the critically damped spring that's
* criticalFreqTime radians in by then. A negative bounce then changes the
* shape of the curve, not when it arrives. 1 at a damping ratio of 1, so
* there's no jump at a bounce of 0.
*
* Solved by Newton on the remaining distance (times fast - slow), which is
* convex from criticalFreqTime onwards. criticalFreqTime is always below the
* answer, so it converges from below without overshooting.
*/
function overdampedFreqScale(dampingRatio, criticalFreqTime) {
if (!(dampingRatio > 1))
return 1;
const root = Math.sqrt(dampingRatio * dampingRatio - 1);
const slow = dampingRatio - root;
const fast = dampingRatio + root;
const target = 2 * root * Math.exp(-criticalFreqTime) * (1 + criticalFreqTime);
return (approximateRoot((freqTime) => fast * Math.exp(-slow * freqTime) -
slow * Math.exp(-fast * freqTime) -
target, (freqTime) => Math.exp(-fast * freqTime) - Math.exp(-slow * freqTime), criticalFreqTime) / criticalFreqTime);
}
function calcAngularFreq(undampedFreq, dampingRatio) {
return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio);
}
const rootIterations = 12;
function approximateRoot(envelope, derivative, initialGuess) {
let result = initialGuess;
for (let i = 1; i < rootIterations; i++) {
result = result - envelope(result) / derivative(result);
}
return result;
}
/**
* This is ported from the Framer implementation of duration-based spring resolution.
*/
const safeMin = 0.001;
/**
* Assumes zero initial velocity and the default mass: duration springs start
* from rest so they end exactly at their duration. Only resolves without
* valid physics. Returns NaN physics when the root search doesn't converge.
*/
function findSpring({ duration = defaultDuration, bounce = defaultBounce, }) {
let envelope;
let derivative;
process.env.NODE_ENV !== "production" &&
warning(duration <= secondsToMilliseconds(maxDuration), "Spring duration must be 10 seconds or less", "spring-duration-limit");
const dampingRatio = bounceToDampingRatio(bounce);
duration = clamp(minDuration, maxDuration, millisecondsToSeconds(duration));
if (dampingRatio < 1) {
/**
* Underdamped spring
*/
envelope = (undampedFreq) => {
const exponentialDecay = undampedFreq * dampingRatio;
const delta = exponentialDecay * duration;
const b = calcAngularFreq(undampedFreq, dampingRatio);
const c = Math.exp(-delta);
return safeMin - (exponentialDecay / b) * c;
};
derivative = (undampedFreq) => {
const exponentialDecay = undampedFreq * dampingRatio;
const delta = exponentialDecay * duration;
const e = dampingRatio *
dampingRatio *
undampedFreq *
undampedFreq *
duration;
const f = Math.exp(-delta);
const g = calcAngularFreq(undampedFreq * undampedFreq, dampingRatio);
const factor = -envelope(undampedFreq) + safeMin > 0 ? -1 : 1;
return (factor * -e * f) / g;
};
}
else {
/**
* Critically-damped spring. Overdamped springs reuse this root,
* scaled below.
*/
envelope = (undampedFreq) => {
const a = Math.exp(-undampedFreq * duration);
const b = undampedFreq * duration + 1;
return -safeMin + a * b;
};
derivative = (undampedFreq) => {
const a = Math.exp(-undampedFreq * duration);
const b = -undampedFreq * (duration * duration);
return a * b;
};
}
const initialGuess = 5 / duration;
const criticalFreq = approximateRoot(envelope, derivative, initialGuess);
const undampedFreq = criticalFreq *
overdampedFreqScale(dampingRatio, criticalFreq * duration);
const stiffness = undampedFreq * undampedFreq;
return {
stiffness,
damping: dampingRatio * 2 * Math.sqrt(stiffness),
duration: secondsToMilliseconds(duration),
};
}
/**
* Spring physics must be finite. stiffness and mass are also divisors so must
* be positive, whereas a damping of 0 is a valid, perpetually oscillating
* spring. Relational rather than Number.isFinite so numeric strings still
* coerce.
*/
const isValidPhysics = (value, canBeZero) => (canBeZero ? value >= 0 : value > 0) && value < Infinity;
/**
* Returns value if it's usable spring physics, otherwise undefined so callers
* fall back to the default. An explicit `undefined`, e.g. from a forwarded
* optional prop, must fall back too. Invalid physics would otherwise resolve
* to NaN spring values that never report done.
*/
function resolvePhysics(value, canBeZero) {
if (isValidPhysics(value, canBeZero))
return value;
if (process.env.NODE_ENV !== "production") {
warning(value === undefined || value === "auto", "Spring stiffness and mass must be positive, damping 0 or greater", "spring-invalid-physics");
}
return undefined;
}
function getSpringOptions(options) {
/**
* Resolve physics before choosing between physics- and duration-based
* resolution, so an invalid stiffness doesn't also silently discard a
* valid duration/bounce.
*/
const validStiffness = resolvePhysics(options.stiffness);
const validDamping = resolvePhysics(options.damping, true);
const validMass = resolvePhysics(options.mass);
const springOptions = {
...options,
stiffness: validStiffness ?? defaultStiffness,
damping: validDamping ?? defaultDamping,
mass: validMass ?? defaultMass,
isResolvedFromDuration: false,
// stiffness/damping/mass overrides duration/bounce
isTimeDefined: (validStiffness ?? validDamping ?? validMass) === undefined &&
(options.duration !== undefined || options.bounce !== undefined),
};
if (springOptions.isTimeDefined) {
if (options.visualDuration) {
const dampingRatio = bounceToDampingRatio(options.bounce || 0);
const root = ((2 * Math.PI) / (options.visualDuration * 1.2)) *
overdampedFreqScale(dampingRatio, (2 * Math.PI) / 1.2);
springOptions.stiffness = root * root;
springOptions.damping =
2 * dampingRatio * Math.sqrt(springOptions.stiffness);
}
else {
Object.assign(springOptions, findSpring(springOptions));
springOptions.isResolvedFromDuration = true;
}
/**
* Time options can degenerate: a NaN bounce gives a NaN damping, an
* infinite visualDuration a 0 stiffness, and findSpring NaN for both
* when it doesn't converge. Replace the two together, so the
* relationship duration resolution establishes between them is never
* left half-overwritten.
*/
if (!isValidPhysics(springOptions.stiffness) ||
!isValidPhysics(springOptions.damping, true)) {
springOptions.stiffness = defaultStiffness;
springOptions.damping = defaultDamping;
}
}
return springOptions;
}
function springGenerator(optionsOrVisualDuration = defaultVisualDuration, bounce = defaultBounce) {
const options = typeof optionsOrVisualDuration !== "object"
? {
visualDuration: optionsOrVisualDuration,
keyframes: [0, 1],
bounce,
}
: optionsOrVisualDuration;
const origin = options.keyframes[0];
const target = options.keyframes[options.keyframes.length - 1];
/**
* This is the Iterator-spec return value. We ensure it's mutable rather than using a generator
* to reduce GC during animation.
*/
const state = { done: false, value: origin };
const { stiffness, damping, mass, duration, isResolvedFromDuration, isTimeDefined, } = getSpringOptions({ ...options });
/**
* A duration spring must end exactly at its duration, and it's settled
* from rest by then. Inherited velocity could leave it still moving, so
* it starts from rest. Every other spring, visualDuration included, ends
* when it comes to rest, so inherits velocity as its initial condition.
*/
const inheritVelocity = (velocity) => isResolvedFromDuration ? 0 : -millisecondsToSeconds(velocity);
const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass));
const undampedAngularFreq = millisecondsToSeconds(Math.sqrt(stiffness / mass));
const decay = dampingRatio * undampedAngularFreq;
/**
* Everything that changes when the spring is retargeted: written by
* retarget() and update(), read by the resolvers. Grouped on one object,
* like the coefficients (c) below. Writing doubles to object fields
* measured marginally faster than to captured let variables in optimised
* code; neither allocates, so this is a grouping choice, not a GC one.
*/
const s = {
target,
delta: target - origin,
velocity: inheritVelocity(options.velocity) || 0,
restSpeed: 0,
restDelta: 0,
};
/**
* If we're working on a granular scale, use smaller defaults for determining
* when the spring is finished.
*
* These defaults have been selected empirically based on what strikes a good
* ratio between feeling good and finishing as soon as changes are imperceptible.
*/
const setRestThresholds = () => {
const isGranularScale = Math.abs(s.delta) < 5;
s.restSpeed =
options.restSpeed ||
(isGranularScale ? restSpeedGranular : restSpeedDefault);
s.restDelta =
options.restDelta ||
(isGranularScale ? restDeltaGranular : restDeltaDefault);
};
setRestThresholds();
let resolveSpring;
let resolveVelocity;
/**
* Derives the coefficients that depend on origin, target and initial
* velocity. Called once now and again whenever the spring is retargeted.
*/
let update;
if (dampingRatio < 1) {
const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio);
/**
* A is the position coefficient, sinC/cosC the coefficients of the
* analytical derivative (px/ms). The exp/sin/cos terms depend only
* on t, so they're memoized by t independently of the target: a
* spring retargeted every frame samples the same t each frame and
* skips the transcendentals entirely.
*/
const c = { A: 0, sinC: 0, cosC: 0, t: -1, env: 0, sin: 0, cos: 0 };
update = () => {
c.A = (s.velocity + decay * s.delta) / angularFreq;
c.sinC = decay * c.A + s.delta * angularFreq;
c.cosC = decay * s.delta - c.A * angularFreq;
};
const sample = (t) => {
if (t !== c.t) {
c.t = t;
c.env = Math.exp(-decay * t);
c.sin = Math.sin(angularFreq * t);
c.cos = Math.cos(angularFreq * t);
}
};
// Underdamped spring
resolveSpring = (t) => {
sample(t);
return s.target - c.env * (c.A * c.sin + s.delta * c.cos);
};
resolveVelocity = (t) => {
sample(t);
return c.env * (c.sinC * c.sin + c.cosC * c.cos);
};
}
else if (dampingRatio === 1) {
// Critically damped spring
resolveSpring = (t) => s.target -
Math.exp(-undampedAngularFreq * t) *
(s.delta + (s.velocity + undampedAngularFreq * s.delta) * t);
// Analytical derivative of critically damped spring (px/ms)
const c = { C: 0 };
update = () => {
c.C = s.velocity + undampedAngularFreq * s.delta;
};
resolveVelocity = (t) => Math.exp(-undampedAngularFreq * t) *
(undampedAngularFreq * c.C * t - s.velocity);
}
else {
/**
* Overdamped spring: the sum of a slow and a fast decaying
* exponential, so no term can overflow however heavily it's damped.
*/
const dampedAngularFreq = undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1);
const slow = decay - dampedAngularFreq;
const fast = decay + dampedAngularFreq;
/**
* Physics-defined springs keep a non-physical limit: once
* dampedAngularFreq * t passes 300, both terms decay at the damping
* rate. Heavily overdamped drag springs (dragElastic: 0,
* dragTransition) rely on this to settle almost at once rather than
* creep at stiffness / damping. It only applies when reached within
* 300ms (a dampedAngularFreq of 1 rad/ms), so it reads as a stiff
* spring snapping into place: any later and it cuts off a slow spring
* that's visibly still moving (#1207). Negative bounce needs the
* exact curve, so time-defined springs are exempt.
*/
const limit = dampedAngularFreq < 1 || isTimeDefined
? Infinity
: 300 / dampedAngularFreq;
const decayAt = (rate, t) => Math.exp(t > limit ? (decay - rate) * limit - decay * t : -rate * t);
const c = { S: 0, F: 0 };
update = () => {
const P = (s.velocity + decay * s.delta) / dampedAngularFreq;
c.S = (s.delta + P) / 2;
c.F = (s.delta - P) / 2;
};
resolveSpring = (t) => s.target - c.S * decayAt(slow, t) - c.F * decayAt(fast, t);
// Analytical derivative of overdamped spring (px/ms)
resolveVelocity = (t) => slow * c.S * decayAt(slow, t) + fast * c.F * decayAt(fast, t);
}
update();
const calculatedDuration = isResolvedFromDuration ? duration || null : null;
const generator = {
calculatedDuration,
/**
* Aim the spring at a new target from its current position and
* velocity, reusing the resolved physics and closures.
*/
retarget: (keyframes, newVelocity) => {
s.target = keyframes[keyframes.length - 1];
s.delta = s.target - keyframes[0];
s.velocity = inheritVelocity(newVelocity);
// Default thresholds depend on the scale of the new delta
if (!(options.restSpeed && options.restDelta))
setRestThresholds();
// Invalidate any duration lazily cached by JSAnimation
generator.calculatedDuration = calculatedDuration;
state.done = false;
update();
},
velocity: (t) => secondsToMilliseconds(resolveVelocity(t)),
next: (t) => {
const current = resolveSpring(t);
if (!isResolvedFromDuration) {
const currentVelocity = secondsToMilliseconds(resolveVelocity(t));
state.done =
Math.abs(currentVelocity) <= s.restSpeed &&
Math.abs(s.target - current) <= s.restDelta;
}
else {
state.done = t >= duration;
}
state.value = state.done ? s.target : current;
return state;
},
toString: () => {
const easingDuration = Math.min(calcGeneratorDuration(generator), maxGeneratorDuration);
const easing = generateLinearEasing((progress) => generator.next(easingDuration * progress).value, easingDuration, 30);
return easingDuration + "ms " + easing;
},
};
return generator;
}
const spring = /* @__PURE__ */ Object.assign(springGenerator, {
applyToOptions: (options) => {
const generatorOptions = createGeneratorEasing(options, 100, springGenerator);
options.ease = generatorOptions.ease;
options.duration = secondsToMilliseconds(generatorOptions.duration);
options.type = "keyframes";
return options;
},
});
export { getSpringOptions, spring };
//# sourceMappingURL=spring.mjs.map