Home Manual Reference Source Repository

src/core/timeline.js

import events from 'events';

import Keyboard from '../interactions/keyboard';
import LayerTimeContext from './layer-time-context';
import Surface from '../interactions/surface';
import TimelineTimeContext from './timeline-time-context';
import Track from './track';
import TrackCollection from './track-collection';


/**
 * Is the main entry point to create a temporal visualization.
 *
 * A `timeline` instance mainly provides the context for any visualization of
 * temporal data and maintains the hierarchy of `Track`, `Layer` and `Shape`
 * over the entiere visualisation.
 *
 * Its main responsabilites are:
 * - maintaining the temporal consistency accross the visualisation through
 *   its `timeContext` property (instance of `TimelineTimeContext`).
 * - handling interactions to its current state (acting here as a simple
 *   state machine).
 *
 * @TODO insert figure
 *
 * It also contains a reference to all the register track allowing to `render`
 * or `update` all the layer from a single entry point.
 *
 * ## Example Usage
 *
 * ```js
 * const visibleWidth = 500; // default width in pixels for all created `Track`
 * const duration = 10; // the visible area represents 10 seconds
 * const pixelsPerSeconds = visibleWidth / duration;
 * const timeline = new ui.core.Timeline(pixelsPerSecond, width);
 * ```
 */
export default class Timeline extends events.EventEmitter {
  /**
   * @param {Number} [pixelsPerSecond=100] - the default scaling between time and pixels.
   * @param {Number} [visibleWidth=1000] - the default visible area for all registered tracks.
   */
  constructor(pixelsPerSecond = 100, visibleWidth = 1000, {
    registerKeyboard = true
  } = {}) {

    super();

    this._tracks = new TrackCollection(this);
    this._state = null;

    // default interactions
    this._surfaceCtor = Surface;

    if (registerKeyboard) {
      this.createInteraction(Keyboard, document);
    }

    // stores
    this._trackById = {};
    this._groupedLayers = {};

    /** @type {TimelineTimeContext} - master time context for the visualization. */
    this.timeContext = new TimelineTimeContext(pixelsPerSecond, visibleWidth);
  }

  /**
   * Returns `TimelineTimeContext`'s `offset` time domain value.
   *
   * @type {Number} [offset=0]
   */
  get offset() {
    return this.timeContext.offset;
  }

  /**
   * Updates `TimelineTimeContext`'s `offset` time domain value.
   *
   * @type {Number} [offset=0]
   */
  set offset(value) {
    this.timeContext.offset = value;
  }

  /**
   * Returns the `TimelineTimeContext`'s `zoom` value.
   *
   * @type {Number} [offset=0]
   */
  get zoom() {
    return this.timeContext.zoom;
  }

  /**
   * Updates the `TimelineTimeContext`'s `zoom` value.
   *
   * @type {Number} [offset=0]
   */
  set zoom(value) {
    this.timeContext.zoom = value;
  }

  /**
   * Returns the `TimelineTimeContext`'s `pixelsPerSecond` ratio.
   *
   * @type {Number} [offset=0]
   */
  get pixelsPerSecond() {
    return this.timeContext.pixelsPerSecond;
  }

  /**
   * Updates the `TimelineTimeContext`'s `pixelsPerSecond` ratio.
   *
   * @type {Number} [offset=0]
   */
  set pixelsPerSecond(value) {
    this.timeContext.pixelsPerSecond = value;
  }

  /**
   * Returns the `TimelineTimeContext`'s `visibleWidth` pixel domain value.
   *
   * @type {Number} [offset=0]
   */
  get visibleWidth() {
    return this.timeContext.visibleWidth;
  }

  /**
   * Updates the `TimelineTimeContext`'s `visibleWidth` pixel domain value.
   *
   * @type {Number} [offset=0]
   */
  set visibleWidth(value) {
    this.timeContext.visibleWidth = value;
  }

  /**
   * Returns `TimelineTimeContext`'s `timeToPixel` transfert function.
   *
   * @type {Function}
   */
  get timeToPixel() {
    return this.timeContext.timeToPixel;
  }

  /**
   * Returns `TimelineTimeContext`'s `visibleDuration` helper value.
   *
   * @type {Number}
   */
  get visibleDuration() {
    return this.timeContext.visibleDuration;
  }

  /**
   * Updates the `TimelineTimeContext`'s `maintainVisibleDuration` value.
   * Defines if the duration of the visible area should be maintain when
   * the `visibleWidth` attribute is updated.
   *
   * @type {Boolean}
   */
  set maintainVisibleDuration(bool) {
    this.timeContext.maintainVisibleDuration = bool;
  }

  /**
   * Returns `TimelineTimeContext`'s `maintainVisibleDuration` current value.
   *
   * @type {Boolean}
   */
  get maintainVisibleDuration() {
    return this.timeContext.maintainVisibleDuration;
  }

  /**
   * Object maintaining arrays of `Layer` instances ordered by their `groupId`.
   * Is used internally by the `TrackCollection` instance.
   *
   * @type {Object}
   */
  get groupedLayers() {
    return this._groupedLayers;
  }

  /**
   * Overrides the default `Surface` that is instanciated on each `Track`
   * instance. This methos should be called before adding any `Track` instance
   * to the current `timeline`.
   *
   * @param {EventSource} ctor - The constructor to use in order to catch mouse
   *    events on each `Track` instances.
   */
  configureSurface(ctor) {
    this._surfaceCtor = ctor;
  }

  /**
   * Factory method to add interaction modules the timeline should listen to.
   * By default, the timeline instanciate a global `Keyboard` instance and a
   * `Surface` instance on each container.
   * Should be used to install new interactions implementing the `EventSource` interface.
   *
   * @param {EventSource} ctor - The contructor of the interaction module to instanciate.
   * @param {Element} $el - The DOM element which will be binded to the `EventSource` module.
   * @param {Object} [options={}] - Options to be applied to the `ctor`.
   */
  createInteraction(ctor, $el, options = {}) {
    const interaction = new ctor($el, options);
    interaction.on('event', (e) => this._handleEvent(e));
  }

  /**
   * Returns a list of the layers situated under the position of a `WaveEvent`.
   *
   * @param {WavesEvent} e - An event triggered by a `WaveEvent`
   * @return {Array} - Matched layers
   */
  getHitLayers(e) {
    const clientX = e.originalEvent.clientX;
    const clientY = e.originalEvent.clientY;
    let layers = [];

    this.layers.forEach((layer) => {
      if (!layer.params.hittable) { return; }
      const br = layer.$el.getBoundingClientRect();

      if (
        clientX > br.left && clientX < br.right &&
        clientY > br.top && clientY < br.bottom
      ) {
        layers.push(layer);
      }
    });

    return layers;
  }

  /**
   * The callback that is used to listen to interactions modules.
   *
   * @param {WaveEvent} e - An event generated by an interaction modules (`EventSource`).
   */
  _handleEvent(e) {
    const hitLayers = (e.source === 'surface') ?
      this.getHitLayers(e) : null;
    // emit event as a middleware
    this.emit('event', e, hitLayers);
    // propagate to the state
    if (!this._state) { return; }
    this._state.handleEvent(e, hitLayers);
  }

  /**
   * Updates the state of the timeline.
   *
   * @type {BaseState}
   */
  set state(state) {
    if (this._state) { this._state.exit(); }
    this._state = state;
    if (this._state) { this._state.enter(); }
  }

  /**
   * Returns the current state of the timeline.
   *
   * @type {BaseState}
   */
  get state() {
    return this._state;
  }

  /**
   * Returns the `TrackCollection` instance.
   *
   * @type {TrackCollection}
   */
  get tracks() {
    return this._tracks;
  }

  /**
   * Returns the list of all registered layers.
   *
   * @type {Array}
   */
  get layers() {
    return this._tracks.layers;
  }

  /**
   * Adds a new track to the timeline.
   *
   * @param {Track} track - The new track to be registered in the timeline.
   * @param {String} [trackId=null] - Optionnal unique id to associate with
   *    the track, this id only exists in timeline's context and should be used
   *    in conjonction with `addLayer` method.
   */
  add(track, trackId = null) {
    if (this.tracks.indexOf(track) !== -1) {
      throw new Error('track already added to the timeline');
    }

    this._registerTrackId(track, trackId);
    track.configure(this.timeContext);

    this.tracks.push(track);
    this.createInteraction(this._surfaceCtor, track.$el);
  }

  /**
   * Removes a track from the timeline.
   *
   * @param {Track} track - the track to remove from the timeline.
   * @todo not implemented.
   */
  remove(track) {
    // should destroy interaction too, avoid ghost eventListeners
  }

  /**
   * Helper to create a new `Track` instance. The `track` is added,
   * rendered and updated before being returned.
   *
   * @param {Element} $el - The DOM element where the track should be inserted.
   * @param {Number} trackHeight - The height of the newly created track.
   * @param {String} [trackId=null] - Optionnal unique id to associate with
   *    the track, this id only exists in timeline's context and should be used in
   *    conjonction with `addLayer` method.
   * @return {Track}
   */
  createTrack($el, trackHeight = 100, trackId = null) {
    const track = new Track($el, trackHeight);
    // Add track to the timeline
    this.add(track, trackId);
    track.render();
    track.update();

    return track;
  }

  /**
   * If track id is defined, associate a track with a unique id.
   */
  _registerTrackId(track, trackId) {
    if (trackId !== null) {
      if (this._trackById[trackId] !== undefined) {
        throw new Error(`trackId: "${trackId}" is already used`);
      }

      this._trackById[trackId] = track;
    }
  }

  /**
   * Helper to add a `Layer` instance into a given `Track`. Is designed to be
   * used in conjonction with the `Timeline~getLayersByGroup` method. The
   * layer is internally rendered and updated.
   *
   * @param {Layer} layer - The `Layer` instance to add into the visualization.
   * @param {(Track|String)} trackOrTrackId - The `Track` instance (or its `id`
   *    as defined in the `createTrack` method) where the `Layer` instance should be inserted.
   * @param {String} [groupId='default'] - An optionnal group id in which the
   *    `Layer` should be inserted.
   * @param {Boolean} [isAxis] - Set to `true` if the added `layer` is an
   *    instance of `AxisLayer` (these layers shares the `TimlineTimeContext` instance
   *    of the timeline).
   */
  addLayer(layer, trackOrTrackId, groupId = 'default', isAxis = false) {
    let track = trackOrTrackId;

    if (typeof trackOrTrackId === 'string') {
      track = this.getTrackById(trackOrTrackId);
    }

    // creates the `LayerTimeContext` if not present
    if (!layer.timeContext) {
      const timeContext = isAxis ?
        this.timeContext : new LayerTimeContext(this.timeContext);

      layer.setTimeContext(timeContext);
    }

    // we should have a Track instance at this point
    track.add(layer);

    if (!this._groupedLayers[groupId]) {
      this._groupedLayers[groupId] = [];
    }

    this._groupedLayers[groupId].push(layer);

    layer.render();
    layer.update();
  }

  /**
   * Removes a layer from its track. The layer is detatched from the DOM but
   * can still be reused later.
   *
   * @param {Layer} layer - The layer to remove.
   */
  removeLayer(layer) {
    this.tracks.forEach(function(track) {
      const index = track.layers.indexOf(layer);
      if (index !== -1) { track.remove(layer); }
    });

    // clean references in helpers
    for (let groupId in this._groupedLayers) {
      const group = this._groupedLayers[groupId];
      const index = group.indexOf(layer);

      if (index !== -1) { group.splice(layer, 1); }

      if (!group.length) {
        delete this._groupedLayers[groupId];
      }
    }
  }

  /**
   * Returns a `Track` instance from it's given id.
   *
   * @param {String} trackId
   * @return {Track}
   */
  getTrackById(trackId) {
    return this._trackById[trackId];
  }

  /**
   * Returns the track containing a given DOM Element, returns null if no match found.
   *
   * @param {Element} $el - The DOM Element to be tested.
   * @return {Track}
   */
  getTrackFromDOMElement($el) {
    let $svg = null;
    let track = null;
    // find the closest `.track` element
    do {
      if ($el.classList.contains('track')) {
        $svg = $el;
      }
      $el = $el.parentNode;
    } while ($svg === null);
    // find the related `Track`
    this.tracks.forEach(function(_track) {
      if (_track.$svg === $svg) { track = _track; }
    });

    return track;
  }

  /**
   * Returns an array of layers from their given group id.
   *
   * @param {String} groupId - The id of the group as defined in `addLayer`.
   * @return {(Array|undefined)}
   */
  getLayersByGroup(groupId) {
    return this._groupedLayers[groupId];
  }

  /**
   * Iterates through the added tracks.
   */
  *[Symbol.iterator]() {
    yield* this.tracks[Symbol.iterator]();
  }
}