Home Manual Reference Source Repository

src/core/track.js

import ns from './namespace';


/**
 * Acts as a placeholder to organize the vertical layout of the visualization
 * and the horizontal alignement to an abscissa that correspond to a common
 * time reference. It basically offer a view on the overall timeline.
 *
 * Tracks are inserted into a given DOM element, allowing to create DAW like
 * representations. Each `Track` instance can host multiple `Layer` instances.
 * A track must be added to a timeline before being updated.
 *
 * ### A timeline with 3 tracks:
 *
 * ```
 * 0                 6                               16
 * +- - - - - - - - -+-------------------------------+- - - - - - -
 * |                 |x track 1 xxxxxxxxxxxxxxxxxxxxx|
 * +- - - - - - - - -+-------------------------------+- - - - - - -
 * |                 |x track 2 xxxxxxxxxxxxxxxxxxxxx|
 * +- - - - - - - - -+-------------------------------+- - - - - - -
 * |                 |x track 3 xxxxxxxxxxxxxxxxxxxxx|
 * +- - - - - - - - ---------------------------------+- - - - - - -
 * +----------------->
 * timeline.timeContext.timeToPixel(timeline.timeContext.offset)
 *
 *                   <------------------------------->
 *                   timeline's tracks defaults to 1000px
 *                   with a default pixelsPerSecond of 100px/s.
 *                   and a default `stretchRatio = 1`
 *                   track1 shows 10 seconds of the timeline
 * ```
 *
 * ### Track DOM structure
 *
 * ```html
 * <svg width="${visibleWidth}">
 *   <!-- background -->
 *   <rect><rect>
 *   <!-- main view -->
 *   <g class="offset" transform="translate(${offset}, 0)">
 *     <g class="layout">
 *       <!-- layers -->
 *     </g>
 *   </g>
 *   <g class="interactions"><!-- for feedback --></g>
 * </svg>
 * ```
 */
export default class Track {
  /**
   * @param {DOMElement} $el
   * @param {Number} [height = 100]
   */
  constructor($el, height = 100) {
    this._height = height;

    /**
     * The DOM element in which the track is created.
     * @type {Element}
     */
    this.$el = $el;
    /**
     * A placeholder to add shapes for interactions feedback.
     * @type {Element}
     */
    this.$interactions = null;
    /** @type {Element} */
    this.$layout = null;
    /** @type {Element} */
    this.$offset = null;
    /** @type {Element} */
    this.$svg = null;
    /** @type {Element} */
    this.$background = null;

    /**
     * An array of all the layers belonging to the track.
     * @type {Array<Layer>}
     */
    this.layers = [];
    /**
     * The context used to maintain the DOM structure of the track.
     * @type {TimelineTimeContext}
     */
    this.renderingContext = null;

    this._createContainer();
  }

  /**
   * Returns the height of the track.
   *
   * @type {Number}
   */
  get height() {
    return this._height;
  }

  /**
   * Sets the height of the track.
   *
   * @todo propagate to layers, keeping ratio? could be handy for vertical
   *    resize. This is why a set/get is implemented here.
   * @type {Number}
   */
  set height(value) {
    this._height = value;
  }

  /**
   * This method is called when the track is added to the timeline. The
   * track cannot be updated without being added to a timeline.
   *
   * @private
   * @param {TimelineTimeContext} renderingContext
   */
  configure(renderingContext) {
    this.renderingContext = renderingContext;
  }

  /**
   * Destroy the track. The layers from this track can still be reused elsewhere.
   */
  destroy() {
    // Detach everything from the DOM
    this.$el.removeChild(this.$svg);
    this.layers.forEach((layer) => this.$layout.removeChild(layer.$el));
    // clean references
    this.$el = null;
    this.renderingContext = null;
    this.layers.length = 0;
  }

  /**
   * Creates the DOM structure of the track.
   */
  _createContainer() {
    const $svg = document.createElementNS(ns, 'svg');
    $svg.setAttributeNS(null, 'shape-rendering', 'optimizeSpeed');
    $svg.setAttributeNS(null, 'height', this.height);
    $svg.setAttribute('xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
    $svg.classList.add('track');

    const $background = document.createElementNS(ns, 'rect');
    $background.setAttributeNS(null, 'height', '100%');
    $background.setAttributeNS(null, 'width', '100%');
    $background.style.fillOpacity = 0;
    // $background.style.pointerEvents = 'none';

    const $defs = document.createElementNS(ns, 'defs');

    const $offsetGroup = document.createElementNS(ns, 'g');
    $offsetGroup.classList.add('offset');

    const $layoutGroup = document.createElementNS(ns, 'g');
    $layoutGroup.classList.add('layout');

    const $interactionsGroup = document.createElementNS(ns, 'g');
    $interactionsGroup.classList.add('interactions');

    $offsetGroup.appendChild($layoutGroup);
    $svg.appendChild($defs);
    $svg.appendChild($background);
    $svg.appendChild($offsetGroup);
    $svg.appendChild($interactionsGroup);
    this.$el.appendChild($svg);
    // removes additionnal height added who knows why...
    this.$el.style.fontSize = 0;
    // fixes one of the (many ?) weird canvas rendering bugs in Chrome
    this.$el.style.transform = 'translateZ(0)';

    this.$layout = $layoutGroup;
    this.$offset = $offsetGroup;
    this.$interactions = $interactionsGroup;
    this.$svg = $svg;
    this.$background = $background;
  }

  /**
   * Adds a layer to the track.
   *
   * @param {Layer} layer - the layer to add to the track.
   */
  add(layer) {
    this.layers.push(layer);
    // Create a default renderingContext for the layer if missing
    this.$layout.appendChild(layer.$el);
  }

  /**
   * Removes a layer from the track. The layer can be reused elsewhere.
   *
   * @param {Layer} layer - the layer to remove from the track.
   */
  remove(layer) {
    this.layers.splice(this.layers.indexOf(layer), 1);
    // Removes layer from its container
    this.$layout.removeChild(layer.$el);
  }

  /**
   * Tests if a given element belongs to the track.
   *
   * @param {Element} $el
   * @return {bool}
   */
  hasElement($el) {
    do {
      if ($el === this.$el) {
        return true;
      }

      $el = $el.parentNode;
    } while ($el !== null);

    return false;
  }

  /**
   * Render all the layers added to the track.
   */
  render() {
    for (let layer of this) { layer.render(); }
  }

  /**
   * Updates the track DOM structure and updates the layers.
   *
   * @param {Array<Layer>} [layers=null] - if not null, a subset of the layers to update.
   */
  update(layers = null) {
    this.updateContainer();
    this.updateLayers(layers);
  }

  /**
   * Updates the track DOM structure.
   */
  updateContainer() {
    const $svg = this.$svg;
    const $offset = this.$offset;
    // Should be in some update layout
    const renderingContext = this.renderingContext;
    const height = this.height;
    const width = Math.round(renderingContext.visibleWidth);
    const offsetX = Math.round(renderingContext.timeToPixel(renderingContext.offset));
    const translate = `translate(${offsetX}, 0)`;

    $svg.setAttributeNS(null, 'height', height);
    $svg.setAttributeNS(null, 'width', width);
    $svg.setAttributeNS(null, 'viewbox', `0 0 ${width} ${height}`);

    $offset.setAttributeNS(null, 'transform', translate);
  }

  /**
   * Updates the layers.
   *
   * @param {Array<Layer>} [layers=null] - if not null, a subset of the layers to update.
   */
  updateLayers(layers = null) {
    layers = (layers === null) ? this.layers : layers;

    layers.forEach((layer) => {
      if (this.layers.indexOf(layer) === -1) { return; }
      layer.update();
    });
  }

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