Source: Backbone.js

//////////////////////////////////////////////////////////////////////////////
// Backbone
//////////////////////////////////////////////////////////////////////////////

import CGObject from './CGObject';
import Color from './Color';
import utils from './Utils';

/**
 * The CGView Backbone represents the sequence of the map. When zoomed in far
 * enough the sequence will be shown on the backbone. If contigs are present,
 * they will be represented as arcs or arrows on the backbone.
 *
 * ### Action and Events
 *
 * Action                                    | Viewer Method                   | Backbone Method      | Event
 * ------------------------------------------|--------------------------------- |---------------------|-----
 * [Update](../docs.html#s.updating-records) | -                                | [update()](#update) | backbone-update
 * [Read](../docs.html#s.reading-records)    | [backbone](Viewer.html#backbone) | -                   | -
 *
 * <a name="attributes"></a>
 * ### Attributes
 *
 * Attribute                         | Type      | Description
 * ----------------------------------|-----------|------------
 * [thickness](#thickness)           | Number    | Thickness of backbone [Default: 5]
 * [color](#color)                   | String    | A string describing the main backbone color [Default: 'grey']. See {@link Color} for details.
 * [colorAlternate](#alternateColor) | String    | A string describing the alternate color used for contigs [Default: 'rgb(200,200,200)']. See {@link Color} for details.
 * [decoration](#decoration)         | String    | How the bakcbone should be drawn. Choices: 'arc', 'arrow' [Default: arc for single contig, arrow for muliple contigs]
 * [visible](CGObject.html#visible)  | Boolean   | Backbone is visible [Default: true]
 * [meta](CGObject.html#meta)        | Object    | [Meta data](../tutorials/details-meta-data.html)
 *
 * ### Examples
 * ```js
 * cgv.backbone.update({
 *   thickness: 20
 * });
 *
 * @extends CGObject
 */
class Backbone extends CGObject {

  /**
   * Create the Backbone.
   * @param {Viewer} viewer - The viewer
   * @param {Object} options - [Attributes](#attributes) used to create the backbone
   * @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the backbone.
   */
  constructor(viewer, options = {}, meta = {}) {
    super(viewer, options, meta);
    this.color = utils.defaultFor(options.color, 'grey');
    this.colorAlternate = utils.defaultFor(options.colorAlternate, 'rgb(200,200,200)');
    this.thickness = utils.defaultFor(options.thickness, 5);
    this._bpThicknessAddition = 0;
    // Default decoration is arrow for multiple contigs and arc for single contig
    const defaultDecoration = this.sequence.hasMultipleContigs ? 'arrow' : 'arc';
    this.decoration = utils.defaultFor(options.decoration, defaultDecoration);

    this.viewer.trigger('backbone-update', { attributes: this.toJSON({includeDefaults: true}) });
  }

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


  get visible() {
    return this._visible;
  }

  set visible(value) {
    this._visible = value;
    this.viewer._initialized && this.refreshThickness();
    // FIXME:
    this.viewer.layout && this.viewer.layout._adjustProportions();
  }

  /**
   * @member {Color} - Get or set the backbone color. When setting the color, a string representing the color or a {@link Color} object can be used. For details see {@link Color}.
   */
  get color() {
    return this._color;
  }

  set color(value) {
    if (value.toString() === 'Color') {
      this._color = value;
    } else {
      this._color = new Color(value);
    }
  }

  /**
   * @member {Color} - Get or set the backbone alternate color. This color is used when contigs are present. 
   *    The first contigs will be use *color*, the second will use *colorAlternate*, the third will use *color* and so on. When setting the color, a string representing the color or a {@link Color} object can be used. For details see {@link Color}.
   */
  get colorAlternate() {
    return this._colorAlternate;
  }

  set colorAlternate(value) {
    if (value.toString() === 'Color') {
      this._colorAlternate = value;
    } else {
      this._colorAlternate = new Color(value);
    }
  }

  /**
   * @member {String} - Get or set the decoration for the backbone contigs: 'arrow' or 'arc'
   */
  get decoration() {
    return this._decoration;
  }

  set decoration(value) {
    this._decoration = value;
  }

  /**
   * @member {Number} - Get or set the backbone centerOffset. This is the unzoomed centerOffset.
   */
  set centerOffset(value) {
    if (utils.isNumeric(value)) {
      this._centerOffset = value;
      // FIXME: zoommax will be based on map thickness, instead of backbone radius
      this.viewer._updateZoomMax();
    }
  }

  get centerOffset() {
    return this._centerOffset;
  }

  /**
   * @member {Number} - Get the zoomed backbone radius. This is the radius * zoomFacter
   */
  get adjustedCenterOffset() {
    return this.layout.adjustedBackboneCenterOffset(this.centerOffset);
  }

  /**
   * @member {Number} - Get or set the backbone thickness. This is the unzoomed thickness.
   */
  set thickness(value) {
    if (utils.isNumeric(value)) {
      this._thickness = Number(value);
      // FIXME:
      this.viewer.layout && this.viewer.layout._adjustProportions();
    }
  }

  get thickness() {
    return this.visible ? this._thickness : 0;
  }

  /**
   * @member {Number} - Get the zoomed backbone thickness.
   */
  // get zoomedThickness() {
    // NOTE: Can not divide by centerOffset
  //   return (Math.min(this.adjustedCenterOffset, this.viewer.maxZoomedRadius()) * (this.thickness / this.centerOffset)) + (this.bpThicknessAddition / CGV.pixel(1));
  // }

  /**
   * @member {Number} - Get the backbone thickness adjusted for visibility, zoom level and space for the sequence.
   */
  get adjustedThickness() {
    if (!this.visible) { return 0; }
    // FIXME: need to calculate the max zoom level for changing backbone thickness
    //        - should depend on the zoomFactor to at which pont the map thickness is at the maximum?
    //        - Used to depend on the maxZoomedRadius which was set to minDimension
    //        - for now set to 4
    return (Math.min(this.viewer.zoomFactor, 4) * this.thickness) + this.bpThicknessAddition;
  }

  /**
   * @member {Number} - Maximum thickness the backbone should become to allow viewing of the sequence
   */
  get maxThickness() {
    // return Math.max(this.thickness, this.sequence.thickness)
    return Math.max(this.adjustedThickness, this.sequence.thickness);
  }

  /**
   * Get the factor used to increase backbone thickness when approaching the ability to see the sequence.
   * @member {number}
   */
  get bpThicknessAddition() {
    return this._bpThicknessAddition;
  }

  /**
   * The visible range
   * @member {Range}
   */
  get visibleRange() {
    return this._visibleRange;
  }

  // Return the pixelLength of the backbone at a zoom level of 1
  get pixelLength() {
    return this.layout.pixelsPerBp(this.adjustedCenterOffset) / this.viewer.zoomFactor * this.sequence.length;
  }

  /**
   * Does the backbone contain the given *centerOffset*.
   * @param {Number} offset - The centerOffset.
   * @return {Boolean}
   */
  containsCenterOffset(offset) {
    const halfthickness = this.adjustedThickness / 2;
    const adjustedCenterOffset = this.adjustedCenterOffset;
    return (offset >= (adjustedCenterOffset - halfthickness)) && (offset <= (adjustedCenterOffset + halfthickness));
  }

  /**
   * The maximum zoom factor to get the correct spacing between basepairs.
   * @return {Number}
   */
  maxZoomFactor() {
    return (this.sequence.length * (this.sequence.bpSpacing + (this.sequence.bpMargin * 2))) / this.pixelLength;
  }

  /**
   * The number of pixels per basepair along the backbone circumference.
   * @return {Number}
   */
  pixelsPerBp() {
    return this.layout.pixelsPerBp();
  }

  directionalDecorationForContig(contig) {
    if (this.decoration === 'arrow') {
      return contig.orientation === '+' ? 'clockwise-arrow' : 'counterclockwise-arrow';
    } else {
      return this.decoration;
    }
  }

  invertColors() {
    this.update({
      color: this.color.invert().rgbaString,
      colorAlternate: this.colorAlternate.invert().rgbaString
    });
  }

  draw() {
    this._visibleRange = this.canvas.visibleRangeForCenterOffset( this.adjustedCenterOffset, 100);
    if (this.visibleRange && this.visible) {
      this.refreshThickness();

      if (this.sequence.hasMultipleContigs) {
        const contigs = this.sequence.contigsForMapRange(this.visibleRange);
        for (let i = 0, len = contigs.length; i < len; i++) {
          const contig = contigs[i];
          // Postions:
          // Large arcs (ie contigs) car drawn wrong when zoomed in (Safari)
          // So the start/stop should be adjusted to the visible range
          let start = this.sequence.bpForContig(contig);
          if (start < this.visibleRange.start && !this.visibleRange.isWrapped()) {
            start = this.visibleRange.start;
          }
          let stop = this.sequence.bpForContig(contig, contig.length);
          if (stop > this.visibleRange.stop && !this.visibleRange.isWrapped()) {
            stop = this.visibleRange.stop;
          }
          let color = (contig.index % 2 === 0) ? this.color : this.colorAlternate;
          if (contig.color) {
            color = contig.color;
          }
          this.viewer.canvas.drawElement('map', start, stop, this.adjustedCenterOffset, color.rgbaString, this.adjustedThickness, this.directionalDecorationForContig(contig));
        }
      } else {
        if (this.visibleRange.isWrapped() && this.decoration === 'arrow') {
          this.viewer.canvas.drawElement('map', this.visibleRange.start, this.sequence.length, this.adjustedCenterOffset, this.color.rgbaString, this.adjustedThickness, this.directionalDecorationForContig(this.sequence.mapContig));
          this.viewer.canvas.drawElement('map', 1, this.visibleRange.stop, this.adjustedCenterOffset, this.color.rgbaString, this.adjustedThickness, this.directionalDecorationForContig(this.sequence.mapContig));
        } else {
          this.viewer.canvas.drawElement('map', this.visibleRange.start, this.visibleRange.stop, this.adjustedCenterOffset, this.color.rgbaString, this.adjustedThickness, this.directionalDecorationForContig(this.sequence.mapContig));
        }
      }

      if (this.pixelsPerBp() > 1) {
        this.sequence.draw();
      }
    }
  }

  refreshThickness() {
    const pixelsPerBp = this.pixelsPerBp();
    if (pixelsPerBp > 1 && this.visible) {
      // const zoomedThicknessWithoutAddition = Math.min(this.adjustedCenterOffset, this.viewer.maxZoomedRadius()) * (this.thickness / this.centerOffset);
      // FIXME: see adjustedThickness for note. Use 4 for now.
      const zoomedThicknessWithoutAddition = Math.min(this.viewer.zoomFactor, 4) * this.thickness;
      const addition = pixelsPerBp * 2;
      if ( (zoomedThicknessWithoutAddition + addition ) >= this.maxThickness) {
        this._bpThicknessAddition = this.maxThickness - zoomedThicknessWithoutAddition;
      } else {
        this._bpThicknessAddition = addition;
      }
    } else {
      this._bpThicknessAddition = 0;
    }
  }

  /**
   * Update backbone [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.updateRecords(this, attributes, {
      recordClass: 'Backbone',
      validKeys: ['color', 'colorAlternate', 'thickness', 'decoration', 'visible']
    });
    this.viewer.trigger('backbone-update', { attributes });
  }

  /**
   * Returns JSON representing the object
   */
  toJSON(options = {}) {
    const json = {
      color: this.color.rgbaString,
      colorAlternate: this.colorAlternate.rgbaString,
      thickness: this._thickness,
      decoration: this.decoration
    };
    // Optionally add default values
    if (!this.visible || options.includeDefaults) {
      json.visible = this.visible;
    }
    return json;
  }

}

export default Backbone;