Source: Track.js

//////////////////////////////////////////////////////////////////////////////
// Track
//////////////////////////////////////////////////////////////////////////////

import CGObject from './CGObject';
import CGArray from './CGArray';
import Slot from './Slot';
import utils from './Utils';

// TODO: - Instead of check for features or plot. There could be a data attribute which
//         will point to features or a plot.

/**
 * The Track is used for layout information...
 *
 * ### Action and Events
 *
 * Action                                    | Viewer Method                              | Track Method        | Event
 * ------------------------------------------|--------------------------------------------|---------------------|-----
 * [Add](../docs.html#adding-tracks)         | [addTracks()](Viewer.html#addTracks)       | -                   | tracks-add
 * [Update](../docs.html#updating-tracks)    | [updateTracks()](Viewer.html#updateTracks) | [update()](#update) | tracks-update
 * [Remove](../docs.html#removing-tracks)    | [removeTracks()](Viewer.html#removeTracks) | [remove()](#remove) | tracks-remove
 * [Reorder](../docs.html#reordering-tracks) | [moveTrack()](Viewer.html#moveTrack)       | [move()](#move)     | tracks-reorder
 * [Read](../docs.html#reading-tracks)       | [tracks()](Viewer.html#tracks)             | -                   | -
 *
 * <a name="attributes"></a>
 * ### Attributes
 *
 * Attribute                         | Type      | Description
 * ----------------------------------|-----------|------------
 * [name](#name)                     | String    | Name of track [Default: "Unknown"]
 * [dataType](#dataType)             | String    | Type of data shown by the track: plot, feature [Default: feature]
 * [dataMethod](#dataMethod)         | String    | Methods used to extract/connect to features or a plot: sequence, source, type, tag [Default: source]
 * [dataKeys](#dataKeys)             | String\|Array | Values used by dataMethod to extract features or a plot.
 * [position](#position)             | String    | Position relative to backbone: inside, outside, or both [Default: both]
 * [separateFeaturesBy](#separateFeaturesBy) | String    | How features should be separated: none, strand, or readingFrame [Default: strand]
 * [thicknessRatio](#thicknessRatio) | Number    | Thickness of track compared to other tracks [Default: 1]
 * [loadProgress](#loadProgress)     | Number    | Number between 0 and 100 indicating progress of track loading. Used internally by workers.
 * [drawOrder](#loadProgress)        | String    | Order to draw features in: position, score [Default: position]
 * [favorite](#favorite)             | Boolean   | Track is a favorite [Default: false]
 * [visible](CGObject.html#visible)  | Boolean   | Track is visible [Default: true]
 * [meta](CGObject.html#meta)        | Object    | [Meta data](../tutorials/details-meta-data.html) for Track
 *
 * ### Examples
 *
 * @extends CGObject
 */
class Track extends CGObject {

  /**
   * Create a new track.
   * @param {Viewer} viewer - The viewer
   * @param {Object} options - [Attributes](#attributes) used to create the track.
   * @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the track.
   */
  constructor(viewer, data = {}, meta = {}) {
    super(viewer, data, meta);
    this.viewer = viewer;
    this._plot;
    this._features = new CGArray();
    this._slots = new CGArray();
    this.name = utils.defaultFor(data.name, 'Unknown');
    this.separateFeaturesBy = utils.defaultFor(data.separateFeaturesBy, 'strand');
    this.position = utils.defaultFor(data.position, 'both');
    this.drawOrder = utils.defaultFor(data.drawOrder, 'position');
    this.dataType = utils.defaultFor(data.dataType, 'feature');
    this.dataMethod = utils.defaultFor(data.dataMethod, 'source');
    this.dataKeys = data.dataKeys;
    this.dataOptions = data.dataOptions || {};
    this._thicknessRatio = utils.defaultFor(data.thicknessRatio, 1);
    this._loadProgress = 0;
    this.refresh();
  }

  /**
   * Return the class name as a string.
   * @return {String} - 'Track'
   */
  toString() {
    return 'Track';
  }

  /**
   * @member {Viewer} - Get the *Viewer*
   */
  get viewer() {
    return this._viewer;
  }

  set viewer(viewer) {
    if (this.viewer) {
      // TODO: Remove if already attached to Viewer
    }
    this._viewer = viewer;
    viewer._tracks.push(this);
  }


  set visible(value) {
    // super.visible = value;
    this._visible = value;
    if (this.layout) {
      this.layout._adjustProportions();
    }
  }

  get visible() {
    // return super.visible
    return this._visible;
  }

  /**
   * @member {String} - Alias for getting the name. Useful for querying CGArrays.
   */
  get id() {
    return this.name;
  }

  /**
   * @member {String} - Get or set the *name*.
   */
  get name() {
    return this._name;
  }

  set name(value) {
    this._name = value;
  }

  /** * @member {Viewer} - Get the *Layout*
   */
  get layout() {
    return this.viewer.layout;
  }


  /**
   * @member {String} - Get or set the *drawOrder*. Must be one of 'position' or 'score' [Default: 'position']
   * - position: Features are drawn in the (opposite) order they appear in the sequence. From end of strand backwards. This makes the arrow heads apear above features.
   * - score: Features are drawn in order of score (lowest to highest).
   */
  get drawOrder() {
    return this._drawOrder;
  }

  set drawOrder(value) {
    if ( utils.validate(value, ['position', 'score']) ) {
      this._drawOrder = value;
    }
  }

  /**
   * @member {String} - Get or set the *dataType*. Must be one of 'feature' or 'plot' [Default: 'feature']
   */
  get dataType() {
    return this._dataType;
  }

  set dataType(value) {
    if ( utils.validate(value, ['feature', 'plot']) ) {
      this._dataType = value;
    }
  }

  /** * @member {String} - Alias for *dataType*.
   */
  get type() {
    return this.dataType;
    // return this.contents.type;
  }

  /**
   * @member {String} - Get or set the *dataMethod* attribute. *dataMethod* describes how the features/plot should be extracted.
   *    Options are 'source', 'type', 'tag', or 'sequence' [Default: 'source']
   */
  get dataMethod() {
    return this._dataMethod;
  }

  set dataMethod(value) {
    if ( utils.validate(value, ['source', 'type', 'tag', 'sequence']) ) {
      this._dataMethod = value;
    }
  }

  /**
   * @member {String} - Get or set the *dataKeys* attribute. *dataKeys* describes which features/plot should be extracted. For example,
   *    if *dataMethod* is 'type', and *dataKeys* is 'CDS', then all features with a type of 'CDS' will be used to create the track.
   *    For *dataMethod* of 'sequence', the following values are possible for *dataKeys*: 'orfs', 'start-stop-codons', 'gc-content', 'gc-skew'.
   */
  get dataKeys() {
    return this._dataKeys;
  }

  set dataKeys(value) {
    this._dataKeys = (value === undefined) ? new CGArray() : new CGArray(value);
  }

  /** * @member {Object} - Get or set the *dataOptions*. The *dataOptions* are passed to the SequenceExtractor.
   */
  get dataOptions() {
    return this._dataOptions;
  }

  set dataOptions(value) {
    this._dataOptions = value;
  }


  /**
   * @member {String} - Get or set separateFeaturesBy. Possible values are 'none', 'strand', or 'readingFrame'.
   */
  get separateFeaturesBy() {
    return this._separateFeaturesBy;
  }

  set separateFeaturesBy(value) {
    if ( utils.validate(value, ['none', 'strand', 'readingFrame']) ) {
      this._separateFeaturesBy = value;
      this.updateSlots();
    }
  }

  /**
   * @member {String} - Get or set the position. Possible values are 'inside', 'outside', or 'both'.
   */
  get position() {
    return this._position;
  }

  set position(value) {
    if (utils.validate(value, ['inside', 'outside', 'both'])) {
      this._position = value;
      this.updateSlots();
    }
  }

  /**
   * @member {Plot} - Get the plot associated with this track
   */
  get plot() {
    return this._plot;
  }

  /**
   * @member {Number} - Get or set the load progress position (integer between 0 and 100)
   */
  get loadProgress() {
    return this._loadProgress;
  }

  set loadProgress(value) {
    this._loadProgress = value;
    // this.viewer.trigger('track-load-progress-changed', this);
  }

  /**
   * @member {Number} - Return the number of features or plot points contained in this track.
   */
  get itemCount() {
    if (this.type === 'plot') {
      return (this.plot) ? this.plot.length : 0;
    } else if (this.type === 'feature') {
      return this.features().length;
    } else {
      return 0;
    }
  }

  /**
   * @member {Viewer} - Get or set the track size as a ratio to all other tracks
   */
  get thicknessRatio() {
    return this._thicknessRatio;
  }

  set thicknessRatio(value) {
    this._thicknessRatio = Number(value);
    this.layout._adjustProportions();
  }

  /**
   * Update track [attributes](#attributes).
   * See [updating records](../docs.html#s.updating-records) for details.
   * @param {Object} attributes - Object describing the properties to change
   */
  update(attributes) {
    this.viewer.updateTracks(this, attributes);
  }

  /**
   * Remove track
   */
  remove() {
    this.viewer.removeTracks(this);
  }

  /**
   * Move this track to a new index in the array of Viewer tracks.
   * @param {Number} newIndex - New index for this track (0-based)
   */
  move(newIndex) {
    const currentIndex = this.viewer.tracks().indexOf(this);
    this.viewer.moveTrack(currentIndex, newIndex);
  }


  /**
   * Returns an [CGArray](CGArray.html) of Features or a single Feature from all the features in this track.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  features(term) {
    return this._features.get(term);
  }

  slots(term) {
    return this._slots.get(term);
  }

  /**
   * Returns an [CGArray](CGArray.html) of Features or a single Feature from all the unique features in this track.
   * Unique features are ones that only appear in this track.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   * @private
   */
  uniqueFeatures(term) {
    const features = new CGArray();
    for (let i = 0, len = this._features.length; i < len; i++) {
      if (this._features[i].tracks().length === 1) {
        features.push(this._features[i]);
      }
    }
    return features.get(term);
  }

  /**
   * Remove a feature or array of features from the track and slots.
   *
   * @param {Feature|Array} features - The Feature(s) to remove.
   */
  removeFeatures(features) {
    features = (features.toString() === 'CGArray') ? features : new CGArray(features);
    // this._features = new CGArray(
    //   this._features.filter( (f) => { return !features.includes(f) })
    // );
    this._features = this._features.filter( f => !features.includes(f) );
    this.slots().each( (i, slot) => {
      slot.removeFeatures(features);
    });
    this.viewer.trigger('track-update', this);
  }

  /**
   * Remove the plot from the track and slots.
   */
  removePlot() {
    this._plot = undefined;
    this.slots().each( (i, slot) => {
      slot.removePlot();
    });
    this.viewer.trigger('track-update', this);
  }

  // NOTE:
  // - features and plots extracted from sequence are empheral and will be removed and readded on refresh
  refresh() {
    const tempPlot = this._plot;
    const tempFeatures = this._features;
    this._features = new CGArray();
    this._plot = undefined;
    if (this.dataMethod === 'sequence') {
      tempPlot?.remove();
      this.viewer.removeFeatures(tempFeatures);
      this.extractFromSequence();
    } else if (this.type === 'feature') {
      this.updateFeatures();
    } else if (this.type === 'plot') {
      this.updatePlot();
    }
    this.updateSlots();
  }

  extractFromSequence() {
    const sequenceExtractor = this.viewer.sequence.sequenceExtractor;
    if (sequenceExtractor) {
      sequenceExtractor.extractTrackData(this, this.dataKeys[0], this.dataOptions);
    } else {
      console.error('No sequence is available to extract features/plots from');
    }
  }

  updateFeatures() {
    // Methods where the feature will contain a single value
    if (this.dataMethod === 'source' || this.dataMethod === 'type') {
      this.viewer.features().each( (i, feature) => {
        if (this.dataKeys.includes(feature[this.dataMethod]) && feature.contig.visible) {
          this._features.push(feature);
        }
      });
    // Methods where the feature will contain an array of values
    } else if (this.dataMethod === 'tag') {
      this.viewer.features().each( (i, feature) => {
        if (this.dataKeys.some( k => feature.tags.includes(k)) && feature.contig.visible) {
          this._features.push(feature);
        }
      });
    }
  }

  updatePlot() {
    if (this.dataMethod === 'source') {
      // Plot with particular Source
      this.viewer.plots().find( (plot) => {
        if (plot.source === this.dataKeys[0]) {
          this._plot = plot;
        }
      });
    }
  }

  updateSlots() {
    if (this.type === 'feature') {
      this.updateFeatureSlots();
    } else if (this.type === 'plot') {
      this.updatePlotSlot();
    }
    this.layout._adjustProportions();
    // this.viewer.trigger('track-update', this);
  }

  updateFeatureSlots() {
    this._slots = new CGArray();
    if (this.separateFeaturesBy === 'readingFrame') {
      const features = this.sequence.featuresByReadingFrame(this.features());
      // Direct Reading Frames
      for (const rf of [1, 2, 3]) {
        const slot = new Slot(this, {strand: 'direct'});
        slot.replaceFeatures(features[`rfPlus${rf}`]);
      }
      // Reverse Reading Frames
      for (const rf of [1, 2, 3]) {
        const slot = new Slot(this, {strand: 'reverse'});
        slot.replaceFeatures(features[`rfMinus${rf}`]);
      }
    } else if (this.separateFeaturesBy === 'strand') {
      const features = this.featuresByStrand();
      // Direct Slot
      let slot = new Slot(this, {strand: 'direct'});
      slot.replaceFeatures(features.direct);
      // Reverse Slot
      slot = new Slot(this, {strand: 'reverse'});
      slot.replaceFeatures(features.reverse);
    } else {
      // Combined Slot
      const slot = new Slot(this, {strand: 'direct'});
      slot.replaceFeatures(this.features());
    }
  }

  // FIXME: this should become simply (update)
  // update(attributes = {}) {
  //   this.viewer.updateTracks(this, attributes);
  // }
  triggerUpdate() {
    this.viewer.updateTracks(this);
  }

  featuresByStrand() {
    const features = {};
    features.direct = new CGArray();
    features.reverse = new CGArray();
    this.features().each( (i, feature) => {
      if (feature.strand === -1) {
        features.reverse.push(feature);
      } else {
        features.direct.push(feature);
      }
    });
    return features;
  }

  updatePlotSlot() {
    this._slots = new CGArray();
    const slot = new Slot(this, {type: 'plot'});
    slot._plot = this._plot;
  }

  highlight(color = '#FFB') {
    if (this.visible) {
      this.slots().each( (i, slot) => {
        slot.highlight(color);
      });
    }
  }

  /**
   * Returns JSON representing the object
   */
  toJSON(options = {}) {
    const json = {
      name: this.name,
      separateFeaturesBy: this.separateFeaturesBy,
      position: this.position,
      thicknessRatio: this.thicknessRatio,
      dataType: this.dataType,
      dataMethod: this.dataMethod
    };
    // DataKeys
    json.dataKeys = (this.dataKeys.length === 1) ? this.dataKeys[0] : [...this.dataKeys];
    // DataOptions
    if (this.dataOptions && Object.keys(this.dataOptions).length > 0) {
      json.dataOptions = this.dataOptions;
    }
    // Optionally add default values
    if (!this.visible || options.includeDefaults) {
      json.visible = this.visible;
    }
    if (this.drawOrder != 'position') {
      json.drawOrder = this.drawOrder;
    }
    // This could be a new Track specific toJSON option
    if (options.includeDefaults) {
      json.loadProgress = this.loadProgress;
    }
    return json;
  }

}

export default Track;