Source: Layout.js

// FIXME: Use delegate for layout format
//   - move zoomFactor to layout
//////////////////////////////////////////////////////////////////////////////
// Layout for Circular Maps
//////////////////////////////////////////////////////////////////////////////

import LayoutCircular from './LayoutCircular';
import LayoutLinear from './LayoutLinear';
import utils from './Utils';
import * as d3 from 'd3';

// NOTES:
//  - _adjustProportions is called when components: dividers, backbone, tracks/slots
//      - change in number, visibility or thickness
//      - layout format changes
//      - max/min slot thickness change
//      - initial/max map thickness proportion
//  - updateLayout is called when
//      - proportions are updated
//      - every draw loop only if the zoom level has changed

  /**
   * Layout controls how everything is draw on the map.
   * It also determines the best size for the tracks so they fit on the map.
   * See [Map Scales](../tutorials/details-map-scales.html) for details on
   * circular and linear layouts.
   */
class Layout {

  /**
   * Create a the Layout
   */
  constructor(viewer) {
    this._viewer = viewer;

    // _fastMaxFeatures is the maximum number of features allowed to be drawn in fast mode.
    this._fastMaxFeatures = 1000;
    // FIXME: move to settings
    // this._minSlotThickness = CGV.defaultFor(data.minSlotThickness, 1);
    // this._maxSlotThickness = CGV.defaultFor(data.maxSlotThickness, 50);
    this._minSlotThickness = 1;
    this._maxSlotThickness = 50;
    // Default values. These will be overridden by the values in Settings.
    this._maxMapThicknessProportion = 0.5;
    this._initialMapThicknessProportion = 0.1;

    // Setup scales
    this._scale = {};

    this._adjustProportions();
  }

  toString() {
    return 'Layout';
  }

  // FIXME: make all these convience properties like in the delegates
  //  - this will clear up the documentation and reduce the lines of unexciting code
  /**
   * @member {Viewer} - Get the *Viewer*
   */
  get viewer() {
    return this._viewer;
  }


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

  /** * @member {Number} - Get the canvas width
   */
  get width() {
    return this.canvas.width;
  }

  /** * @member {Number} - Get the canvas height
   */
  get height() {
    return this.canvas.height;
  }

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

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

  /**
   * @member {Object} - Return an object that contains the 3 [D3 Continuous Scales](https://github.com/d3/d3-scale#continuous-scales) used by CGView.
   * See [Map Scales](../tutorials/details-map-scales.html) for details.
   *
   * Scale | Description
   * ------|------------
   *  x    | Convert between the canvas x position (0 is left side of canvas) and map x position (center of map).
   *  y    | Convert between the canvas y position (0 is top side of canvas) and map y position (center of map).
   *  bp - circular | Convert between bp and radians (Top of map is 1 bp and -π/2).
   *  bp - linear   | Convert between bp and distance on x-axis
   */
  // ```js
  // // Examples:
  // // For a map with canvas width and height of 600. Before moving or zooming the map.
  // canvas.scale.x(0)          // 300
  // canvas.scale.y(0)          // 300
  // canvas.scale.x.invert(300) // 0
  // canvas.scale.y.invert(300) // 0
  // // For a map with a length of 1000
  // canvas.scale.bp(1)        // -π/2
  // canvas.scale.bp(250)      // 0
  // canvas.scale.bp(500)      // π/2
  // canvas.scale.bp(750)      // π
  // canvas.scale.bp(1000)     // 3π/2
  // canvas.scale.bp(1000)     // 3π/2
  // canvas.scale.bp.invert(π) // 750
  // ```
  get scale() {
    return this._scale;
  }

  get delegate() {
    return this._delegate;
  }

  /**
   * @member {Canvas} - Get or set the layout type: linear or circular.
   */
  get type() {
    return this.delegate && this.delegate.type;
  }

  set type(value) {
    // Determine map center bp before changing layout
    const centerBp = this.delegate && this.canvas.bpForCanvasCenter();
    const layoutChanged = Boolean(this.delegate && this.type !== value);
    if (value === 'linear') {
      this._delegate = new LayoutLinear(this);
    } else if (value === 'circular') {
      this._delegate = new LayoutCircular(this);
    } else {
      throw 'Layout type must be one of the following: linear, circular';
    }
    this._adjustProportions();
    this.updateScales(layoutChanged, centerBp);
  }

  /** * @member {Number} - Get the distance from the backbone to the inner/bottom edge of the map.
   */
  get bbInsideOffset() {
    return this._bbInsideOffset;
  }

  /** * @member {Number} - Get the distance from the backbone to the outer/top edge of the map.
   */
  get bbOutsideOffset() {
    return this._bbOutsideOffset;
  }

  /** * @member {Number} - Get the distance from the center of the map to the inner/bottom edge of the map.
   */
  get centerInsideOffset() {
    return this._bbInsideOffset + this.backbone.adjustedCenterOffset;
  }

  /** * @member {Number} - Get the distance from the center of the map to the outer/top edge of the map.
   */
  get centerOutsideOffset() {
    return this._bbOutsideOffset + this.backbone.adjustedCenterOffset;
  }

  /** * @member {Number} - Get an object with stats about slot thickness ratios.
   * @private
   */
  get slotThicknessRatioStats() {
    return this._slotThicknessRatioStats;
  }

  /** * @member {Number} - Get an object with stats about slot proportion of map thickness.
   * @private
   */
  get slotProportionStats() {
    return this._slotProportionStats;
  }

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

  /**
   * @typedef {Object} Point
   * @property {number} x The X Coordinate
   * @property {number} y The Y Coordinate
   */

  /**
   * Returns the point on the canvas for the given *bp* and *centerOffset*.
   * @param {Number} bp - Basepair
   * @param {Number} [centerOffset={@link Backbone#adjustedCenterOffset Backbone.adjustedCenterOffset}] - Distance from the center of the map. For a circular map, this is the radius, while for a linear map, it's the distance from the backbone.
   *
   * @returns {Point} - The point on the canvas.
   */
  pointForBp(...args) {
    return this.delegate.pointForBp(...args);
  }

  /**
   * Returns the basepair corresponding to the given *point* on the canvas.
   * @param {Point} point - Point on the canvas.
   *
   * @returns {Number} - The basepair.
   */
  bpForPoint(...args) {
    return this.delegate.bpForPoint(...args);
  }

  /**
   * Returns the Center Offset for the given *point* on the canvas.
   * Center offset is the distance from the center of the map.
   * For a circular map, this is the radius, while for a linear map, it's the distance from the backbone.
   * @param {Point} point - Point on the canvas.
   *
   * @returns {Number} - Center Offset
   */
  centerOffsetForPoint(...args) {
    return this.delegate.centerOffsetForPoint(...args);
  }

  /**
   * Returns the X and Y scale domains for the given *bp* and *zoomFactor*.
   * @param {Number} bp - Basepair
   * @param {Number} [zoomFactor=Current viewer zoom factor] - The zoom factor used to calculate the domains
   *
   * @returns {Array} - The X and Y scale domains in the form of [[X1, X2], [Y1, Y2]].
   */
  domainsFor(...args) {
    return this.delegate.domainsFor(...args);
  }

  /**
   * Adjust the scale.bp.range. This methods is mainly required for Linear maps and is called
   * when ever the zoomFactor is changed. For circular maps, it only needs to be called when
   * initializing the bp scale.
   * @param {Boolean} initialize - Only used by Circular maps.
   * @private
   */
  adjustBpScaleRange(...args) {
    return this.delegate.adjustBpScaleRange(...args);
  }

  /**
   * Return the CGRange for the sequence visisible at the given *centerOffset*.
   * The *margin* is a distance in pixels added on to the Canvas size when
   * calculating the CGRange.
   * @param {Number} centerOffset - The distance from the center of them map.
   * @param {Number} margin - An amount (in pixels) added to the Canvas in all dimensions.
   *
   * @returns {CGRange} - the visible range.
   */
  // visibleRangeForCenterOffset(offset, margin = 0) {
  visibleRangeForCenterOffset(...args) {
    return this.delegate.visibleRangeForCenterOffset(...args);
  }

  /**
   * Return the maximum thickness of the map. Depends on the dimensions of the Canvas.
   * @returns {Number}
   * @private
   */
  maxMapThickness() {
    return this.delegate.maxMapThickness();
  }

  /**
   * The number of pixels per basepair along the given *centerOffset*.
   * @param {Number} [centerOffset={@link Backbone#adjustedCenterOffset Backbone.adjustedCenterOffset}] - Distance from the center of the map. For a circular map, this is the radius, while for a linear map, it's the distance from the backbone.
   * @return {Number} - Pixels per basepair.
   */
  pixelsPerBp(...args) {
    return this.delegate.pixelsPerBp(...args);
  }

  /**
   * Returns the clock position (1-12) for the supplied bp.
   * For example, the top of the map would be 12, the bottom would be 6 and
   * the right side of a circular map will be 3.
   * @param {Number} bp - Basepair position on the map.
   * @param {Boolean} invers - When true, give the opposite clock position (e.g. 6 instead of 12).
   *
   * @returns {Number} - An integer between 1 and 12.
   */
  clockPositionForBp(...args) {
    return this.delegate.clockPositionForBp(...args);
  }

  /**
   * Estimate of the zoom factor, if the viewer was only showing the given *bpLength*
   * as a portion of the total length.
   * @param {Number} bpLength - Length in basepairs.
   * @returns {Number} - Zoom Factor
   * @private
   */
  zoomFactorForLength(...args) {
    return this.delegate.zoomFactorForLength(...args);
  }

  /**
   * Return the initial maximum space/thickness to draw the map around the backbone.
   * This is usually some fraction of the viewer dimensions.
   * @returns {Number}
   * @private
   */
  initialWorkingSpace() {
    return this.delegate.initialWorkingSpace();
  }

  /**
   * Set the backbone centerOffset based on the approximate inside and outside
   * thickness of the map.
   * @param {Number} insideThickness - The thickness of the inside of the map. From
   *   the backbone down (linear) or towards the center (circular).
   * @param {Number} outsideThickness - The thickness of the outside of the map. From
   *   the backbone up (linear) or towards the outside (circular).
   *   @private
   */
  updateInitialBackboneCenterOffset(...args) {
    this.delegate.updateInitialBackboneCenterOffset(...args);
  }

  /**
   * Return an the backbone center offset adjusted for the zoom level.
   * @param {Number} centerOffset - The backbone initial centerOffset.
   * @returns {Number} adjustedCenterOffset
   */
  adjustedBackboneCenterOffset(...args) {
    return this.delegate.adjustedBackboneCenterOffset(...args);
  }

  // FIXME: update arguments
  /**
   * Adds a lineTo path to the given *layer*. Path does not draw. It only adds lineTo and optionally moveTo
   * commands to the context for the given *layer*.
   * @param {String} layer - The name of the canvas layer to add the path.
   * @param {Number} centerOffset - This distance from the center of the Map.
   * @param {Number} startBp - The start position in basepairs.
   * @param {Number} stopBp - The stop position in basepairs.
   * @param {Boolean} [anticlockwise=false] - For circular maps the default direction is clockwise. Set this to true to draw arcs, anticlockwise.
   * @param {String} [startType='moveTo'] - How the path should be started. Allowed values:
   * <br /><br />
   *  - moveTo:  *moveTo* start; *lineTo* stop
   *  - lineTo: *lineTo* start; *lineTo* stop
   *  - noMoveTo:  ingore start; *lineTo* stop
   */
  path(...args) {
    this.delegate.path(...args);
  }

  // Returns appropriate center point for captions
  // e.g. center of circlular map or right below linear map
  centerCaptionPoint() {
    return this.delegate.centerCaptionPoint();
  }


  //////////////////////////////////////////////////////////////////////////
  // Common methods for current layouts: linear, circular
  //  - These methods may have to be altered if additional layouts are added
  //////////////////////////////////////////////////////////////////////////

  // NOTES:
  //  - 3 scenarios
  //    - scales have not been initialized so simple center the map
  //    - scales already initialized and layout has not changed
  //      - keep the map centered as the scales change
  //    - layout changed
  //      - based on zoom will the whole map be in the canvas (determine from radius for the zoom)
  //        - if so: center the map
  //        - if not: center the map on the backbone at the bp that was the linear center
  updateScales(layoutChanged, bp) {
    if (!this.sequence) { return; }
    bp = bp && this.sequence.constrain(bp);
    const canvas = this.canvas;
    const scale = this.scale;

    // BP Scale
    scale.bp = d3.scaleLinear()
      .domain([1, this.sequence.length]);
    // The argument 'true' only affects the circular version of this method
    this.adjustBpScaleRange(true);
    this.viewer._updateZoomMax();

    // X/Y Scales
    if (layoutChanged) {
      // Deleting the current scales will cause the map to be centered
      scale.x = undefined;
      scale.y = undefined;
      this._updateScaleForAxis('x', canvas.width);
      this._updateScaleForAxis('y', canvas.height);
      // At larger zoom levels and when a bp was given, center the map on that bp
      const zoomFactorCutoff = 1.25;
      if (this.viewer.zoomFactor > zoomFactorCutoff && bp) {
        this.viewer.zoomTo(bp, this.viewer.zoomFactor, {duration: 0});
      }
    } else {
      // The canvas is being resized or initialized
      this._updateScaleForAxis('x', canvas.width);
      this._updateScaleForAxis('y', canvas.height);
    }
  }

  // The center of the zoom will be the supplied bp position on the backbone.
  // The default bp will be based on the center of the canvas.
  zoom(zoomFactor, bp = this.canvas.bpForCanvasCenter()) {
    // Center of zoom before zooming
    const {x: centerX1, y: centerY1} = this.pointForBp(bp);

    zoomFactor = utils.constrain(zoomFactor, this.viewer.minZoomFactor, this.viewer.maxZoomFactor);

    // Update the d3.zoom transform.
    // Only need to do this if setting Viewer.zoomFactor. The zoom transform is set
    // automatically when zooming via d3 (ie. in Viewer-Zoom.js)
    d3.zoomTransform(this.canvas.node('ui')).k = zoomFactor;

    // Update zoom factor
    this.viewer._zoomFactor = zoomFactor;

    // Update the BP scale, currently this is only needed for the linear layout
    this.adjustBpScaleRange();

    // Center of zoom after zooming
    // pointForBp is on the backbone by default
    const {x: centerX2, y: centerY2} = this.pointForBp(bp);

    // Find differerence in x/y and translate the domains
    const dx = centerX1 - centerX2;
    const dy = centerY2 - centerY1;
    this.translate(dx, -dy);
  }

  translate(dx, dy) {
    const domainX = this.scale.x.domain();
    const domainY = this.scale.y.domain();
    this.scale.x.domain([domainX[0] - dx, domainX[1] - dx]);
    this.scale.y.domain([domainY[0] + dy, domainY[1] + dy]);
  }


  //////////////////////////////////////////////////////////////////////////
  // Methods for determining offsets and Drawing
  // FIXME: Organized better
  //////////////////////////////////////////////////////////////////////////

  _updateSlotThicknessRatioStats(slots = this.visibleSlots()) {
    const thicknessRatios = slots.map( s => s.thicknessRatio );
    this._slotThicknessRatioStats = {
      min: d3.min(thicknessRatios),
      max: d3.max(thicknessRatios),
      sum: d3.sum(thicknessRatios)
    };
  }

  _updateSlotProportionStats(slots = this.visibleSlots()) {
    const proportions = slots.map( s => s.proportionOfMap );
    this._slotProportionStats = {
      min: d3.min(proportions),
      max: d3.max(proportions),
      sum: d3.sum(proportions)
    };
  }

  // position: 'inside', 'outside'
  _trackNonSlotSpace(track, position = 'inside') {
    const dividers = this.viewer.dividers;

    const slots = track.slots().filter( s =>  s.visible && s[position] );

    let space = 0;
    if (slots.length > 0) {
      // Add track start and end divider spacing
      space += dividers.track.adjustedSpacing * 2;
      // Add track divider thickness
      space += dividers.track.adjustedThickness;
      // Add slot divider spacing and thickness
      const slotDividerCount = slots.length - 1;
      space += slotDividerCount * ((dividers.slot.adjustedSpacing * 2) + dividers.slot.adjustedThickness);
    }
    return space;
  }

  // Returns the space (in pixels) of everything but the slots
  // i.e. dividers, spacing, and backbone
  // position: 'inside', 'outside', 'both'
  // Note: the backbone is only included if position is 'both'
  _nonSlotSpace(position = 'both') {
    let space = 0;
    const visibleTracks = this.tracks().filter( t =>  t.visible );
    for (let i = 0, len = visibleTracks.length; i < len; i++) {
      const track = visibleTracks[i];
      if (position === 'both') {
        space += this._trackNonSlotSpace(track, 'inside');
        space += this._trackNonSlotSpace(track, 'outside');
      } else {
        space += this._trackNonSlotSpace(track, position);
      }
    }
    if (position === 'both') {
      space += this.backbone.adjustedThickness;
    }

    return space;
  }

  _findSpace(visibleSlots, spaceType = 'min') {
    visibleSlots = visibleSlots || this.visibleSlots();
    const findMinSpace = (spaceType === 'min');
    const minSlotThickness = this.minSlotThickness;
    const maxSlotThickness = this.maxSlotThickness;
    const minThicknessRatio = this.slotThicknessRatioStats.min;
    const maxThicknessRatio = this.slotThicknessRatioStats.max;
    // let space = this._nonSlotSpace(visibleSlots);
    let space = this._nonSlotSpace();
    // If the min and max slot thickness range is too small for the min/max thickness ratio,
    // we have to scale the ratios
    const scaleRatios = (minSlotThickness / minThicknessRatio * maxThicknessRatio > maxSlotThickness);
    for (let i = 0, len = visibleSlots.length; i < len; i++) {
      const slot = visibleSlots[i];
      // Add Slot thickness based on thicknessRatio and min/max slot thickness
      if (scaleRatios) {
        space += utils.scaleValue(slot.thicknessRatio,
          {min: minThicknessRatio, max: maxThicknessRatio},
          {min: minSlotThickness, max: maxSlotThickness});
      } else {
        if (findMinSpace) {
          space += slot.thicknessRatio * minSlotThickness / minThicknessRatio;
        } else {
          space += slot.thicknessRatio * maxSlotThickness / maxThicknessRatio;
        }
      }
    }
    return space;
  }

  _minSpace(visibleSlots) {
    return this._findSpace(visibleSlots, 'min');
  }

  _maxSpace(visibleSlots) {
    return this._findSpace(visibleSlots, 'max');
  }

  /**
   * Calculate the backbone centerOffset and slot proportions based on the Viewer size and
   * the number of slots. Note, that since this will usually move the map
   * backbone for circular maps, it also recenters the map backbone If the 
   * zoomFactor is above 2.
   * @private
   */
  _adjustProportions() {
    const viewer = this.viewer;
    if (viewer.loading) { return; }
    const visibleSlots = this.visibleSlots();
    this._updateSlotThicknessRatioStats(visibleSlots);
    // The initial maximum amount of space for drawing slots, backbone, dividers, etc
    const workingSpace = this.initialWorkingSpace();
    // Minimum Space required (based on minSlotThickness)
    const minSpace = this._minSpace(visibleSlots);
    // May need to scale slots, backbone, dividers and spacing to fit everything
    const thicknessScaleFactor = Math.min(workingSpace / minSpace, 1);
    // Calculate nonSlotSpace
    // const nonSlotSpace = this._nonSlotSpace() * thicknessScaleFactor;
    // let slotSpace = (workingSpace * thicknessScaleFactor) - nonSlotSpace;

    // FIXME: Issues with negative slot space for above. Try this for now:
    // I really need to rethink this
    const minSize = this.initialWorkingSpace() * viewer.zoomFactor;
    const mapThickness = Math.min(minSize, this.maxMapThickness());
    const slotSpace = mapThickness;
    // console.log(workingSpace, slotSpace, thicknessScaleFactor, nonSlotSpace)

    // The sum of the thickness ratios
    const thicknessRatioSum = this.slotThicknessRatioStats.sum;

    let outsideThickness = this._nonSlotSpace('outside');
    let insideThickness = this._nonSlotSpace('inside');

    // Update slot thick proportions
    this.visibleSlots().each( (i, slot) => {
      slot.proportionOfMap = slot.thicknessRatio / thicknessRatioSum;
      const slotThickness = slotSpace * slot.proportionOfMap;
      if (slot.inside) {
        insideThickness += slotThickness;
      } else {
        outsideThickness += slotThickness;
      }
    });
    this._updateSlotProportionStats(visibleSlots);

    this.updateInitialBackboneCenterOffset(insideThickness, outsideThickness);

    this._calculateMaxMapThickness();

    this.updateLayout(true);
    // Recenter map
    if (viewer.zoomFactor > 2) {
      viewer.moveTo(undefined, undefined, {duration: 0});
    }
  }
  // NOTE:
  // - Also calculate the maxSpace
  //   - then convert to proportion of radius [maxSpaceProportion]
  //   - then use the min(maxSpaceProportion and calculated proportion [slot.thicknessRation / sum slot.thicknessRatio ]
  //   - then assign proportionOfRadius to each slot
  //     - calculated proportion / the min (from above)
  //     - could use scaler here
  // - or drawing slots, dividers, etc should use layout.scaleFactor when drawing
  // console.log({
  //   workingSpace: workingSpace,
  //   minSpace: minSpace,
  //   thicknessScaleFactor: thicknessScaleFactor,
  //   nonSlotSpace: nonSlotSpace,
  //   slotSpace: slotSpace,
  //   // thicknessRatios: thicknessRatios,
  //   thicknessRatioSum: thicknessRatioSum
  // });

  // FIXME: temp while i figure things out
  // - IF this is used, create slotSpace method
  _calculateMaxMapThickness() {
    const viewer = this.viewer;
    const savedZoomFactor = viewer.zoomFactor;
    // Default Map Width
    viewer._zoomFactor = 1;
    this.updateLayout(true);
    const defaultMapWidth =  this.bbOutsideOffset - this.bbInsideOffset;

    let defaultSlotTotalThickness = 0;

    const visibleTracks = this.tracks().filter( t =>  t.visible );
    for (let i = 0, tracksLength = visibleTracks.length; i < tracksLength; i++) {
      const track = visibleTracks[i];
      const slots = track.slots().filter( s => s.visible );
      if (slots.length > 0) {
        for (let j = 0, slotsLength = slots.length; j < slotsLength; j++) {
          const slot = slots[j];
          defaultSlotTotalThickness += slot.thickness;
        }
      }
    }

    // Max Map Width
    viewer._zoomFactor = viewer.maxZoomFactor;
    this.updateLayout(true);
    const computedMaxMapWidth =  this.bbOutsideOffset - this.bbInsideOffset;

    let computedSlotTotalThickness = 0;

    for (let i = 0, tracksLength = visibleTracks.length; i < tracksLength; i++) {
      const track = visibleTracks[i];
      const slots = track.slots().filter( s => s.visible );
      if (slots.length > 0) {
        for (let j = 0, slotsLength = slots.length; j < slotsLength; j++) {
          const slot = slots[j];
          computedSlotTotalThickness += slot.thickness;
        }
      }
    }

    // FIXME: temp
    this._maxMapThicknessZoomFactor = computedSlotTotalThickness / defaultSlotTotalThickness;

    // Restore
    viewer._zoomFactor = savedZoomFactor;

    // console.log(this._nonSlotSpace());
    // console.log(defaultMapWidth, computedMaxMapWidth, computedMaxMapWidth / defaultMapWidth);
    // console.log(defaultSlotTotalThickness, computedSlotTotalThickness, computedSlotTotalThickness / defaultSlotTotalThickness);
  }

  // FIXME: temp with above
  // adjustedBBOffsetFor(bbOffset) {
  //   const viewer = this.viewer;
  //   const backbone = viewer.backbone;
  //   const maxMapThicknessZoomFactor = this._maxMapThicknessZoomFactor;
  //   const zoomFactor = (viewer.zoomFactor > maxMapThicknessZoomFactor) ? maxMapThicknessZoomFactor : viewer.zoomFactor;
  //   return (bbOffset * zoomFactor) + (backbone.adjustedThickness - backbone.thickness);
  // }

  // Calculate centerOffset for the supplied mapOffset
  // - Positive (+ve) mapOffsets are the distance from the outer/top edge of the map.
  // - Negative (-ve) mapOffsets are the distance from the inner/bottom edge of the map.
  centerOffsetForMapOffset(mapOffset) {
    return mapOffset + ( (mapOffset >= 0) ? this.centerOutsideOffset : this.centerInsideOffset );
  }

  // Calculate centerOffset for the supplied bbOffsetPercent:
  // -    0: center of backbone
  // -  100: outside/top edge of map
  // - -100: inside/bottom edge of map
  centerOffsetForBBOffsetPercent(bbOffsetPercent) {
    const bbOffset = this.backbone.adjustedCenterOffset;
    if (bbOffsetPercent === 0) {
      return bbOffset;
    } else if (bbOffsetPercent > 0) {
      return bbOffset + (bbOffsetPercent / 100 * this.bbOutsideOffset);
    } else if (bbOffsetPercent < 0) {
      return bbOffset - (bbOffsetPercent / 100 * this.bbInsideOffset);
    }
  }


  tracks(term) {
    return this.viewer.tracks(term);
  }

  slots(term) {
    return this.viewer.slots(term);
  }

  visibleSlots(term) {
    return this.slots().filter( s => s.visible && s.track.visible ).get(term);
  }

  slotForCenterOffset(offset) {
    const slots = this.visibleSlots();
    let slot;
    for (let i = 0, len = slots.length; i < len; i++) {
      if (slots[i].containsCenterOffset(offset)) {
        slot = slots[i];
        break;
      }
    }
    return slot;
  }

  get slotLength() {
    return this._slotLength || 0;
  }

  get fastMaxFeatures() {
    return this._fastMaxFeatures;
  }

  get fastFeaturesPerSlot() {
    return this._fastFeaturesPerSlot;
  }

  /**
   * Get or set the max slot thickness.
   */
  get maxSlotThickness() {
    return this._maxSlotThickness;
  }

  set maxSlotThickness(value) {
    this._maxSlotThickness = Number(value);
    this._adjustProportions();
  }

  /**
   * Get or set the min slot thickness.
   */
  get minSlotThickness() {
    return this._minSlotThickness;
  }

  set minSlotThickness(value) {
    this._minSlotThickness = Number(value);
    this._adjustProportions();
  }

  /**
   * Get or set the initial map thickness as a proportion of a viewer dimension
   * (height for linear maps, minimum dimension for circular maps). The initial
   * map thickness is at a zoomFactor of 1.
   */
  get initialMapThicknessProportion() {
    return this._initialMapThicknessProportion;
  }

  set initialMapThicknessProportion(value) {
    this._initialMapThicknessProportion = Number(value);
    this._adjustProportions();
  }

  /**
   * Get or set the maximum map thickness as a proportion of a viewer dimension
   * (height for linear maps, minimum dimension for circular maps).
   */
  get maxMapThicknessProportion() {
    return this._maxMapThicknessProportion;
  }

  set maxMapThicknessProportion(value) {
    this._maxMapThicknessProportion = Number(value);
    this._adjustProportions();
  }

  // Draw everything but the slots and thier features.
  // e.g. draws backbone, dividers, ruler, labels, progress
  drawMapWithoutSlots(fast) {
    const viewer = this.viewer;
    const backbone = viewer.backbone;
    const canvas = this.canvas;
    // let startTime = new Date().getTime();

    viewer.clear('map');
    viewer.clear('foreground');
    viewer.clear('ui');

    if (viewer.messenger.visible) {
      viewer.messenger.close();
    }

    // All Text should have base line top
    // FIXME: contexts
    // ctx.textBaseline = 'top';

    // Draw Backbone
    backbone.draw();

    // Recalculate the slot offsets and thickness if the zoom level has changed
    this.updateLayout();

    // Divider rings
    viewer.dividers.draw();
    // Ruler
    const rulerOffsetAdjustment = viewer.dividers.track.adjustedThickness;
    viewer.ruler.draw(this.centerInsideOffset - rulerOffsetAdjustment, this.centerOutsideOffset + rulerOffsetAdjustment);
    // Labels
    if (viewer.annotation.visible) {
      viewer.annotation.draw(this.centerInsideOffset, this.centerOutsideOffset, fast);
    }

    // Captions on the Map layer
    for (let i = 0, len = viewer._captions.length; i < len; i++) {
      if (viewer._captions[i].onMap) {
        viewer._captions[i].draw();
      }
    }
    if (viewer.legend.position.onMap) {
      viewer.legend.draw();
    }

    // Progess
    this.drawProgress();

    // Note: now done in Canvas
    // if (canvas._testDrawRange) {
    //   const ctx = canvas.context('canvas');
    //   ctx.strokeStyle = 'grey';
    //   ctx.rect(0, 0, canvas.width, canvas.height);
    //   ctx.stroke();
    // }

    // Slots timout
    this._slotIndex = 0;
    if (this._slotTimeoutID) {
      clearTimeout(this._slotTimeoutID);
      this._slotTimeoutID = undefined;
    }
  }

  drawFast() {
    const startTime = new Date().getTime();
    this.drawMapWithoutSlots(true);
    this.drawAllSlots(true);
    // Debug
    if (this.viewer.debug) {
      this.viewer.debug.data.time.fastDraw = utils.elapsedTime(startTime);
      this.viewer.debug.draw();
    }
  }

  drawFull() {
    this.drawMapWithoutSlots();
    this.drawAllSlots(true);
    this._drawFullStartTime = new Date().getTime();
    this.drawSlotWithTimeOut(this);
  }

  drawExport() {
    this.drawMapWithoutSlots();
    this.drawAllSlots(false);
  }

  draw(fast) {
    fast ? this.drawFast() : this.drawFull();
  }

  drawAllSlots(fast) {
    let track, slot;
    // for (let i = 0, trackLen = this._tracks.length; i < trackLen; i++) {
    //   track = this._tracks[i];
    const tracks = this.tracks();
    for (let i = 0, trackLen = tracks.length; i < trackLen; i++) {
      track = tracks[i];
      if (!track.visible) { continue; }
      for (let j = 0, slotLen = track._slots.length; j < slotLen; j++) {
        slot = track._slots[j];
        if (!slot.visible) { continue; }
        slot.draw(this.canvas, fast);
      }
    }
  }

  drawSlotWithTimeOut(layout) {
    const slots = layout.visibleSlots();
    const slot = slots[layout._slotIndex];
    if (!slot) { return; }
    slot.clear();
    slot.draw(layout.canvas);
    layout._slotIndex++;
    if (layout._slotIndex < slots.length) {
      layout._slotTimeoutID = setTimeout(layout.drawSlotWithTimeOut, 0, layout);
    } else {
      if (layout.viewer.debug) {
        layout.viewer.debug.data.time.fullDraw = utils.elapsedTime(layout._drawFullStartTime);
        layout.viewer.debug.draw();
      }
      // if (typeof complete === 'function') { complete.call() }
    }
  }

  // position must be: 'inside' or 'outside'
  _updateLayoutFor(position = 'inside') {
    const viewer = this.viewer;
    const dividers = viewer.dividers;
    const direction = (position === 'outside') ? 1 : -1;
    let bbOffset = this.backbone.adjustedThickness / 2;
    // Distance between slots
    const slotGap = (dividers.slot.adjustedSpacing * 2) + dividers.slot.adjustedThickness;
    const visibleTracks = this.tracks().filter( t =>  t.visible );
    for (let i = 0, tracksLength = visibleTracks.length; i < tracksLength; i++) {
      const track = visibleTracks[i];
      const slots = track.slots().filter( s => s.visible && s[position] );
      if (slots.length > 0) {
        bbOffset += dividers.track.adjustedSpacing;
        for (let j = 0, slotsLength = slots.length; j < slotsLength; j++) {
          const slot = slots[j];
          const slotThickness = this._calculateSlotThickness(slot.proportionOfMap);
          slot._thickness = slotThickness;
          bbOffset += (slotThickness / 2);
          slot._bbOffset = direction * bbOffset;
          bbOffset += (slotThickness / 2);
          if (j === (slotsLength - 1)) {
            // Last slot for track - Use track divider
            bbOffset += dividers.track.adjustedSpacing;
            dividers.addBbOffset(direction * (bbOffset + (dividers.track.adjustedThickness / 2)), 'track');
            bbOffset += dividers.track.adjustedThickness;
          } else {
            // More slots for track - Use slot divider
            dividers.addBbOffset(direction * (bbOffset + (slotGap / 2)), 'slot');
            bbOffset += slotGap;
          }
        }
      }
    }
    return direction * bbOffset;
  }

  /**
   * Updates the bbOffset and thickness of every slot, divider and ruler, only if the zoom level has changed
   * @private
   */
  updateLayout(force) {
    const viewer = this.viewer;
    if (!force && this._savedZoomFactor === viewer._zoomFactor) {
      return;
    } else {
      this._savedZoomFactor = viewer._zoomFactor;
    }
    viewer.dividers.clearBbOffsets();

    this._fastFeaturesPerSlot = this._fastMaxFeatures / this.visibleSlots().length;
    this._bbInsideOffset = this._updateLayoutFor('inside');
    this._bbOutsideOffset = this._updateLayoutFor('outside');
  }

  /**
   * Slot thickness is based on a proportion of the Map thickness.
   * As the viewer is zoomed the slot thickness increases until
   *  - The max map thickness is reached, or
   *  - The slot thickness is greater than the maximum allowed slot thickness
   *  @private
   */
  _calculateSlotThickness(proportionOfMap) {
    const viewer = this.viewer;

    // FIXME: should not be based on adjustedCenterOffset
    // const mapThickness = Math.min(viewer.backbone.adjustedCenterOffset, this.maxMapThickness());
    // TEMP
    // Maybe this should be based on slotSpace from adjust proportions.
    // Should slot space be saved
    // const minSize = this.maxMapThickness() / 6 * viewer.zoomFactor;
    // const minSize = this.testSlotSpace * viewer.zoomFactor;
    const minSize = this.initialWorkingSpace() * viewer.zoomFactor;
    const mapThickness = Math.min(minSize, this.maxMapThickness());

    const maxAllowedProportion = this.maxSlotThickness / mapThickness;
    const slotProportionStats = this.slotProportionStats;
    if (slotProportionStats.max > maxAllowedProportion) {
      if (slotProportionStats.min === slotProportionStats.max) {
        proportionOfMap = maxAllowedProportion;
      } else {
        // SCALE
        // Based on the min and max allowed proportionOf Radii allowed
        const minAllowedProportion = this.minSlotThickness / mapThickness;
        const minMaxRatio = slotProportionStats.max / slotProportionStats.min;
        const minProportionOfMap = maxAllowedProportion / minMaxRatio;
        const minTo = (minProportionOfMap < minAllowedProportion) ? minAllowedProportion : minProportionOfMap;
        proportionOfMap = utils.scaleValue(proportionOfMap,
          {min: slotProportionStats.min, max: slotProportionStats.max},
          {min: minTo, max: maxAllowedProportion});
      }
    }
    return proportionOfMap * mapThickness;
  }

  // When updating scales because the canvas has been resized, we want to
  // keep the map at the same position in the canvas.
  // Axis must be 'x' or 'y'
  // Used to initialize or resize the circle x/y or linear y scale
  _updateScaleForAxis(axis, dimension) {
    const scale = this.scale;
    // Default Fractions to center the map when the scales have not been defined yet
    let f1 = (axis === 'x') ? -0.5 : 0.5;
    let f2 = (axis === 'x') ? 0.5 : -0.5;
    // Save scale domains to keep tract of translation
    if (scale[axis]) {
      const origDomain = scale[axis].domain();
      const origDimension = Math.abs(origDomain[1] - origDomain[0]);
      f1 = origDomain[0] / origDimension;
      f2 = origDomain[1] / origDimension;
    }
    scale[axis] = d3.scaleLinear()
      .domain([dimension * f1, dimension * f2])
      .range([0, dimension]);
    // console.log(scale[axis].domain())
  }

  drawProgress() {
    this.canvas.clear('background');
    let track, slot, progress;
    const visibleTracks = this.tracks().filter( t =>  t.visible );
    for (let i = 0, trackLen = visibleTracks.length; i < trackLen; i++) {
      track = visibleTracks[i];
      progress = track.loadProgress;
      for (let j = 0, slotLen = track._slots.length; j < slotLen; j++) {
        slot = track._slots[j];
        slot.drawProgress(progress);
      }
    }
  }
  //
  // moveTrack(oldIndex, newIndex) {
  //   this._tracks.move(oldIndex, newIndex);
  //   this._adjustProportions();
  // }
  //
  // removeTrack(track) {
  //   this._tracks = this._tracks.remove(track);
  //   this._adjustProportions();
  // }
  //
  // toJSON() {
  //   const json = {
  //     minSlotThickness: this.minSlotThickness,
  //     maxSlotThickness: this.maxSlotThickness,
  //     tracks: []
  //   };
  //   this.tracks().each( (i, track) => {
  //     json.tracks.push(track.toJSON());
  //   });
  //   return json;
  // }


}

export default Layout;