Home Manual Reference Source Repository

src/core/layer.js

import events from 'events';
import ns from './namespace';
import scales from '../utils/scales';
import Segment from '../shapes/segment';
import TimeContextBehavior from '../behaviors/time-context-behavior';

// time context bahevior
let timeContextBehavior = null;
let timeContextBehaviorCtor = TimeContextBehavior;

/**
 * The layer class is the main visualization class. It is mainly defines by its
 * related `LayerTimeContext` which determines its position in the overall
 * timeline (through the `start`, `duration`, `offset` and `stretchRatio`
 * attributes) and by it's registered Shape which defines how to display the
 * data associated to the layer. Each created layer must be inserted into a
 * `Track` instance in order to be displayed.
 *
 * _Note: in the context of the layer, an __item__ is the SVG element
 * returned by a `Shape` instance and associated with a particular __datum__._
 *
 * ### Layer DOM structure
 * ```
 * <g class="layer" transform="translate(${start}, 0)">
 *   <svg class="bounding-box" width="${duration}">
 *     <g class="offset" transform="translate(${offset, 0})">
 *       <!-- background -->
 *       <rect class="background"></rect>
 *       <!-- shapes and common shapes are inserted here -->
 *     </g>
 *     <g class="interactions"><!-- for feedback --></g>
 *   </svg>
 * </g>
 * ```
 */
export default class Layer extends events.EventEmitter {
  /**
   * @param {String} dataType - Defines how the layer should look at the data.
   *    Can be 'entity' or 'collection'.
   * @param {(Array|Object)} data - The data associated to the layer.
   * @param {Object} options - Configures the layer.
   * @param {Number} [options.height=100] - Defines the height of the layer.
   * @param {Number} [options.top=0] - Defines the top position of the layer.
   * @param {Number} [options.opacity=1] - Defines the opacity of the layer.
   * @param {Number} [options.yDomain=[0,1]] - Defines boundaries of the data
   *    values in y axis (for exemple to display an audio buffer, this attribute
   *    should be set to [-1, 1].
   * @param {String} [options.className=null] - An optionnal class to add to each
   *    created shape.
   * @param {String} [options.className='selected'] - The class to add to a shape
   *    when selected.
   * @param {Number} [options.contextHandlerWidth=2] - The width of the handlers
   *    displayed to edit the layer.
   * @param {Number} [options.hittable=false] - Defines if the layer can be interacted
   *    with. Basically, the layer is not returned by `BaseState.getHitLayers` when
   *    set to false (a common use case is a layer that contains a cursor)
   */
  constructor(dataType, data, options = {}) {
    super();

    const defaults = {
      height: 100,
      top: 0,
      opacity: 1,
      yDomain: [0, 1],
      className: null,
      selectedClassName: 'selected',
      contextHandlerWidth: 2,
      hittable: true, // when false the layer is not returned by `BaseState.getHitLayers`
      id: '', // used ?
      overflow: 'hidden', // usefull ?
    };

    /**
     * Parameters of the layers, `defaults` overrided with options.
     * @type {Object}
     */
    this.params = Object.assign({}, defaults, options);
    /**
     * Defines how the layer should look at the data (`'entity'` or `'collection'`).
     * @type {String}
     */
    this.dataType = dataType; // 'entity' || 'collection';
    /** @type {LayerTimeContext} */
    this.timeContext = null;
    /** @type {Element} */
    this.$el = null;
    /** @type {Element} */
    this.$background = null;
    /** @type {Element} */
    this.$boundingBox = null;
    /** @type {Element} */
    this.$offset = null;
    /** @type {Element} */
    this.$interactions = null;
    /**
     * A Segment instanciated to interact with the Layer itself.
     * @type {Segment}
     */
    this.contextShape = null;

    this._shapeConfiguration = null;       // { ctor, accessors, options }
    this._commonShapeConfiguration = null; // { ctor, accessors, options }
    this._$itemShapeMap = new Map();
    this._$itemDataMap = new Map();
    this._$itemCommonShapeMap = new Map();

    this._isContextEditable = false;
    this._behavior = null;

    this.data = data;

    this._valueToPixel = scales.linear()
      .domain(this.params.yDomain)
      .range([0, this.params.height]);

    // initialize timeContext layout
    this._renderContainer();
    // creates the timeContextBehavior for all layers
    if (timeContextBehavior === null) {
      timeContextBehavior = new timeContextBehaviorCtor();
    }
  }

  /**
   * Destroy the layer, clear all references.
   */
  destroy() {
    this.timeContext = null;
    this.data = null;
    this.params = null;
    this._behavior = null;

    this._$itemShapeMap.clear();
    this._$itemDataMap.clear();
    this._$itemCommonShapeMap.clear();

    this.removeAllListeners();
  }

  /**
   * Allows to override default the `TimeContextBehavior` used to edit the layer.
   *
   * @param {Object} ctor
   */
  static configureTimeContextBehavior(ctor) {
    timeContextBehaviorCtor = ctor;
  }

  /**
   * Returns `LayerTimeContext`'s `start` time domain value.
   *
   * @type {Number}
   */
  get start() {
    return this.timeContext.start;
  }

  /**
   * Sets `LayerTimeContext`'s `start` time domain value.
   *
   * @type {Number}
   */
  set start(value) {
    this.timeContext.start = value;
  }

  /**
   * Returns `LayerTimeContext`'s `offset` time domain value.
   *
   * @type {Number}
   */
  get offset() {
    return this.timeContext.offset;
  }

  /**
   * Sets `LayerTimeContext`'s `offset` time domain value.
   *
   * @type {Number}
   */
  set offset(value) {
    this.timeContext.offset = value;
  }

  /**
   * Returns `LayerTimeContext`'s `duration` time domain value.
   *
   * @type {Number}
   */
  get duration() {
    return this.timeContext.duration;
  }

  /**
   * Sets `LayerTimeContext`'s `duration` time domain value.
   *
   * @type {Number}
   */
  set duration(value) {
    this.timeContext.duration = value;
  }

  /**
   * Returns `LayerTimeContext`'s `stretchRatio` time domain value.
   *
   * @type {Number}
   */
  get stretchRatio() {
    return this.timeContext.stretchRatio;
  }

  /**
   * Sets `LayerTimeContext`'s `stretchRatio` time domain value.
   *
   * @type {Number}
   */
  set stretchRatio(value) {
    this.timeContext.stretchRatio = value;
  }

  /**
   * Set the domain boundaries of the data for the y axis.
   *
   * @type {Array}
   */
  set yDomain(domain) {
    this.params.yDomain = domain;
    this._valueToPixel.domain(domain);
  }

  /**
   * Returns the domain boundaries of the data for the y axis.
   *
   * @type {Array}
   */
  get yDomain() {
    return this.params.yDomain;
  }

  /**
   * Sets the opacity of the whole layer.
   *
   * @type {Number}
   */
  set opacity(value) {
    this.params.opacity = value;
  }

  /**
   * Returns the opacity of the whole layer.
   *
   * @type {Number}
   */
  get opacity() {
    return this.params.opacity;
  }

  /**
   * Returns the transfert function used to display the data in the x axis.
   *
   * @type {Number}
   */
  get timeToPixel() {
    return this.timeContext.timeToPixel;
  }

  /**
   * Returns the transfert function used to display the data in the y axis.
   *
   * @type {Number}
   */
  get valueToPixel() {
    return this._valueToPixel;
  }

  /**
   * Returns an array containing all the displayed items.
   *
   * @type {Array<Element>}
   */
  get items() {
    return Array.from(this._$itemDataMap.keys());
  }

  /**
   * Returns the data associated to the layer.
   *
   * @type {Object[]}
   */
  get data() { return this._data; }

  /**
   * Sets the data associated with the layer.
   *
   * @type {Object|Object[]}
   */
  set data(data) {
    switch (this.dataType) {
      case 'entity':
        if (this._data) {  // if data already exists, reuse the reference
          this._data[0] = data;
        } else {
          this._data = [data];
        }
        break;
      case 'collection':
        this._data = data;
        break;
    }
  }

  // --------------------------------------
  // Initialization
  // --------------------------------------

  /**
   * Renders the DOM in memory on layer creation to be able to use it before
   * the layer is actually inserted in the DOM.
   */
  _renderContainer() {
    // wrapper group for `start, top and context flip matrix
    this.$el = document.createElementNS(ns, 'g');
    this.$el.classList.add('layer');
    if (this.params.className !== null) {
      this.$el.classList.add(this.params.className);
    }
    // clip the context with a `svg` element
    this.$boundingBox = document.createElementNS(ns, 'svg');
    this.$boundingBox.classList.add('bounding-box');
    this.$boundingBox.style.overflow = this.params.overflow;
    // group to apply offset
    this.$offset = document.createElementNS(ns, 'g');
    this.$offset.classList.add('offset', 'items');
    // layer background
    this.$background = document.createElementNS(ns, 'rect');
    this.$background.setAttributeNS(null, 'height', '100%');
    this.$background.setAttributeNS(null, 'width', '100%');
    this.$background.classList.add('background');
    this.$background.style.fillOpacity = 0;
    this.$background.style.pointerEvents = 'none';
    // context interactions
    this.$interactions = document.createElementNS(ns, 'g');
    this.$interactions.classList.add('interactions');
    this.$interactions.style.display = 'none';
    // @NOTE: works but king of ugly... should be cleaned
    this.contextShape = new Segment();
    this.contextShape.install({
      opacity: () => 0.1,
      color  : () => '#787878',
      width  : () => this.timeContext.duration,
      height : () => this._renderingContext.valueToPixel.domain()[1],
      y      : () => this._renderingContext.valueToPixel.domain()[0]
    });

    this.$interactions.appendChild(this.contextShape.render());
    // create the DOM tree
    this.$el.appendChild(this.$boundingBox);
    this.$boundingBox.appendChild(this.$offset);
    this.$offset.appendChild(this.$background);
    this.$boundingBox.appendChild(this.$interactions);
  }

  // --------------------------------------
  // Component Configuration
  // --------------------------------------

  /**
   * Sets the context of the layer, thus defining its `start`, `duration`,
   * `offset` and `stretchRatio`.
   *
   * @param {TimeContext} timeContext - The timeContext in which the layer is displayed.
   */
  setTimeContext(timeContext) {
    this.timeContext = timeContext;
    // create a mixin to pass to the shapes
    this._renderingContext = {};
    this._updateRenderingContext();
  }

  /**
   * Register a shape and its configuration to use in order to render the data.
   *
   * @param {BaseShape} ctor - The constructor of the shape to be used.
   * @param {Object} [accessors={}] - Defines how the shape should adapt to a particular data struture.
   * @param {Object} [options={}] - Global configuration for the shapes, is specific to each `Shape`.
   */
  configureShape(ctor, accessors = {}, options = {}) {
    this._shapeConfiguration = { ctor, accessors, options };
  }

  /**
   * Optionnaly register a shape to be used accros the entire collection.
   *
   * @param {BaseShape} ctor - The constructor of the shape to be used.
   * @param {Object} [accessors={}] - Defines how the shape should adapt to a particular data struture.
   * @param {Object} [options={}] - Global configuration for the shapes, is specific to each `Shape`.
   */
  configureCommonShape(ctor, accessors = {}, options = {}) {
    this._commonShapeConfiguration = { ctor, accessors, options };
  }

  /**
   * Register the behavior to use when interacting with a shape.
   *
   * @param {BaseBehavior} behavior
   */
  setBehavior(behavior) {
    behavior.initialize(this);
    this._behavior = behavior;
  }

  /**
   * Updates the values stored int the `_renderingContext` passed  to shapes
   * for rendering and updating.
   */
  _updateRenderingContext() {
    this._renderingContext.timeToPixel = this.timeContext.timeToPixel;
    this._renderingContext.valueToPixel = this._valueToPixel;

    this._renderingContext.height = this.params.height;
    this._renderingContext.width  = this.timeContext.timeToPixel(this.timeContext.duration);
    // for foreign object issue in chrome
    this._renderingContext.offsetX = this.timeContext.timeToPixel(this.timeContext.offset);
    this._renderingContext.startX = this.timeContext.parent.timeToPixel(this.timeContext.start);

    // @todo replace with `minX` and `maxX` representing the visible pixels in which
    // the shapes should be rendered, could allow to not update the DOM of shapes
    // who are not in this area.
    this._renderingContext.trackOffsetX = this.timeContext.parent.timeToPixel(this.timeContext.parent.offset);
    this._renderingContext.visibleWidth = this.timeContext.parent.visibleWidth;
  }

  // --------------------------------------
  // Behavior Accessors
  // --------------------------------------

  /**
   * Returns the items marked as selected.
   *
   * @type {Array<Element>}
   */
  get selectedItems() {
    return this._behavior ? this._behavior.selectedItems : [];
  }

  /**
   * Mark item(s) as selected.
   *
   * @param {Element|Element[]} $items
   */
  select(...$items) {
    if (!this._behavior) { return; }
    if (!$items.length) { $items = this._$itemDataMap.keys(); }
    if (Array.isArray($items[0])) { $items = $items[0]; }

    for (let $item of $items) {
      const datum = this._$itemDataMap.get($item);
      this._behavior.select($item, datum);
      this._toFront($item);
    }
  }

  /**
   * Removes item(s) from selected items.
   *
   * @param {Element|Element[]} $items
   */
  unselect(...$items) {
    if (!this._behavior) { return; }
    if (!$items.length) { $items = this._$itemDataMap.keys(); }
    if (Array.isArray($items[0])) { $items = $items[0]; }

    for (let $item of $items) {
      const datum = this._$itemDataMap.get($item);
      this._behavior.unselect($item, datum);
    }
  }

  /**
   * Toggle item(s) selection state according to their current state.
   *
   * @param {Element|Element[]} $items
   */
  toggleSelection(...$items) {
    if (!this._behavior) { return; }
    if (!$items.length) { $items = this._$itemDataMap.keys(); }
    if (Array.isArray($items[0])) { $items = $items[0]; }

    for (let $item of $items) {
      const datum = this._$itemDataMap.get($item);
      this._behavior.toggleSelection($item, datum);
    }
  }

  /**
   * Edit item(s) according to the `edit` defined in the registered `Behavior`.
   *
   * @param {Element|Element[]} $items - The item(s) to edit.
   * @param {Number} dx - The modification to apply in the x axis (in pixels).
   * @param {Number} dy - The modification to apply in the y axis (in pixels).
   * @param {Element} $target - The target of the interaction (for example, left
   *    handler DOM element in a segment).
   */
  edit($items, dx, dy, $target) {
    if (!this._behavior) { return; }
    $items = !Array.isArray($items) ? [$items] : $items;

    for (let $item of $items) {
      const shape = this._$itemShapeMap.get($item);
      const datum = this._$itemDataMap.get($item);

      this._behavior.edit(this._renderingContext, shape, datum, dx, dy, $target);
      this.emit('edit', shape, datum);
    }
  }

  /**
   * Defines if the `Layer`, and thus the `LayerTimeContext` is editable or not.
   *
   * @params {Boolean} [bool=true]
   */
  setContextEditable(bool = true) {
    const display = bool ? 'block' : 'none';
    this.$interactions.style.display = display;
    this._isContextEditable = bool;
  }

  /**
   * Edit the layer and thus its related `LayerTimeContext` attributes.
   *
   * @param {Number} dx - The modification to apply in the x axis (in pixels).
   * @param {Number} dy - The modification to apply in the y axis (in pixels).
   * @param {Element} $target - The target of the event of the interaction.
   */
  editContext(dx, dy, $target) {
    timeContextBehavior.edit(this, dx, dy, $target);
  }

  /**
   * Stretch the layer and thus its related `LayerTimeContext` attributes.
   *
   * @param {Number} dx - The modification to apply in the x axis (in pixels).
   * @param {Number} dy - The modification to apply in the y axis (in pixels).
   * @param {Element} $target - The target of the event of the interaction.
   */
  stretchContext(dx, dy, $target) {
    timeContextBehavior.stretch(this, dx, dy, $target);
  }

  // --------------------------------------
  // Helpers
  // --------------------------------------

  /**
   * Returns an item from a DOM element related to the shape, null otherwise.
   *
   * @param {Element} $el - the element to be tested
   * @return {Element|null}
   */
  getItemFromDOMElement($el) {
    let $item;

    do {
      if ($el.classList && $el.classList.contains('item')) {
        $item = $el;
        break;
      }

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

    return this.hasItem($item) ? $item : null;
  }

  /**
   * Returns the datum associated to a specific item.
   *
   * @param {Element} $item
   * @return {Object|Array|null}
   */
  getDatumFromItem($item) {
    const datum = this._$itemDataMap.get($item);
    return datum ? datum : null;
  }

  /**
   * Returns the datum associated to a specific item from any DOM element
   * composing the shape. Basically a shortcut for `getItemFromDOMElement` and
   * `getDatumFromItem` methods.
   *
   * @param {Element} $el
   * @return {Object|Array|null}
   */
  getDatumFromDOMElement($el) {
    var $item = this.getItemFromDOMElement($el);
    if ($item === null) { return null; }
    return this.getDatumFromItem($item);
  }

  /**
   * Tests if the given DOM element is an item of the layer.
   *
   * @param {Element} $item - The item to be tested.
   * @return {Boolean}
   */
  hasItem($item) {
    return this._$itemDataMap.has($item);
  }

  /**
   * Defines if a given element belongs to the layer. Is more general than
   * `hasItem`, can mostly used to check interactions elements.
   *
   * @param {Element} $el - The DOM element to be tested.
   * @return {bool}
   */
  hasElement($el) {
    do {
      if ($el === this.$el) {
        return true;
      }

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

    return false;
  }

  /**
   * Retrieve all the items in a given area as defined in the registered `Shape~inArea` method.
   *
   * @param {Object} area - The area in which to find the elements
   * @param {Number} area.top
   * @param {Number} area.left
   * @param {Number} area.width
   * @param {Number} area.height
   * @return {Array} - list of the items presents in the area
   */
  getItemsInArea(area) {
    const start    = this.timeContext.parent.timeToPixel(this.timeContext.start);
    const duration = this.timeContext.timeToPixel(this.timeContext.duration);
    const offset   = this.timeContext.timeToPixel(this.timeContext.offset);
    const top      = this.params.top;
    // be aware af context's translations - constrain in working view
    let x1 = Math.max(area.left, start);
    let x2 = Math.min(area.left + area.width, start + duration);
    x1 -= (start + offset);
    x2 -= (start + offset);
    // keep consistent with context y coordinates system
    let y1 = this.params.height - (area.top + area.height);
    let y2 = this.params.height - area.top;

    y1 += this.params.top;
    y2 += this.params.top;

    const $filteredItems = [];

    for (let [$item, datum] of this._$itemDataMap.entries()) {
      const shape = this._$itemShapeMap.get($item);
      const inArea = shape.inArea(this._renderingContext, datum, x1, y1, x2, y2);

      if (inArea) { $filteredItems.push($item); }
    }

    return $filteredItems;
  }

  // --------------------------------------
  // Rendering / Display methods
  // --------------------------------------

  /**
   * Moves an item to the end of the layer to display it front of its
   * siblings (svg z-index...).
   *
   * @param {Element} $item - The item to be moved.
   */
  _toFront($item) {
    this.$offset.appendChild($item);
  }

  /**
   * Create the DOM structure of the shapes according to the given data. Inspired
   * from the `enter` and `exit` d3.js paradigm, this method should be called
   * each time a datum is added or removed from the data. While the DOM is
   * created the `update` method must be called in order to update the shapes
   * attributes and thus place them where they should.
   */
  render() {
    // render `commonShape` only once
    if (
      this._commonShapeConfiguration !== null &&
      this._$itemCommonShapeMap.size === 0
    ) {
      const { ctor, accessors, options } = this._commonShapeConfiguration;
      const $group = document.createElementNS(ns, 'g');
      const shape = new ctor(options);

      shape.install(accessors);
      $group.appendChild(shape.render());
      $group.classList.add('item', 'common', shape.getClassName());

      this._$itemCommonShapeMap.set($group, shape);
      this.$offset.appendChild($group);
    }

    // append elements all at once
    const fragment = document.createDocumentFragment();
    const values = this._$itemDataMap.values(); // iterator

    // enter
    this.data.forEach((datum) => {
      for (let value of values) { if (value === datum) { return; } }

      const { ctor, accessors, options } = this._shapeConfiguration;
      const shape = new ctor(options);
      shape.install(accessors);

      const $el = shape.render(this._renderingContext);
      $el.classList.add('item', shape.getClassName());

      this._$itemShapeMap.set($el, shape);
      this._$itemDataMap.set($el, datum);

      fragment.appendChild($el);
    });

    this.$offset.appendChild(fragment);

    // remove
    for (let [$item, datum] of this._$itemDataMap.entries()) {
      if (this.data.indexOf(datum) !== -1) { continue; }

      const shape = this._$itemShapeMap.get($item);

      this.$offset.removeChild($item);
      shape.destroy();
      // a removed item cannot be selected
      if (this._behavior) {
        this._behavior.unselect($item, datum);
      }

      this._$itemDataMap.delete($item);
      this._$itemShapeMap.delete($item);
    }
  }

  /**
   * Updates the container of the layer and the attributes of the existing shapes.
   */
  update() {
    this.updateContainer();
    this.updateShapes();
  }

  /**
   * Updates the container of the layer.
   */
  updateContainer() {
    this._updateRenderingContext();

    const timeContext = this.timeContext;
    const width  = timeContext.timeToPixel(timeContext.duration);
    // x is relative to timeline's timeContext
    const x      = timeContext.parent.timeToPixel(timeContext.start);
    const offset = timeContext.timeToPixel(timeContext.offset);
    const top    = this.params.top;
    const height = this.params.height;
    // matrix to invert the coordinate system
    const translateMatrix = `matrix(1, 0, 0, -1, ${x}, ${top + height})`;

    this.$el.setAttributeNS(null, 'transform', translateMatrix);

    this.$boundingBox.setAttributeNS(null, 'width', width);
    this.$boundingBox.setAttributeNS(null, 'height', height);
    this.$boundingBox.style.opacity = this.params.opacity;

    this.$offset.setAttributeNS(null, 'transform', `translate(${offset}, 0)`);
    // maintain context shape
    this.contextShape.update(this._renderingContext, this.timeContext, 0);
  }

  /**
   * Updates the attributes of all the `Shape` instances rendered into the layer.
   *
   * @todo - allow to filter which shape(s) should be updated.
   */
  updateShapes() {
    this._updateRenderingContext();
    // update common shapes
    this._$itemCommonShapeMap.forEach((shape, $item) => {
      shape.update(this._renderingContext, this.data);
    });

    for (let [$item, datum] of this._$itemDataMap.entries()) {
      const shape = this._$itemShapeMap.get($item);
      shape.update(this._renderingContext, datum);
    }
  }
}