Source: Viewer.js

/**
 * @author Jason Grant <jason.grant@ualberta.ca>
 * @requires D3
 */
import { version } from '../package.json';
import utils from './Utils';
import CGArray from './CGArray';
import Canvas from './Canvas';
import Layout from './Layout';
import IO from './IO';
import Events from './Events';
import Sequence from './Sequence';
import Backbone from './Backbone';
import EventMonitor from './EventMonitor';
import Messenger from './Messenger';
import Settings from './Settings';
import Legend from './Legend';
import Dividers from './Dividers';
import Annotation from './Annotation';
import Ruler from './Ruler';
import { Highlighter } from './Highlighter';
import { CodonTables } from './CodonTable';
import ColorPicker from './ColorPicker';
import Debug from './Debug';
import Track from './Track';
import Caption from './Caption';
import Feature from './Feature';
import Contig from './Contig';
import Plot from './Plot';
import Bookmark from './Bookmark';
import CGRange from './CGRange';
import initializeZooming from './Viewer-Zoom';
import * as d3 from 'd3';

console.log(`CGView.js Version: ${version}`)

/**
 * The Viewer is the main container class for CGView. It controls the
 * overal appearance of the map (e.g. width, height, etc).
 * It also contains all the major components of the map (e.g. [Layout](Layout.html),
 * [Sequence](Sequence.html), [Ruler](Ruler.html), etc). Many
 * of component options can be set during construction of the Viewer.
 *
 * ### Action and Events
 *
 * Action                                  | Viewer Method                        | Event
 * ----------------------------------------|--------------------------------------|-----
 * [Update](../docs.html#updating-records) | [update()](Viewer.html#update)       | viewer-update
 *
 * <a name="attributes"></a>
 * ### Attributes
 *
 * Attribute                         | Type      | Description
 * ----------------------------------|-----------|------------
 * [name](#name)                     | String    | Name for the map
 * [id](#id)                         | String    | ID for the map [Default: random 20 character HexString]
 * [width](#width)                   | Number    | Width of the viewer map in pixels [Default: 600]
 * [height](#height)                 | Number    | Height of the viewer map in pixels [Default: 600]
 * [dataHasChanged](#dataHasChanged) | Boolean   | Indicates that data been update/added since this attribute was reset
 * [meta](#meta)                     | Boolean   | Meta data for the map. Updating this attribute will overwrite **all** the current meta data.
 * [sequence](#sequence)<sup>iu</sup>    | Object | [Sequence](Sequence.html) options
 * [settings](#settings)<sup>iu</sup>    | Object | [Settings](Settings.html) options
 * [legend](#legend)<sup>iu</sup>        | Object | [Legend](Legend.html) options
 * [backbone](#backbone)<sup>iu</sup>    | Object | [Backbone](Backbone.html) options
 * [layout](#layout)<sup>iu</sup>        | Object | [Layout](Layout.html) options
 * [ruler](#ruler)<sup>iu</sup>          | Object | [Ruler](Ruler.html) options
 * [dividers](#dividers)<sup>iu</sup>    | Object | [Dividers](Dividers.html) options
 * [annotation](#annotation)<sup>iu</sup> | Object | [Annotation](Annotation.html) options
 * [highlighter](#highlighter)<sup>iu</sup> | Object | [Highlighter](Highlighter.html) options
 * 
 * <sup>iu</sup> Ignored on Viewer update
 *
 * ### Examples
 * ```js
 * cgv = new CGV.Viewer('#my-viewer', {
 *   height: 500,
 *   width: 500,
 *   sequence: {
 *     // The length of the sequence
 *     length: 1000
 *     // Or, you can provide a sequence
 *     // seq: 'ATGTAGCATGCATCAGTAGCTA...'
 *   }
 * });
 * 
 * // Draw the map
 * cgv.draw()
 * ```
 *
 * See the [tutorials](../tutorials/index.html) to learn more about making maps.
 */
class Viewer {


  /**
   * Create a viewer
   * @param {String} containerId - The ID (with or without '#') of the element to contain the viewer.
   * @param {Object} options - [Attributes](#attributes) used to create the viewer.
   *    Component options will be passed to the contructor of that component.
   */
  constructor(containerId, options = {}) {
    this.containerId = containerId.replace('#', '');
    this._container = d3.select(`#${this.containerId}`);
    // Get options
    this._width = utils.defaultFor(options.width, 600);
    this._height = utils.defaultFor(options.height, 600);
    this._wrapper = this._container.append('div')
      .attr('class', 'cgv-wrapper')
      .style('position', 'relative')
      .style('width', `${this.width}px`)
      .style('height', `${this.height}px`);

    // Create map id
    this._id = utils.randomHexString(40);

    // Create object to contain all CGObjects
    this._objects = {};

    // Initialize containers
    this._features = new CGArray();
    this._tracks = new CGArray();
    this._plots = new CGArray();
    this._captions = new CGArray();
    this._bookmarks = new CGArray();

    this._loading = true;

    // Initialize Canvas
    this.canvas = new Canvas(this, this._wrapper, {width: this.width, height: this.height});

    // Initialize Layout and set the default map format (ie. topology).
    this._layout = new Layout(this, options.layout);
    this.format = utils.defaultFor(options.format, 'circular');

    this._zoomFactor = 1;
    this._minZoomFactor = 0.5;

    // Initialize IO
    this.io = new IO(this);
    // Initialize DragAndDrop
    this.allowDragAndDrop = utils.defaultFor(options.allowDragAndDrop, true);
    // Initialize Events
    this._events = new Events();
    // Initialize Sequence
    this._sequence = new Sequence(this, options.sequence);
    // Initialize Backbone
    this._backbone = new Backbone(this, options.backbone);
    // this.initializeDragging();
    initializeZooming(this);
    // Initial Event Monitor
    this.eventMonitor = new EventMonitor(this);
    // Initial Messenger
    this.messenger = new Messenger(this, options.messenger);
    // Initialize General Setttings
    this._settings = new Settings(this, options.settings);
    // Initial Legend
    this._legend = new Legend(this, options.legend);
    // Initialize Slot Divider
    this._dividers = new Dividers(this, options.dividers);
    // Initialize Annotation
    this._annotation = new Annotation(this, options.annotation);
    // Initialize Ruler
    this._ruler = new Ruler(this, options.ruler);
    // Initialize Highlighter
    this._highlighter = new Highlighter(this, options.highlighter);
    // Initialize Codon Tables
    this.codonTables = new CodonTables;
    // Initialize Debug
    this.debug = utils.defaultFor(options.debug, false);

    this.layout.updateScales();

    // Integrate external dependencies for specific features
    this.externals = {};
    // Adding SVG using svgcanvas
    // https://github.com/zenozeng/svgcanvas
    this.externals.SVGContext = options.SVGContext;

    // TEMP adding
    if (options.features) {
      this.addFeatures(options.features);
    }

    // TEMP TESTING FOR EDIT MODE
    this.shiftSet = false;
    const shiftTest = (e) => {if (e.shiftKey) {console.log(e);}}
    this._wrapper.on('mouseover', () => {
      if (!this.shiftSet) {
        document.addEventListener('keydown', shiftTest);
        this.shiftSet = true;
      }
    }).on('mouseout', () => {
      if (this.shiftSet) {
        document.removeEventListener('keydown', shiftTest);
        this.shiftSet = false;
      }
    });

    this._loading = false;
    this.draw();
  }

  //////////////////////////////////////////////////////////////////////////
  // STATIC CLASSS METHODS
  //////////////////////////////////////////////////////////////////////////
  static get debugSections() {
    return ['time', 'zoom', 'position', 'n'];
  }

  //////////////////////////////////////////////////////////////////////////
  // MEMBERS
  //////////////////////////////////////////////////////////////////////////

  /**
   * @member {String} - Get CGView version
   */
  get version() {
    return version;
  }

  /**
   * @member {String} - Get map id
   */
  get id() {
    return this._id;
  }

  set id(value) {
    this._id = value;
  }

  /**
   * @member {String} - Get or set the map format: circular, linear
   */
  get format() {
    return this.layout.type;
    // return this.settings.format.type;
  }

  set format(value) {
    this.layout.type = value;
    // this.settings.type = value;
  }

  /**
   * @member {Layout} - Get the map [layout](Layout.html) object
   */
  get layout() {
    return this._layout;
  }

  /**
   * @member {Legend} - Get the map [legend](Legend.html) object
   */
  get legend() {
    return this._legend;
  }

  /**
   * @member {Annotation} - Get the map [annotation](Annotation.html) object
   */
  get annotation() {
    return this._annotation;
  }

  /**
   * @member {Dividers} - Get the map [dividers](Dividers.html) object
   */
  get dividers() {
    return this._dividers;
  }

  /**
   * @member {Ruler} - Get the map [ruler](Ruler.html) object
   */
  get ruler() {
    return this._ruler;
  }

  /**
   * @member {Settings} - Get the map [settings](Settings.html) object
   */
  get settings() {
    return this._settings;
  }

  /**
   * @member {Sequence} - Get the [Sequence](Sequence.html)
   */
  get sequence() {
    return this._sequence;
  }

  /**
   * @member {Backbone} - Get the [Backbone](Backbone.html)
   */
  get backbone() {
    return this._backbone;
  }

  /**
   * @member {Highlighter} - Get the [Highlighter](Highlighter.html)
   */
  get highlighter() {
    return this._highlighter;
  }


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

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

  /**
   * @member {Number} - Get or set the genetic code used for translation.
   * This genetic code will be used unless a feature has an overriding genetic code.
   * Alias for Settings.geneticCode.
   * Default: 11
   */
  get geneticCode() {
    // return this._geneticCode || 11;
    return this.settings.geneticCode;
  }

  set geneticCode(value) {
    // this._geneticCode = value;
    this.settings.geneticCode = value;
  }

  /**
   * @member {Number} - Get or set the width of the Viewer
   */
  get width() {
    return this._width;
  }

  set width(value) {
    this.resize(value);
  }

  /**
   * @member {Number} - Get or set the width of the Viewer
   */
  get height() {
    return this._height;
  }

  set height(value) {
    this.resize(null, value);
  }

  /**
   * @member {Number} - Get the height or the width of the viewer, which ever is smallest.
   */
  get minDimension() {
    return Math.min(this.height, this.width);
  }

  /**
   * @member {Number} - Get the height or the width of the viewer, which ever is largest.
   */
  get maxDimension() {
    return Math.max(this.height, this.width);
  }

  /**
   * @member {Number} - Get or set the zoom level of the map. A value of 1 is the intial zoom level.
   *   Increasing the zoom level to 2 will double the length of the backbone, and so on.
   */
  get zoomFactor() {
    return this._zoomFactor;
  }

  // FIXME: this should be done by layout?? OR not allowed
  set zoomFactor(value) {
    this.layout.zoom(Number(value));
  }

  /**
   * @member {Number} - Get the bp for the center of the canvas. Alias for Canvas.bpForCanvasCenter().
   */
  get bp() {
    return this.canvas.bpForCanvasCenter();
  }

  /**
   * @member {Number} - Get the distance from the backbone to the center of the canvas.
   */
  get bbOffset() {
    const halfRangeWidth = this.scale.x.range()[1] / 2;
    const halfRangeHeight = this.scale.y.range()[1] / 2;
    const offset = this.layout.centerOffsetForPoint({x: halfRangeWidth, y: halfRangeHeight});
    return this.backbone.adjustedCenterOffset - offset;
  }

  /**
   * @member {Number} - Get the minimum allowed zoom level
   */
  get minZoomFactor() {
    return this._minZoomFactor;
  }

  /**
   * @member {Number} - Get the maximum allowed zoom level. The maximum zoom level is set so
   * that at the maximum, the sequence can be clearly seen.
   */
  get maxZoomFactor() {
    return this.backbone.maxZoomFactor();
  }

  /**
   * @member {Object} - Return the canvas [scales](Canvas.html#scale)
   */
  get scale() {
    return this.layout.scale;
  }

  get colorPicker() {
    if (this._colorPicker === undefined) {
      // Create Color Picker
      const colorPickerId = `${this.containerId}-color-picker`;
      this._wrapper.append('div')
        // .classed('cp-color-picker-dialog', true)
        .attr('id', `${this.containerId}-color-picker`);
      this._colorPicker = new ColorPicker(colorPickerId);
    }
    return this._colorPicker;
  }

  get debug() {
    return this._debug;
  }

  set debug(options) {
    if (options) {
      if (options === true) {
        // Select all sections
        options = {};
        options.sections = Viewer.debugSections;
      }
      this._debug = new Debug(this, options);
    } else {
      this._debug = undefined;
    }
  }

  /**
   * Return true if viewer is being initialized or loading new data.
   */
  get loading() {
    return this._loading;
  }

  /**
   * @member {Boolean} - Get or set the dataHasChanged property. This will be
   * set to false, anytime the data API (add, update, remove, reorder) is
   * used. It is reset to false automatically when a new JSON is loaded via
   * [IO.loadJSON()](IO.html#loadJSON).
   */
  get dataHasChanged() {
    return this._dataHasChanged;
  }

  set dataHasChanged(value) {
    // console.log('DATA', value)
    this._dataHasChanged = value;
  }

  /**
   * Get the [Events](Events.html) object.
   */
  get events() {
    return this._events;
  }

  /**
   * @member {Object} - Get the last mouse position on canvas
   * @private
   */
  get mouse() {
    return this.eventMonitor.mouse;
  }

  /**
   * @member {Boolean} - Returns true if an animation started with 
   * [Viewer.animate()](Viewer.html#animate) is in progress.
   */
  get isAnimating() {
    return Boolean(this._animateTimeoutID);
  }

  ///////////////////////////////////////////////////////////////////////////
  // METHODS
  ///////////////////////////////////////////////////////////////////////////

  /**
   * Resizes the the Viewer
   *
   * @param {Number} width - New width
   * @param {Number} height - New height
   * @param {Boolean} keepAspectRatio - If only one of width/height is given the ratio will remain the same. (NOT IMPLEMENTED YET)
   * @param {Boolean} fast -  After resize, should the viewer be draw redrawn fast.
   */
  resize(width, height, keepAspectRatio = true, fast) {
    this._width = width || this.width;
    this._height = height || this.height;

    this._wrapper
      .style('width', `${this.width}px`)
      .style('height', `${this.height}px`);

    this.canvas.resize(this.width, this.height);

    this.refreshCanvasLayer();
    // Hide Color Picker: otherwise it may disappear off the screen
    this.colorPicker.close();

    this.layout._adjustProportions();

    this.draw(fast);

    // this.trigger('resize');
  }

  /**
   * Returns an [CGArray](CGArray.html) of CGObjects or a single CGObject from all the CGObjects in the viewer.
   * Term      | Returns
   * ----------|----------------
   * undefined | All objects
   * String    | CGObject with a cgvID equal to the string or undefined
   * Array     | CGArray of CGObjects with matching cgvIDs
   *
   * @param {String|Array} term - The values returned depend on the term (see above table).
   * @return {CGArray|or|CGObject}
   */
  objects(term) {
    if (term === undefined) {
      return this._objects;
    } else if (typeof term === 'string') {
      return this._objects[term];
    } else if (Array.isArray(term)) {
      const array = new CGArray();
      for (let i = 0, len = term.length; i < len; i++) {
        array.push(this._objects[term[i]]);
      }
      return array;
    } else {
      return new CGArray();
    }
  }

  /**
   * Returns an [CGArray](CGArray.html) of Slots or a single Slot from all the Slots in the Layout.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  slots(term) {
    let slots = new CGArray();
    for (let i = 0, len = this._tracks.length; i < len; i++) {
      slots = slots.concat(this._tracks[i]._slots);
    }
    return slots.get(term);
  }

  /**
   * Returns a [CGArray](CGArray.html) of features or a single feature.
   * See [reading records](../docs.html#s.reading-records) for details.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {Feature|CGArray}
   */
  features(term) {
    return this._features.get(term);
  }

  /**
   * Returns an [CGArray](CGArray.html) of contigs or a single contig from all the contigs in the viewer. This is an alias for Viewer.sequence.contigs().
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  contigs(term) {
    return this.sequence.contigs(term);
  }

  update(attributes) {
    // Validate attribute keys
    let keys = Object.keys(attributes);
    const validKeys = ['name', 'id', 'width', 'height', 'dataHasChanged', 'meta'];
    if (!utils.validate(keys, validKeys)) { return; }

    // Special Case for Resizing - we don't want to update width and height separately
    if (keys.includes('width') && keys.includes('height')) {
      this.resize(attributes.width, attributes.height);
      keys = keys.filter( i => i !== 'width' && i !== 'height' );
    }

    // Trigger ignores 'viewer-update' for dataHasChanged. So we add it here if needed.
    if (keys.length > 0 && !keys.includes('dataHasChanged')) {
      attributes.dataHasChanged = true;
    }

    for (let i = 0; i < keys.length; i++) {
      this[keys[i]] = attributes[keys[i]];
    }
    this.trigger('viewer-update', { attributes });
  }


  /**
   * Returns a [CGArray](CGArray.html) of tracks or a single track.
   * See [reading records](../docs.html#s.reading-records) for details.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {Track|CGArray}
   */
  tracks(term) {
    return this._tracks.get(term);
  }

  /**
   * Add one or more [tracks](Track.html) (see [attributes](Track.html#attributes)).
   * See [adding records](../docs.html#s.adding-records) for details.
   * @param {Object|Array} data - Object or array of objects describing the tracks
   * @return {CGArray<Track>} CGArray of added tracks
   */
  addTracks(trackData = []) {
    trackData = CGArray.arrayerize(trackData);
    const tracks = trackData.map( (data) => new Track(this, data));

    // Recenter the map tracks if zoomed in if zoomed in
    if (!(this.backbone.visibleRange && this.backbone.visibleRange.overHalfMapLength())) {
      this.recenterTracks();
    }
    this.annotation.refresh();

    this.dirty = true;

    this.trigger('tracks-add', tracks);
    return tracks;
  }

  /**
   * Remove tracks.
   * See [removing records](../docs.html#s.removing-records) for details.
   * @param {Track|Array} tracks - Track or a array of tracks to remove
   */
  removeTracks(tracks) {
    tracks = CGArray.arrayerize(tracks);
    this._tracks = this._tracks.filter( t => !tracks.includes(t) );
    this.layout._adjustProportions();
    // Remove from Objects
    tracks.forEach( t => t.deleteFromObjects() );
    this.trigger('tracks-remove', tracks);
  }


  /**
   * Update track properties to the viewer. If no attribtes are given, the trigger event will still be called.
   */
  // updateTracks(tracks, attributes) {
  //   tracks = CGArray.arrayerize(tracks);
  //   if (attributes) {
  //     // Validate attribute keys
  //     const keys = Object.keys(attributes);
  //     const validKeys = ['name', 'position', 'separateFeaturesBy', 'visible', 'thicknessRatio', 'loadProgress', 'contents'];
  //     if (!validate(keys, validKeys)) { return false; }
  //     const contents = attributes.contents;
  //     if (contents) {
  //       // Validate content attribute keys
  //       const contentKeys = Object.keys(contents);
  //       const validContentKeys = ['type', 'from', 'extract', 'options'];
  //       if (!validate(contentKeys, validContentKeys)) { return false; }
  //       for (const track of tracks) {
  //         for (const contentKey of contentKeys) {
  //           const value = contents[contentKey];
  //           track.contents[contentKey] = value;
  //         }
  //         track.refresh();
  //       }
  //       // const {contents, ...modifiedAttributes} = attributes;
  //       const modifiedAttributes = keys.reduce( (obj, k) => {
  //         if (k !== 'contents') { obj[k] = attributes[k]; }
  //         return obj;
  //       }, {});
  //       tracks.attr(modifiedAttributes);
  //     } else {
  //       tracks.attr(attributes);
  //     }
  //   }
  //   this.trigger('tracks-update', { tracks, attributes });
  // }
  /**
   * Update [attributes](Track.html#attributes) for one or more tracks.
   * See [updating records](../docs.html#s.updating-records) for details.
   * @param {Track|Array|Object} tracksOrUpdates - Track, array of tracks or object describing updates
   * @param {Object} attributes - Object describing the properties to change
   */
  updateTracks(tracksOrUpdates, attributes) {
    const { records: tracks, updates } = this.updateRecords(tracksOrUpdates, attributes, {
      recordClass: 'Track',
      validKeys: ['name', 'position', 'separateFeaturesBy', 'dataType', 'dataMethod', 'dataKeys', 'dataOptions', 'favorite', 'visible', 'loadProgress', 'thicknessRatio', 'drawOrder']
    });
    let tracksToRefresh = [];
    if (updates) {
      const cgvIDs = Object.keys(updates);
      for (let cgvID of cgvIDs) {
        const value = updates[cgvID];
        const track = this.objects(cgvID);
        //TODO: try Sets
        const keys = Object.keys(value);
        if (keys.includes('dataMethod') || keys.includes('dataType') || keys.includes('dataKeys')) {
          if (!tracksToRefresh.includes(track)) {
            tracksToRefresh.push(track);
          }
        }
        if (keys.includes('visible')) {
          this.annotation.refresh();
        }
      }
    } else if (attributes) {
      const keys = Object.keys(attributes);
      if (keys.includes('dataMethod') || keys.includes('dataType') || keys.includes('dataKeys')) {
        tracksToRefresh = tracks;
      }
      if (keys.includes('visible')) {
        this.annotation.refresh();
      }
    }
    for (const track of tracksToRefresh) {
      track.refresh();
    }
    this.trigger('tracks-update', { tracks, attributes, updates });
  }

  /**
   * Move a track from one index to a new one
   * @param {Number} oldIndex - Index of track to move (0-based)
   * @param {Number} newIndex - New index for the track (0-based)
   */
  moveTrack(oldIndex, newIndex) {
    this._tracks.move(oldIndex, newIndex);
    this.layout._adjustProportions();
    this.trigger('tracks-moved', {oldIndex: oldIndex, newIndex: newIndex});
  }

  /**
   * Returns an [CGArray](CGArray.html) of Captions or a single Caption.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  captions(term) {
    return this._captions.get(term);
  }

  visibleCaptions(term) {
    return this._captions.filter( i => i.visible ).get(term);
  }

  /**
   * Add one or more [captions](Caption.html) (see [attributes](Caption.html#attributes)).
   * See [adding records](../docs.html#s.adding-records) for details.
   * @param {Object|Array} data - Object or array of objects describing the captions
   * @return {CGArray<Caption>} CGArray of added captions
   */
  addCaptions(captionData = []) {
    captionData = CGArray.arrayerize(captionData);
    const captions = captionData.map( (data) => new Caption(this, data));
    this.trigger('captions-add', captions);
    return captions;
  }

  updateCaptions(captionsOrUpdates, attributes) {
    const { records: captions, updates } = this.updateRecords(captionsOrUpdates, attributes, {
      recordClass: 'Caption',
      validKeys: ['name', 'on', 'anchor', 'position', 'font', 'visible', 'fontColor', 'textAlignment', 'backgroundColor']
    });
    this.trigger('captions-update', { captions, attributes, updates });
  }

  removeCaptions(captions) {
    captions = CGArray.arrayerize(captions);
    this._captions = this._captions.filter( f => !captions.includes(f) );
    // Update Layers
    this.clear('canvas');
    this.refreshCanvasLayer();
    // Remove from Objects
    captions.forEach( c => c.deleteFromObjects() );

    this.trigger('captions-remove', captions);
  }

  /**
   * Move a caption from one index to a new one
   * @param {Number} oldIndex - Index of caption to move (0-based)
   * @param {Number} newIndex - New index for the caption (0-based)
   */
  moveCaption(oldIndex, newIndex) {
    this._captions.move(oldIndex, newIndex);
    this.refreshCanvasLayer();
    this.trigger('captions-moved', {oldIndex: oldIndex, newIndex: newIndex});
  }

  /**
   * Returns a [CGArray](CGArray.html) of plots or a single plot.
   * See [reading records](../docs.html#s.reading-records) for details.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {Plot|CGArray}
   */
  plots(term) {
    return this._plots.get(term);
  }

  /**
   * Returns an [CGArray](CGArray.html) of Feature/Plot Source name or a single item.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  // FIXME: need better way to keep track of sources
  // FIXME: sources should not contain things like orfs???
  // FIXME: contains empty source for sequence plots.
  sources(term) {
    const featureSources = this._features.map( f => f.source );
    const plotSources = this._plots.map( p => p.source );
    const trackSources = this.tracks().
      filter( c => c.dataMethod === 'source').
      map( c => c.dataKeys ).flat();

    const allSources = featureSources.concat(plotSources).concat(trackSources);
    return new CGArray([...new Set(allSources)]).get(term);
  }

  /**
   * Returns an [CGArray](CGArray.html) of all Feature/Plot tags or a single item.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {CGArray}
   */
  // FIXME: need better way to keep track of tags
  // FIXME: add plots tags
  tags(term) {
    const featureTags = this._features.map( f => f.tags );
    // const plotTags = this._plots.map( p => p.tags );
    const trackTags = this.tracks().
      filter( c => c.dataMethod === 'tag').
      map( c => c.dataKeys );

    // const allTags = featureTags.concat(plotTags).concat(trackTags).flat();
    const allTags = featureTags.concat(trackTags).flat();
    return new CGArray([...new Set(allTags)]).get(term);
  }

  updateRecordsWithAttributes(records, attributes, options = {}) {
    const validKeys = options.validKeys;
    const recordClass = options.recordClass;
    // Validate attribute keys
    const attibuteKeys = Object.keys(attributes);
    if (validKeys && !utils.validate(attibuteKeys, validKeys)) { return; }
    // Validate record Class
    records = CGArray.arrayerize(records);
    if (recordClass && records.some( r => r.toString() !== recordClass )) {
      console.error(`The following records were not of the Class '${recordClass}':`, records.filter ( r => r.toString() != recordClass));
      return;
    }
    // Update Records
    records.attr(attributes);
    return records;
  }

  updateRecordsIndividually(updates, options = {}) {
    const validKeys = options.validKeys;
    const recordClass = options.recordClass;
    // Validate attribute keys
    if (validKeys) {
      let allAttributeKeys = [];
      const values = Object.values(updates);
      for (const value of values) {
        allAttributeKeys = allAttributeKeys.concat(Object.keys(value));
      }
      const uniqAttributeKeys = [...new Set(allAttributeKeys)];
      if (!utils.validate(uniqAttributeKeys, validKeys)) { return; }
    }
    // Get records form cgvIDs update keys
    const cgvIDs = new CGArray(Object.keys(updates));
    const records = cgvIDs.map( id => this.objects(id) );
    // Validate record Class
    if (recordClass && records.some( r => r.toString() !== recordClass )) {
      console.error(`The following records were not of the Class '${recordClass}':`, records.filter ( r => r.toString() != recordClass));
      return;
    }
    // Update Records
    for (const record of records) {
      const attributes = Object.keys(updates[record.cgvID]);
      for (const attribute of attributes) {
        record[attribute] = updates[record.cgvID][attribute];
      }
    }
    return records;
  }

  // Returns records (CGArray), updates, attributes
  // NOTE: Not used by Viewer.updateTracks or Viewer.update
  updateRecords(recordsOrUpdates = [], attributes = {}, options = {}) {
    let records, updates;
    if (recordsOrUpdates.toString() === '[object Object]') {
      // Assume recordsOrUpdate is an object of updates
      updates = recordsOrUpdates;
      records = this.updateRecordsIndividually(updates, options);
    } else {
      // Assume recordsOrUpdate is an individual record or an array of records
      records = this.updateRecordsWithAttributes(recordsOrUpdates, attributes, options);
    }
    return { records, updates, attributes };
  }

  /**
   * Returns a CGArray of the records that have had the attributesOfInterest changed.
   * If attributes has any of the attributesOfInterest then all the records are returned.
   * Otherwise any record in updates that has an attributesOfInterest of changed is returned.
   * @private
   */
  recordsWithChangedAttributes(attributesOfInterest, records, attributes = {}, updates) {
    records = CGArray.arrayerize(records);
    let returnedRecords = new CGArray();
    attributesOfInterest = CGArray.arrayerize(attributesOfInterest);
    const attributeKeys = Object.keys(attributes);
    if (attributeKeys.length > 0) {
      for (const attribute of attributesOfInterest) {
        if (attributeKeys.includes(attribute)) {
          return returnedRecords = records;
        }
      }
    } else if (updates) {
      for (const record of records) {
        for (const attribute of attributesOfInterest) {
          if (Object.keys(updates[record.cgvID]).includes(attribute)) {
            returnedRecords.push(record);
            continue;
          }
        }
      }
    }
    return returnedRecords;
  }

  /**
   * Add one or more [features](Feature.html) (see [attributes](Feature.html#attributes)).
   * See [adding records](../docs.html#s.adding-records) for details.
   * @param {Object|Array} data - Object or array of objects describing the features
   * @return {CGArray<Feature>} CGArray of added features
   */
  // FIXME: for History, we will want to be able to handle passing an array of features
  //  not just feature data. That way they don't have to be reinitialized and they keep the same cgvIDs.
  addFeatures(featureData = []) {
    featureData = CGArray.arrayerize(featureData);
    const features = featureData.map( (data) => new Feature(this, data));
    this.annotation.refresh();
    // FIXME: need to update tracks??
    // This causes sequence-based (e.g. orfs) to reload too
    // this.tracks().each( (i,t) => t.refresh() );
    this.trigger('features-add', features);
    return features;
  }

  /**
   * Remove features.
   * See [removing records](../docs.html#s.removing-records) for details.
   * @param {Feature|Array} features - Feature or a array of features to remove
   */
  removeFeatures(features) {
    features = CGArray.arrayerize(features);
    this._features = this._features.filter( f => !features.includes(f) );
    // Update Annotationa and Tracks
    const labels = features.map( f => f.label );
    this.annotation.removeLabels(labels);
    this.tracks().each( (i, track) => {
      track.removeFeatures(features);
    });
    this.annotation.refresh();
    // Update Contigs
    Contig.removeFeatures(features);
    // Remove from Objects
    features.forEach( f => f.deleteFromObjects() );

    this.trigger('features-remove', features);
  }

  /**
   * Update [attributes](Feature.html#attributes) for one or more features.
   * See [updating records](../docs.html#s.updating-records) for details.
   * @param {Feature|Array|Object} featuresOrUpdates - Feature, array of features or object describing updates
   * @param {Object} attributes - Object describing the properties to change
   */
  updateFeatures(featuresOrUpdates, attributes) {
    const { records: features, updates } = this.updateRecords(featuresOrUpdates, attributes, {
      recordClass: 'Feature',
      validKeys: ['name', 'type', 'contig', 'legendItem', 'source', 'tags', 'favorite', 'visible', 'strand', 'start', 'stop','score', 'mapStart', 'mapStop']
    });
    // Refresh tracks if any attribute is source, type, tags
    let refreshTracks;
    if (updates) {
      const values = Object.values(updates);
      for (let value of values) {
        refreshTracks = Object.keys(values).some( a => ['source', 'type', 'tags'].includes(a));
      }
    } else if (attributes) {
      refreshTracks = Object.keys(attributes).some( a => ['source', 'type', 'tags'].includes(a));
    }
    if (refreshTracks) {
      for (let track of cgv.tracks()) {
        track.refresh();
      }
    }
    // Refresh labels if any attribute is start, stop or visible
    let updateLabels;
    if (updates) {
      const values = Object.values(updates);
      for (let value of values) {
        if (Object.keys(value).includes('start') || Object.keys(value).includes('stop') || Object.keys(value).includes('visible')) {
          updateLabels = true;
        }
      }
    } else {
      updateLabels = attributes && (Object.keys(attributes).includes('start') || Object.keys(attributes).includes('stop') || Object.keys(attributes).includes('visible'));
    }
    if (updateLabels) {
      this.annotation.refresh();
    }
    this.trigger('features-update', { features, attributes, updates });
  }

  /**
   * Add one or more [plots](Plot.html) (see [attributes](Plot.html#attributes)).
   * See [adding records](../docs.html#s.adding-records) for details.
   * @param {Object|Array} data - Object or array of objects describing the plots
   * @return {CGArray<Plot>} CGArray of added plots
   */
  addPlots(plotData = []) {
    plotData = CGArray.arrayerize(plotData);
    const plots = plotData.map( (data) => new Plot(this, data));
    this.annotation.refresh();
    this.trigger('plots-add', plots);
    return plots;
  }

  /**
   * Remove plots.
   * See [removing records](../docs.html#s.removing-records) for details.
   * @param {Plot|Array} plots - Plot or a array of plots to remove
   */
  removePlots(plots) {
    plots = CGArray.arrayerize(plots);
    this._plots = this._plots.filter( p => !plots.includes(p) );
    plots.each( (i, plot) => {
      plot.tracks().each( (j, track) => {
        track.removePlot();
      });
    });
    // Remove from Objects
    plots.forEach( f => f.deleteFromObjects() );

    this.trigger('plots-remove', plots);
  }

  /**
   * Update [attributes](Plot.html#attributes) for one or more plot.
   * See [updating records](../docs.html#s.updating-records) for details.
   * @param {Plot|Array|Object} plotsOrUpdates - Plot, array of plot or object describing updates
   * @param {Object} attributes - Object describing the properties to change
   */
  updatePlots(plotsOrUpdates, attributes) {
    const { records: plots, updates } = this.updateRecords(plotsOrUpdates, attributes, {
      recordClass: 'Plot',
      validKeys: ['name', 'type','legend', 'legendPositive', 'legendNegative', 'source',
        'favorite', 'visible', 'baseline', 'axisMin', 'axisMax']
    });
    // Refresh tracks if any attribute is source
    // let sourceChanged;
    // if (plotsOrUpdates.toString() === '[object Object]') {
    //   const values = Object.values(plotsOrUpdates);
    //   for (let value of values) {
    //     if (Object.keys(value).includes('source')) {
    //       sourceChanged = true;
    //     }
    //   }
    // } else {
    //   sourceChanged = attributes && Object.keys(attributes).includes('source');
    // }
    // if (sourceChanged) {
    //   for (let track of cgv.tracks()) {
    //     track.refresh();
    //   }
    // }
    this.trigger('plots-update', { plots, attributes, updates });
  }

  /**
   * Returns a [CGArray](CGArray.html) of Bookmarks or a single Bookmark.
   * See [reading records](../docs.html#s.reading-records) for details.
   * @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
   * @return {Bookmark|CGArray<Bookmark>}
   */
  bookmarks(term) {
    return this._bookmarks.get(term);
  }

  /**
   * Add one or more [Bookmarks](Bookmark.html) (see [attributes](Bookmark.html#attributes)).
   * See [adding records](../docs.html#s.adding-records) for details.
   * @param {Object|Array} data - Object or array of objects describing the bookmarks
   * @return {CGArray<Bookmark>} CGArray of added bookmarks
   */
  addBookmarks(bookmarkData = []) {
    bookmarkData = CGArray.arrayerize(bookmarkData);
    const bookmarks = bookmarkData.map( (data) => new Bookmark(this, data));
    this.trigger('bookmarks-add', bookmarks);
    return bookmarks;
  }

  /**
   * Remove bookmarks.
   * See [removing records](../docs.html#s.removing-records) for details.
   * @param {Bookmark | Array} bookmarks - Bookmark or a array of bookmarks to remove
   */
  removeBookmarks(bookmarks) {
    bookmarks = CGArray.arrayerize(bookmarks);
    this._bookmarks = this._bookmarks.filter( b => !bookmarks.includes(b) );
    // Remove from Objects
    bookmarks.forEach( b => b.deleteFromObjects() );
    this.trigger('bookmarks-remove', bookmarks);
  }

  bookmarkByShortcut(shortcut) {
    return this.bookmarks().find( b => b.shortcut && b.shortcut === `${shortcut}` );
  }

  /**
   * Update [attributes](Bookmark.html#attributes) for one or more bookmarks.
   * See [updating records](../docs.html#s.updating-records) for details.
   * @param {Bookmark | Array| Object } bookmarksOrUpdates - Bookmark, array of bookmarks or object describing updates
   * @param {Object} attributes - Object describing the properties to change
   */
  updateBookmarks(bookmarksOrUpdates, attributes) {
    const { records: bookmarks, updates } = this.updateRecords(bookmarksOrUpdates, attributes, {
      recordClass: 'Bookmark',
      validKeys: ['name', 'bp', 'zoom', 'format', 'favorite', 'shortcut', 'bbOffset']
    });
    this.trigger('bookmarks-update', { bookmarks, attributes, updates });
  }

  /**
   * Clear the viewer canvas
   */
  clear(layerName = 'map') {
    this.canvas.clear(layerName);
  }

  /**
  * Flash a message on the center of the viewer.
  * @private
  */
  flash(msg) {
    this.messenger.flash(msg);
  }

  fillBackground() {
    this.clear('background');
  }

  drawFull() {
    this.layout.drawFull();
  }

  drawFast() {
    this.layout.drawFast();
  }

  drawExport() {
    this.layout.drawExport();
  }

  /**
   * Draw the map. By default the full version of the map is drawn. The map can be drawn faster but this will
   * reduce the number of features and other components are drawn.
   * @param {Boolean} fast - If true, a fast version of the map is draw. Fast drawing is best for zooming and scrolling.
   */
  draw(fast) {
    this.layout.draw(fast);
  }

  featureTypes(term) {
    return this._features.map( f => f.type ).unique().get(term);
  }

  featuresByType(type) {
    return this._features.filter( f => f.type === type );
  }

  featuresBySource(source) {
    return this._features.filter( f => f.source === source );
  }

  refreshCanvasLayer() {
    for (let i = 0, len = this._captions.length; i < len; i++) {
      if (this._captions[i].visible) {
        this._captions[i].refresh();
      }
    }
    this.legend && this.legend.refresh();
  }

  /**
   * Animate through a defined set of elements (eg. features, bookmarks) or a
   * random number of features. By default the map will reset between
   * animations. To stop the animation, click the map canvas or call
   * [Viewer.stopAnimate()](Viewer.html#stopAnimate).
   * @param {Number|Array} elements - An array of [features](Feature.html) or
   *   [bookmarks](Bookmark.html). If a number is provided, that number of random
   *   features will be animated.
   * @param {Object} options - Options for the animations:
   * <br />
   * Name         | Type    | Description
   * -------------|---------|------------
   * noReset      | Boolean | If set to true, the map will not reset between animations [Default: false]
   * resetPosition  | Feature,Bookmark | A feature or bookmark to reset the map to between animations [Default: call [Viewer.reset()](Viewer.html#reset)]
   * resetDuration  | Number | Number of milliseconds for the reset animation [Default: 3000]
   * resetPause  | Number | Number of milliseconds to pause on the reset position [Default: 1000]
   * elementDuration  | Number | Number of milliseconds for each element animation [Default: 3000]
   * elementPause  | Number | Number of milliseconds to pause on each element position [Default: 1000]
   *
   * @param {Number} step - The element index (base-0) to start the animation with [Default: 0]
   * @param {Boolean} reset - Whether this is a reset animation or not [Default: false]
   * @param {Boolean} newAnimation - Whether this is a newAnimation or a continuation of a previous one [Default: true]
   */
  animate(elements=5, options={}, step=0, reset=false, newAnimation=true) {
    const noReset = options.noReset;
    const resetPosition = options.resetPosition;
    const resetDuration = utils.defaultFor(options.resetDuration, 3000);
    const resetPause = utils.defaultFor(options.resetPause, 1000);
    const elementDuration = utils.defaultFor(options.elementDuration, 3000);
    const elementPause = utils.defaultFor(options.elementPause, 1000);

    if (newAnimation) {
      // Stop previous animations
      this.stopAnimate();
    }

    // Get random features if an integer was provided for elements
    if (Number.isInteger(elements)) {
      const allFeatures = this.features();
      if (allFeatures.length > 0) {
        let animateFeatures = [];
        for (let i = 0; i < elements; i++) {
          const randomIndex = Math.floor(Math.random() * allFeatures.length);
          const randomFeature = allFeatures[randomIndex];
          animateFeatures.push(randomFeature);
        }
        elements = animateFeatures;
      } else {
        console.error('No features to animate');
        return;
      }
    }

    // Is this step reseting the animation?
    const resetStep = reset && !noReset;

    // Duration for timeout depends on resetStep and element/resetDuration and element/resetPause
    const timeoutDuration = resetStep ? (resetDuration + resetPause) : (elementDuration + elementPause);

    // console.log(`Animate: Step ${step}; Reseting: ${resetStep}; Duration: ${timeoutDuration}`);

    if (resetStep) {
      if (resetPosition) {
        resetPosition.moveTo(resetDuration);
      } else {
        this.reset(resetDuration);
      }
    } else {
      elements[step].moveTo(elementDuration);
      step = (step >= (elements.length - 1)) ? 0 : step + 1;
    }
    this._animateTimeoutID = setTimeout( () => {
      this.animate(elements, options, step, !reset, false)
    }, timeoutDuration);
  }

  /**
   * Stops an animation started with [Viewer.animate()](Viewer.html#animate).
   */
  stopAnimate() {
    clearTimeout(this._animateTimeoutID);
    this._animateTimeoutID = undefined;
    d3.select(this.canvas.node('ui')).interrupt();
  }

  /**
   * Move the viewer to show the map from the *start* to the *stop* position.
   * If only the *start* position is provided,
   * the viewer will center the image on that bp with the current zoom level.
   *
   * @param {Number} start - The start position in bp
   * @param {Number} stop - The stop position in bp
   * @param {Object} options - Options for the move:
   * <br />
   * Name         | Type   | Description
   * -------------|--------|------------
   * bbOffset       | Number | Distance the map backbone should be moved from center [Default: 0]
   * duration     | Number | The animation duration in milliseconds [Default: 1000]
   * ease         | Number | The d3 animation ease [Default: d3.easeCubic]
   * callback     | Function | Function called after the animation is complete.
   */
  moveTo(start, stop, options = {}) {
    if (stop) {
      const bpLength = this.sequence.lengthOfRange(start, stop);
      const bp = this.sequence.addBp(start, bpLength / 2);

      const zoomFactor = this.layout.zoomFactorForLength(bpLength);

      // this.zoomTo(bp, zoomFactor, duration, ease, callback);
      this.zoomTo(bp, zoomFactor, options);
    } else {
      // this._moveTo(start, duration, ease, callback);
      this._moveTo(start, options);
    }
  }

  _moveTo(bp, options = {}) {
    const self = this;
    const layout = this.layout;
    const backboneZoomThreshold = 3;

    const {
      bbOffset = utils.defaultFor(options.bbOffset, 0),
      duration = utils.defaultFor(options.duration, 1000),
      ease = utils.defaultFor(options.ease, d3.easeCubic),
      callback
    } = options;

    const { startProps, endProps } = this._moveProps(bp, undefined, bbOffset);

    const isCircular = this.settings.format === 'circular';

    d3.select(this.canvas.node('ui')).transition()
      .duration(duration)
      .ease(ease)
      .tween('move', function() {
        const intermProps = d3.interpolateObject(startProps, endProps);
        return function(t) {
          if (isCircular && startProps.zoomFactor > backboneZoomThreshold && endProps.zoomFactor > backboneZoomThreshold) {
            // Move along map backbone
            const domains = layout.domainsFor(intermProps(t).bp, intermProps(t).zoomFactor, intermProps(t).bbOffset);
            self.scale.x.domain([domains[0], domains[1]]);
            self.scale.y.domain([domains[2], domains[3]]);
          } else {
            // Move from linearly from start to stop
            self.scale.x.domain([intermProps(t).domainX0, intermProps(t).domainX1]);
            self.scale.y.domain([intermProps(t).domainY0, intermProps(t).domainY1]);
          }

          self.trigger('zoom');
          self.drawFast();
        };
      }).on('end', function() {
        callback ? callback.call() : self.drawFull();
      });
  }

  _moveLeftRight(factor=0.5, direction, options = {}) {
    const currentBp = this.canvas.bpForCanvasCenter();
    const length = this.sequence.length;
    let bpChange = length * factor / this.zoomFactor;
    // console.log(factor)

    if (direction !== 'right') {
      bpChange *= -1;
    }

    let newBp = currentBp + bpChange;
    if (this.format === 'linear') {
      newBp = (utils.constrain((currentBp + bpChange), 1, this.sequence.length));
    }
    this.moveTo(newBp, null, options);
  }

  /**
   * Moves the map left or counterclockwise by factor, where the factor is the fraction of the current visable range.
   * For example, if 1000 bp are currently visible then the default (factor = 0.5) move
   * would be 500 bp.
   * @param {Number} factor - the fraction of the current visible region to move [Default: 0.5]
   * @param {Object} options - Options for the moving:
   * <br />
   * Name         | Type   | Description
   * -------------|--------|------------
   * bbOffset     | Number | Distance the map backbone should be moved from center [Default: 0]
   * duration     | Number | The animation duration in milliseconds [Default: 1000]
   * ease         | Number | The d3 animation ease [Default: d3.easeCubic]
   * callback     | Function | Function called after the animation is complete.
   */
  moveLeft(factor, options = {}) {
    this._moveLeftRight(factor, 'left', options);
  }

  /**
   * Moves the map right or clockwise by factor, where the factor is the fraction of the current visable range.
   * For example, if 1000 bp are currently visible then the default (factor = 0.5) move
   * would be 500 bp.
   * @param {Number} factor - the fraction of the current visible region to move [Default: 0.5]
   * @param {Object} options - Options for the moving:
   * <br />
   * Name         | Type   | Description
   * -------------|--------|------------
   * bbOffset     | Number | Distance the map backbone should be moved from center [Default: 0]
   * duration     | Number | The animation duration in milliseconds [Default: 1000]
   * ease         | Number | The d3 animation ease [Default: d3.easeCubic]
   * callback     | Function | Function called after the animation is complete.
   */
  moveRight(factor, options = {}) {
    this._moveLeftRight(factor, 'right', options);
  }

  // Returns a number of properties for the current position and the position
  // at the provdied bp, zoomFactor and bbOffset.
  // These properties can be interpolated with d3.interpolateObject(startProps, endProps);
  // Returns an object: {startProps, endProps}
  // Both startProps and endProps contain:
  // - bp, zoomFactor, bbOffset, domainX0, domainX1, domainY0, domainY1
  _moveProps(bp=this.bp, zoomFactor=this.zoomFactor, bbOffset=this.bbOffset) {
    // Current Domains
    const domainX = this.scale.x.domain();
    const domainY = this.scale.y.domain();

    let startBp = this.bp;
    let endBp = bp;

    // For circular maps take the shortest root (e.g. across origin)
    // NOTE: Negative values and values above length only work on circular maps
    const isCircular = this.settings.format === 'circular';
    if (isCircular) {
      const distance = Math.abs(endBp - startBp);
      if (distance > (this.sequence.length / 2)) {
        if (endBp > startBp) {
          endBp  = endBp - this.sequence.length;
        } else {
          startBp  = startBp - this.sequence.length;
        }
      }
    }
      
    const endDomains = this.layout.domainsFor(bp, zoomFactor, bbOffset);

    const startProps = {
      bp: startBp, zoomFactor: this.zoomFactor, bbOffset: this.bbOffset,
      domainX0: domainX[0], domainX1: domainX[1], domainY0: domainY[0], domainY1: domainY[1]
    };

    const endProps = {
      bp: endBp, zoomFactor: zoomFactor, bbOffset: bbOffset,
      domainX0: endDomains[0], domainX1: endDomains[1], domainY0: endDomains[2], domainY1: endDomains[3]
    };

    return {startProps, endProps};
  }

  /**
   * Move the viewer to *bp* position at the provided *zoomFactor*.
   * If *bp* is falsy (inc. 0), the map is centered.
   *
   * @param {Number} bp - The position in bp
   * @param {Number} zoomFactor - The zoome level
   * @param {Object} options - Options for the zoom:
   * <br />
   * Name         | Type   | Description
   * -------------|--------|------------
   * bbOffset     | Number | Distance the map backbone should be moved from center [Default: 0]
   * duration     | Number | The animation duration in milliseconds [Default: 1000]
   * ease         | Number | The d3 animation ease [Default: d3.easeCubic]
   * callback     | Function | Function called after the animation is complete.
   */
  // Implementation Notes:
  // For linear maps:
  // - Interpolate linearly between start and end domains
  // For cicular maps:
  // - when zoomed out (zoomFactor <= backboneZoomThreshold) do as with linear maps
  // - when zoomed in (zoomFactor > backboneZoomThreshold) use bp to interpolate along backbone
  zoomTo(bp, zoomFactor, options = {}) {
    const self = this;
    const layout = this.layout;
    const backboneZoomThreshold = 3;

    const {
      bbOffset = utils.defaultFor(options.bbOffset, 0),
      duration = utils.defaultFor(options.duration, 1000),
      ease = utils.defaultFor(options.ease, d3.easeCubic),
      callback
    } = options;

    const zoomExtent = self._zoom.scaleExtent();
    zoomFactor = utils.constrain(zoomFactor, zoomExtent[0], zoomExtent[1]);

    const { startProps, endProps } = this._moveProps(bp, zoomFactor, bbOffset);

    const isCircular = this.settings.format === 'circular';

    d3.select(this.canvas.node('ui')).transition()
      .duration(duration)
      .ease(ease)
      .tween('move', function() {
        const intermProps = d3.interpolateObject(startProps, endProps);
        return function(t) {

          if (isCircular && startProps.zoomFactor > backboneZoomThreshold && endProps.zoomFactor > backboneZoomThreshold) {
            // Move along map backbone
            const domains = layout.domainsFor(intermProps(t).bp, intermProps(t).zoomFactor, intermProps(t).bbOffset);
            self.scale.x.domain([domains[0], domains[1]]);
            self.scale.y.domain([domains[2], domains[3]]);
          } else {
            // Move from linearly from start to stop
            self.scale.x.domain([intermProps(t).domainX0, intermProps(t).domainX1]);
            self.scale.y.domain([intermProps(t).domainY0, intermProps(t).domainY1]);
          }
          self._zoomFactor = intermProps(t).zoomFactor;
          d3.zoomTransform(self.canvas.node('ui')).k = intermProps(t).zoomFactor;

          self.layout.adjustBpScaleRange();

          self.trigger('zoom');
          self.drawFast();
        };
      }).on('start', function() {
        self.trigger('zoom-start');
      }).on('end', function() {
        self.trigger('zoom-end');
        callback ? callback.call() : self.drawFull();
      });
  }

  /**
   * Zoom in on the current bp a factor
   * @param {Number} factor - Amount to zoom in by [Default: 2]
   * @param {Object} options - Options passed to [Viewer.zoomTo()](Viewer.html#zoomTo)
   */
  zoomIn(factor=2, options) {
    const bp = utils.constrain(this.canvas.bpForCanvasCenter(), 1, this.sequence.length);
    this.zoomTo(bp, this.zoomFactor * factor, options);
  }

  /**
   * Zoom out on the current bp a factor
   * @param {Number} factor - Amount to zoom out by [Default: 2]
   * @param {Object} options - Options passed to [Viewer.zoomTo()](Viewer.html#zoomTo)
   */
  zoomOut(factor=2, options) {
    const bp = utils.constrain(this.canvas.bpForCanvasCenter(), 1, this.sequence.length);
    this.zoomTo(bp, this.zoomFactor / factor, options);
  }

  /**
   * Set zoom level to 1 and centers map
   */
  reset(duration = 1000, ease) {
    this.zoomTo(0, 1, {duration, ease});
  }

  /**
   * Recenter the map tracks at the current bp position
   */
  recenterTracks(duration = 0) {
    this.moveTo(this.bp, undefined, {duration});
  }


  _updateZoomMax() {
    if (this._zoom) {
      this._zoom.scaleExtent([this.minZoomFactor, this.maxZoomFactor]);
    }
  };

  // FIXME: Each object must use update API
  /**
   * Inverts the colors of all map elements (e.g. legendItems, backbone, background).
   */
  invertColors() {
    this.settings.update({backgroundColor: this.settings.backgroundColor.invert().rgbaString});

    this.legend.invertColors();
    this.captions().each( (i, caption) => caption.invertColors() );
    this.refreshCanvasLayer();
    this.ruler.invertColors();
    this.dividers.invertColors();
    this.backbone.invertColors();
    this.sequence.invertColors();
    this.annotation.invertColors();
    this.draw();
  }

  /**
   * See [Events.on()](Events.html#on) 
   */
  on(event, callback) {
    this.events.on(event, callback);
  }

  /**
   * See [Events.off()](Events.html#off) 
   */
  off(event, callback) {
    this.events.off(event, callback);
  }

  /**
   * See [Events.trigger()](Events.html#trigger) 
   */
  trigger(event, object) {
    this.events.trigger(event, object);
    // Almost all events will results in data changing with the following exceptions
    const eventsToIgnoreForDataChange = ['viewer-update', 'cgv-json-load', 'bookmarks-shortcut', 'zoom-start', 'zoom', 'zoom-end'];
    if (!this.loading && !eventsToIgnoreForDataChange.includes(event)) {
      // console.log(event, object)
      // Also need to ignore track-update with loadProgress
      // const attributeKeys = object && object.attributes && Object.keys(object.attributes);
      // if ( !(attributeKeys && attributeKeys.length === 1 && attributeKeys[0] === 'loadProgress')) {
      //   this.update({dataHasChanged: true});
      // }
      // Special conditions where we do not want to say dataHasChanged
      // Ignore track-update with loadProgress
      const attributeKeys = object && object.attributes && Object.keys(object.attributes);
      if ( attributeKeys && attributeKeys.length === 1 && attributeKeys[0] === 'loadProgress') {
        // console.log('Skip loadProgress')
        return;
      }
      // Ignore plot-add with SequenceExtracted plots
      if (event === 'plots-add') {
        const plots = object;
        if (plots.every( p => p.extractedFromSequence) ) {
          // console.log('Skip Extracted Plot')
          return;
        }
      }
      // Ignore features-add with SequenceExtracted features
      if (event === 'features-add') {
        const features = object;
        if (features.every( f => f.extractedFromSequence) ) {
          // console.log('Skip Extracted Features')
          return;
        }
      }
      if (event === 'tracks-update') {
        const attributes = object && object.attributes;
        if (attributes === undefined) {
          // console.log('Skip track update with no attributes')
          return;
        }
      }
      this.update({dataHasChanged: true});
    }
  }

}

export default Viewer;