Source: LayoutCircular.js

//////////////////////////////////////////////////////////////////////////////
// Layout for Circular Maps
//////////////////////////////////////////////////////////////////////////////

import CGRange from './CGRange';
import utils from './Utils';

/**
 * This Layout is in control of handling and drawing the map as a circle
 */
class LayoutCircular {

  /**
   * Create a Layout
   * @private
   */
  constructor(layout) {
    this._layout = layout;
  }

  toString() {
    return 'LayoutCircular';
  }

  // Convenience properties
  get layout() { return this._layout; }
  get viewer() { return this.layout.viewer; }
  get canvas() { return this.layout.canvas; }
  get backbone() { return this.layout.backbone; }
  get sequence() { return this.layout.sequence; }
  get scale() { return this.layout.scale; }
  get width() { return this.layout.width; }
  get height() { return this.layout.height; }

  get type() {
    return 'circular';
  }

  //////////////////////////////////////////////////////////////////////////
  // Required Delegate Methods
  //////////////////////////////////////////////////////////////////////////

  // Return point on Canvas.
  // centerOffset is the radius for circular maps
  pointForBp(bp, centerOffset = this.backbone.adjustedCenterOffset) {
    const radians = this.scale.bp(bp);
    const x = this.scale.x(0) + (centerOffset * Math.cos(radians));
    const y = this.scale.y(0) + (centerOffset * Math.sin(radians));
    return {x: x, y: y};
  }

  bpForPoint(point) {
    const mapX = this.scale.x.invert(point.x);
    const mapY = this.scale.y.invert(point.y);
    return Math.round( this.scale.bp.invert( utils.angleFromPosition(mapX, mapY) ) );
  }


  centerOffsetForPoint(point) {
    // return Math.sqrt( (point.x * point.x) + (point.y * point.y) );
    const mapX = this.scale.x.invert(point.x);
    const mapY = this.scale.y.invert(point.y);
    return Math.sqrt( (mapX * mapX) + (mapY * mapY) );
  }

  // Return the X and Y domains for a bp and zoomFactor
  // Offset: Distances of map center from backbone
  //   0: backbone centered
  //   Minus: backbone moved down from canvas center
  //   Positive: backbone move up from canvas center
  domainsFor(bp, zoomFactor = this.viewer.zoomFactor, bbOffset = 0) {
    const halfRangeWidth = this.scale.x.range()[1] / 2;
    const halfRangeHeight = this.scale.y.range()[1] / 2;

    const centerOffset = (this.backbone.centerOffset * zoomFactor) - bbOffset;
    const centerPt = this._mapPointForBp(bp, centerOffset);

    const x = bp ? centerPt.x : 0;
    const y = bp ? centerPt.y : 0;

    return [ x - halfRangeWidth, x + halfRangeWidth, y + halfRangeHeight, y - halfRangeHeight];
  }

  // Zoom Factor does not affect circular bp scale so we only need
  // to set this once on initialization
  // Note that since the domain will be from 1 to length,
  // the range goes from the top of the circle to 1 bp less
  // than the top of the circle.
  adjustBpScaleRange(initialize = false) {
    if (initialize) {
      const radiansPerBp = (2 * Math.PI) / this.sequence.length;
      const rangeStart = -1 / 2 * Math.PI;
      const rangeStop = (3 / 2 * Math.PI) - radiansPerBp;
      this.scale.bp.range([rangeStart, rangeStop]);
    }
  }


  // TODO if undefined, see if centerOffset is visible
  visibleRangeForCenterOffset(centerOffset, margin = 0) {
    const ranges = this._visibleRangesForRadius(centerOffset, margin);
    if (ranges.length === 2) {
      return new CGRange(this.sequence.mapContig, ranges[0], ranges[1]);
    } else if (ranges.length > 2) {
      return new CGRange(this.sequence.mapContig, ranges[0], ranges[ranges.length - 1]);
    } else if ( (centerOffset - margin) > this._maximumVisibleRadius() ) {
      return undefined;
    } else if ( (centerOffset + margin) < this._minimumVisibleRadius() ) {
      return undefined;
    } else {
      return new CGRange(this.sequence.mapContig, 1, this.sequence.length);
    }
    // } else {
    //   return undefined
    // }
  }

  maxMapThickness() {
    // return this.viewer.minDimension / 2;
    return this.viewer.minDimension * this.layout._maxMapThicknessProportion;
  }

  pixelsPerBp(centerOffset = this.backbone.adjustedCenterOffset) {
    return (centerOffset * 2 * Math.PI) / this.sequence.length;
  }

  clockPositionForBp(bp, inverse = false) {
    const radians = this.scale.bp(bp);
    return utils.clockPositionForAngle( inverse ? (radians + Math.PI) : radians );
  }

  zoomFactorForLength(bpLength) {
    // Use viewer width as estimation arc length
    const arcLength = this.viewer.width;
    const zoomedRadius = arcLength / (bpLength / this.sequence.length * Math.PI * 2);
    return zoomedRadius / this.backbone.centerOffset;
  }

  initialWorkingSpace() {
    // return 0.25 * this.viewer.minDimension;
    return this.viewer.minDimension * this.layout._initialMapThicknessProportion;
  }

  // Calculate the backbone centerOffset (radius) so that the map is centered between the
  // circle center and the edge of the canvas (minDimension)
  updateInitialBackboneCenterOffset(insideThickness, outsideThickness) {
    // midRadius is the point between the circle center and the edge of the canvas
    // on the minDimension.
    const midRadius = this.viewer.minDimension * 0.25;
    // Minimum extra space inside of map
    const insideBuffer = 40; 
    // The mid radius has to have enough space for the inside thickness
    const adjustedMidRadius = Math.max(midRadius, insideThickness + insideBuffer)
    this.backbone.centerOffset = adjustedMidRadius - ((outsideThickness - insideThickness) / 2);
  }

  adjustedBackboneCenterOffset(centerOffset) {
    return centerOffset * this.viewer._zoomFactor;
  }

  path(layer, centerOffset, startBp, stopBp, anticlockwise = false, startType = 'moveTo') {
    // FIXME: change canvas to this where appropriate
    const canvas = this.canvas;
    const ctx = canvas.context(layer);
    const scale = this.scale;

    // Features less than 1000th the length of the sequence are drawn as straight lines
    const rangeLength = anticlockwise ? canvas.sequence.lengthOfRange(stopBp, startBp) : canvas.sequence.lengthOfRange(startBp, stopBp);
    if ( rangeLength < (canvas.sequence.length / 1000)) {
      const p2 = this.pointForBp(stopBp, centerOffset);
      if (startType === 'lineTo') {
        const p1 = this.pointForBp(startBp, centerOffset);
        ctx.lineTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
      } else if (startType === 'moveTo') {
        const p1 = this.pointForBp(startBp, centerOffset);
        ctx.moveTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
      } else if (startType === 'noMoveTo') {
        ctx.lineTo(p2.x, p2.y);
      }
    } else {
      // ctx.arc(scale.x(0), scale.y(0), centerOffset, scale.bp(startBp), scale.bp(stopBp), anticlockwise);

      // console.log(startBp, stopBp)
      // console.log(scale.bp(startBp))
      // console.log(scale.bp(stopBp))

      // This code is required to draw SVG images correctly
      // SVG can not handle arcs drawn as circles
      // So for arcs that are close to becoming full circles, 
      // they are split into 2 arcs
      if ( (rangeLength / canvas.sequence.length) > 0.95) {
        const startRads = scale.bp(startBp);
        const stopRads = scale.bp(stopBp);
        let midRads = startRads + ((stopRads - startRads) / 2);
        // 1 bp of cushion is given to prevent calling this when start and stop are the same
        // but floating point issues cause one to be larger than the other
        if ( (startBp > (stopBp+1) && !anticlockwise) || (startBp < (stopBp-1) && anticlockwise) ) {
          // Mid point is on opposite side of circle
          midRads += Math.PI;
        }
        ctx.arc(scale.x(0), scale.y(0), centerOffset, startRads, midRads, anticlockwise);
        ctx.arc(scale.x(0), scale.y(0), centerOffset, midRads, stopRads, anticlockwise);
      } else {
        ctx.arc(scale.x(0), scale.y(0), centerOffset, scale.bp(startBp), scale.bp(stopBp), anticlockwise);
      }

    }
  }

  centerCaptionPoint() {
    return this.pointForBp(0, 0);
  }

  //////////////////////////////////////////////////////////////////////////
  // Helper Methods
  //////////////////////////////////////////////////////////////////////////

  // Return map point (map NOT canvas coordinates) for given bp and centerOffset.
  // centerOffset is the radius for circular maps
  _mapPointForBp(bp, centerOffset = this.backbone.adjustedCenterOffset) {
    const radians = this.scale.bp(bp);
    const x = centerOffset * Math.cos(radians);
    const y = -centerOffset * Math.sin(radians);
    return {x: x, y: y};
  }

  _centerVisible() {
    const x = this.scale.x(0);
    const y = this.scale.y(0);
    return (x >= 0 &&
            x <= this.width &&
            y >= 0 &&
            y <= this.height);
  }

  /**
   * Return the distance between the circle center and the farthest corner of the canvas
   */
  _maximumVisibleRadius() {
    // Maximum distance on x axis between circle center and the canvas 0 or width
    const maxX = Math.max( Math.abs(this.scale.x.invert(0)), Math.abs(this.scale.x.invert(this.width)) );
    // Maximum distance on y axis between circle center and the canvas 0 or height
    const maxY = Math.max( Math.abs(this.scale.y.invert(0)), Math.abs(this.scale.y.invert(this.height)) );
    // Return the hypotenuse
    return Math.sqrt( (maxX * maxX) + (maxY * maxY) );
  }

  _minimumVisibleRadius() {
    if (this._centerVisible()) {
      // Center is visible so the minimum radius has to be 0
      return 0;
    } else if ( utils.oppositeSigns(this.scale.x.invert(0), this.scale.x.invert(this.width)) ) {
      // The canvas straddles 0 on the x axis, so the minimum radius is the distance to the closest horizontal line
      return Math.min( Math.abs(this.scale.y.invert(0)), Math.abs(this.scale.y.invert(this.height)));
    } else if ( utils.oppositeSigns(this.scale.y.invert(0), this.scale.y.invert(this.height)) ) {
      // The canvas straddles 0 on the y axis, so the minimum radius is the distance to the closest vertical line
      return Math.min( Math.abs(this.scale.x.invert(0)), Math.abs(this.scale.x.invert(this.width)));
    } else {
      // Closest corner of the canvas
      // Minimum distance on x axis between circle center and the canvas 0 or width
      const minX = Math.min( Math.abs(this.scale.x.invert(0)), Math.abs(this.scale.x.invert(this.width)) );
      // Minimum distance on y axis between circle center and the canvas 0 or height
      const minY = Math.min( Math.abs(this.scale.y.invert(0)), Math.abs(this.scale.y.invert(this.height)) );
      // Return the hypotenuse
      return Math.sqrt( (minX * minX) + (minY * minY) );
    }
  }

  _visibleRangesForRadius(radius, margin = 0) {
    const angles = utils.circleAnglesFromIntersectingRect(radius,
      this.scale.x.invert(0 - margin),
      this.scale.y.invert(0 - margin),
      this.width + (margin * 2),
      this.height + (margin * 2)
    );
    return angles.map( a => Math.round(this.scale.bp.invert(a)) );
  }

}

export default LayoutCircular;