UNPKG

13.2 kBJavaScriptView Raw
1// TODO list
2// Use functions as keyFrames
3// Test metronomic on real animation
4
5// Create jquery FX like queue
6
7var Emitter = require("events").EventEmitter;
8var util = require("util");
9var ease = require("ease-component");
10var Fn = require("./fn");
11var temporal;
12
13
14/**
15 * The max time we want to allow a temporal animation segment to run.
16 * When running, temporal can push CPU utilization to 100%. When this
17 * time (in ms) is reached we will fall back to setInterval which is less
18 * accurate (by nanoseconds) but perfectly serviceable.
19 **/
20var temporalTTL = 5000;
21
22/**
23 * Placeholders for Symbol
24 */
25Animation.keys = "@@keys";
26Animation.normalize = "@@normalize";
27Animation.render = "@@render";
28
29/**
30 * Temporal will run up the CPU. temporalFallback is used
31 * for long running animations.
32 */
33Animation.TemporalFallback = function(animation) {
34 this.interval = setInterval(function() {
35 animation.loopFunction({
36 calledAt: Date.now()
37 });
38 }, animation.rate);
39};
40
41Animation.TemporalFallback.prototype.stop = function() {
42 if (this.interval) {
43 clearInterval(this.interval);
44 }
45};
46
47/**
48 * Animation
49 * @constructor
50 *
51 * @param {target} A Servo or Servo.Collection to be animated
52 *
53 * Animating a single servo
54 *
55 * var servo = new five.Servo(10);
56 * var animation = new five.Animation(servo);
57 * animation.enqueue({
58 * cuePoints: [0, 0.25, 0.75, 1],
59 * keyFrames: [{degrees: 90}, 60, -120, {degrees: 90}],
60 * duration: 2000
61 * });
62 *
63 *
64 * Animating a servo array
65 *
66 * var a = new five.Servo(9),
67 * b = new five.Servo(10);
68 * var servos = new five.Servo.Collection([a, b]);
69 * var animation = new five.Animation(servos);
70 * animation.enqueue({
71 * cuePoints: [0, 0.25, 0.75, 1],
72 * keyFrames: [
73 * [{degrees: 90}, 60, -120, {degrees: 90}],
74 * [{degrees: 180}, -120, 90, {degrees: 180}],
75 * ],
76 * duration: 2000
77 * });
78 *
79 */
80
81function Animation(target) {
82
83 // Necessary to avoid loading temporal unless necessary
84 if (!temporal) {
85 temporal = require("temporal");
86 }
87
88 if (!(this instanceof Animation)) {
89 return new Animation(target);
90 }
91
92 Animation.Segment.call(this);
93
94 this.defaultTarget = target || {};
95}
96
97util.inherits(Animation, Emitter);
98
99/**
100 * Animation.Segment()
101 *
102 * Create a defaulted segment.
103 *
104 * Every property ever used on an animation segment
105 * MUST be listed here, otherwise properties will
106 * persist across segments. This default object is
107 * primarily for resetting state.
108 *
109 */
110Animation.Segment = function(options) {
111 this.cuePoints = [0, 1];
112 this.duration = 1000;
113 this.easing = "linear";
114 this.loop = false;
115 this.loopback = 0;
116 this.metronomic = false;
117 this.currentSpeed = 1;
118 this.progress = 0;
119 this.fps = 60;
120 this.rate = 1000 / 60;
121 this.paused = false;
122 this.isRunning = false;
123 this.segments = [];
124 this.onstart = null;
125 this.onpause = null;
126 this.onstop = null;
127 this.oncomplete = null;
128 this.onloop = null;
129
130 if (options) {
131 Object.assign(this, options);
132
133 if (options.segments) {
134 this.segments = options.segments.slice();
135 }
136 }
137};
138
139
140/**
141 * Add an animation segment to the animation queue
142 * @param {Object} opts Options: cuePoints, keyFrames, duration,
143 * easing, loop, metronomic, progress, fps, onstart, onpause,
144 * onstop, oncomplete, onloop
145 */
146Animation.prototype.enqueue = function(opts) {
147
148 opts = opts || {};
149
150 /* istanbul ignore else */
151 if (typeof opts.target === "undefined") {
152 opts.target = this.defaultTarget;
153 }
154
155 this.segments.push(opts);
156
157 /* istanbul ignore if */
158 if (!this.paused && !this.isRunning) {
159 this.next();
160 }
161
162 return this;
163
164};
165
166/**
167 * Plays next segment in queue
168 * Users need not call this. It's automatic
169 */
170Animation.prototype.next = function() {
171
172 if (this.isRunning) {
173 return this;
174 } else {
175 this.isRunning = true;
176 }
177
178 if (this.segments.length > 0) {
179 var segment = new Animation.Segment(this.segments.shift());
180
181 Object.assign(this, segment);
182 this.paused = this.currentSpeed === 0 ? true : false;
183
184 if (this.onstart) {
185 this.onstart();
186 }
187
188 this.normalizeKeyframes();
189
190 if (this.reverse) {
191 this.currentSpeed *= -1;
192 }
193
194 if (this.currentSpeed !== 0) {
195 this.play();
196 } else {
197 this.paused = true;
198 }
199 } else {
200 this.playLoop.stop();
201 }
202
203 return this;
204};
205
206/**
207 * pause
208 *
209 * Pause animation while maintaining progress, speed and segment queue
210 *
211 */
212
213Animation.prototype.pause = function() {
214
215 this.emit("animation:pause");
216
217 if (this.playLoop) {
218 this.playLoop.stop();
219 }
220 this.paused = true;
221
222 if (this.onpause) {
223 this.onpause();
224 }
225
226};
227
228/**
229 * stop
230 *
231 * Stop all animations
232 *
233 */
234
235Animation.prototype.stop = function() {
236
237 this.emit("animation:stop");
238
239 this.segments = [];
240 this.isRunning = false;
241 if (this.playLoop) {
242 this.playLoop.stop();
243 }
244
245 if (this.onstop) {
246 this.onstop();
247 }
248
249};
250
251/**
252 * speed
253 *
254 * Get or set the current playback speed
255 *
256 * @param {Number} speed
257 *
258 */
259
260Animation.prototype.speed = function(speed) {
261
262 if (typeof speed === "undefined") {
263 return this.currentSpeed;
264 } else {
265 this.currentSpeed = speed;
266
267 // Find our timeline endpoints and refresh rate
268 this.scaledDuration = this.duration / Math.abs(this.currentSpeed);
269 this.startTime = Date.now() - this.scaledDuration * this.progress;
270 this.endTime = this.startTime + this.scaledDuration;
271
272 if (!this.paused) {
273 this.play();
274 }
275 return this;
276 }
277};
278
279/**
280 * This function is called in each frame of our animation
281 * Users need not call this. It's automatic
282 */
283
284Animation.prototype.loopFunction = function(loop) {
285
286 // Find the current timeline progress
287 var progress = this.calculateProgress(loop.calledAt);
288
289 // Find the left and right cuePoints/keyFrames;
290 var indices = this.findIndices(progress);
291
292 // call render function with tweened value
293 this.target[Animation.render](this.tweenedValue(indices, progress));
294
295 /**
296 * If this animation has been running in temporal for too long
297 * fall back to using setInterval so we don't melt the user's CPU
298 **/
299 if (loop.calledAt > this.fallBackTime) {
300 this.fallBackTime = Infinity;
301 if (this.playLoop) {
302 this.playLoop.stop();
303 }
304 this.playLoop = new Animation.TemporalFallback(this);
305 }
306
307 // See if we have reached the end of the animation
308 /* istanbul ignore else */
309 if ((this.progress === 1 && !this.reverse) || (progress === this.loopback && this.reverse)) {
310
311 if (this.loop || (this.metronomic && !this.reverse)) {
312
313 if (this.onloop) {
314 this.onloop();
315 }
316
317 if (this.metronomic) {
318 this.reverse = this.reverse ? false : true;
319 }
320
321 this.normalizeKeyframes();
322 this.progress = this.loopback;
323 this.startTime = Date.now() - this.scaledDuration * this.progress;
324 this.endTime = this.startTime + this.scaledDuration;
325 } else {
326
327 this.isRunning = false;
328
329 if (this.oncomplete) {
330 process.nextTick(this.oncomplete.bind(this));
331 }
332
333 if (this.segments.length > 0) {
334 process.nextTick(() => { this.next(); });
335 } else {
336 this.stop();
337 }
338 }
339 }
340};
341
342/**
343 * play
344 *
345 * Start a segment
346 */
347
348Animation.prototype.play = function() {
349 var now = Date.now();
350
351 if (this.playLoop) {
352 this.playLoop.stop();
353 }
354
355 this.paused = false;
356 this.isRunning = true;
357
358 // Find our timeline endpoints and refresh rate
359 this.scaledDuration = this.duration / Math.abs(this.currentSpeed);
360 this.startTime = now - this.scaledDuration * this.progress;
361 this.endTime = this.startTime + this.scaledDuration;
362
363 // If our animation runs for more than 5 seconds switch to setTimeout
364 this.fallBackTime = now + temporalTTL;
365 this.frameCount = 0;
366
367 /* istanbul ignore else */
368 if (this.fps) {
369 this.rate = 1000 / this.fps;
370 }
371
372 this.rate = this.rate | 0;
373
374 this.playLoop = temporal.loop(this.rate, this.loopFunction.bind(this));
375};
376
377Animation.prototype.findIndices = function(progress) {
378 var indices = {
379 left: null,
380 right: null
381 };
382
383 // Find our current before and after cuePoints
384 indices.right = this.cuePoints.findIndex(function(point) {
385 return point >= progress;
386 });
387
388 indices.left = indices.right === 0 ? /* istanbul ignore next */ 0 : indices.right - 1;
389
390 return indices;
391};
392
393Animation.prototype.calculateProgress = function(calledAt) {
394
395 var progress = (calledAt - this.startTime) / this.scaledDuration;
396
397 if (progress > 1) {
398 progress = 1;
399 }
400
401 this.progress = progress;
402
403 if (this.reverse) {
404 progress = 1 - progress;
405 }
406
407 // Ease the timeline
408 // to do: When reverse replace inFoo with outFoo and vice versa. skip inOutFoo
409 progress = ease[this.easing](progress);
410 progress = Fn.constrain(progress, 0, 1);
411
412 return progress;
413};
414
415Animation.prototype.tweenedValue = function(indices, progress) {
416
417 var tween = {
418 duration: null,
419 progress: null
420 };
421
422 var result = this.normalizedKeyFrames.map(function(keyFrame) {
423 // Note: "this" is bound to the animation object
424
425 var memberIndices = {
426 left: null,
427 right: null
428 };
429
430 // If the keyframe at indices.left is null, move left
431 for (memberIndices.left = indices.left; memberIndices.left > -1; memberIndices.left--) {
432 /* istanbul ignore else */
433 if (keyFrame[memberIndices.left] !== null) {
434 break;
435 }
436 }
437
438 // If the keyframe at indices.right is null, move right
439 memberIndices.right = keyFrame.findIndex(function(frame, index) {
440 return index >= indices.right && frame !== null;
441 });
442
443 // Find our progress for the current tween
444 tween.duration = this.cuePoints[memberIndices.right] - this.cuePoints[memberIndices.left];
445 tween.progress = (progress - this.cuePoints[memberIndices.left]) / tween.duration;
446
447 // Catch divide by zero
448 if (!Number.isFinite(tween.progress)) {
449 /* istanbul ignore next */
450 tween.progress = this.reverse ? 0 : 1;
451 }
452
453 var left = keyFrame[memberIndices.left],
454 right = keyFrame[memberIndices.right];
455
456 // Apply tween easing to tween.progress
457 // to do: When reverse replace inFoo with outFoo and vice versa. skip inOutFoo
458 tween.progress = ease[right.easing](tween.progress);
459
460 // Calculate this tween value
461 var calcValue;
462
463 if (right.position) {
464 // This is a tuple
465 calcValue = right.position.map(function(value, index) {
466 return (value - left.position[index]) *
467 tween.progress + left.position[index];
468 });
469 } else {
470 if (typeof right.value === "number" && typeof left.value === "number") {
471 calcValue = (right.value - left.value) * tween.progress + left.value;
472 } else {
473 calcValue = this.target[Animation.keys].reduce(function(accum, key) {
474 accum[key] = (right.value[key] - left.value[key]) * tween.progress + left.value[key];
475 return accum;
476 }, {});
477 }
478 }
479
480 return calcValue;
481 }, this);
482
483 return result;
484};
485
486// Make sure our keyframes conform to a standard
487Animation.prototype.normalizeKeyframes = function() {
488
489 var previousVal,
490 keyFrameSet = Fn.cloneDeep(this.keyFrames),
491 cuePoints = this.cuePoints;
492
493 // Run through the target's normalization
494 keyFrameSet = this.target[Animation.normalize](keyFrameSet);
495
496 // keyFrames can be passed as a single dimensional array if
497 // there is just one servo/device. If the first element is not an
498 // array, nest keyFrameSet so we only have to deal with one format
499 if (!Array.isArray(keyFrameSet[0])) {
500 keyFrameSet = [keyFrameSet];
501 }
502
503 keyFrameSet.forEach(function(keyFrames) {
504
505 // Pad the right side of keyFrames arrays with null
506 for (var i = keyFrames.length; i < cuePoints.length; i++) {
507 keyFrames.push(null);
508 }
509
510 keyFrames.forEach(function(keyFrame, i, source) {
511
512 if (keyFrame !== null) {
513
514 // keyFrames need to be converted to objects
515 if (typeof keyFrame !== "object") {
516 keyFrame = {
517 step: keyFrame,
518 easing: "linear"
519 };
520 }
521
522 // Replace step values
523 if (typeof keyFrame.step !== "undefined") {
524 keyFrame.value = keyFrame.step === false ?
525 previousVal : previousVal + keyFrame.step;
526 }
527
528 // Set a default easing function
529 if (!keyFrame.easing) {
530 keyFrame.easing = "linear";
531 }
532
533 // Copy value from another frame
534 /* istanbul ignore if */
535 if (typeof keyFrame.copyValue !== "undefined") {
536 keyFrame.value = source[keyFrame.copyValue].value;
537 }
538
539 // Copy everything from another keyframe in this array
540 /* istanbul ignore if */
541 if (keyFrame.copyFrame) {
542 keyFrame = source[keyFrame.copyFrame];
543 }
544
545 previousVal = keyFrame.value;
546
547 } else {
548
549 if (i === source.length - 1) {
550 keyFrame = {
551 value: previousVal,
552 easing: "linear"
553 };
554 } else {
555 keyFrame = null;
556 }
557
558 }
559 source[i] = keyFrame;
560
561 }, this);
562 });
563
564 this.normalizedKeyFrames = keyFrameSet;
565
566 return this;
567};
568
569module.exports = Animation;