UNPKG

pacer-js

Version:

Getting you from A to B since 2025.

772 lines (535 loc) 16.9 kB
// Copyright ©️ 2025 Stewart Smith. See LICENSE for details. /////// // ////// /////// /////// // // //// // // // // // // // // // // ////// // // /////// /////// // // // /////// // // // ////// /////// // // // // // // // import { isUsefulNumber, isNotUsefulNumber, isUsefulString, normalize, normalize01, lerp } from 'shoes-js' class Key { constructor( timeAbsolute, values, callback ){ this.timeAbsolute = timeAbsolute this.values = values instanceof Object ? values : {} this.onKey = callback this.tween = Pacer.linear// Default tween method is linear interpolation. this.label = '' this.guarantee = true } } class Pacer { constructor( label, units ){ this._label = isUsefulString( label ) ? label : 'Untitled Pacer instance' this._units = isUsefulString( units ) ? units : 'ms'// ms, milliseconds, s, seconds, #, n, norm, normalize, normalized, %, percent this.keys = [] this.keyIndex = -1 this.lastTouchedKey = null this.values = {} this.n = 0 this.direction = 1 this.isClamped = true this.isEnabled = true this.instanceIndex = Pacer.all.length Pacer.all.push( this ) } // Non-chainable. inspect( useRelative ){ const scope = this let out = '' out += '\n'+ this._label out += '\n'+ new Array( this._label.length ).fill( '─' ).join( '' ) out += this.keys .reduce( function( output, key ){ output += '\n' output += useRelative === true && isUsefulNumber( key.timeRelative ) ? ' +'+ key.timeRelative : ' '+ key.timeAbsolute output += scope._units +' ' if( isUsefulString( key.label )) output += key.label +' ' output += JSON.stringify( key.values ) return output }, '' ) // Should add things like timeCursor, total n, etc. return out +'\n\n' } getFirstKey(){ return this.keys[ 0 ] } getLastKey(){ return this.keys[ this.keys.length - 1 ] } getCurrentKey(){ return this.keys[ this.keyIndex ] } tweenKeys( keyA, keyB, now, direction ){ if( isNotUsefulNumber( direction )) direction = 1 let tween = keyA.tween // This logic is unnecessary, // but leaving it here for future reference. // const tweenLabel = keyA.tween.label // if( direction < 0 && tweenLabel !== 'linear' ){ // let tweenStyle = keyA.tween.style // if( tweenStyle === 'in' ) tweenStyle = 'out' // else if( tweenStyle === 'out' ) tweenStyle = 'in' // console.log( tweenLabel, tweenStyle ) // tween = Pacer[ tweenLabel ][ tweenStyle ] // } const method = this.isClamped ? normalize01 : normalize, n = method( now, keyA.timeAbsolute, keyB.timeAbsolute ), a = Object.keys( keyA.values ), b = Object.keys( keyB.values ), c = a.reduce( function( output, key ){ const a = keyA.values[ key ] if( isNotUsefulNumber( a )) return output const b = keyB.values[ key ] if( isNotUsefulNumber( b )) return output output[ key ] = lerp( tween( n ), a, b ) return output }, {}) // Make current values easily available on the instance. this.values = c return n } // Chainable key-focussed methods. setTimeBounds(){ this.timeStart = this.getFirstKey().timeAbsolute this.timeStop = this.getLastKey().timeAbsolute this.duration = this.timeStop - this.timeStart return this } sortKeys(){ this.keys .sort( function( a, b ){ return a.timeAbsolute - b.timeAbsolute }) .forEach( function( key, i, keys ){ // Yes, we have to operate directly on the `keys` Array // rather than `key` element reference // if we want to actually write this property. // Otherwise it silently fails. Lovely. // And we don’t want to use Array.map // in case we ever need a DEEP copy of an element’s properties. keys[ i ].index = i // Also, here’s a nicety: // if there’s a key with no values, // let’s just copy the values from the previous key. if( key.values instanceof Object !== true && typeof keys[ i - 1 ] !== 'undefined' && keys[ i - 1 ].values instanceof Object === true ){ keys[ i ].values = keys[ i - 1 ].values // There’s an argument to made for this instead of the above: // keys[ i ].values = Object.assign( {}, keys[ i - 1 ].values ) } }) this.setTimeBounds() return this } key( time, values, callback, isAbsolute ){ if( isAbsolute !== true &&// Making a theoretical `isRelative` the default for backwards compatibility. this.keys.length > 0 ){ time += this.getLastKey().timeAbsolute } if( this.keys.length === 0 ) this.values = values const key = new Key( time, values, callback ) this.lastTouchedKey = key this.keys.push( key ) this.sortKeys() if( this.keys.length === 1 ) this.timeCursor = this.timeStart - 1 return this } rel( timeRelative, values, callback ){ return this.key( timeRelative, values, callback, false ) } abs( timeAbsolute, values, callback ){ return this.key( timeAbsolute, values, callback, true ) } labelPacer( s ){ this._label = s return this } label( s ){ this.lastTouchedKey.label = s return this } values( v ){ this.lastTouchedKey.values = v return this } tween( fn ){ this.lastTouchedKey.tween = fn return this } clamp(){ this.isClamped = true return this } unclamp(){ this.isClamped = false return this } units( u ){ this._units = u return this } onKey( fn ){ this.lastTouchedKey.onKey = fn return this } onTween( fn ){ this.lastTouchedKey.onTween = fn return this } onCancel( fn ){ this.lastTouchedKey.onCancel = fn return this } // Chainable instance-wide methods. onBeforeAll( fn ){ this._onBefore = fn return this } onAfterAll( fn ){ this._onAfter = fn return this } onEveryKey( fn ){ this._onEveryKey = fn return this } onEveryTween( fn ){ this._onEveryTween = fn return this } // Chainable commands. update( now ){ // We only need to update // if we are enabled // and we have at least one keyframe // that might have either a values object, // an onKey callback, // or would be included if there’s an onEveryKey callback. if( this.isEnabled !== true ) return this if( this.keys.length < 1 ) return this // So I guess we’re doing this. // What time is it? // And what direction are we flowing? if( isNotUsefulNumber( now )) now = Date.now() if( now === this.timeCursor ) return this// Unlikely for standalone animations, but very likely for scroll animations. const direction = now < this.timeCursor ? -1 : 1 // What’s our total N gain for the this entire instance? const method = this.isClamped ? normalize01 : normalize this.n = method( now, this.timeStart, this.timeStop ) // We know our keys are already sorted by time, // and we’ve previously set the convenience variables // `timeStart` and `timeStop`. // Note 1: Direction has no effect on the order of // keyA and keyB, because we always want this to be true: // keyA.timeAbsolute < keyB.timeAbsolute. // And we always use the tween attached to keyA! // Note 2: For this step, it is perfectly reasonable // for keyA or keyB to be undefined. let targetIndex = 0, keyA, keyB if( now < this.timeStart ){ targetIndex = -1// Intentionally out of range. keyA = this.keys[ 0 ] keyB = this.keys[ 1 ] } else if( now >= this.timeStop ){ targetIndex = this.keys.length// Intentionally out of range. keyA = this.keys[ this.keys.length - 2 ] keyB = this.keys[ this.keys.length - 1 ] } else { // Price-is-Right rules: // CLOSEST WITHOUT GOING OVER. // If our direction is +1, we want the LATEST keyframe // where `now` is still >= keyframe.timeAbsolute. // If our direction is -1, we want the EARLIEST keyframe // where `now` is still <= keyframe.timeAbsolute. // That index gives us KeyA, // and keys[ index + direction ] give us keyB. let i = Math.min( Math.max( 0, this.keyIndex ), this.keys.length - 1 ) keyA = this.keys[ i ] keyB = this.keys[ i + 1 ] if( direction > 0 ){ while( keyB instanceof Key && keyB.timeAbsolute < now ){ i ++ keyA = this.keys[ i ] keyB = this.keys[ i + 1 ] } } else if( direction < 0 ){ while( keyA instanceof Key && keyA.timeAbsolute > now ){ i -- keyA = this.keys[ i ] keyB = this.keys[ i + 1 ] } } targetIndex = i } // Let’s prep for calling onKey -- // You never know what someone’s callbacks // are going to ask for on this instance! const keyIndexPrior = this.keyIndex this.direction = direction /////////////// // // // onKey // // // /////////////// // Ok. Perhaps you were wondering // why we hold onto a targetIndex value at all. // We already have keyA and keyB -- just tween, right? // Well... We’re in the business of // GUARANTEEING keyframe onKey callbacks. // That means, unless told otherwise, we need to hit // each of those key frames and onKey() callbacks // between wherever we were previously, and now. // I had originally combined the following logic into one block, // but debugging the subtleties became a true ass pain, // so for clarity I separated them back out based on direction. if( direction > 0 ){ for( let i = keyIndexPrior + 1; i <= targetIndex; i ++ ){ // Yes, we do expect (and are accounting for!) // a moment where i > this.keys.length - 1 // and therefore tempKey === undefined. // This is expected behavior! // You are going to be ok. Okay. O.K. OK. const tempKey = this.keys[ i ] this.keyIndex = i if( tempKey instanceof Key && tempKey.guarantee === true ){ if( typeof tempKey.onKey === 'function' ){ tempKey.onKey( tempKey.values, this ) } if( typeof this._onEveryKey === 'function' ){ this._onEveryKey( tempKey.values, this ) } } } } else if( direction < 0 ){ for( let i = keyIndexPrior; i > targetIndex; i -- ){ // See above disclaimer about expecting // tempKey === undefined when at the edge // of this.keys[]. const tempKey = this.keys[ i ] this.keyIndex = i if( tempKey instanceof Key && tempKey.guarantee === true ){ if( typeof tempKey.onKey === 'function' ){ tempKey.onKey( tempKey.values, this ) } if( typeof this._onEveryKey === 'function' ){ this._onEveryKey( tempKey.values, this ) } } } } this.keyIndex = targetIndex this.timeCursor = now ///////////////// // // // onTween // // // ///////////////// // If we have just keyframed during this update() loop, // no need to attempt to tween -- in fact that could // cause an awful stutter or bounce. // We need TWO valid keyframes in order to tween anything. // If we don’t got, we bail now. if( keyA instanceof Key !== true || keyB instanceof Key !== true ){ // console.warn( 'One of these keyframes was unresolved.', keyA, keyB ) return this } this.tweenKeys( keyA, keyB, now, direction ) // Do we need to implement onBefore with no valid keyB? // Just pass keyA vals??? +++ if( targetIndex < 0 && typeof this._onBefore === 'function' ){ this._onBefore( this.values, this ) // Note: We are NOT calling this._onEveryTween(). return this } if( targetIndex > this.keys.length - 1 && typeof this._onAfter === 'function' ){ this._onAfter( this.values, this ) // Note: We are NOT calling this._onEveryTween(). return this } if( targetIndex >= 0 && targetIndex < this.keys.length ){ if( typeof keyA._onTween === 'function' ){ keyA._onTween( this.values, this ) } if( typeof this._onEveryTween === 'function' ){ this._onEveryTween( this.values, this ) } } return this } reset( newTimeStartAbsolute ){ this.disable() if( isNotUsefulNumber( newTimeStartAbsolute )) newTimeStartAbsolute = Date.now() let timeCursor = newTimeStartAbsolute this.keys .forEach( function( key, i, keys ){ timeCursor += key.timeRelative keys[ i ].timeAbsolute = timeCursor// Again, see reasoning above for using .forEach rather than .reduce or .map here. }) this.setTimeBounds() this.keyIndex = -1 this.timeCursor = this.timeStart - 1 this.enable() return this } // A quick way to turn individual pacers on/off, // particuarly convenient if doing builk updates // like Pacer.update() ← Note that’s the Class method itself, // not an instance method. enable(){ this.isEnabled = true return this } disable(){ this.isEnabled = false return this } // All those moments will be lost in time, // like tears in rain. // Time to die. remove(){ Pacer.remove( this ) return this } // STATICS: `this === Pacer` static all = [] static update( now ){ this.all.forEach( function( p ){ p.update( now ) }) return this } static inspect(){ return this.all .reduce( function( output, entry ){ return output +'\n'+ entry.inspect() }, '' ) } static remove( instance ){ instance.isEnabled = false// Immediately prevents update() calls on the instance itself. const index = this.all.indexOf( instance ) this.all.splice( index, 1 ) instance = null return this } static removeAll(){ this.all.forEach( function( p ){ // Immediately prevents update() calls on the instance itself, // which may be in the process of being called // by some outside bit of script’s looping update() function // that is holding a reference to the instance itself. // And we of course do not want that. p.isEnabled = false }) this.all = [] return this } } // Tweening functions, aka Easing functions. // “Tween” is of course short for “between”, as in _between_ the keyframes. // We’ll start with our default tween (no easing): Pacer.linear = function( n ){ return n } Pacer.linear.label = 'linear' // Look how ’purty these symetric functions are boxed up. // Down the road we ought to add Bezier() and Custom options. Object.entries({ sine: n => 1 - Math.cos(( n * Math.PI ) / 2 ), quadratic: n => Math.pow( n, 2 ), cubic: n => Math.pow( n, 3 ), quartic: n => Math.pow( n, 4 ), quintic: n => Math.pow( n, 5 ), exponential: n => n === 0 ? 0 : Math.pow( 2, 10 * n - 10 ), circular: n => 1 - Math.sqrt( 1 - Math.pow( n, 2 )), elastic: n => { const c4 = ( 2 * Math.PI ) / 3 return n === 0 ? 0 : n === 1 ? 1 : -Math.pow( 2, 10 * n - 10 ) * Math.sin(( n * 10 - 10.75 ) * c4 ) }, back: ( n, c1, c3 )=> { if( isNotUsefulNumber( c1 )) c1 = 1.70158 c3 = c1 + 1 return c3 * Math.pow( n, 3 ) - c1 * Math.pow( n, 2 ) } }) .forEach( function( entry ){ const key = entry[ 0 ], val = entry[ 1 ] Pacer[ key ] = { label: key, in: val, out: n => 1 - val( 1 - n ), inOut: n => n < 0.5 ? val( n * 2 ) / 2 : val( n * 2 - 1 ) / 2 + 0.5 } // Why do this? // To make logic it easy to build logic off of this, // like “What is this tween and what’s its inverse?” Pacer[ key ].in.label = key Pacer[ key ].in.style = 'in' Pacer[ key ].out.label = key Pacer[ key ].out.style = 'out' Pacer[ key ].inOut.label = key Pacer[ key ].inOut.style = 'inOut' }) // Bounce doesn’t really make sense for “in” or “inOut” // but I’m including it here for completeness. Pacer.bounce = { label: 'bounce', in: n => 1 - val( 1 - n ), out: function( n, n1, d1 ){ if( isNotUsefulNumber( n1 )) n1 = 7.5625 if( isNotUsefulNumber( d1 )) d1 = 2.75 if( n < 1 / d1 ) return n1 * Math.pow( n, 2 ) else if( n < 2 / d1 ) return n1 * ( n -= 1.5 / d1 ) * n + 0.75 else if( n < 2.5 / d1 ) return n1 * ( n -= 2.25 / d1 ) * n + 0.9375 else return n1 * ( n -= 2.625 / d1 ) * n + 0.984375 }, inOut: n => n < 0.5 ? val( n * 2 ) / 2 : val( n * 2 - 1 ) / 2 + 0.5 } Pacer.bounce.in.label = 'bounce' Pacer.bounce.in.style = 'in' Pacer.bounce.out.label = 'bounce' Pacer.bounce.out.style = 'out' Pacer.bounce.inOut.label = 'bounce' Pacer.bounce.inOut.style = 'inOut' export default Pacer // If my whitespace makes you uncomfortable, // go weep into the bosom of your favorite dominatrix linter, // you feeble coward. // Do you feel that every function must be an arrow function? // I’m sorry you feel that way. Cry harder, feely boi. // Line-ending semicolons are for perverts.