From 28e0752c8011184c8db7d9a67826b258224be9c6 Mon Sep 17 00:00:00 2001 From: Etienne Deladonchamps Date: Wed, 25 Jan 2023 02:07:33 +0100 Subject: [PATCH] Refacto SpringSequence and add stable --- deno_dist/Spring.ts | 142 ++++++----- deno_dist/SpringConfig.ts | 32 +-- deno_dist/SpringSequence.ts | 475 +++++++++++++++++++----------------- deno_dist/mod.ts | 8 +- deno_dist/utils.ts | 13 +- src/Spring.ts | 142 ++++++----- src/SpringConfig.ts | 32 +-- src/SpringSequence.ts | 475 +++++++++++++++++++----------------- src/mod.ts | 8 +- src/utils.ts | 13 +- tests/utils.ts | 4 +- 11 files changed, 714 insertions(+), 630 deletions(-) diff --git a/deno_dist/Spring.ts b/deno_dist/Spring.ts index 6d0af84..b06e5e1 100644 --- a/deno_dist/Spring.ts +++ b/deno_dist/Spring.ts @@ -1,5 +1,5 @@ -import { invariant, normalizeT, makeSpringFn, toPrecision } from './utils.ts'; -import { SpringConfig } from './SpringConfig.ts'; +import { invariant, normalizeT, makeSpringFn, toPrecision, isStable } from './utils.ts'; +import { type ISpringConfig, SpringConfig } from './SpringConfig.ts'; export interface SpringResult { position: number; @@ -10,10 +10,13 @@ export interface SpringFn { (t: number): SpringResult; readonly position: (t: number) => number; readonly velocity: (t: number) => number; - readonly config: Readonly>; + // returns true if the spring is stable at time t + // i.e. position === equilibrium && velocity === 0 + readonly stable: (t: number) => boolean; + readonly config: Readonly>; } -export function Spring(config: Partial = {}): SpringFn { +export function Spring(config: Partial = {}): SpringFn { const conf = SpringConfig.defaults(config); invariant(conf.dampingRatio >= 0, 'Damping Ration must be >= 0'); @@ -25,7 +28,7 @@ export function Spring(config: Partial = {}): SpringFn { if (conf.angularFrequency <= conf.dampingRatioPrecision) { return springIdentity(conf.position, conf.velocity, config); } - if (isStable(conf.position, conf.velocity, conf)) { + if (isStable(conf, conf)) { return springIdentity(conf.position, conf.velocity, config); } if (conf.dampingRatio > 1 + conf.dampingRatioPrecision) { @@ -41,21 +44,18 @@ export function Spring(config: Partial = {}): SpringFn { return springCriticallyDamped(conf, config); } -function isStable(position: number, velocity: number, conf: SpringConfig): boolean { - return Math.abs(position - conf.equilibrium) <= conf.positionPrecision && Math.abs(velocity) <= conf.velocityPrecision; -} - -function springIdentity(position: number, velocity: number, originalConf: Partial): SpringFn { +function springIdentity(position: number, velocity: number, originalConf: Partial): SpringFn { const identity = { position, velocity }; return makeSpringFn( originalConf, () => identity, () => position, - () => velocity + () => velocity, + () => true ); } -function springOverDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springOverDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const za = -conf.angularFrequency * conf.dampingRatio; const zb = conf.angularFrequency * Math.sqrt(conf.dampingRatio * conf.dampingRatio - 1); const z1 = za - zb; @@ -63,25 +63,27 @@ function springOverDamped(conf: SpringConfig, originalConf: Partial { + const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); + return { + position: springOverDampedPosition( + conf.positionPrecision, + conf.equilibrium, + posDiff, + e1_Over_TwoZb, + z2, + z2e2_Over_TwoZb, + e2, + conf.velocity, + e2_Over_TwoZb + ), + velocity: springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); - return { - position: springOverDampedPosition( - conf.positionPrecision, - conf.equilibrium, - posDiff, - e1_Over_TwoZb, - z2, - z2e2_Over_TwoZb, - e2, - conf.velocity, - e2_Over_TwoZb - ), - velocity: springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity), - }; - }, + main, (t) => { const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); return springOverDampedPosition( @@ -99,7 +101,8 @@ function springOverDamped(conf: SpringConfig, originalConf: Partial { const [e2, e1_Over_TwoZb, _e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); return springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity); - } + }, + (t) => isStable(main(t), conf) ); } @@ -143,38 +146,40 @@ function springOverDampedVelocity( return toPrecision(posDiff * ((z1e1_Over_TwoZb - z2e2_Over_TwoZb + e2) * z2) + vel * (-z1e1_Over_TwoZb + z2e2_Over_TwoZb), precision); } -function springUnderDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springUnderDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const omegaZeta = conf.angularFrequency * conf.dampingRatio; const alpha = conf.angularFrequency * Math.sqrt(1 - conf.dampingRatio * conf.dampingRatio); const posDiff = conf.position - conf.equilibrium; + const main = (t: number) => { + const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); + return { + position: springUnderDampedPosition( + conf.positionPrecision, + conf.equilibrium, + posDiff, + expCos, + expOmegaZetaSin_Over_Alpha, + conf.velocity, + expSin, + invAlpha + ), + velocity: springUnderDampedVelocity( + conf.velocityPrecision, + posDiff, + expSin, + alpha, + omegaZeta, + expOmegaZetaSin_Over_Alpha, + conf.velocity, + expCos + ), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); - return { - position: springUnderDampedPosition( - conf.positionPrecision, - conf.equilibrium, - posDiff, - expCos, - expOmegaZetaSin_Over_Alpha, - conf.velocity, - expSin, - invAlpha - ), - velocity: springUnderDampedVelocity( - conf.velocityPrecision, - posDiff, - expSin, - alpha, - omegaZeta, - expOmegaZetaSin_Over_Alpha, - conf.velocity, - expCos - ), - }; - }, + main, (t) => { const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); return springUnderDampedPosition( @@ -191,7 +196,8 @@ function springUnderDamped(conf: SpringConfig, originalConf: Partial { const [_invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); return springUnderDampedVelocity(conf.velocityPrecision, posDiff, expSin, alpha, omegaZeta, expOmegaZetaSin_Over_Alpha, conf.velocity, expCos); - } + }, + (t) => isStable(main(t), conf) ); } @@ -238,17 +244,20 @@ function springUnderDampedVelocity( ); } -function springCriticallyDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springCriticallyDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const oldPos = conf.position - conf.equilibrium; // update in equilibrium relative space + + const main = (t: number) => { + const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); + return { + position: springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium), + velocity: springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); - return { - position: springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium), - velocity: springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm), - }; - }, + main, (t) => { const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); return springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium); @@ -256,7 +265,8 @@ function springCriticallyDamped(conf: SpringConfig, originalConf: Partial { const [expTerm, _timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); return springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm); - } + }, + (t) => isStable(main(t), conf) ); } diff --git a/deno_dist/SpringConfig.ts b/deno_dist/SpringConfig.ts index 27fc45f..f18d566 100644 --- a/deno_dist/SpringConfig.ts +++ b/deno_dist/SpringConfig.ts @@ -1,10 +1,4 @@ -// Default to 1000 so time is in milliseconds -export const DEFAULT_TIME_SCALE = 1000; - -// This value ensure a valid binary rounding -export const DEFAULT_PRECISION = 1 / (1 << 14); - -export interface SpringConfig { +export interface ISpringConfig { // initial position position: number; // initial velocity @@ -25,7 +19,13 @@ export interface SpringConfig { dampingRatioPrecision: number; } -const DEFAULT_CONFIG: SpringConfig = { +// Default to 1000 so time is in milliseconds +export const DEFAULT_TIME_SCALE = 1000; + +// This value ensure a valid binary rounding +export const DEFAULT_PRECISION = 1 / (1 << 14); + +export const DEFAULT_CONFIG: ISpringConfig = { position: 0, velocity: 0, equilibrium: 1, @@ -55,14 +55,14 @@ export const SpringConfig = { angularFrequencyFromSpringConstant, }; -function defaults(config: Partial = {}): SpringConfig { +function defaults(config: Partial = {}): ISpringConfig { return { ...DEFAULT_CONFIG, ...config, }; } -function basic(config: Partial = {}): Partial { +function basic(config: Partial = {}): Partial { return { angularFrequency: 1, dampingRatio: 1, @@ -70,7 +70,7 @@ function basic(config: Partial = {}): Partial { }; } -function gentle(config: Partial = {}): Partial { +function gentle(config: Partial = {}): Partial { return { angularFrequency: 0.6, dampingRatio: 0.6, @@ -78,7 +78,7 @@ function gentle(config: Partial = {}): Partial { }; } -function wobbly(config: Partial = {}): Partial { +function wobbly(config: Partial = {}): Partial { return { angularFrequency: 0.8, dampingRatio: 0.4, @@ -86,7 +86,7 @@ function wobbly(config: Partial = {}): Partial { }; } -function stiff(config: Partial = {}): Partial { +function stiff(config: Partial = {}): Partial { return { angularFrequency: 1.1, dampingRatio: 0.7, @@ -94,7 +94,7 @@ function stiff(config: Partial = {}): Partial { }; } -function slow(config: Partial = {}): Partial { +function slow(config: Partial = {}): Partial { return { angularFrequency: 0.5, dampingRatio: 1, @@ -102,7 +102,7 @@ function slow(config: Partial = {}): Partial { }; } -function decay(config: Partial = {}): Partial { +function decay(config: Partial = {}): Partial { const resolved = { ...DEFAULT_CONFIG, ...config, @@ -117,7 +117,7 @@ function decay(config: Partial = {}): Partial { }; } -function stable(equilibrium: number, config: Partial = {}): Partial { +function stable(equilibrium: number, config: Partial = {}): Partial { return { ...config, velocity: 0, diff --git a/deno_dist/SpringSequence.ts b/deno_dist/SpringSequence.ts index 185fd22..37d0c66 100644 --- a/deno_dist/SpringSequence.ts +++ b/deno_dist/SpringSequence.ts @@ -1,255 +1,284 @@ import { Spring, SpringFn, SpringResult } from './Spring.ts'; -import { DEFAULT_TIME_SCALE, SpringConfig } from './SpringConfig.ts'; +import { DEFAULT_TIME_SCALE, ISpringConfig, SpringConfig } from './SpringConfig.ts'; import { makeSpringFn } from './utils.ts'; -type SpringSequenceStep = { time: number; config: Partial; spring: SpringFn | null }; +type SpringSequenceStep = { time: number; config: Partial; spring: SpringFn | null }; -export interface SpringSequenceFn { - (t: number): SpringResult; - readonly position: (t: number) => number; - readonly velocity: (t: number) => number; +export interface SpringSequenceConfig { + timeScale?: number; + defaultConfig?: Partial; + initial?: Partial; } -type SpringSequenceConfig = { - timeScale?: number; - defaultConfig?: Partial; - initial?: Partial; -}; +export interface ISpringSequence { + readonly spring: SpringFn; -export class SpringSequence { - public static create(options: SpringSequenceConfig = {}): SpringSequence { - return new SpringSequence([], options); - } + clone(): ISpringSequence; + setInitial(initial: Partial): ISpringSequence; + setDefaultConfig(config: Partial): ISpringSequence; + setTimeScale(timeScale: number): ISpringSequence; + insertAt(time: number, config: number | Partial): ISpringSequence; + replaceTail(time: number, config: number | Partial): ISpringSequence; + replaceAll(time: number, config: number | Partial): ISpringSequence; + decay(time: number, config?: Partial): ISpringSequence; + clearBefore(time: number): ISpringSequence; + offset(offset: number): ISpringSequence; +} - private readonly steps: Array = []; - private timeScale: number; - private defaultConfig: Partial; - // spring that return initial state at any time - private initialSpring: SpringSequenceFn; +export const SpringSequence = (() => { + return { create }; - public readonly spring: SpringSequenceFn; + function create(options: SpringSequenceConfig = {}): ISpringSequence { + return createInternal([], options); + } - private constructor(steps: Array, { timeScale = DEFAULT_TIME_SCALE, defaultConfig = {}, initial = {} }: SpringSequenceConfig) { - this.steps = steps; - this.timeScale = timeScale; - this.defaultConfig = defaultConfig; - this.initialSpring = createInitialSpring({ position: initial.position ?? 0, velocity: initial.velocity ?? 0 }); - this.spring = makeSpringFn( + function createInternal(steps: Array, config: SpringSequenceConfig): ISpringSequence { + let timeScale: number = config.timeScale ?? DEFAULT_TIME_SCALE; + let defaultConfig: Partial = config.defaultConfig ?? {}; + // const that return initial state at any time + let initialSpring: SpringFn = Spring({ ...defaultConfig, ...resolveInitialConfig(config.initial ?? {}) }); + const spring: SpringFn = makeSpringFn( defaultConfig, - (t) => this.findSpringAt(t)(t), - (t) => this.findSpringAt(t).position(t), - (t) => this.findSpringAt(t).velocity(t) + (t) => findSpringAt(t)(t), + (t) => findSpringAt(t).position(t), + (t) => findSpringAt(t).velocity(t), + (t) => findSpringAt(t).stable(t) ); - } - private readonly findSpringAt = (t: number): SpringSequenceFn => { - const step = this.findMaybeStepAt(t); - if (step) { - return stepSpringOrThrow(step); + const seq: ISpringSequence = { + spring, + clone, + setInitial, + setDefaultConfig, + setTimeScale, + insertAt, + replaceTail, + replaceAll, + decay, + clearBefore, + offset, + }; + + return seq; + + function findSpringAt(t: number): SpringFn { + const step = findMaybeStepAt(t); + if (step) { + return stepSpringOrThrow(step); + } + return initialSpring; } - return this.initialSpring; - }; - - /** - * Return the first step where t is >= to step.time - * Return null if t is before first step or no steps - */ - private readonly findMaybeStepAt = (t: number): SpringSequenceStep | null => { - if (this.steps.length === 0) { - return null; + + /** + * Return the first step where t is >= to step.time + * Return null if t is before first step or no steps + */ + function findMaybeStepAt(t: number): SpringSequenceStep | null { + if (steps.length === 0) { + return null; + } + if (t < steps[0].time) { + // t is before first time + return null; + } + let prev: null | SpringSequenceStep = null; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (t < step.time) { + break; + } + prev = step; + } + return prev; } - if (t < this.steps[0].time) { - // t is before first time - return null; + + /** + * On initial config, if only one of position or equilibrium is defined, + * we set the other one to the same value. + */ + function resolveInitialConfig(initial: Partial): Partial { + if (initial.position === undefined && initial.equilibrium === undefined) { + return { ...initial, position: 0, equilibrium: 0 }; + } + if (initial.position === undefined && initial.equilibrium !== undefined) { + return { ...initial, position: initial.equilibrium }; + } + if (initial.position !== undefined && initial.equilibrium === undefined) { + return { ...initial, equilibrium: initial.position }; + } + return initial; } - let prev: null | SpringSequenceStep = null; - for (let i = 0; i < this.steps.length; i++) { - const step = this.steps[i]; - if (t < step.time) { - break; + + /** + * Update each step starting at the index + */ + function updateFromIndex(index: number): void { + const indexResolved = index < 0 ? 0 : index; + if (indexResolved >= steps.length) { + return; + } + let prev: SpringFn = indexResolved === 0 ? initialSpring : stepSpringOrThrow(steps[index - 1]); + for (let i = index; i < steps.length; i++) { + const step = steps[i]; + const spring = createSpring(step.time, prev(step.time), step.config); + step.spring = spring; + prev = spring; } - prev = step; } - return prev; - }; - - /** - * Update each step starting at the index - */ - private readonly updateFromIndex = (index: number): void => { - const indexResolved = index < 0 ? 0 : index; - if (indexResolved >= this.steps.length) { - return; + + /** + * Create a spring at a certain time using defaultConfig + */ + function createSpring(time: number, current: SpringResult | null, config: number | Partial): SpringFn { + const resolved = { + ...defaultConfig, + ...resolveConfig(config), + }; + const conf: Partial = { + // inject current state (position & velocity) + ...(current ?? {}), + // user config, note that user can override velocity and position by defining them in the config ! + ...resolved, + // Override timeScale by the one defined in the SpringSequence + timeScale: timeScale, + // config.timeStart is used as an offset + timeStart: time + (resolved.timeStart ?? 0), + }; + return Spring(conf); } - let prev: SpringSequenceFn = indexResolved === 0 ? this.initialSpring : stepSpringOrThrow(this.steps[index - 1]); - for (let i = index; i < this.steps.length; i++) { - const step = this.steps[i]; - const spring = this.createSpring(step.time, prev(step.time), step.config); - step.spring = spring; - prev = spring; + + /** + * Create an identical SpringSequence that does not depent on the source (safe to mutate) + */ + function clone(): ISpringSequence { + return createInternal( + steps.map((step) => ({ ...step })), + { timeScale: timeScale, initial: initialSpring(0), defaultConfig: defaultConfig } + ); } - }; - - /** - * Create a spring at a certain time using defaultConfig - */ - private readonly createSpring = (time: number, current: SpringResult | null, config: number | Partial): SpringFn => { - const resolved = { - ...this.defaultConfig, - ...resolveConfig(config), - }; - const conf: Partial = { - // inject current state (position & velocity) - ...(current ?? {}), - // user config, note that user can override velocity and position by defining them in the config ! - ...resolved, - // Override timeScale by the one defined in the SpringSequence - timeScale: this.timeScale, - // config.timeStart is used as an offset - timeStart: time + (resolved.timeStart ?? 0), - }; - return Spring(conf); - }; - - /** - * Create an identical SpringSequence that does not depent on the source (safe to mutate) - */ - public readonly clone = (): SpringSequence => { - return new SpringSequence( - this.steps.map((step) => ({ ...step })), - { timeScale: this.timeScale, initial: this.initialSpring(0), defaultConfig: this.defaultConfig } - ); - }; - - /** - * Change the initial state of the spring. - * This will update all internal springs. - */ - public readonly setInitial = (initial: Partial): this => { - const current = this.initialSpring(0); // could fetch any time since initialSpring return the same value - this.initialSpring = createInitialSpring({ position: initial.position ?? current.position, velocity: initial.velocity ?? current.velocity }); - this.updateFromIndex(0); - return this; - }; - - /** - * Change the default config. This will update all internal springs. - */ - public readonly setDefaultConfig = (config: Partial): this => { - this.defaultConfig = config; - this.updateFromIndex(0); - return this; - }; - - /** - * Change timescale - */ - public readonly setTimeScale = (timeScale: number): this => { - this.timeScale = timeScale; - this.updateFromIndex(0); - return this; - }; - - /** - * Insert a new step at the specified time - */ - public readonly insertAt = (time: number, config: number | Partial): this => { - const step = this.findMaybeStepAt(time); - const newStep: SpringSequenceStep = { time, config: resolveConfig(config), spring: null }; - if (step === null) { - // insert before all other steps - this.steps.unshift(newStep); - this.updateFromIndex(0); - return this; + + /** + * Change the initial state of the spring. + * This will update all internal springs. + */ + function setInitial(initial: Partial): ISpringSequence { + initialSpring = Spring({ ...defaultConfig, ...resolveInitialConfig(initial) }); + updateFromIndex(0); + return seq; } - const stepIndex = this.steps.indexOf(step); - // if time is the same, we replace the step - const deletePrev = step.time === time; - if (deletePrev) { - this.steps.splice(stepIndex, 1, newStep); - this.updateFromIndex(stepIndex); - return this; + + /** + * Change the default config. This will update all internal springs. + */ + function setDefaultConfig(config: Partial): ISpringSequence { + defaultConfig = config; + updateFromIndex(0); + return seq; } - this.steps.splice(stepIndex + 1, 0, newStep); - this.updateFromIndex(stepIndex + 1); - return this; - }; - - /** - * Add a new step and remove any step after it - */ - public readonly replaceTail = (time: number, config: number | Partial): this => { - const step = this.findMaybeStepAt(time); - const stepIndex = step === null ? 0 : this.steps.indexOf(step); - this.steps.splice(stepIndex + 1, this.steps.length - stepIndex, { time, config: resolveConfig(config), spring: null }); - this.updateFromIndex(stepIndex); - return this; - }; - - /** - * Replace all the steps by the new one - */ - public readonly replaceAll = (time: number, config: number | Partial): this => { - this.steps.splice(0, this.steps.length, { time, config: resolveConfig(config), spring: null }); - this.updateFromIndex(0); - return this; - }; - - /** - * Decay at time and remove everything after - */ - public readonly decay = (time: number, config: Partial = {}): this => { - const stateAtTime = this.spring(time); - const decayConf = SpringConfig.decay({ ...stateAtTime, ...config }); - const step = this.findMaybeStepAt(time); - const stepIndex = step === null ? 0 : this.steps.indexOf(step); - this.steps.splice(stepIndex, this.steps.length - stepIndex, { time, config: decayConf, spring: null }); - this.updateFromIndex(stepIndex); - return this; - }; - - /** - * Remove all steps before time - */ - public readonly clearBefore = (time: number): this => { - const step = this.findMaybeStepAt(time); - if (step === null) { - return this; + + /** + * Change timescale + */ + function setTimeScale(newTimeScale: number): ISpringSequence { + timeScale = newTimeScale; + updateFromIndex(0); + return seq; + } + + /** + * Insert a new step at the specified time + */ + function insertAt(time: number, config: number | Partial): ISpringSequence { + const step = findMaybeStepAt(time); + const newStep: SpringSequenceStep = { time, config: resolveConfig(config), spring: null }; + if (step === null) { + // insert before all other steps + steps.unshift(newStep); + updateFromIndex(0); + return seq; + } + const stepIndex = steps.indexOf(step); + // if time is the same, we replace the step + const deletePrev = step.time === time; + if (deletePrev) { + steps.splice(stepIndex, 1, newStep); + updateFromIndex(stepIndex); + return seq; + } + steps.splice(stepIndex + 1, 0, newStep); + updateFromIndex(stepIndex + 1); + return seq; } - const stepIndex = this.steps.indexOf(step); - const stateAtTime = stepSpringOrThrow(step)(time); - this.steps.splice(0, stepIndex + 1); - // this will update steps - this.setInitial(stateAtTime); - return this; - }; - - /** - * Offset sequence by the specified time - */ - public readonly offset = (offset: number): this => { - this.steps.forEach((step) => { - step.time = step.time + offset; - }); - this.updateFromIndex(0); - return this; - }; -} -function resolveConfig(conf: number | Partial): Partial { + /** + * Add a new step and remove any step after it + */ + function replaceTail(time: number, config: number | Partial): ISpringSequence { + const step = findMaybeStepAt(time); + const stepIndex = step === null ? 0 : steps.indexOf(step); + steps.splice(stepIndex + 1, steps.length - stepIndex, { time, config: resolveConfig(config), spring: null }); + updateFromIndex(stepIndex); + return seq; + } + + /** + * Replace all the steps by the new one + */ + function replaceAll(time: number, config: number | Partial): ISpringSequence { + steps.splice(0, steps.length, { time, config: resolveConfig(config), spring: null }); + updateFromIndex(0); + return seq; + } + + /** + * Decay at time and remove everything after + */ + function decay(time: number, config: Partial = {}): ISpringSequence { + const stateAtTime = spring(time); + const decayConf = SpringConfig.decay({ ...stateAtTime, ...config }); + const step = findMaybeStepAt(time); + const stepIndex = step === null ? 0 : steps.indexOf(step); + steps.splice(stepIndex, steps.length - stepIndex, { time, config: decayConf, spring: null }); + updateFromIndex(stepIndex); + return seq; + } + + /** + * Remove all steps before time + */ + function clearBefore(time: number): ISpringSequence { + const step = findMaybeStepAt(time); + if (step === null) { + return seq; + } + const stepIndex = steps.indexOf(step); + const stateAtTime = stepSpringOrThrow(step)(time); + steps.splice(0, stepIndex + 1); + // this will update steps + setInitial(stateAtTime); + return seq; + } + + /** + * Offset sequence by the specified time + */ + function offset(offset: number): ISpringSequence { + steps.forEach((step) => { + step.time = step.time + offset; + }); + updateFromIndex(0); + return seq; + } + } +})(); + +function resolveConfig(conf: number | Partial): Partial { return typeof conf === 'number' ? { equilibrium: conf } : conf; } -function stepSpringOrThrow(step: SpringSequenceStep): SpringSequenceFn { +function stepSpringOrThrow(step: SpringSequenceStep): SpringFn { if (step.spring === null) { throw new Error(`Internal Error: steo.spring is null.`); } return step.spring; } - -function createInitialSpring(state: SpringResult): SpringSequenceFn { - return Object.assign(() => state, { - position: () => state.position, - velocity: () => state.velocity, - }); -} diff --git a/deno_dist/mod.ts b/deno_dist/mod.ts index 782e829..52e22ef 100644 --- a/deno_dist/mod.ts +++ b/deno_dist/mod.ts @@ -1,5 +1,3 @@ -export { Spring } from './Spring.ts'; -export type { SpringFn, SpringResult } from './Spring.ts'; -export { SpringSequence } from './SpringSequence.ts'; -export type { SpringSequenceFn } from './SpringSequence.ts'; -export { SpringConfig } from './SpringConfig.ts'; +export * from './Spring.ts'; +export * from './SpringSequence.ts'; +export * from './SpringConfig.ts'; diff --git a/deno_dist/utils.ts b/deno_dist/utils.ts index 2a38478..c737e64 100644 --- a/deno_dist/utils.ts +++ b/deno_dist/utils.ts @@ -1,5 +1,5 @@ import { SpringFn, SpringResult } from './Spring.ts'; -import { SpringConfig } from './SpringConfig.ts'; +import { ISpringConfig } from './SpringConfig.ts'; /** * This values is chosen to make a spring with dampingRatio of 0 @@ -21,14 +21,19 @@ export function normalizeT(t: number, timeScale: number, timeStart: number): num } export function makeSpringFn( - config: Partial, + config: Partial, main: (t: number) => SpringResult, position: (t: number) => number, - velocity: (t: number) => number + velocity: (t: number) => number, + stable: (t: number) => boolean ): SpringFn { - return Object.assign(main, { position, velocity, config }); + return Object.assign(main, { position, velocity, stable, config }); } export function toPrecision(num: number, precision: number): number { return Math.round(num / precision) * precision; } + +export function isStable(res: SpringResult, conf: ISpringConfig): boolean { + return Math.abs(res.position - conf.equilibrium) <= conf.positionPrecision && Math.abs(res.velocity) <= conf.velocityPrecision; +} diff --git a/src/Spring.ts b/src/Spring.ts index e25e96f..19e56e6 100644 --- a/src/Spring.ts +++ b/src/Spring.ts @@ -1,5 +1,5 @@ -import { invariant, normalizeT, makeSpringFn, toPrecision } from './utils'; -import { SpringConfig } from './SpringConfig'; +import { invariant, normalizeT, makeSpringFn, toPrecision, isStable } from './utils'; +import { type ISpringConfig, SpringConfig } from './SpringConfig'; export interface SpringResult { position: number; @@ -10,10 +10,13 @@ export interface SpringFn { (t: number): SpringResult; readonly position: (t: number) => number; readonly velocity: (t: number) => number; - readonly config: Readonly>; + // returns true if the spring is stable at time t + // i.e. position === equilibrium && velocity === 0 + readonly stable: (t: number) => boolean; + readonly config: Readonly>; } -export function Spring(config: Partial = {}): SpringFn { +export function Spring(config: Partial = {}): SpringFn { const conf = SpringConfig.defaults(config); invariant(conf.dampingRatio >= 0, 'Damping Ration must be >= 0'); @@ -25,7 +28,7 @@ export function Spring(config: Partial = {}): SpringFn { if (conf.angularFrequency <= conf.dampingRatioPrecision) { return springIdentity(conf.position, conf.velocity, config); } - if (isStable(conf.position, conf.velocity, conf)) { + if (isStable(conf, conf)) { return springIdentity(conf.position, conf.velocity, config); } if (conf.dampingRatio > 1 + conf.dampingRatioPrecision) { @@ -41,21 +44,18 @@ export function Spring(config: Partial = {}): SpringFn { return springCriticallyDamped(conf, config); } -function isStable(position: number, velocity: number, conf: SpringConfig): boolean { - return Math.abs(position - conf.equilibrium) <= conf.positionPrecision && Math.abs(velocity) <= conf.velocityPrecision; -} - -function springIdentity(position: number, velocity: number, originalConf: Partial): SpringFn { +function springIdentity(position: number, velocity: number, originalConf: Partial): SpringFn { const identity = { position, velocity }; return makeSpringFn( originalConf, () => identity, () => position, - () => velocity + () => velocity, + () => true ); } -function springOverDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springOverDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const za = -conf.angularFrequency * conf.dampingRatio; const zb = conf.angularFrequency * Math.sqrt(conf.dampingRatio * conf.dampingRatio - 1); const z1 = za - zb; @@ -63,25 +63,27 @@ function springOverDamped(conf: SpringConfig, originalConf: Partial { + const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); + return { + position: springOverDampedPosition( + conf.positionPrecision, + conf.equilibrium, + posDiff, + e1_Over_TwoZb, + z2, + z2e2_Over_TwoZb, + e2, + conf.velocity, + e2_Over_TwoZb + ), + velocity: springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); - return { - position: springOverDampedPosition( - conf.positionPrecision, - conf.equilibrium, - posDiff, - e1_Over_TwoZb, - z2, - z2e2_Over_TwoZb, - e2, - conf.velocity, - e2_Over_TwoZb - ), - velocity: springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity), - }; - }, + main, (t) => { const [e2, e1_Over_TwoZb, e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); return springOverDampedPosition( @@ -99,7 +101,8 @@ function springOverDamped(conf: SpringConfig, originalConf: Partial { const [e2, e1_Over_TwoZb, _e2_Over_TwoZb, z2e2_Over_TwoZb] = springOverDampedCommon(t, conf.timeScale, conf.timeStart, z1, z2, invTwoZb); return springOverDampedVelocity(conf.velocityPrecision, posDiff, z1, e1_Over_TwoZb, z2e2_Over_TwoZb, e2, z2, conf.velocity); - } + }, + (t) => isStable(main(t), conf) ); } @@ -143,38 +146,40 @@ function springOverDampedVelocity( return toPrecision(posDiff * ((z1e1_Over_TwoZb - z2e2_Over_TwoZb + e2) * z2) + vel * (-z1e1_Over_TwoZb + z2e2_Over_TwoZb), precision); } -function springUnderDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springUnderDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const omegaZeta = conf.angularFrequency * conf.dampingRatio; const alpha = conf.angularFrequency * Math.sqrt(1 - conf.dampingRatio * conf.dampingRatio); const posDiff = conf.position - conf.equilibrium; + const main = (t: number) => { + const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); + return { + position: springUnderDampedPosition( + conf.positionPrecision, + conf.equilibrium, + posDiff, + expCos, + expOmegaZetaSin_Over_Alpha, + conf.velocity, + expSin, + invAlpha + ), + velocity: springUnderDampedVelocity( + conf.velocityPrecision, + posDiff, + expSin, + alpha, + omegaZeta, + expOmegaZetaSin_Over_Alpha, + conf.velocity, + expCos + ), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); - return { - position: springUnderDampedPosition( - conf.positionPrecision, - conf.equilibrium, - posDiff, - expCos, - expOmegaZetaSin_Over_Alpha, - conf.velocity, - expSin, - invAlpha - ), - velocity: springUnderDampedVelocity( - conf.velocityPrecision, - posDiff, - expSin, - alpha, - omegaZeta, - expOmegaZetaSin_Over_Alpha, - conf.velocity, - expCos - ), - }; - }, + main, (t) => { const [invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); return springUnderDampedPosition( @@ -191,7 +196,8 @@ function springUnderDamped(conf: SpringConfig, originalConf: Partial { const [_invAlpha, expSin, expCos, expOmegaZetaSin_Over_Alpha] = springUnderDampedCommon(t, conf.timeScale, conf.timeStart, omegaZeta, alpha); return springUnderDampedVelocity(conf.velocityPrecision, posDiff, expSin, alpha, omegaZeta, expOmegaZetaSin_Over_Alpha, conf.velocity, expCos); - } + }, + (t) => isStable(main(t), conf) ); } @@ -238,17 +244,20 @@ function springUnderDampedVelocity( ); } -function springCriticallyDamped(conf: SpringConfig, originalConf: Partial): SpringFn { +function springCriticallyDamped(conf: ISpringConfig, originalConf: Partial): SpringFn { const oldPos = conf.position - conf.equilibrium; // update in equilibrium relative space + + const main = (t: number) => { + const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); + return { + position: springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium), + velocity: springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm), + }; + }; + return makeSpringFn( originalConf, - (t: number) => { - const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); - return { - position: springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium), - velocity: springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm), - }; - }, + main, (t) => { const [expTerm, timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); return springCriticallyDampedPosition(conf.positionPrecision, oldPos, timeExpFreq, expTerm, conf.velocity, timeExp, conf.equilibrium); @@ -256,7 +265,8 @@ function springCriticallyDamped(conf: SpringConfig, originalConf: Partial { const [expTerm, _timeExp, timeExpFreq] = springCriticallyDampedCommon(t, conf.timeScale, conf.timeStart, conf.angularFrequency); return springCriticallyDampedVelocity(conf.velocityPrecision, oldPos, conf.angularFrequency, timeExpFreq, conf.velocity, expTerm); - } + }, + (t) => isStable(main(t), conf) ); } diff --git a/src/SpringConfig.ts b/src/SpringConfig.ts index 27fc45f..f18d566 100644 --- a/src/SpringConfig.ts +++ b/src/SpringConfig.ts @@ -1,10 +1,4 @@ -// Default to 1000 so time is in milliseconds -export const DEFAULT_TIME_SCALE = 1000; - -// This value ensure a valid binary rounding -export const DEFAULT_PRECISION = 1 / (1 << 14); - -export interface SpringConfig { +export interface ISpringConfig { // initial position position: number; // initial velocity @@ -25,7 +19,13 @@ export interface SpringConfig { dampingRatioPrecision: number; } -const DEFAULT_CONFIG: SpringConfig = { +// Default to 1000 so time is in milliseconds +export const DEFAULT_TIME_SCALE = 1000; + +// This value ensure a valid binary rounding +export const DEFAULT_PRECISION = 1 / (1 << 14); + +export const DEFAULT_CONFIG: ISpringConfig = { position: 0, velocity: 0, equilibrium: 1, @@ -55,14 +55,14 @@ export const SpringConfig = { angularFrequencyFromSpringConstant, }; -function defaults(config: Partial = {}): SpringConfig { +function defaults(config: Partial = {}): ISpringConfig { return { ...DEFAULT_CONFIG, ...config, }; } -function basic(config: Partial = {}): Partial { +function basic(config: Partial = {}): Partial { return { angularFrequency: 1, dampingRatio: 1, @@ -70,7 +70,7 @@ function basic(config: Partial = {}): Partial { }; } -function gentle(config: Partial = {}): Partial { +function gentle(config: Partial = {}): Partial { return { angularFrequency: 0.6, dampingRatio: 0.6, @@ -78,7 +78,7 @@ function gentle(config: Partial = {}): Partial { }; } -function wobbly(config: Partial = {}): Partial { +function wobbly(config: Partial = {}): Partial { return { angularFrequency: 0.8, dampingRatio: 0.4, @@ -86,7 +86,7 @@ function wobbly(config: Partial = {}): Partial { }; } -function stiff(config: Partial = {}): Partial { +function stiff(config: Partial = {}): Partial { return { angularFrequency: 1.1, dampingRatio: 0.7, @@ -94,7 +94,7 @@ function stiff(config: Partial = {}): Partial { }; } -function slow(config: Partial = {}): Partial { +function slow(config: Partial = {}): Partial { return { angularFrequency: 0.5, dampingRatio: 1, @@ -102,7 +102,7 @@ function slow(config: Partial = {}): Partial { }; } -function decay(config: Partial = {}): Partial { +function decay(config: Partial = {}): Partial { const resolved = { ...DEFAULT_CONFIG, ...config, @@ -117,7 +117,7 @@ function decay(config: Partial = {}): Partial { }; } -function stable(equilibrium: number, config: Partial = {}): Partial { +function stable(equilibrium: number, config: Partial = {}): Partial { return { ...config, velocity: 0, diff --git a/src/SpringSequence.ts b/src/SpringSequence.ts index 7da62ed..98f54d7 100644 --- a/src/SpringSequence.ts +++ b/src/SpringSequence.ts @@ -1,255 +1,284 @@ import { Spring, SpringFn, SpringResult } from './Spring'; -import { DEFAULT_TIME_SCALE, SpringConfig } from './SpringConfig'; +import { DEFAULT_TIME_SCALE, ISpringConfig, SpringConfig } from './SpringConfig'; import { makeSpringFn } from './utils'; -type SpringSequenceStep = { time: number; config: Partial; spring: SpringFn | null }; +type SpringSequenceStep = { time: number; config: Partial; spring: SpringFn | null }; -export interface SpringSequenceFn { - (t: number): SpringResult; - readonly position: (t: number) => number; - readonly velocity: (t: number) => number; +export interface SpringSequenceConfig { + timeScale?: number; + defaultConfig?: Partial; + initial?: Partial; } -type SpringSequenceConfig = { - timeScale?: number; - defaultConfig?: Partial; - initial?: Partial; -}; +export interface ISpringSequence { + readonly spring: SpringFn; -export class SpringSequence { - public static create(options: SpringSequenceConfig = {}): SpringSequence { - return new SpringSequence([], options); - } + clone(): ISpringSequence; + setInitial(initial: Partial): ISpringSequence; + setDefaultConfig(config: Partial): ISpringSequence; + setTimeScale(timeScale: number): ISpringSequence; + insertAt(time: number, config: number | Partial): ISpringSequence; + replaceTail(time: number, config: number | Partial): ISpringSequence; + replaceAll(time: number, config: number | Partial): ISpringSequence; + decay(time: number, config?: Partial): ISpringSequence; + clearBefore(time: number): ISpringSequence; + offset(offset: number): ISpringSequence; +} - private readonly steps: Array = []; - private timeScale: number; - private defaultConfig: Partial; - // spring that return initial state at any time - private initialSpring: SpringSequenceFn; +export const SpringSequence = (() => { + return { create }; - public readonly spring: SpringSequenceFn; + function create(options: SpringSequenceConfig = {}): ISpringSequence { + return createInternal([], options); + } - private constructor(steps: Array, { timeScale = DEFAULT_TIME_SCALE, defaultConfig = {}, initial = {} }: SpringSequenceConfig) { - this.steps = steps; - this.timeScale = timeScale; - this.defaultConfig = defaultConfig; - this.initialSpring = createInitialSpring({ position: initial.position ?? 0, velocity: initial.velocity ?? 0 }); - this.spring = makeSpringFn( + function createInternal(steps: Array, config: SpringSequenceConfig): ISpringSequence { + let timeScale: number = config.timeScale ?? DEFAULT_TIME_SCALE; + let defaultConfig: Partial = config.defaultConfig ?? {}; + // const that return initial state at any time + let initialSpring: SpringFn = Spring({ ...defaultConfig, ...resolveInitialConfig(config.initial ?? {}) }); + const spring: SpringFn = makeSpringFn( defaultConfig, - (t) => this.findSpringAt(t)(t), - (t) => this.findSpringAt(t).position(t), - (t) => this.findSpringAt(t).velocity(t) + (t) => findSpringAt(t)(t), + (t) => findSpringAt(t).position(t), + (t) => findSpringAt(t).velocity(t), + (t) => findSpringAt(t).stable(t) ); - } - private readonly findSpringAt = (t: number): SpringSequenceFn => { - const step = this.findMaybeStepAt(t); - if (step) { - return stepSpringOrThrow(step); + const seq: ISpringSequence = { + spring, + clone, + setInitial, + setDefaultConfig, + setTimeScale, + insertAt, + replaceTail, + replaceAll, + decay, + clearBefore, + offset, + }; + + return seq; + + function findSpringAt(t: number): SpringFn { + const step = findMaybeStepAt(t); + if (step) { + return stepSpringOrThrow(step); + } + return initialSpring; } - return this.initialSpring; - }; - - /** - * Return the first step where t is >= to step.time - * Return null if t is before first step or no steps - */ - private readonly findMaybeStepAt = (t: number): SpringSequenceStep | null => { - if (this.steps.length === 0) { - return null; + + /** + * Return the first step where t is >= to step.time + * Return null if t is before first step or no steps + */ + function findMaybeStepAt(t: number): SpringSequenceStep | null { + if (steps.length === 0) { + return null; + } + if (t < steps[0].time) { + // t is before first time + return null; + } + let prev: null | SpringSequenceStep = null; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (t < step.time) { + break; + } + prev = step; + } + return prev; } - if (t < this.steps[0].time) { - // t is before first time - return null; + + /** + * On initial config, if only one of position or equilibrium is defined, + * we set the other one to the same value. + */ + function resolveInitialConfig(initial: Partial): Partial { + if (initial.position === undefined && initial.equilibrium === undefined) { + return { ...initial, position: 0, equilibrium: 0 }; + } + if (initial.position === undefined && initial.equilibrium !== undefined) { + return { ...initial, position: initial.equilibrium }; + } + if (initial.position !== undefined && initial.equilibrium === undefined) { + return { ...initial, equilibrium: initial.position }; + } + return initial; } - let prev: null | SpringSequenceStep = null; - for (let i = 0; i < this.steps.length; i++) { - const step = this.steps[i]; - if (t < step.time) { - break; + + /** + * Update each step starting at the index + */ + function updateFromIndex(index: number): void { + const indexResolved = index < 0 ? 0 : index; + if (indexResolved >= steps.length) { + return; + } + let prev: SpringFn = indexResolved === 0 ? initialSpring : stepSpringOrThrow(steps[index - 1]); + for (let i = index; i < steps.length; i++) { + const step = steps[i]; + const spring = createSpring(step.time, prev(step.time), step.config); + step.spring = spring; + prev = spring; } - prev = step; } - return prev; - }; - - /** - * Update each step starting at the index - */ - private readonly updateFromIndex = (index: number): void => { - const indexResolved = index < 0 ? 0 : index; - if (indexResolved >= this.steps.length) { - return; + + /** + * Create a spring at a certain time using defaultConfig + */ + function createSpring(time: number, current: SpringResult | null, config: number | Partial): SpringFn { + const resolved = { + ...defaultConfig, + ...resolveConfig(config), + }; + const conf: Partial = { + // inject current state (position & velocity) + ...(current ?? {}), + // user config, note that user can override velocity and position by defining them in the config ! + ...resolved, + // Override timeScale by the one defined in the SpringSequence + timeScale: timeScale, + // config.timeStart is used as an offset + timeStart: time + (resolved.timeStart ?? 0), + }; + return Spring(conf); } - let prev: SpringSequenceFn = indexResolved === 0 ? this.initialSpring : stepSpringOrThrow(this.steps[index - 1]); - for (let i = index; i < this.steps.length; i++) { - const step = this.steps[i]; - const spring = this.createSpring(step.time, prev(step.time), step.config); - step.spring = spring; - prev = spring; + + /** + * Create an identical SpringSequence that does not depent on the source (safe to mutate) + */ + function clone(): ISpringSequence { + return createInternal( + steps.map((step) => ({ ...step })), + { timeScale: timeScale, initial: initialSpring(0), defaultConfig: defaultConfig } + ); } - }; - - /** - * Create a spring at a certain time using defaultConfig - */ - private readonly createSpring = (time: number, current: SpringResult | null, config: number | Partial): SpringFn => { - const resolved = { - ...this.defaultConfig, - ...resolveConfig(config), - }; - const conf: Partial = { - // inject current state (position & velocity) - ...(current ?? {}), - // user config, note that user can override velocity and position by defining them in the config ! - ...resolved, - // Override timeScale by the one defined in the SpringSequence - timeScale: this.timeScale, - // config.timeStart is used as an offset - timeStart: time + (resolved.timeStart ?? 0), - }; - return Spring(conf); - }; - - /** - * Create an identical SpringSequence that does not depent on the source (safe to mutate) - */ - public readonly clone = (): SpringSequence => { - return new SpringSequence( - this.steps.map((step) => ({ ...step })), - { timeScale: this.timeScale, initial: this.initialSpring(0), defaultConfig: this.defaultConfig } - ); - }; - - /** - * Change the initial state of the spring. - * This will update all internal springs. - */ - public readonly setInitial = (initial: Partial): this => { - const current = this.initialSpring(0); // could fetch any time since initialSpring return the same value - this.initialSpring = createInitialSpring({ position: initial.position ?? current.position, velocity: initial.velocity ?? current.velocity }); - this.updateFromIndex(0); - return this; - }; - - /** - * Change the default config. This will update all internal springs. - */ - public readonly setDefaultConfig = (config: Partial): this => { - this.defaultConfig = config; - this.updateFromIndex(0); - return this; - }; - - /** - * Change timescale - */ - public readonly setTimeScale = (timeScale: number): this => { - this.timeScale = timeScale; - this.updateFromIndex(0); - return this; - }; - - /** - * Insert a new step at the specified time - */ - public readonly insertAt = (time: number, config: number | Partial): this => { - const step = this.findMaybeStepAt(time); - const newStep: SpringSequenceStep = { time, config: resolveConfig(config), spring: null }; - if (step === null) { - // insert before all other steps - this.steps.unshift(newStep); - this.updateFromIndex(0); - return this; + + /** + * Change the initial state of the spring. + * This will update all internal springs. + */ + function setInitial(initial: Partial): ISpringSequence { + initialSpring = Spring({ ...defaultConfig, ...resolveInitialConfig(initial) }); + updateFromIndex(0); + return seq; } - const stepIndex = this.steps.indexOf(step); - // if time is the same, we replace the step - const deletePrev = step.time === time; - if (deletePrev) { - this.steps.splice(stepIndex, 1, newStep); - this.updateFromIndex(stepIndex); - return this; + + /** + * Change the default config. This will update all internal springs. + */ + function setDefaultConfig(config: Partial): ISpringSequence { + defaultConfig = config; + updateFromIndex(0); + return seq; } - this.steps.splice(stepIndex + 1, 0, newStep); - this.updateFromIndex(stepIndex + 1); - return this; - }; - - /** - * Add a new step and remove any step after it - */ - public readonly replaceTail = (time: number, config: number | Partial): this => { - const step = this.findMaybeStepAt(time); - const stepIndex = step === null ? 0 : this.steps.indexOf(step); - this.steps.splice(stepIndex + 1, this.steps.length - stepIndex, { time, config: resolveConfig(config), spring: null }); - this.updateFromIndex(stepIndex); - return this; - }; - - /** - * Replace all the steps by the new one - */ - public readonly replaceAll = (time: number, config: number | Partial): this => { - this.steps.splice(0, this.steps.length, { time, config: resolveConfig(config), spring: null }); - this.updateFromIndex(0); - return this; - }; - - /** - * Decay at time and remove everything after - */ - public readonly decay = (time: number, config: Partial = {}): this => { - const stateAtTime = this.spring(time); - const decayConf = SpringConfig.decay({ ...stateAtTime, ...config }); - const step = this.findMaybeStepAt(time); - const stepIndex = step === null ? 0 : this.steps.indexOf(step); - this.steps.splice(stepIndex, this.steps.length - stepIndex, { time, config: decayConf, spring: null }); - this.updateFromIndex(stepIndex); - return this; - }; - - /** - * Remove all steps before time - */ - public readonly clearBefore = (time: number): this => { - const step = this.findMaybeStepAt(time); - if (step === null) { - return this; + + /** + * Change timescale + */ + function setTimeScale(newTimeScale: number): ISpringSequence { + timeScale = newTimeScale; + updateFromIndex(0); + return seq; + } + + /** + * Insert a new step at the specified time + */ + function insertAt(time: number, config: number | Partial): ISpringSequence { + const step = findMaybeStepAt(time); + const newStep: SpringSequenceStep = { time, config: resolveConfig(config), spring: null }; + if (step === null) { + // insert before all other steps + steps.unshift(newStep); + updateFromIndex(0); + return seq; + } + const stepIndex = steps.indexOf(step); + // if time is the same, we replace the step + const deletePrev = step.time === time; + if (deletePrev) { + steps.splice(stepIndex, 1, newStep); + updateFromIndex(stepIndex); + return seq; + } + steps.splice(stepIndex + 1, 0, newStep); + updateFromIndex(stepIndex + 1); + return seq; } - const stepIndex = this.steps.indexOf(step); - const stateAtTime = stepSpringOrThrow(step)(time); - this.steps.splice(0, stepIndex + 1); - // this will update steps - this.setInitial(stateAtTime); - return this; - }; - - /** - * Offset sequence by the specified time - */ - public readonly offset = (offset: number): this => { - this.steps.forEach((step) => { - step.time = step.time + offset; - }); - this.updateFromIndex(0); - return this; - }; -} -function resolveConfig(conf: number | Partial): Partial { + /** + * Add a new step and remove any step after it + */ + function replaceTail(time: number, config: number | Partial): ISpringSequence { + const step = findMaybeStepAt(time); + const stepIndex = step === null ? 0 : steps.indexOf(step); + steps.splice(stepIndex + 1, steps.length - stepIndex, { time, config: resolveConfig(config), spring: null }); + updateFromIndex(stepIndex); + return seq; + } + + /** + * Replace all the steps by the new one + */ + function replaceAll(time: number, config: number | Partial): ISpringSequence { + steps.splice(0, steps.length, { time, config: resolveConfig(config), spring: null }); + updateFromIndex(0); + return seq; + } + + /** + * Decay at time and remove everything after + */ + function decay(time: number, config: Partial = {}): ISpringSequence { + const stateAtTime = spring(time); + const decayConf = SpringConfig.decay({ ...stateAtTime, ...config }); + const step = findMaybeStepAt(time); + const stepIndex = step === null ? 0 : steps.indexOf(step); + steps.splice(stepIndex, steps.length - stepIndex, { time, config: decayConf, spring: null }); + updateFromIndex(stepIndex); + return seq; + } + + /** + * Remove all steps before time + */ + function clearBefore(time: number): ISpringSequence { + const step = findMaybeStepAt(time); + if (step === null) { + return seq; + } + const stepIndex = steps.indexOf(step); + const stateAtTime = stepSpringOrThrow(step)(time); + steps.splice(0, stepIndex + 1); + // this will update steps + setInitial(stateAtTime); + return seq; + } + + /** + * Offset sequence by the specified time + */ + function offset(offset: number): ISpringSequence { + steps.forEach((step) => { + step.time = step.time + offset; + }); + updateFromIndex(0); + return seq; + } + } +})(); + +function resolveConfig(conf: number | Partial): Partial { return typeof conf === 'number' ? { equilibrium: conf } : conf; } -function stepSpringOrThrow(step: SpringSequenceStep): SpringSequenceFn { +function stepSpringOrThrow(step: SpringSequenceStep): SpringFn { if (step.spring === null) { throw new Error(`Internal Error: steo.spring is null.`); } return step.spring; } - -function createInitialSpring(state: SpringResult): SpringSequenceFn { - return Object.assign(() => state, { - position: () => state.position, - velocity: () => state.velocity, - }); -} diff --git a/src/mod.ts b/src/mod.ts index 1b57a11..33f75c5 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,5 +1,3 @@ -export { Spring } from './Spring'; -export type { SpringFn, SpringResult } from './Spring'; -export { SpringSequence } from './SpringSequence'; -export type { SpringSequenceFn } from './SpringSequence'; -export { SpringConfig } from './SpringConfig'; +export * from './Spring'; +export * from './SpringSequence'; +export * from './SpringConfig'; diff --git a/src/utils.ts b/src/utils.ts index a0541d0..4461f55 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { SpringFn, SpringResult } from './Spring'; -import { SpringConfig } from './SpringConfig'; +import { ISpringConfig } from './SpringConfig'; /** * This values is chosen to make a spring with dampingRatio of 0 @@ -21,14 +21,19 @@ export function normalizeT(t: number, timeScale: number, timeStart: number): num } export function makeSpringFn( - config: Partial, + config: Partial, main: (t: number) => SpringResult, position: (t: number) => number, - velocity: (t: number) => number + velocity: (t: number) => number, + stable: (t: number) => boolean ): SpringFn { - return Object.assign(main, { position, velocity, config }); + return Object.assign(main, { position, velocity, stable, config }); } export function toPrecision(num: number, precision: number): number { return Math.round(num / precision) * precision; } + +export function isStable(res: SpringResult, conf: ISpringConfig): boolean { + return Math.abs(res.position - conf.equilibrium) <= conf.positionPrecision && Math.abs(res.velocity) <= conf.velocityPrecision; +} diff --git a/tests/utils.ts b/tests/utils.ts index 016b838..e4db9dc 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,5 +1,5 @@ import { createCanvas, CanvasRenderingContext2D } from 'canvas'; -import { SpringSequenceFn } from '../src/mod'; +import { SpringFn } from '../src/mod'; import { createWriteStream } from 'fs'; import { resolve } from 'path'; @@ -50,7 +50,7 @@ export type SpringExport = Array<[time: number, pos: number, vel: number]>; * and returns data */ export async function canvasImage( - spring: SpringSequenceFn, + spring: SpringFn, fileName: string, { width = 600, timeAxis, position, velocity, events }: CanvasImageConfig ): Promise {