johnny-five
Version:
The JavaScript Robotics and Hardware Programming Framework. Use with: Arduino (all models), Electric Imp, Beagle Bone, Intel Galileo & Edison, Linino One, Pinoccio, pcDuino3, Raspberry Pi, Particle/Spark Core & Photon, Tessel 2, TI Launchpad and more!
545 lines (442 loc) • 13.2 kB
JavaScript
const Emitter = require("./mixins/emitter");
const ease = require("./easing");
const { cloneDeep, constrain } = require("./fn");
let temporal;
// TODO list
// Use functions as keyFrames
// Test metronomic on real animation
// Create jquery FX like queue
/**
* The max time we want to allow a temporal animation segment to run.
* When running, temporal can push CPU utilization to 100%. When this
* time (in ms) is reached we will fall back to setInterval which is less
* accurate (by nanoseconds) but perfectly serviceable.
**/
let temporalTTL = 5000;
/**
* Animation
* @constructor
*
* @param {target} A Servo or Servo.Collection 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.Collection([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
* });
*
*/
class Animation extends Emitter {
constructor(target) {
super();
// Necessary to avoid loading temporal unless necessary
if (!temporal) {
temporal = require("temporal");
}
Object.assign(this, new Animation.Segment());
this.defaultTarget = target || {};
}
/**
* Add an animation segment to the animation queue
* @param {Object} options Options: cuePoints, keyFrames, duration,
* easing, loop, metronomic, progress, fps, onstart, onpause,
* onstop, oncomplete, onloop
*/
enqueue(options = {}) {
/* istanbul ignore else */
if (typeof options.target === "undefined") {
options.target = this.defaultTarget;
}
this.segments.push(options);
/* istanbul ignore if */
if (!this.paused && !this.isRunning) {
this.next();
}
return this;
}
/**
* Plays next segment in queue
* Users need not call this. It's automatic
*/
next() {
if (this.isRunning) {
return this;
} else {
this.isRunning = true;
}
if (this.segments.length > 0) {
Object.assign(this, new Animation.Segment(this.segments.shift()));
this.paused = this.currentSpeed === 0 ? true : false;
if (this.onstart) {
this.onstart();
}
this.normalizeKeyframes();
if (this.reverse) {
this.currentSpeed *= -1;
}
if (this.currentSpeed !== 0) {
this.play();
} else {
this.paused = true;
}
} else {
this.playLoop.stop();
}
return this;
}
/**
* pause
*
* Pause animation while maintaining progress, speed and segment queue
*
*/
pause() {
this.emit("animation:pause");
if (this.playLoop) {
this.playLoop.stop();
}
this.paused = true;
if (this.onpause) {
this.onpause();
}
}
/**
* stop
*
* Stop all animations
*
*/
stop() {
this.emit("animation:stop");
this.segments = [];
this.isRunning = false;
if (this.playLoop) {
this.playLoop.stop();
}
if (this.onstop) {
this.onstop();
}
}
/**
* speed
*
* Get or set the current playback speed
*
* @param {Number} speed
*
*/
speed(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;
if (!this.paused) {
this.play();
}
return this;
}
}
/**
* This function is called in each frame of our animation
* Users need not call this. It's automatic
*/
loopFunction({calledAt}) {
// Find the current timeline progress
const progress = this.calculateProgress(calledAt);
// Find the left and right cuePoints/keyFrames;
const indices = this.findIndices(progress);
// call render function with tweened value
this.target[Animation.render](this.tweenedValue(indices, progress));
/**
* If this animation has been running in temporal for too long
* fall back to using setInterval so we don't melt the user's CPU
**/
if (calledAt > this.fallBackTime) {
this.fallBackTime = Infinity;
if (this.playLoop) {
this.playLoop.stop();
}
this.playLoop = new Animation.TemporalFallback(this);
}
// See if we have reached the end of the animation
/* istanbul ignore else */
if ((this.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.normalizeKeyframes();
this.progress = this.loopback;
this.startTime = Date.now() - this.scaledDuration * this.progress;
this.endTime = this.startTime + this.scaledDuration;
} else {
this.isRunning = false;
if (this.oncomplete) {
process.nextTick(() => this.oncomplete());
}
if (this.segments.length > 0) {
process.nextTick(() => this.next());
} else {
this.stop();
}
}
}
}
/**
* play
*
* Start a segment
*/
play() {
const now = Date.now();
if (this.playLoop) {
this.playLoop.stop();
}
this.paused = false;
this.isRunning = true;
// Find our timeline endpoints and refresh rate
this.scaledDuration = this.duration / Math.abs(this.currentSpeed);
this.startTime = now - this.scaledDuration * this.progress;
this.endTime = this.startTime + this.scaledDuration;
// If our animation runs for more than 5 seconds switch to setTimeout
this.fallBackTime = now + temporalTTL;
this.frameCount = 0;
/* istanbul ignore else */
if (this.fps) {
this.rate = 1000 / this.fps;
}
this.rate = this.rate | 0;
this.playLoop = temporal.loop(this.rate, data => this.loopFunction(data));
}
findIndices(progress) {
const indices = {
left: null,
right: null
};
// Find our current before and after cuePoints
indices.right = this.cuePoints.findIndex(point => point >= progress);
indices.left = indices.right === 0 ? /* istanbul ignore next */ 0 : indices.right - 1;
return indices;
}
calculateProgress(calledAt) {
let 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
return constrain(ease[this.easing](progress), 0, 1);
}
tweenedValue(indices, progress) {
const tween = {
duration: null,
progress: null
};
const result = this.normalizedKeyFrames.map(keyFrame => {
const kIndices = {
left: null,
right: null
};
// If the keyframe at indices.left is null, move left
for (kIndices.left = indices.left; kIndices.left > -1; kIndices.left--) {
/* istanbul ignore else */
if (keyFrame[kIndices.left] !== null) {
break;
}
}
// If the keyframe at indices.right is null, move right
kIndices.right = keyFrame.findIndex((frame, index) =>
index >= indices.right && frame !== null
);
// Find our progress for the current tween
tween.duration = this.cuePoints[kIndices.right] - this.cuePoints[kIndices.left];
tween.progress = (progress - this.cuePoints[kIndices.left]) / tween.duration;
// Catch divide by zero
if (!Number.isFinite(tween.progress)) {
/* istanbul ignore next */
tween.progress = this.reverse ? 0 : 1;
}
const left = keyFrame[kIndices.left];
const right = keyFrame[kIndices.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
let calcValue;
if (right.position) {
// This is a tuple
calcValue = right.position.map((value, index) => (value - left.position[index]) *
tween.progress + left.position[index]);
} else {
if (typeof right.value === "number" && typeof left.value === "number") {
calcValue = (right.value - left.value) * tween.progress + left.value;
} else {
calcValue = this.target[Animation.keys].reduce((accum, key) => {
accum[key] = (right.value[key] - left.value[key]) * tween.progress + left.value[key];
return accum;
}, {});
}
}
return calcValue;
});
return result;
}
// Make sure our keyframes conform to a standard
normalizeKeyframes() {
let previousVal;
let keyFrameSet = cloneDeep(this.keyFrames);
const cuePoints = this.cuePoints;
// Run through the target's normalization
keyFrameSet = this.target[Animation.normalize](keyFrameSet);
// 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 (let i = keyFrames.length; i < cuePoints.length; i++) {
keyFrames.push(null);
}
keyFrames.forEach((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
/* istanbul ignore if */
if (typeof keyFrame.copyValue !== "undefined") {
keyFrame.value = source[keyFrame.copyValue].value;
}
// Copy everything from another keyframe in this array
/* istanbul ignore if */
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);
});
this.normalizedKeyFrames = keyFrameSet;
return this;
}
}
/**
* Placeholders for Symbol
*/
Animation.keys = "@@keys";
Animation.normalize = "@@normalize";
Animation.render = "@@render";
/**
* Temporal will run up the CPU. temporalFallback is used
* for long running animations.
*/
Animation.TemporalFallback = class {
constructor(animation) {
this.interval = setInterval(() => {
animation.loopFunction({
calledAt: Date.now()
});
}, animation.rate);
}
stop() {
if (this.interval) {
clearInterval(this.interval);
}
}
};
/**
* Animation.Segment()
*
* Create a defaulted segment.
*
* Every property ever used on an animation segment
* MUST be listed here, otherwise properties will
* persist across segments. This default object is
* primarily for resetting state.
*
*/
Animation.Segment = class {
constructor(options) {
this.cuePoints = [0, 1];
this.duration = 1000;
this.easing = "linear";
this.loop = false;
this.loopback = 0;
this.metronomic = false;
this.currentSpeed = 1;
this.progress = 0;
this.fps = 60;
this.rate = 1000 / 60;
this.paused = false;
this.isRunning = false;
this.segments = [];
this.onstart = null;
this.onpause = null;
this.onstop = null;
this.oncomplete = null;
this.onloop = null;
if (options) {
Object.assign(this, options);
if (options.segments) {
this.segments = options.segments.slice();
}
}
}
};
module.exports = Animation;