UNPKG

johnny-five-electron

Version:

Temporary fork to support Electron (to be deprecated)

467 lines (356 loc) 10.5 kB
// TODO list // Use functions as keyFrames // Test metronomic on real animation // Create jquery FX like queue var ease = require("ease-component"), Emitter = require("events").EventEmitter, util = require("util"), __ = require("../lib/fn.js"), temporal; Animation.DEFAULTS = { cuePoints: [0, 1], duration: 1000, easing: "linear", loop: false, loopback: 0, metronomic: false, currentSpeed: 1, progress: 0, fps: 60, rate: 1000 / 60, paused: false, segments: [], onstart: null, onpause: null, onstop: null, oncomplete: null, onloop: null }; /** * Placeholders for Symbol */ Animation.normalize = "@@normalize"; Animation.render = "@@render"; /** * Animation * @constructor * * @param {target} A Servo or Servo.Array to be animated * * Animating a single servo * * var servo = new five.Servo(10); * var animation = new five.Animation(servo); * animation.enqueue({ * cuePoints: [0, 0.25, 0.75, 1], * keyFrames: [{degrees: 90}, 60, -120, {degrees: 90}], * duration: 2000 * }); * * * Animating a servo array * * var a = new five.Servo(9), * b = new five.Servo(10); * var servos = new five.Servo.Array([a, b]); * var animation = new five.Animation(servos); * animation.enqueue({ * cuePoints: [0, 0.25, 0.75, 1], * keyFrames: [ * [{degrees: 90}, 60, -120, {degrees: 90}], * [{degrees: 180}, -120, 90, {degrees: 180}], * ], * duration: 2000 * }); * */ function Animation(target) { // Necessary to avoid loading temporal unless necessary if (!temporal) { temporal = require("temporal"); } if (!(this instanceof Animation)) { return new Animation(target); } this.defaultTarget = target; __.extend(this, Animation.DEFAULTS); } util.inherits(Animation, Emitter); /** * Add an animation segment to the animation queue * @param {Object} opts Options: cuePoints, keyFrames, duration, * easing, loop, metronomic, progress, fps, onstart, onpause, * onstop, oncomplete, onloop */ Animation.prototype.enqueue = function(opts) { if (typeof opts.target === "undefined") { opts.target = this.defaultTarget; } __.defaults(opts, Animation.DEFAULTS); this.segments.push(opts); if (!this.paused) { this.next(); } return this; }; /** * Plays next segment in queue * Users need not call this. It's automatic */ Animation.prototype.next = function() { if (this.segments.length > 0) { __.extend(this, this.segments.shift()); if (this.onstart) { this.onstart(); } this.normalizedKeyFrames = __.cloneDeep(this.keyFrames); this.normalizedKeyFrames = this.target[Animation.normalize](this.normalizedKeyFrames); this.normalizedKeyFrames = this.normalizeKeyframes(this.normalizedKeyFrames, this.cuePoints); if (this.reverse) { this.currentSpeed *= -1; } if (this.currentSpeed !== 0) { this.play(); } else { this.paused = true; } } else { this.playLoop.stop(); } }; /** * pause * * Pause animation while maintaining progress, speed and segment queue * */ Animation.prototype.pause = function() { this.emit("animation:pause"); this.playLoop.stop(); this.paused = true; if (this.onpause) { this.onpause(); } }; /** * stop * * Stop all animations * */ Animation.prototype.stop = function() { this.emit("animation:stop"); this.segments = []; temporal.clear(); if (this.onstop) { this.onstop(); } }; /** * speed * * Get or set the current playback speed * * @param {Number} speed * */ Animation.prototype.speed = function(speed) { if (typeof speed === "undefined") { return this.currentSpeed; } else { this.currentSpeed = speed; // Find our timeline endpoints and refresh rate this.scaledDuration = this.duration / Math.abs(this.currentSpeed); this.startTime = Date.now() - this.scaledDuration * this.progress; this.endTime = this.startTime + this.scaledDuration; return this; } }; /** * play * * Start a segment */ Animation.prototype.play = function() { if (this.playLoop) { this.playLoop.stop(); } this.paused = false; // Find our timeline endpoints and refresh rate this.scaledDuration = this.duration / Math.abs(this.currentSpeed); this.startTime = Date.now() - this.scaledDuration * this.progress; this.endTime = this.startTime + this.scaledDuration; if (this.fps) { this.rate = 1000 / this.fps; } this.rate = this.rate | 0; this.playLoop = temporal.loop(this.rate, function(loop) { // Note: "this" is bound to the animation object // Find the current timeline progress var progress = this.calculateProgress(loop.calledAt); // Find the left and right cuePoints/keyFrames; var indices = this.findIndices(progress); // Get tweened value var val = this.tweenedValue(indices, progress); // call render function this.target[Animation.render](val); // See if we have reached the end of the animation if ((progress === 1 && !this.reverse) || (progress === this.loopback && this.reverse)) { if (this.loop || (this.metronomic && !this.reverse)) { if (this.onloop) { this.onloop(); } if (this.metronomic) { this.reverse = this.reverse ? false : true; } this.normalizedKeyFrames = __.cloneDeep(this.keyFrames); this.normalizedKeyFrames = this.target[Animation.normalize](this.normalizedKeyFrames); this.normalizedKeyFrames = this.normalizeKeyframes(); this.progress = this.loopback; this.startTime = Date.now() - this.scaledDuration * this.progress; this.endTime = this.startTime + this.scaledDuration; } else { this.stop(); if (this.oncomplete) { this.oncomplete(); } this.next(); } } }.bind(this)); }; Animation.prototype.findIndices = function(progress) { var indices = { left: null, right: null }; // Find our current before and after cuePoints indices.right = this.cuePoints.findIndex(function(point) { return point >= progress; }); indices.left = indices.right === 0 ? 0 : indices.right - 1; return indices; }; Animation.prototype.calculateProgress = function(calledAt) { var progress = (calledAt - this.startTime) / this.scaledDuration; if (progress > 1) { progress = 1; } this.progress = progress; if (this.reverse) { progress = 1 - progress; } // Ease the timeline // to do: When reverse replace inFoo with outFoo and vice versa. skip inOutFoo progress = ease[this.easing](progress); progress = __.constrain(progress, 0, 1); return progress; }; Animation.prototype.tweenedValue = function(indices, progress) { var tween = { duration: null, progress: null }; var result = this.normalizedKeyFrames.map(function(keyFrame) { // Note: "this" is bound to the animation object var memberIndices = { left: null, right: null }; // If the keyframe at indices.left is null, move left for (memberIndices.left = indices.left; memberIndices.left > -1; memberIndices.left--) { if (keyFrame[memberIndices.left] !== null) { break; } } // If the keyframe at indices.right is null, move right memberIndices.right = keyFrame.findIndex(function(frame, index) { return index >= indices.right && frame !== null; }); // Find our progress for the current tween tween.duration = this.cuePoints[memberIndices.right] - this.cuePoints[memberIndices.left]; tween.progress = (progress - this.cuePoints[memberIndices.left]) / tween.duration; // Catch divide by zero if (!Number.isFinite(tween.progress)) { tween.progress = this.reverse ? 0 : 1; } var left = keyFrame[memberIndices.left], right = keyFrame[memberIndices.right]; // Apply tween easing to tween.progress // to do: When reverse replace inFoo with outFoo and vice versa. skip inOutFoo tween.progress = ease[right.easing](tween.progress); // Calculate this tween value var calcValue; if (right.position) { // This is a tuple calcValue = right.position.map(function(value, index) { return (value - left.position[index]) * tween.progress + left.position[index]; }); } else { calcValue = (right.value - left.value) * tween.progress + left.value; } return calcValue; }, this); return result; }; // Make sure our keyframes conform to a standard Animation.prototype.normalizeKeyframes = function() { var previousVal, keyFrameSet = this.normalizedKeyFrames, cuePoints = this.cuePoints; // keyFrames can be passed as a single dimensional array if // there is just one servo/device. If the first element is not an // array, nest keyFrameSet so we only have to deal with one format if (!Array.isArray(keyFrameSet[0])) { keyFrameSet = [keyFrameSet]; } keyFrameSet.forEach(function(keyFrames) { // Pad the right side of keyFrames arrays with null for (var i = keyFrames.length; i < cuePoints.length; i++) { keyFrames.push(null); } keyFrames.forEach(function(keyFrame, i, source) { if (keyFrame !== null) { // keyFrames need to be converted to objects if (typeof keyFrame !== "object") { keyFrame = { step: keyFrame, easing: "linear" }; } // Replace step values if (typeof keyFrame.step !== "undefined") { keyFrame.value = keyFrame.step === false ? previousVal : previousVal + keyFrame.step; } // Set a default easing function if (!keyFrame.easing) { keyFrame.easing = "linear"; } // Copy value from another frame if (typeof keyFrame.copyValue !== "undefined") { keyFrame.value = source[keyFrame.copyValue].value; } // Copy everything from another keyframe in this array if (keyFrame.copyFrame) { keyFrame = source[keyFrame.copyFrame]; } previousVal = keyFrame.value; } else { if (i === source.length - 1) { keyFrame = { value: previousVal, easing: "linear" }; } else { keyFrame = null; } } source[i] = keyFrame; }, this); }); return keyFrameSet; }; module.exports = Animation;