rekapi.js

import { Tweenable, setBezierFunction } from 'shifty';
import { Actor } from './actor';

import {
  each,
  pick,
  without
} from './utils';

const UPDATE_TIME = 1000 / 60;

export const DEFAULT_EASING = 'linear';

/*!
 * Fire an event bound to a Rekapi.
 * @param {Rekapi} rekapi
 * @param {string} eventName
 * @param {Object} [data={}] Optional event-specific data
 */
export const fireEvent = (rekapi, eventName, data = {}) =>
  rekapi._events[eventName].forEach(handler => handler(rekapi, data));

/*!
 * @param {Rekapi} rekapi
 */
export const invalidateAnimationLength = rekapi =>
  rekapi._animationLengthValid = false;

/*!
 * Determines which iteration of the loop the animation is currently in.
 * @param {Rekapi} rekapi
 * @param {number} timeSinceStart
 */
export const determineCurrentLoopIteration = (rekapi, timeSinceStart) => {
  const animationLength = rekapi.getAnimationLength();

  if (animationLength === 0) {
    return timeSinceStart;
  }

  return Math.floor(timeSinceStart / animationLength);
};

/*!
 * Calculate how many milliseconds since the animation began.
 * @param {Rekapi} rekapi
 * @return {number}
 */
export const calculateTimeSinceStart = rekapi =>
  Tweenable.now() - rekapi._loopTimestamp;

/*!
 * Determines if the animation is complete or not.
 * @param {Rekapi} rekapi
 * @param {number} currentLoopIteration
 * @return {boolean}
 */
export const isAnimationComplete = (rekapi, currentLoopIteration) =>
  currentLoopIteration >= rekapi._timesToIterate
    && rekapi._timesToIterate !== -1;

/*!
 * Stops the animation if it is complete.
 * @param {Rekapi} rekapi
 * @param {number} currentLoopIteration
 * @fires rekapi.animationComplete
 */
export const updatePlayState = (rekapi, currentLoopIteration) => {
  if (isAnimationComplete(rekapi, currentLoopIteration)) {
    rekapi.stop();
    fireEvent(rekapi, 'animationComplete');
  }
};

/*!
 * Calculate how far in the animation loop `rekapi` is, in milliseconds,
 * based on the current time.  Also overflows into a new loop if necessary.
 * @param {Rekapi} rekapi
 * @param {number} forMillisecond
 * @param {number} currentLoopIteration
 * @return {number}
 */
export const calculateLoopPosition = (rekapi, forMillisecond, currentLoopIteration) => {
  const animationLength = rekapi.getAnimationLength();

  return animationLength === 0 ?
    0 :
    isAnimationComplete(rekapi, currentLoopIteration) ?
      animationLength :
      forMillisecond % animationLength;
};

/*!
 * Calculate the timeline position and state for a given millisecond.
 * Updates the `rekapi` state internally and accounts for how many loop
 * iterations the animation runs for.
 * @param {Rekapi} rekapi
 * @param {number} forMillisecond
 * @fires rekapi.animationLooped
 */
export const updateToMillisecond = (rekapi, forMillisecond) => {
  const currentIteration = determineCurrentLoopIteration(rekapi, forMillisecond);
  const loopPosition = calculateLoopPosition(
    rekapi, forMillisecond, currentIteration
  );

  rekapi._loopPosition = loopPosition;

  const keyframeResetList = [];

  if (currentIteration > rekapi._latestIteration) {
    fireEvent(rekapi, 'animationLooped');

    rekapi._actors.forEach(actor => {

      const { _keyframeProperties } = actor;
      const fnKeyframes = Object.keys(_keyframeProperties).reduce(
        (acc, propertyId) => {
          const property = _keyframeProperties[propertyId];

          if (property.name === 'function') {
            acc.push(property);
          }

          return acc;
        },
        []
      );

      const lastFnKeyframe = fnKeyframes[fnKeyframes.length - 1];

      if (lastFnKeyframe && !lastFnKeyframe.hasFired) {
        lastFnKeyframe.invoke();
      }

      keyframeResetList.push(...fnKeyframes);
    });
  }

  rekapi._latestIteration = currentIteration;
  rekapi.update(loopPosition, true);
  updatePlayState(rekapi, currentIteration);

  keyframeResetList.forEach(fnKeyframe => {
    fnKeyframe.hasFired = false;
  });
};

/*!
 * Calculate how far into the animation loop `rekapi` is, in milliseconds,
 * and update based on that time.
 * @param {Rekapi} rekapi
 */
export const updateToCurrentMillisecond = rekapi =>
  updateToMillisecond(rekapi, calculateTimeSinceStart(rekapi));

/*!
 * This is the heartbeat of an animation.  This updates `rekapi`'s state and
 * then calls itself continuously.
 * @param {Rekapi} rekapi
 */
const tick = rekapi =>
  // Need to check for .call presence to get around an IE limitation.  See
  // annotation for cancelLoop for more info.
  rekapi._loopId = rekapi._scheduleUpdate.call ?
    rekapi._scheduleUpdate.call(global, rekapi._updateFn, UPDATE_TIME) :
    setTimeout(rekapi._updateFn, UPDATE_TIME);

/*!
 * @return {Function}
 */
const getUpdateMethod = () =>
  // requestAnimationFrame() shim by Paul Irish (modified for Rekapi)
  // http://paulirish.com/2011/requestanimationframe-for-smart-animating/
  global.requestAnimationFrame       ||
  global.webkitRequestAnimationFrame ||
  global.oRequestAnimationFrame      ||
  global.msRequestAnimationFrame     ||
  (global.mozCancelRequestAnimationFrame && global.mozRequestAnimationFrame) ||
  global.setTimeout;

/*!
 * @return {Function}
 */
const getCancelMethod = () =>
  global.cancelAnimationFrame           ||
  global.webkitCancelAnimationFrame     ||
  global.oCancelAnimationFrame          ||
  global.msCancelAnimationFrame         ||
  global.mozCancelRequestAnimationFrame ||
  global.clearTimeout;

/*!
 * Cancels an update loop.  This abstraction is needed to get around the fact
 * that in IE, clearTimeout is not technically a function
 * (https://twitter.com/kitcambridge/status/206655060342603777) and thus
 * Function.prototype.call cannot be used upon it.
 * @param {Rekapi} rekapi
 */
const cancelLoop = rekapi =>
  rekapi._cancelUpdate.call ?
    rekapi._cancelUpdate.call(global, rekapi._loopId) :
    clearTimeout(rekapi._loopId);

const STOPPED = 'stopped';
const PAUSED = 'paused';
const PLAYING = 'playing';

/*!
 * @type {Object.<function>} Contains the context init function to be called in
 * the Rekapi constructor.  This array is populated by modules in the
 * renderers/ directory.
 */
export const rendererBootstrappers = [];

/**
 * If this is a rendered animation, the appropriate renderer is accessible as
 * `this.renderer`.  If provided, a reference to `context` is accessible
 * as `this.context`.
 * @param {(Object|CanvasRenderingContext2D|HTMLElement)} [context={}] Sets
 * {@link rekapi.Rekapi#context}. This determines how to render the animation.
 * {@link rekapi.Rekapi} will also automatically set up all necessary {@link
 * rekapi.Rekapi#renderers} based on this value:
 *
 * * If this is not provided or is a plain object (`{}`), the animation will
 * not render anything and {@link rekapi.Rekapi#renderers} will be empty.
 * * If this is a
 * [`CanvasRenderingContext2D`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D),
 * {@link rekapi.Rekapi#renderers} will contain a {@link
 * rekapi.CanvasRenderer}.
 * * If this is a DOM element, {@link rekapi.Rekapi#renderers} will contain a
 * {@link rekapi.DOMRenderer}.
 * @constructs rekapi.Rekapi
 */
export class Rekapi {
  constructor (context = {}) {
    /**
     * @member {(Object|CanvasRenderingContext2D|HTMLElement)}
     * rekapi.Rekapi#context The rendering context for an animation.
     * @default {}
     */
    this.context = context;
    this._actors = [];
    this._playState = STOPPED;

    /**
     * @member {(rekapi.actorSortFunction|null)} rekapi.Rekapi#sort Optional
     * function for sorting the render order of {@link rekapi.Actor}s.  If set,
     * this is called each frame before the {@link rekapi.Actor}s are rendered.
     * If not set, {@link rekapi.Actor}s will render in the order they were
     * added via {@link rekapi.Rekapi#addActor}.
     *
     * The following example assumes that all {@link rekapi.Actor}s are circles
     * that have a `radius` {@link rekapi.KeyframeProperty}.  The circles will
     * be rendered in order of the value of their `radius`, from smallest to
     * largest.  This has the effect of layering larger circles on top of
     * smaller circles, thus giving a sense of perspective.
     *
     *     const rekapi = new Rekapi();
     *     rekapi.sort = actor => actor.get().radius;
     * @default null
     */
    this.sort = null;

    this._events = {
      animationComplete: [],
      playStateChange: [],
      play: [],
      pause: [],
      stop: [],
      beforeUpdate: [],
      afterUpdate: [],
      addActor: [],
      removeActor: [],
      beforeAddKeyframeProperty: [],
      addKeyframeProperty: [],
      removeKeyframeProperty: [],
      removeKeyframePropertyComplete: [],
      beforeRemoveKeyframeProperty: [],
      addKeyframePropertyTrack: [],
      removeKeyframePropertyTrack: [],
      timelineModified: [],
      animationLooped: []
    };

    // How many times to loop the animation before stopping
    this._timesToIterate = -1;

    // Millisecond duration of the animation
    this._animationLength = 0;
    this._animationLengthValid = false;

    // The setTimeout ID of `tick`
    this._loopId = null;

    // The UNIX time at which the animation loop started
    this._loopTimestamp = null;

    // Used for maintaining position when the animation is paused
    this._pausedAtTime = null;

    // The last millisecond position that was updated
    this._lastUpdatedMillisecond = 0;

    // The most recent loop iteration a frame was calculated for
    this._latestIteration = 0;

    // The most recent millisecond position within the loop that the animation
    // was updated to
    this._loopPosition = null;

    this._scheduleUpdate = getUpdateMethod();
    this._cancelUpdate = getCancelMethod();

    this._updateFn = () => {
      tick(this);
      updateToCurrentMillisecond(this);
    };

    /**
     * @member {Array.<rekapi.renderer>} rekapi.Rekapi#renderers Instances of
     * {@link rekapi.renderer} classes, as inferred by the `context`
     * parameter provided to the {@link rekapi.Rekapi} constructor.  You can
     * add more renderers to this list manually; see the {@tutorial
     * multiple-renderers} tutorial for an example.
     */
    this.renderers = rendererBootstrappers
      .map(renderer => renderer(this))
      .filter(_ => _);
  }

  /**
   * Add a {@link rekapi.Actor} to the animation.  Decorates the added {@link
   * rekapi.Actor} with a reference to this {@link rekapi.Rekapi} instance as
   * {@link rekapi.Actor#rekapi}.
   *
   * @method rekapi.Rekapi#addActor
   * @param {(rekapi.Actor|Object)} [actor={}] If this is an `Object`, it is used to as
   * the constructor parameters for a new {@link rekapi.Actor} instance that
   * is created by this method.
   * @return {rekapi.Actor} The {@link rekapi.Actor} that was added.
   * @fires rekapi.addActor
   */
  addActor (actor = {}) {
    const rekapiActor = actor instanceof Actor ?
      actor :
      new Actor(actor);

    // You can't add an actor more than once.
    if (~this._actors.indexOf(rekapiActor)) {
      return rekapiActor;
    }

    rekapiActor.context = rekapiActor.context || this.context;
    rekapiActor.rekapi = this;

    // Store a reference to the actor internally
    this._actors.push(rekapiActor);

    invalidateAnimationLength(this);
    rekapiActor.setup();

    fireEvent(this, 'addActor', rekapiActor);

    return rekapiActor;
  }

  /**
   * @method rekapi.Rekapi#getActor
   * @param {number} actorId
   * @return {rekapi.Actor} A reference to an actor from the animation by its
   * `id`.  You can use {@link rekapi.Rekapi#getActorIds} to get a list of IDs
   * for all actors in the animation.
   */
  getActor (actorId) {
    return this._actors.filter(actor => actor.id === actorId)[0];
  }

  /**
   * @method rekapi.Rekapi#getActorIds
   * @return {Array.<number>} The `id`s of all {@link rekapi.Actor}`s in the
   * animation.
   */
  getActorIds () {
    return this._actors.map(actor => actor.id);
  }

  /**
   * @method rekapi.Rekapi#getAllActors
   * @return {Array.<rekapi.Actor>} All {@link rekapi.Actor}s in the animation.
   */
  getAllActors () {
    return this._actors.slice();
  }

  /**
   * @method rekapi.Rekapi#getActorCount
   * @return {number} The number of {@link rekapi.Actor}s in the animation.
   */
  getActorCount () {
    return this._actors.length;
  }

  /**
   * Remove an actor from the animation.  This does not destroy the actor, it
   * only removes the link between it and this {@link rekapi.Rekapi} instance.
   * This method calls the actor's {@link rekapi.Actor#teardown} method, if
   * defined.
   * @method rekapi.Rekapi#removeActor
   * @param {rekapi.Actor} actor
   * @return {rekapi.Actor} The {@link rekapi.Actor} that was removed.
   * @fires rekapi.removeActor
   */
  removeActor (actor) {
    // Remove the link between Rekapi and actor
    this._actors = without(this._actors, actor);
    delete actor.rekapi;

    actor.teardown();
    invalidateAnimationLength(this);

    fireEvent(this, 'removeActor', actor);

    return actor;
  }

  /**
   * Remove all {@link rekapi.Actor}s from the animation.
   * @method rekapi.Rekapi#removeAllActors
   * @return {Array.<rekapi.Actor>} The {@link rekapi.Actor}s that were
   * removed.
   */
  removeAllActors () {
    return this.getAllActors().map(actor => this.removeActor(actor));
  }

  /**
   * Play the animation.
   *
   * @method rekapi.Rekapi#play
   * @param {number} [iterations=-1] If omitted, the animation will loop
   * endlessly.
   * @return {rekapi.Rekapi}
   * @fires rekapi.playStateChange
   * @fires rekapi.play
   */
  play (iterations = -1) {
    cancelLoop(this);

    if (this._playState === PAUSED) {
      // Move the playhead to the correct position in the timeline if resuming
      // from a pause
      this._loopTimestamp += Tweenable.now() - this._pausedAtTime;
    } else {
      this._loopTimestamp = Tweenable.now();
    }

    this._timesToIterate = iterations;
    this._playState = PLAYING;

    // Start the update loop
    tick(this);

    fireEvent(this, 'playStateChange');
    fireEvent(this, 'play');

    return this;
  }

  /**
   * Move to a specific millisecond on the timeline and play from there.
   *
   * @method rekapi.Rekapi#playFrom
   * @param {number} millisecond
   * @param {number} [iterations] Works as it does in {@link
   * rekapi.Rekapi#play}.
   * @return {rekapi.Rekapi}
   */
  playFrom (millisecond, iterations) {
    this.play(iterations);
    this._loopTimestamp = Tweenable.now() - millisecond;

    this._actors.forEach(
      actor => actor._resetFnKeyframesFromMillisecond(millisecond)
    );

    return this;
  }

  /**
   * Play from the last frame that was rendered with {@link
   * rekapi.Rekapi#update}.
   *
   * @method rekapi.Rekapi#playFromCurrent
   * @param {number} [iterations] Works as it does in {@link
   * rekapi.Rekapi#play}.
   * @return {rekapi.Rekapi}
   */
  playFromCurrent (iterations) {
    return this.playFrom(this._lastUpdatedMillisecond, iterations);
  }

  /**
   * Pause the animation.  A "paused" animation can be resumed from where it
   * left off with {@link rekapi.Rekapi#play}.
   *
   * @method rekapi.Rekapi#pause
   * @return {rekapi.Rekapi}
   * @fires rekapi.playStateChange
   * @fires rekapi.pause
   */
  pause () {
    if (this._playState === PAUSED) {
      return this;
    }

    this._playState = PAUSED;
    cancelLoop(this);
    this._pausedAtTime = Tweenable.now();

    fireEvent(this, 'playStateChange');
    fireEvent(this, 'pause');

    return this;
  }

  /**
   * Stop the animation.  A "stopped" animation will start from the beginning
   * if {@link rekapi.Rekapi#play} is called.
   *
   * @method rekapi.Rekapi#stop
   * @return {rekapi.Rekapi}
   * @fires rekapi.playStateChange
   * @fires rekapi.stop
   */
  stop () {
    this._playState = STOPPED;
    cancelLoop(this);

    // Also kill any shifty tweens that are running.
    this._actors.forEach(actor =>
      actor._resetFnKeyframesFromMillisecond(0)
    );

    fireEvent(this, 'playStateChange');
    fireEvent(this, 'stop');

    return this;
  }

  /**
   * @method rekapi.Rekapi#isPlaying
   * @return {boolean} Whether or not the animation is playing (meaning not paused or
   * stopped).
   */
  isPlaying () {
    return this._playState === PLAYING;
  }

  /**
   * @method rekapi.Rekapi#isPaused
   * @return {boolean} Whether or not the animation is paused (meaning not playing or
   * stopped).
   */
  isPaused () {
    return this._playState === PAUSED;
  }

  /**
   * @method rekapi.Rekapi#isStopped
   * @return {boolean} Whether or not the animation is stopped (meaning not playing or
   * paused).
   */
  isStopped () {
    return this._playState === STOPPED;
  }

  /**
   * Render an animation frame at a specific point in the timeline.
   *
   * @method rekapi.Rekapi#update
   * @param {number} [millisecond=this._lastUpdatedMillisecond] The point in
   * the timeline at which to render.  If omitted, this renders the last
   * millisecond that was rendered (it's a re-render).
   * @param {boolean} [doResetLaterFnKeyframes=false] If `true`, allow all
   * {@link rekapi.keyframeFunction}s later in the timeline to be run again.
   * This is a low-level feature, it should not be `true` (or even provided)
   * for most use cases.
   * @return {rekapi.Rekapi}
   * @fires rekapi.beforeUpdate
   * @fires rekapi.afterUpdate
   */
  update (
    millisecond = this._lastUpdatedMillisecond,
    doResetLaterFnKeyframes = false
  ) {
    fireEvent(this, 'beforeUpdate');

    const { sort } = this;

    const renderOrder = sort ?
      this._actors.sort((a, b) => sort(a) - sort(b)) :
      this._actors;

    // Update and render each of the actors
    renderOrder.forEach(actor => {
      actor._updateState(millisecond, doResetLaterFnKeyframes);

      if (actor.wasActive) {
        actor.render(actor.context, actor.get());
      }
    });

    this._lastUpdatedMillisecond = millisecond;
    fireEvent(this, 'afterUpdate');

    return this;
  }

  /**
   * @method rekapi.Rekapi#getLastPositionUpdated
   * @return {number} The normalized timeline position (between 0 and 1) that
   * was last rendered.
   */
  getLastPositionUpdated () {
    return (this._lastUpdatedMillisecond / this.getAnimationLength());
  }

  /**
   * @method rekapi.Rekapi#getLastMillisecondUpdated
   * @return {number} The millisecond that was last rendered.
   */
  getLastMillisecondUpdated () {
    return this._lastUpdatedMillisecond;
  }

  /**
   * @method rekapi.Rekapi#getAnimationLength
   * @return {number} The length of the animation timeline, in milliseconds.
   */
  getAnimationLength () {
    if (!this._animationLengthValid) {
      this._animationLength = Math.max.apply(
        Math,
        this._actors.map(actor => actor.getEnd())
      );

      this._animationLengthValid = true;
    }

    return this._animationLength;
  }

  /**
   * Bind a {@link rekapi.eventHandler} function to a Rekapi event.
   * @method rekapi.Rekapi#on
   * @param {string} eventName
   * @param {rekapi.eventHandler} handler The event handler function.
   * @return {rekapi.Rekapi}
   */
  on (eventName, handler) {
    if (!this._events[eventName]) {
      return this;
    }

    this._events[eventName].push(handler);

    return this;
  }

  /**
   * Manually fire a Rekapi event, thereby calling all {@link
   * rekapi.eventHandler}s bound to that event.
   * @param {string} eventName The name of the event to trigger.
   * @param {any} [data] Optional data to provide to the `eventName` {@link
   * rekapi.eventHandler}s.
   * @method rekapi.Rekapi#trigger
   * @return {rekapi.Rekapi}
   * @fires *
   */
  trigger (eventName, data) {
    fireEvent(this, eventName, data);

    return this;
  }

  /**
   * Unbind one or more handlers from a Rekapi event.
   * @method rekapi.Rekapi#off
   * @param {string} eventName Valid values correspond to the list under
   * {@link rekapi.Rekapi#on}.
   * @param {rekapi.eventHandler} [handler] A reference to the {@link
   * rekapi.eventHandler} to unbind.  If omitted, all {@link
   * rekapi.eventHandler}s bound to `eventName` are unbound.
   * @return {rekapi.Rekapi}
   */
  off (eventName, handler) {
    if (!this._events[eventName]) {
      return this;
    }

    this._events[eventName] = handler ?
      without(this._events[eventName], handler) :
      [];

    return this;
  }

  /**
   * Export the timeline to a `JSON.stringify`-friendly `Object`.
   *
   * @method rekapi.Rekapi#exportTimeline
   * @param {Object} [config]
   * @param {boolean} [config.withId=false] If `true`, include internal `id`
   * values in exported data.
   * @return {rekapi.timelineData} This data can later be consumed by {@link
   * rekapi.Rekapi#importTimeline}.
   */
  exportTimeline ({ withId = false } = {}) {
    const exportData = {
      duration: this.getAnimationLength(),
      actors: this._actors.map(actor => actor.exportTimeline({ withId }))
    };

    const { formulas } = Tweenable;

    const filteredFormulas = Object.keys(formulas).filter(
      formulaName => typeof formulas[formulaName].x1 === 'number'
    );

    const pickProps = ['displayName', 'x1', 'y1', 'x2', 'y2'];

    exportData.curves = filteredFormulas.reduce((acc, formulaName) => {
        const formula = formulas[formulaName];
        acc[formula.displayName] = pick(formula, pickProps);

        return acc;
      },
      {}
    );

    return exportData;
  }

  /**
   * Import data that was created by {@link rekapi.Rekapi#exportTimeline}.
   * This sets up all actors, keyframes, and custom easing curves specified in
   * the `rekapiData` parameter.  These two methods collectively allow you
   * serialize an animation (for sending to a server for persistence, for
   * example) and later recreating an identical animation.
   *
   * @method rekapi.Rekapi#importTimeline
   * @param {rekapi.timelineData} rekapiData Any object that has the same data
   * format as the object generated from {@link rekapi.Rekapi#exportTimeline}.
   */
  importTimeline (rekapiData) {
    each(rekapiData.curves, (curve, curveName) =>
      setBezierFunction(
        curveName,
        curve.x1,
        curve.y1,
        curve.x2,
        curve.y2
      )
    );

    rekapiData.actors.forEach(actorData => {
      const actor = new Actor();
      actor.importTimeline(actorData);
      this.addActor(actor);
    });
  }

  /**
   * @method rekapi.Rekapi#getEventNames
   * @return {Array.<string>} The list of event names that this Rekapi instance
   * supports.
   */
  getEventNames () {
    return Object.keys(this._events);
  }

  /**
   * Get a reference to a {@link rekapi.renderer} that was initialized for this
   * animation.
   * @method rekapi.Rekapi#getRendererInstance
   * @param {rekapi.renderer} rendererConstructor The type of {@link
   * rekapi.renderer} subclass (such as {@link rekapi.CanvasRenderer} or {@link
   * rekapi.DOMRenderer}) to look up an instance of.
   * @return {rekapi.renderer|undefined} The matching {@link rekapi.renderer},
   * if any.
   */
  getRendererInstance (rendererConstructor) {
    return this.renderers.filter(renderer =>
      renderer instanceof rendererConstructor
    )[0];
  }

  /**
   * Move a {@link rekapi.Actor} around within the internal render order list.
   * By default, a {@link rekapi.Actor} is rendered in the order it was added
   * with {@link rekapi.Rekapi#addActor}.
   *
   * This method has no effect if {@link rekapi.Rekapi#sort} is set.
   *
   * @method rekapi.Rekapi#moveActorToPosition
   * @param {rekapi.Actor} actor
   * @param {number} layer This should be within `0` and the total number of
   * {@link rekapi.Actor}s in the animation.  That number can be found with
   * {@link rekapi.Rekapi#getActorCount}.
   * @return {rekapi.Rekapi}
   */
  moveActorToPosition (actor, position) {
    if (position < this._actors.length && position > -1) {
      this._actors = without(this._actors, actor);
      this._actors.splice(position, 0, actor);
    }

    return this;
  }
}