All files / animations/generators spring.ts

100% Statements 66/66
100% Branches 30/30
100% Functions 13/13
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207                4x 4x     35x           21x                   21x       2x   2x             2x     21x             21x 21x 21x 21x 21x           21x     21x 21x 21x 21x 21x 21x   21x 21x     26x 26x 26x 26x           26x   26x 21x           21x 527x       527x                     21x   518x       518x                                           5x   2x 27x               3x   3x 27x         27x   27x                             21x   21x   581x   581x 572x   572x   572x 572x     9x     581x 581x     5x 5x 5x         4x 16x   54x  
import {
    SpringOptions,
    PhysicsSpringOptions,
    Animation,
    AnimationState,
} from "../types"
import { calcAngularFreq, findSpring } from "../utils/find-spring"
 
const durationKeys = ["duration", "bounce"]
const physicsKeys = ["stiffness", "damping", "mass"]
 
function isSpringType(options: SpringOptions, keys: string[]) {
    return keys.some((key) => (options as any)[key] !== undefined)
}
 
function getSpringOptions(
    options: SpringOptions
): PhysicsSpringOptions & { isResolvedFromDuration: boolean } {
    let springOptions = {
        velocity: 0.0,
        stiffness: 100,
        damping: 10,
        mass: 1.0,
        isResolvedFromDuration: false,
        ...options,
    }
 
    // stiffness/damping/mass overrides duration/bounce
    if (
        !isSpringType(options, physicsKeys) &&
        isSpringType(options, durationKeys)
    ) {
        const derived = findSpring(options)
 
        springOptions = {
            ...springOptions,
            ...derived,
            velocity: 0.0,
            mass: 1.0,
        }
 
        springOptions.isResolvedFromDuration = true
    }
 
    return springOptions
}
 
/**
 * This is based on the spring implementation of Wobble https://github.com/skevy/wobble
 */
export function spring({
    from = 0.0,
    to = 1.0,
    restSpeed = 2,
    restDelta,
    ...options
}: SpringOptions): Animation<number> {
    /**
     * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator
     * to reduce GC during animation.
     */
    const state: AnimationState<number> = { done: false, value: from }
 
    let {
        stiffness,
        damping,
        mass,
        velocity,
        isResolvedFromDuration,
    } = getSpringOptions(options)
 
    let resolveSpring = zero
    let resolveVelocity = zero
 
    function createSpring() {
        const initialVelocity = velocity ? -(velocity / 1000) : 0.0
        const initialDelta = to - from
        const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass))
        const undampedAngularFreq = Math.sqrt(stiffness / mass) / 1000
 
        /**
         * If we're working within what looks like a 0-1 range, change the default restDelta
         * to 0.01
         */
        restDelta ??= Math.abs(to - from) <= 1 ? 0.01 : 0.4
 
        if (dampingRatio < 1) {
            const angularFreq = calcAngularFreq(
                undampedAngularFreq,
                dampingRatio
            )
 
            // Underdamped spring
            resolveSpring = (t: number) => {
                const envelope = Math.exp(
                    -dampingRatio * undampedAngularFreq * t
                )
 
                return (
                    to -
                    envelope *
                        (((initialVelocity +
                            dampingRatio * undampedAngularFreq * initialDelta) /
                            angularFreq) *
                            Math.sin(angularFreq * t) +
                            initialDelta * Math.cos(angularFreq * t))
                )
            }
 
            resolveVelocity = (t: number) => {
                // TODO Resolve these calculations with the above
                const envelope = Math.exp(
                    -dampingRatio * undampedAngularFreq * t
                )
 
                return (
                    dampingRatio *
                        undampedAngularFreq *
                        envelope *
                        ((Math.sin(angularFreq * t) *
                            (initialVelocity +
                                dampingRatio *
                                    undampedAngularFreq *
                                    initialDelta)) /
                            angularFreq +
                            initialDelta * Math.cos(angularFreq * t)) -
                    envelope *
                        (Math.cos(angularFreq * t) *
                            (initialVelocity +
                                dampingRatio *
                                    undampedAngularFreq *
                                    initialDelta) -
                            angularFreq *
                                initialDelta *
                                Math.sin(angularFreq * t))
                )
            }
        } else if (dampingRatio === 1) {
            // Critically damped spring
            resolveSpring = (t: number) =>
                to -
                Math.exp(-undampedAngularFreq * t) *
                    (initialDelta +
                        (initialVelocity + undampedAngularFreq * initialDelta) *
                            t)
        } else {
            // Overdamped spring
            const dampedAngularFreq =
                undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1)
 
            resolveSpring = (t: number) => {
                const envelope = Math.exp(
                    -dampingRatio * undampedAngularFreq * t
                )
 
                // When performing sinh or cosh values can hit Infinity so we cap them here
                const freqForT = Math.min(dampedAngularFreq * t, 300)
 
                return (
                    to -
                    (envelope *
                        ((initialVelocity +
                            dampingRatio * undampedAngularFreq * initialDelta) *
                            Math.sinh(freqForT) +
                            dampedAngularFreq *
                                initialDelta *
                                Math.cosh(freqForT))) /
                        dampedAngularFreq
                )
            }
        }
    }
 
    createSpring()
 
    return {
        next: (t: number) => {
            const current = resolveSpring(t)
 
            if (!isResolvedFromDuration) {
                const currentVelocity = resolveVelocity(t) * 1000
                const isBelowVelocityThreshold =
                    Math.abs(currentVelocity) <= restSpeed
                const isBelowDisplacementThreshold =
                    Math.abs(to - current) <= restDelta
                state.done =
                    isBelowVelocityThreshold && isBelowDisplacementThreshold
            } else {
                state.done = t >= options.duration
            }
 
            state.value = state.done ? to : current
            return state
        },
        flipTarget: () => {
            velocity = -velocity
            ;[from, to] = [to, from]
            createSpring()
        },
    }
}
 
spring.needsInterpolation = (a: any, b: any) =>
    typeof a === "string" || typeof b === "string"
 
const zero = (_t: number) => 0