actor.js

import { Tweenable } from 'shifty';
import { KeyframeProperty } from './keyframe-property';
import {
  fireEvent,
  invalidateAnimationLength,
  DEFAULT_EASING
} from './rekapi';

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

import sortedIndexBy from 'lodash.sortedindexby';

const noop = () => {};

/*!
 * @param {Object} obj
 * @return {number} millisecond
 */
const getMillisecond = obj => obj.millisecond;

// TODO: Make this a prototype method
/*!
 * @param {Actor} actor
 * @param {string} event
 * @param {any} [data]
 */
const fire = (actor, event, data) =>
  actor.rekapi && fireEvent(actor.rekapi, event, data);

/*!
 * Retrieves the most recent property cache entry for a given millisecond.
 * @param {Actor} actor
 * @param {number} millisecond
 * @return {(Object|undefined)} undefined if there is no property cache for
 * the millisecond, i.e. an empty cache.
 */
const getPropertyCacheEntryForMillisecond = (actor, millisecond) => {
  const { _timelinePropertyCache } = actor;
  const index = sortedIndexBy(
    _timelinePropertyCache,
    { _millisecond: millisecond },
    obj => obj._millisecond
  );

  if (!_timelinePropertyCache[index]) {
    return;
  }

  return _timelinePropertyCache[index]._millisecond === millisecond ?
    _timelinePropertyCache[index] :
      index >= 1 ?
        _timelinePropertyCache[index - 1] :
        _timelinePropertyCache[0];
};

/*!
 * Search property track `track` and find the correct index to insert a
 * new element at `millisecond`.
 * @param {Array(KeyframeProperty)} track
 * @param {number} millisecond
 * @return {number} index
 */
const insertionPointInTrack = (track, millisecond) =>
  sortedIndexBy(track, { millisecond }, getMillisecond);

/*!
 * Gets all of the current and most recent Rekapi.KeyframeProperties for a
 * given millisecond.
 * @param {Actor} actor
 * @param {number} forMillisecond
 * @return {Object} An Object containing Rekapi.KeyframeProperties
 */
const getLatestProperties = (actor, forMillisecond) => {
  const latestProperties = {};

  each(actor._propertyTracks, (propertyTrack, propertyName) => {
    const index = insertionPointInTrack(propertyTrack, forMillisecond);

    latestProperties[propertyName] =
      propertyTrack[index] && propertyTrack[index].millisecond === forMillisecond ?
        // Found forMillisecond exactly.
        propertyTrack[index] :
          index >= 1 ?
            // forMillisecond doesn't exist in the track and index is
            // where we'd need to insert it, therefore the previous
            // keyframe is the most recent one before forMillisecond.
            propertyTrack[index - 1] :
            // Return first property.  This is after forMillisecond.
            propertyTrack[0];
  });

  return latestProperties;
};

/*!
 * Search property track `track` and find the index to the element that is
 * at `millisecond`.  Returns `undefined` if not found.
 * @param {Array(KeyframeProperty)} track
 * @param {number} millisecond
 * @return {number} index or -1 if not present
 */
const propertyIndexInTrack = (track, millisecond) => {
  const index = insertionPointInTrack(track, millisecond);

  return track[index] && track[index].millisecond === millisecond ?
    index : -1;
};

/*!
 * Mark the cache of internal KeyframeProperty data as invalid.  The cache
 * will be rebuilt on the next call to ensurePropertyCacheValid.
 * @param {Actor}
 */
const invalidateCache = actor => actor._timelinePropertyCacheValid = false;

/*!
 * Empty out and rebuild the cache of internal KeyframeProperty data if it
 * has been marked as invalid.
 * @param {Actor}
 */
const ensurePropertyCacheValid = actor => {
  if (actor._timelinePropertyCacheValid) {
    return;
  }

  actor._timelinePropertyCache = [];
  actor._timelineFunctionCache = [];

  const {
    _keyframeProperties,
    _timelineFunctionCache,
    _timelinePropertyCache
  } = actor;

  // Build the cache map
  const props = Object.keys(_keyframeProperties)
    .map(key => _keyframeProperties[key])
    .sort((a, b) => a.millisecond - b.millisecond);

  let curCacheEntry = getLatestProperties(actor, 0);

  curCacheEntry._millisecond = 0;
  _timelinePropertyCache.push(curCacheEntry);

  props.forEach(property => {
    if (property.millisecond !== curCacheEntry._millisecond) {
      curCacheEntry = clone(curCacheEntry);
      curCacheEntry._millisecond = property.millisecond;
      _timelinePropertyCache.push(curCacheEntry);
    }

    curCacheEntry[property.name] = property;

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

  actor._timelinePropertyCacheValid = true;
};

/*!
 * Remove any property tracks that are empty.
 * @param {Actor} actor
 * @fires rekapi.removeKeyframePropertyTrack
 */
const removeEmptyPropertyTracks = actor => {
  const { _propertyTracks } = actor;

  Object.keys(_propertyTracks).forEach(trackName => {
    if (!_propertyTracks[trackName].length) {
      delete _propertyTracks[trackName];
      fire(actor, 'removeKeyframePropertyTrack', trackName);
    }
  });
};

/*!
 * Stably sort all of the property tracks of an actor
 * @param {Actor} actor
 */
const sortPropertyTracks = actor => {
  each(actor._propertyTracks, (propertyTrack, trackName) => {
    propertyTrack = propertyTrack.sort(
      (a, b) => a.millisecond - b.millisecond
    );

    propertyTrack.forEach((keyframeProperty, i) =>
      keyframeProperty.linkToNext(propertyTrack[i + 1])
    );

    actor._propertyTracks[trackName] = propertyTrack;
  });
};

/*!
 * Updates internal Rekapi and Actor data after a KeyframeProperty
 * modification method is called.
 *
 * @param {Actor} actor
 * @fires rekapi.timelineModified
 */
const cleanupAfterKeyframeModification = actor => {
  sortPropertyTracks(actor);
  invalidateCache(actor);

  if (actor.rekapi) {
    invalidateAnimationLength(actor.rekapi);
  }

  fire(actor, 'timelineModified');
};

/**
 * A {@link rekapi.Actor} represents an individual component of an animation.
 * An animation may have one or many {@link rekapi.Actor}s.
 *
 * @param {Object} [config={}]
 * @param {(Object|CanvasRenderingContext2D|HTMLElement)} [config.context] Sets
 * {@link rekapi.Actor#context}.
 * @param {Function} [config.setup] Sets {@link rekapi.Actor#setup}.
 * @param {rekapi.render} [config.render] Sets {@link rekapi.Actor#render}.
 * @param {Function} [config.teardown] Sets {@link rekapi.Actor#teardown}.
 * @constructs rekapi.Actor
 */
export class Actor extends Tweenable {
  constructor (config = {}) {
    super();

    /**
     * @member {rekapi.Rekapi|undefined} rekapi.Actor#rekapi The {@link
     * rekapi.Rekapi} instance to which this {@link rekapi.Actor} belongs, if
     * any.
     */

    Object.assign(this, {
      _propertyTracks: {},
      _timelinePropertyCache: [],
      _timelineFunctionCache: [],
      _timelinePropertyCacheValid: false,
      _keyframeProperties: {},

      /**
       * @member {string} rekapi.Actor#id The unique ID of this {@link rekapi.Actor}.
       */
      id: uniqueId(),

      /**
        * @member {(Object|CanvasRenderingContext2D|HTMLElement|undefined)}
        * [rekapi.Actor#context] If this {@link rekapi.Actor} was created by or
        * provided as an argument to {@link rekapi.Rekapi#addActor}, then this
        * member is a reference to that {@link rekapi.Rekapi}'s {@link
        * rekapi.Rekapi#context}.
        */
      context: config.context,

      /**
       * @member {Function} rekapi.Actor#setup Gets called when an actor is
       * added to an animation by {@link rekapi.Rekapi#addActor}.
       */
      setup: config.setup || noop,

      /**
       * @member {rekapi.render} rekapi.Actor#render The function that renders
       * this {@link rekapi.Actor}.
       */
      render: config.render || noop,

      /**
       * @member {Function} rekapi.Actor#teardown Gets called when an actor is
       * removed from an animation by {@link rekapi.Rekapi#removeActor}.
       */
      teardown: config.teardown || noop,

      /**
       * @member {boolean} rekapi.Actor#wasActive A flag that records whether
       * this {@link rekapi.Actor} had any state in the previous updated cycle.
       * Handy for immediate-mode renderers (such as {@link
       * rekapi.CanvasRenderer}) to prevent unintended renders after the actor
       * has no state. Also used to prevent redundant {@link
       * rekapi.keyframeFunction} calls.
       */
      wasActive: true
    });
  }

  /**
   * Create a keyframe for the actor.  The animation timeline begins at `0`.
   * The timeline's length will automatically "grow" to accommodate new
   * keyframes as they are added.
   *
   * `state` should contain all of the properties that define this keyframe's
   * state.  These properties can be any value that can be tweened by
   * [Shifty](http://jeremyckahn.github.io/shifty/doc/) (numbers,
   * RGB/hexadecimal color strings, and CSS property strings).  `state` can
   * also be a [function]{@link rekapi.keyframeFunction}, but
   * [this works differently]{@tutorial keyframes-in-depth}.
   *
   * __Note:__ Internally, this creates {@link rekapi.KeyframeProperty}s and
   * places them on a "track." Tracks are automatically named to match the
   * relevant {@link rekapi.KeyframeProperty#name}s.  These {@link
   * rekapi.KeyframeProperty}s are managed for you by the {@link rekapi.Actor}
   * APIs.
   *
   * ## [Click to learn about keyframes in depth]{@tutorial keyframes-in-depth}
   * @method rekapi.Actor#keyframe
   * @param {number} millisecond Where on the timeline to set the keyframe.
   * @param {(Object|rekapi.keyframeFunction)} state The state properties of
   * the keyframe.  If this is an Object, the properties will be interpolated
   * between this and those of the following keyframe for a given point on the
   * animation timeline.  If this is a function ({@link
   * rekapi.keyframeFunction}), it will be called at the keyframe specified by
   * `millisecond`.
   * @param {(string|Object)} [easing] Optional easing string or Object.  If
   * `state` is a function, this is ignored.
   * @return {rekapi.Actor}
   * @fires rekapi.timelineModified
   */
  keyframe (millisecond, state, easing = DEFAULT_EASING) {
    if (state instanceof Function) {
      state = { 'function': state };
    }

    each(state, (value, name) =>
      this.addKeyframeProperty(
        new KeyframeProperty(
          millisecond,
          name,
          value,
          typeof easing === 'string' ?
            easing :
            (easing[name] || DEFAULT_EASING)
        )
      )
    );

    if (this.rekapi) {
      invalidateAnimationLength(this.rekapi);
    }

    invalidateCache(this);
    fire(this, 'timelineModified');

    return this;
  }

  /**
   * @method rekapi.Actor#hasKeyframeAt
   * @param {number} millisecond Point on the timeline to query.
   * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the
   * lookup to a particular track.
   * @return {boolean} Whether or not the actor has any {@link
   * rekapi.KeyframeProperty}s set at `millisecond`.
   */
  hasKeyframeAt (millisecond, trackName = undefined) {
    const { _propertyTracks } = this;

    if (trackName && !_propertyTracks[trackName]) {
      return false;
    }

    const propertyTracks = trackName ?
      pick(_propertyTracks, [trackName]) :
      _propertyTracks;

    return Object.keys(propertyTracks).some(track =>
      propertyTracks.hasOwnProperty(track) &&
      !!this.getKeyframeProperty(track, millisecond)
    );
  }

  /**
   * Copies all of the {@link rekapi.KeyframeProperty}s from one point on the
   * actor's timeline to another. This is particularly useful for animating an
   * actor back to its original position.
   *
   *     actor
   *       .keyframe(0, {
   *         x: 10,
   *         y: 15
   *       }).keyframe(1000, {
   *         x: 50,
   *         y: 75
   *       });
   *
   *     // Return the actor to its original position
   *     actor.copyKeyframe(0, 2000);
   *
   * @method rekapi.Actor#copyKeyframe
   * @param {number} copyFrom The timeline millisecond to copy {@link
   * rekapi.KeyframeProperty}s from.
   * @param {number} copyTo The timeline millisecond to copy {@link
   * rekapi.KeyframeProperty}s to.
   * @return {rekapi.Actor}
   */
  copyKeyframe (copyFrom, copyTo) {
    // Build the configuation objects to be passed to Actor#keyframe
    const sourcePositions = {};
    const sourceEasings = {};

    each(this._propertyTracks, (propertyTrack, trackName) => {
      const keyframeProperty =
        this.getKeyframeProperty(trackName, copyFrom);

      if (keyframeProperty) {
        sourcePositions[trackName] = keyframeProperty.value;
        sourceEasings[trackName] = keyframeProperty.easing;
      }
    });

    this.keyframe(copyTo, sourcePositions, sourceEasings);

    return this;
  }

  /**
   * Moves all of the {@link rekapi.KeyframeProperty}s from one point on the
   * actor's timeline to another.  Although this method does error checking for
   * you to make sure the operation can be safely performed, an effective
   * pattern is to use {@link rekapi.Actor#hasKeyframeAt} to see if there is
   * already a keyframe at the requested `to` destination.
   *
   * @method rekapi.Actor#moveKeyframe
   * @param {number} from The millisecond of the keyframe to be moved.
   * @param {number} to The millisecond of where the keyframe should be moved
   * to.
   * @return {boolean} Whether or not the keyframe was successfully moved.
   */
  moveKeyframe (from, to) {
    if (!this.hasKeyframeAt(from) || this.hasKeyframeAt(to)) {
      return false;
    }

    // Move each of the relevant KeyframeProperties to the new location in the
    // timeline
    each(this._propertyTracks, (propertyTrack, trackName) => {
      const oldIndex = propertyIndexInTrack(propertyTrack, from);

      if (oldIndex !== -1) {
        propertyTrack[oldIndex].millisecond = to;
      }
    });

    cleanupAfterKeyframeModification(this);

    return true;
  }

  /**
   * Augment the `value` or `easing` of the {@link rekapi.KeyframeProperty}s
   * at a given millisecond.  Any {@link rekapi.KeyframeProperty}s omitted in
   * `state` or `easing` are not modified.
   *
   *     actor.keyframe(0, {
   *       'x': 10,
   *       'y': 20
   *     }).keyframe(1000, {
   *       'x': 20,
   *       'y': 40
   *     }).keyframe(2000, {
   *       'x': 30,
   *       'y': 60
   *     })
   *
   *     // Changes the state of the keyframe at millisecond 1000.
   *     // Modifies the value of 'y' and the easing of 'x.'
   *     actor.modifyKeyframe(1000, {
   *       'y': 150
   *     }, {
   *       'x': 'easeFrom'
   *     });
   *
   * @method rekapi.Actor#modifyKeyframe
   * @param {number} millisecond
   * @param {Object} state
   * @param {Object} [easing={}]
   * @return {rekapi.Actor}
   */
  modifyKeyframe (millisecond, state, easing = {}) {
    each(this._propertyTracks, (propertyTrack, trackName) => {
      const property = this.getKeyframeProperty(trackName, millisecond);

      if (property) {
        property.modifyWith({
          value: state[trackName],
          easing: easing[trackName]
        });
      } else if (state[trackName]) {
        this.addKeyframeProperty(
          new KeyframeProperty(
            millisecond,
            trackName,
            state[trackName],
            easing[trackName]
          )
        );
      }
    });

    cleanupAfterKeyframeModification(this);

    return this;
  }

  /**
   * Remove all {@link rekapi.KeyframeProperty}s set
   * on the actor at a given millisecond in the animation.
   *
   * @method rekapi.Actor#removeKeyframe
   * @param {number} millisecond The location on the timeline of the keyframe
   * to remove.
   * @return {rekapi.Actor}
   * @fires rekapi.timelineModified
   */
  removeKeyframe (millisecond) {
    each(this._propertyTracks, (propertyTrack, propertyName) => {
      const index = propertyIndexInTrack(propertyTrack, millisecond);

      if (index !== -1) {
        const keyframeProperty = propertyTrack[index];
        this._deleteKeyframePropertyAt(propertyTrack, index);
        keyframeProperty.detach();
      }
    });

    removeEmptyPropertyTracks(this);
    cleanupAfterKeyframeModification(this);
    fire(this, 'timelineModified');

    return this;
  }

  /**
   * Remove all {@link rekapi.KeyframeProperty}s set
   * on the actor.
   *
   * **NOTE**: This method does _not_ fire the `beforeRemoveKeyframeProperty`
   * or `removeKeyframePropertyComplete` events.  This method is a bulk
   * operation that is more efficient than calling {@link
   * rekapi.Actor#removeKeyframeProperty} many times individually, but
   * foregoes firing events.
   *
   * @method rekapi.Actor#removeAllKeyframes
   * @return {rekapi.Actor}
   */
  removeAllKeyframes () {
    each(this._propertyTracks, propertyTrack =>
      propertyTrack.length = 0
    );

    each(this._keyframeProperties, keyframeProperty =>
      keyframeProperty.detach()
    );

    removeEmptyPropertyTracks(this);
    this._keyframeProperties = {};

    // Calling removeKeyframe performs some necessary post-removal cleanup, the
    // earlier part of this method skipped all of that for the sake of
    // efficiency.
    return this.removeKeyframe(0);
  }

  /**
   * @method rekapi.Actor#getKeyframeProperty
   * @param {string} property The name of the property track.
   * @param {number} millisecond The millisecond of the property in the
   * timeline.
   * @return {(rekapi.KeyframeProperty|undefined)} A {@link
   * rekapi.KeyframeProperty} that is stored on the actor, as specified by the
   * `property` and `millisecond` parameters. This is `undefined` if no
   * properties were found.
   */
  getKeyframeProperty (property, millisecond) {
    const propertyTrack = this._propertyTracks[property];

    return propertyTrack[propertyIndexInTrack(propertyTrack, millisecond)];
  }

  /**
   * Modify a {@link rekapi.KeyframeProperty} stored on an actor.
   * Internally, this calls {@link rekapi.KeyframeProperty#modifyWith} and
   * then performs some cleanup.
   *
   * @method rekapi.Actor#modifyKeyframeProperty
   * @param {string} property The name of the {@link rekapi.KeyframeProperty}
   * to modify.
   * @param {number} millisecond The timeline millisecond of the {@link
   * rekapi.KeyframeProperty} to modify.
   * @param {Object} newProperties The properties to augment the {@link
   * rekapi.KeyframeProperty} with.
   * @return {rekapi.Actor}
   */
  modifyKeyframeProperty (property, millisecond, newProperties) {
    const keyframeProperty = this.getKeyframeProperty(property, millisecond);

    if (keyframeProperty) {
      if ('millisecond' in newProperties &&
          this.hasKeyframeAt(newProperties.millisecond, property)
        ) {
        throw new Error(
          `Tried to move ${property} to ${newProperties.millisecond}ms, but a keyframe property already exists there`
        );
      }

      keyframeProperty.modifyWith(newProperties);
      cleanupAfterKeyframeModification(this);
    }

    return this;
  }

  /**
   * Remove a single {@link rekapi.KeyframeProperty}
   * from the actor.
   * @method rekapi.Actor#removeKeyframeProperty
   * @param {string} property The name of the {@link rekapi.KeyframeProperty}
   * to remove.
   * @param {number} millisecond Where in the timeline the {@link
   * rekapi.KeyframeProperty} to remove is.
   * @return {(rekapi.KeyframeProperty|undefined)} The removed
   * KeyframeProperty, if one was found.
   * @fires rekapi.beforeRemoveKeyframeProperty
   * @fires rekapi.removeKeyframePropertyComplete
   */
  removeKeyframeProperty (property, millisecond) {
    const { _propertyTracks } = this;

    if (_propertyTracks[property]) {
      const propertyTrack = _propertyTracks[property];
      const index = propertyIndexInTrack(propertyTrack, millisecond);
      const keyframeProperty = propertyTrack[index];

      fire(this, 'beforeRemoveKeyframeProperty', keyframeProperty);
      this._deleteKeyframePropertyAt(propertyTrack, index);
      keyframeProperty.detach();

      removeEmptyPropertyTracks(this);
      cleanupAfterKeyframeModification(this);
      fire(this, 'removeKeyframePropertyComplete', keyframeProperty);

      return keyframeProperty;
    }
  }

  /**
   *
   * @method rekapi.Actor#getTrackNames
   * @return {Array.<rekapi.KeyframeProperty#name>} A list of all the track
   * names for a {@link rekapi.Actor}.
   */
  getTrackNames () {
    return Object.keys(this._propertyTracks);
  }

  /**
   * Get all of the {@link rekapi.KeyframeProperty}s for a track.
   * @method rekapi.Actor#getPropertiesInTrack
   * @param {rekapi.KeyframeProperty#name} trackName The track name to query.
   * @return {Array(rekapi.KeyframeProperty)}
   */
  getPropertiesInTrack (trackName) {
    return (this._propertyTracks[trackName] || []).slice(0);
  }

  /**
   * @method rekapi.Actor#getStart
   * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the
   * lookup to a particular track.
   * @return {number} The millisecond of the first animating state of a {@link
   * rekapi.Actor} (for instance, if the first keyframe is later than
   * millisecond `0`).  If there are no keyframes, this is `0`.
   */
  getStart (trackName = undefined) {
    const { _propertyTracks } = this;
    const starts = [];

    // Null check to see if trackName was provided and is valid
    if (_propertyTracks.hasOwnProperty(trackName)) {
      const firstKeyframeProperty = _propertyTracks[trackName][0];

      if (firstKeyframeProperty) {
        starts.push(firstKeyframeProperty.millisecond);
      }
    } else {
      // Loop over all property tracks and accumulate the first
      // keyframeProperties from non-empty tracks
      each(_propertyTracks, propertyTrack => {
        if (propertyTrack.length) {
          starts.push(propertyTrack[0].millisecond);
        }
      });
    }

    return starts.length > 0 ?
      Math.min.apply(Math, starts) :
      0;
  }

  /**
   * @method rekapi.Actor#getEnd
   * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the
   * lookup to a particular keyframe track.
   * @return {number} The millisecond of the last state of an actor (the point
   * in the timeline in which it is done animating).  If there are no
   * keyframes, this is `0`.
   */
  getEnd (trackName = undefined) {
    const endingTracks = [0];

    const tracksToInspect = trackName ?
      { [trackName]: this._propertyTracks[trackName] } :
      this._propertyTracks;

    each(tracksToInspect, propertyTrack => {
      if (propertyTrack.length) {
        endingTracks.push(propertyTrack[propertyTrack.length - 1].millisecond);
      }
    });

    return Math.max.apply(Math, endingTracks);
  }

  /**
   * @method rekapi.Actor#getLength
   * @param {rekapi.KeyframeProperty#name} [trackName] Optionally scope the
   * lookup to a particular track.
   * @return {number} The length of time in milliseconds that the actor
   * animates for.
   */
  getLength (trackName = undefined) {
    return this.getEnd(trackName) - this.getStart(trackName);
  }

  /**
   * Extend the last state on this actor's timeline to simulate a pause.
   * Internally, this method copies the final state of the actor in the
   * timeline to the millisecond defined by `until`.
   *
   * @method rekapi.Actor#wait
   * @param {number} until At what point in the animation the Actor should wait
   * until (relative to the start of the animation timeline).  If this number
   * is less than the value returned from {@link rekapi.Actor#getLength},
   * this method does nothing.
   * @return {rekapi.Actor}
   */
  wait (until) {
    const end = this.getEnd();

    if (until <= end) {
      return this;
    }

    const latestProps = getLatestProperties(this, this.getEnd());
    const serializedProps = {};
    const serializedEasings = {};

    each(latestProps, (latestProp, propName) => {
      serializedProps[propName] = latestProp.value;
      serializedEasings[propName] = latestProp.easing;
    });

    this.modifyKeyframe(end, serializedProps, serializedEasings);
    this.keyframe(until, serializedProps, serializedEasings);

    return this;
  }

  /*!
   * Insert a `KeyframeProperty` into a property track at `index`.  The linked
   * list structure of the property track is maintained.
   * @method rekapi.Actor#_insertKeyframePropertyAt
   * @param {KeyframeProperty} keyframeProperty
   * @param {Array(KeyframeProperty)} propertyTrack
   * @param {number} index
   */
  _insertKeyframePropertyAt (keyframeProperty, propertyTrack, index) {
    propertyTrack.splice(index, 0, keyframeProperty);
  }

  /*!
   * Remove the `KeyframeProperty` at `index` from a property track.  The linked
   * list structure of the property track is maintained.  The removed property
   * is not modified or unlinked internally.
   * @method rekapi.Actor#_deleteKeyframePropertyAt
   * @param {Array(KeyframeProperty)} propertyTrack
   * @param {number} index
   */
  _deleteKeyframePropertyAt (propertyTrack, index) {
    propertyTrack.splice(index, 1);
  }

  /**
   * Associate a {@link rekapi.KeyframeProperty} to this {@link rekapi.Actor}.
   * Updates {@link rekapi.KeyframeProperty#actor} to maintain a link between
   * the two objects.  This is a lower-level method and it is generally better
   * to use {@link rekapi.Actor#keyframe}.  This is mostly useful for adding a
   * {@link rekapi.KeyframeProperty} back to an actor after it was {@link
   * rekapi.KeyframeProperty#detach}ed.
   * @method rekapi.Actor#addKeyframeProperty
   * @param {rekapi.KeyframeProperty} keyframeProperty
   * @return {rekapi.Actor}
   * @fires rekapi.beforeAddKeyframeProperty
   * @fires rekapi.addKeyframePropertyTrack
   * @fires rekapi.addKeyframeProperty
   */
  addKeyframeProperty (keyframeProperty) {
    if (this.rekapi) {
      fire(this, 'beforeAddKeyframeProperty', keyframeProperty);
    }

    keyframeProperty.actor = this;
    this._keyframeProperties[keyframeProperty.id] = keyframeProperty;

    const { name } = keyframeProperty;
    const { _propertyTracks, rekapi } = this;

    if (!this._propertyTracks[name]) {
      _propertyTracks[name] = [keyframeProperty];

      if (rekapi) {
        fire(this, 'addKeyframePropertyTrack', keyframeProperty);
      }
    } else {
      const index = insertionPointInTrack(_propertyTracks[name], keyframeProperty.millisecond);

      if (_propertyTracks[name][index]) {
        const newMillisecond = keyframeProperty.millisecond;
        const targetMillisecond = _propertyTracks[name][index].millisecond;

        if (targetMillisecond === newMillisecond) {
          throw new Error(
            `Cannot add duplicate ${name} keyframe property @ ${newMillisecond}ms`
          );
        } else if (rekapi && rekapi._warnOnOutOfOrderKeyframes) {
          console.warn(
            new Error(
              `Added a keyframe property before end of ${name} track @ ${newMillisecond}ms (< ${targetMillisecond}ms)`
            )
          );
        }
      }

      this._insertKeyframePropertyAt(keyframeProperty, _propertyTracks[name], index);
      cleanupAfterKeyframeModification(this);
    }

    if (rekapi) {
      fire(this, 'addKeyframeProperty', keyframeProperty);
    }

    return this;
  }

  /*!
   * TODO: Explain the use case for this method
   * Set the actor to be active or inactive starting at `millisecond`.
   * @method rekapi.Actor#setActive
   * @param {number} millisecond The time at which to change the actor's active state
   * @param {boolean} isActive Whether the actor should be active or inactive
   * @return {rekapi.Actor}
   */
  setActive (millisecond, isActive) {
    const hasActiveTrack = !!this._propertyTracks._active;
    const activeProperty = hasActiveTrack
        && this.getKeyframeProperty('_active', millisecond);

    if (activeProperty) {
      activeProperty.value = isActive;
    } else {
      this.addKeyframeProperty(
        new KeyframeProperty(millisecond, '_active', isActive)
      );
    }

    return this;
  }

  /*!
   * Calculate and set the actor's position at `millisecond` in the animation.
   * @method rekapi.Actor#_updateState
   * @param {number} millisecond
   * @param {boolean} [resetLaterFnKeyframes] If true, allow all function
   * keyframes later in the timeline to be run again.
   */
  _updateState (millisecond, resetLaterFnKeyframes = false) {
    const start = this.getStart();
    const end = this.getEnd();
    const interpolatedObject = {};

    millisecond = Math.min(end, millisecond);

    ensurePropertyCacheValid(this);

    const propertyCacheEntry = clone(
      getPropertyCacheEntryForMillisecond(this, millisecond)
    );

    delete propertyCacheEntry._millisecond;

    // All actors are active at time 0 unless otherwise specified;
    // make sure a future time deactivation doesn't deactive the actor
    // by default.
    if (propertyCacheEntry._active
        && millisecond >= propertyCacheEntry._active.millisecond) {

      this.wasActive = propertyCacheEntry._active.getValueAt(millisecond);

      if (!this.wasActive) {
        return this;
      }
    } else {
      this.wasActive = true;
    }

    if (start === end) {
      // If there is only one keyframe, use that for the state of the actor
      each(propertyCacheEntry, (keyframeProperty, propName) => {
        if (keyframeProperty.shouldInvokeForMillisecond(millisecond)) {
          keyframeProperty.invoke();
          keyframeProperty.hasFired = false;
          return;
        }

        interpolatedObject[propName] = keyframeProperty.value;
      });

    } else {
      each(propertyCacheEntry, (keyframeProperty, propName) => {
        if (this._beforeKeyframePropertyInterpolate !== noop) {
          this._beforeKeyframePropertyInterpolate(keyframeProperty);
        }

        if (keyframeProperty.shouldInvokeForMillisecond(millisecond)) {
          keyframeProperty.invoke();
          return;
        }

        interpolatedObject[propName] =
          keyframeProperty.getValueAt(millisecond);

        if (this._afterKeyframePropertyInterpolate !== noop) {
          this._afterKeyframePropertyInterpolate(
            keyframeProperty, interpolatedObject);
        }
      });
    }

    this.set(interpolatedObject);

    if (!resetLaterFnKeyframes) {
      this._resetFnKeyframesFromMillisecond(millisecond);
    }

    return this;
  }

  /*!
   * @method rekapi.Actor#_resetFnKeyframesFromMillisecond
   * @param {number} millisecond
   */
  _resetFnKeyframesFromMillisecond (millisecond) {
    const cache = this._timelineFunctionCache;
    const { length } = cache;
    let index = sortedIndexBy(cache, { millisecond: millisecond }, getMillisecond);

    while (index < length) {
      cache[index++].hasFired = false;
    }
  }

  /**
   * Export this {@link rekapi.Actor} to a `JSON.stringify`-friendly `Object`.
   * @method rekapi.Actor#exportTimeline
   * @param {Object} [config]
   * @param {boolean} [config.withId=false] If `true`, include internal `id`
   * values in exported data.
   * @return {rekapi.actorData} This data can later be consumed by {@link
   * rekapi.Actor#importTimeline}.
   */
  exportTimeline ({ withId = false } = {}) {
    const exportData = {
      start: this.getStart(),
      end: this.getEnd(),
      trackNames: this.getTrackNames(),
      propertyTracks: {}
    };

    if (withId) {
      exportData.id = this.id;
    }

    each(this._propertyTracks, (propertyTrack, trackName) => {
      const track = [];

      propertyTrack.forEach(keyframeProperty => {
        track.push(keyframeProperty.exportPropertyData({ withId }));
      });

      exportData.propertyTracks[trackName] = track;
    });

    return exportData;
  }

  /**
   * Import an Object to augment this actor's state.  This does not remove
   * keyframe properties before importing new ones.
   * @method rekapi.Actor#importTimeline
   * @param {rekapi.actorData} actorData Any object that has the same data
   * format as the object generated from {@link rekapi.Actor#exportTimeline}.
   */
  importTimeline (actorData) {
    each(actorData.propertyTracks, propertyTrack => {
      propertyTrack.forEach(property => {
        this.keyframe(
          property.millisecond,
          { [property.name]: property.value },
          property.easing
        );
      });
    });
  }
}

Object.assign(Actor.prototype, {
  /*!
   * @method rekapi.Actor#_beforeKeyframePropertyInterpolate
   * @param {KeyframeProperty} keyframeProperty
   * @abstract
   */
  _beforeKeyframePropertyInterpolate: noop,

  /*!
   * @method rekapi.Actor#_afterKeyframePropertyInterpolate
   * @param {KeyframeProperty} keyframeProperty
   * @param {Object} interpolatedObject
   * @abstract
   */
  _afterKeyframePropertyInterpolate: noop
});