Source: EventMonitor.js

//////////////////////////////////////////////////////////////////////////////
// EventMonitor
//////////////////////////////////////////////////////////////////////////////

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

/**
 * EventMonitor sets up mouse click and movement event handlers on the CGView canvas.
 *
 * CGView event contents (based on mouse position):
 *
 * Property   | Description
 * -----------|-----------------------------------------------
 *  bp        | Base pair
 *  centerOffset | Distance from center of the map. For a circular map, this is the radius, while for a linear map, it's the distance from the backbone.
 *  elementType | One of: 'legendItem', 'caption', 'feature', 'plot', 'backbone', 'contig', 'label', or undefined
 *  element   | The element (e.g, a feature), if there is one.
 *  slot      | Slot (if there is one). Track can be accessed from the slot (<em>slot.track</em>).
 *  score     | Score for element (e.g. feature, plot), if available.
 *  canvasX   | Position on the canvas X axis, where the origin is the top-left. See [scales](../tutorials/details-map-scales.html) for details.
 *  canvasY   | Position on the canvas Y axis, where the origin is the top-left. See [scales](../tutorials/details-map-scales.html) for details.
 *  mapX      | Position on the map domain X axis, where the origin is the center of the map. See [scales](../tutorials/details-map-scales.html) for details.
 *  mapY      | Position on the map domain Y axis, where the origin is the center of the map. See [scales](../tutorials/details-map-scales.html) for details.
 *  d3        | The d3 event object.
 *
 * ### Examples
 * ```js
 * // Log the feature name when clicked
 * cgv.on('click', (event) => {
 *   if (event.elementType === 'feature') {
 *     console.log(`Feature '${event.element.name}' was clicked`);
 *   }
 * });
 *
 * // Log the base pair position of the mouse as it moves
 * cgv.on('mousemove', (event) => {
 *   console.log(`BP: ${event.bp}`);
 * });
 * ```
 */
class EventMonitor {

  /**
   * Adds event handlers for mouse clicks and movement
   */
  // NOTE: - a mouse property will be updated with every mouse move
  //       - This will be aliased to Viewer.mouse
  //       - Eventually add to API but for now private
  constructor(viewer) {
    this._viewer = viewer;

    // Setup Events on the viewer
    this.events = viewer.events;

    this._initializeMousemove();
    this._initializeClick();
    this._initializeBookmarkShortcuts();
    // this.events.on('mousemove', (e) => {console.log(e.bp)})
    // this.events.on('click', (e) => {console.log(e);});
    // MoveTo On click
    // this.events.on('click', (e) => {
    //   if (e.feature) {
    //     this.viewer.moveTo(e.feature.start, e.feature.stop);
    //   }
    // })

    this.events.on('mousemove', (e) => {
      if (this.viewer.debug && this.viewer.debug.data.position) {
        this.viewer.debug.data.position.xy = `${Math.round(e.mapX)}, ${Math.round(e.mapY)}`;
        this.viewer.debug.data.position.bp = utils.commaNumber(e.bp);
        this.viewer.debug.data.position.element = e.element && e.element.name;
        this.viewer.debug.data.position.score = e.score;
        this.viewer.debug.draw();
      }
    });

    this._legendSwatchClick();
    this._legendSwatchMouseOver();
    // this._highlighterMouseOver();
  }

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

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

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

  /**
   * Initialize mouse move events under 'cgv' namespace.
   * @private
   */
  _initializeMousemove() {
    const viewer = this.viewer;
    d3.select(this.canvas.node('ui')).on('mousemove.cgv', (d3Event) => {
      const event = this._createEvent(d3Event);
      this._mouse = event;
      viewer.clear('ui');
      this.events.trigger('mousemove', event);
      // this.events.trigger('mousemove', this._createEvent(d3Event));
    });
  }

  /**
   * Initialize clicks events under 'cgv' namespace.
   * @private
   */
  _initializeClick() {
    d3.select(this.canvas.node('ui')).on('click.cgv', (d3Event) => {
      // If the canvas is clicked, stop any animations
      this.viewer.stopAnimate();
      this.events.trigger('click', this._createEvent(d3Event));
    });
  }

  // FIXME: need to be able to turn this off
  // FIXME: there should be an option to turn this off, if it interferes with other program UI
  _initializeBookmarkShortcuts() {
    const ignoredTagsRegex = /^(input|textarea|select|button)$/i;
    document.addEventListener('keypress', (e) => {
      if (ignoredTagsRegex.test(e.target.tagName)) { return; }
      if (e.target.isContentEditable) { return; }
      const bookmark = this.viewer.bookmarkByShortcut(e.key);
      if (bookmark) {
        bookmark.moveTo();
        this.viewer.trigger('bookmarks-shortcut', bookmark);
      }
    });
  }

  /**
   * Create an event object that will be return on mouse clicks and movement
   * @param {Object} d3Event - a d3 event object
   * @private
   */
  _createEvent(d3Event) {
    if (this.viewer.loading) { return {}; }
    const scale = this.viewer.layout.scale;
    const canvasX = d3Event.offsetX;
    const canvasY = d3Event.offsetY;
    const mapX = scale.x.invert(canvasX);
    const mapY = scale.y.invert(canvasY);
    const centerOffset = this.viewer.layout.centerOffsetForPoint({x: canvasX, y: canvasY});
    const slot = this.viewer.layout.slotForCenterOffset(centerOffset);
    const bp = this.canvas.bpForPoint({x: canvasX, y: canvasY});

    const {elementType, element} = this._getElement(slot, bp, centerOffset, canvasX, canvasY);

    let score;
    if (elementType === 'plot') {
      score = element.scoreForPosition(bp).toFixed(2)
    } else {
      score = element && element.score;
    }

    return {
      bp: bp,
      centerOffset: centerOffset,
      slot: slot,
      elementType: elementType,
      element: element,
      score: score,
      canvasX: canvasX,
      canvasY: canvasY,
      mapX: mapX,
      mapY: mapY,
      d3: d3Event
    };
  }

  /**
   * Returns an object with the *element* and *elementType* for the given *slot*, *bp*, and *centerOffset*.
   * ElementType can be one of the following: 'plot', 'feature', 'label', 'legendItem', 'captionItem', 'contig', 'backbone'
   * @param {Slot}  slot - the slot for the event.
   * @param {Number}  bp - the bp for the event.
   * @param {Number}  centerOffset - the centerOffset for the event.
   *
   * @returns {Object} Obejct with properties: element and elementType
   * @private
   */
  _getElement(slot, bp, centerOffset, canvasX, canvasY) {
    let elementType, element;

    // Check Legend
    const legend = this.viewer.legend;
    if (legend.visible && legend.box.containsPt(canvasX, canvasY)) {
      for (let i = 0, len = legend.items().length; i < len; i++) {
        const item = legend.items()[i];
        if (item._textContainsPoint({x: canvasX, y: canvasY})) {
          elementType = 'legendItem';
          element = item;
        }
      }
    }

    // Check Captions
    if (!elementType) {
      const captions = this.viewer.captions();
      for (let i = 0, len = captions.length; i < len; i++) {
        const caption = captions[i];
        if (caption.visible && caption.box.containsPt(canvasX, canvasY)) {
          elementType = 'caption';
          element = caption;
        }
      }
    }

    // Check for feature or plot
    if (!elementType && slot) {
      // If mulitple features are returned, go with the smallest one
      const features = slot.findFeaturesForBp(bp);
      let feature;
      for (let i = 0, len = features.length; i < len; i++) {
        const currentFeature = features[i];
        if (currentFeature.visible) {
          if (!feature || (currentFeature.length < feature.length)) {
            feature = currentFeature;
          }
        }
      }
      if (feature && feature.visible) {
        elementType = 'feature';
        element = feature;
      } else if (slot._plot) {
        elementType = 'plot';
        element = slot._plot;
      }
    }

    // Check for Backbone or Contig
    if (!elementType && this.viewer.backbone.visible && this.viewer.backbone.containsCenterOffset(centerOffset)) {
      const backbone = this.viewer.backbone;
      const sequence = this.viewer.sequence;
      if (sequence.hasMultipleContigs) {
        elementType = 'contig';
        element = sequence.contigForBp(bp);
      } else {
        elementType = 'backbone';
        element = backbone;
      }
    }

    // Check for Labels
    if (!elementType && this.viewer.annotation.visible) {
      const labels = this.viewer.annotation._visibleLabels;
      for (let i = 0, len = labels.length; i < len; i++) {
        const label = labels[i];
        if (label.rect.containsPt(canvasX, canvasY) && label.feature.visible) {
          elementType = 'label';
          element = label;
        }
      }
    }

    return {elementType, element};
  }

  _legendSwatchClick() {
    const viewer = this.viewer;
    this.events.on('click.swatch', (e) => {
      const legend = viewer.legend;
      if (!legend.visible) return;
      const swatchedLegendItems = legend.visibleItems();
      for (let i = 0, len = swatchedLegendItems.length; i < len; i++) {
        if ( swatchedLegendItems[i]._swatchContainsPoint( {x: e.canvasX, y: e.canvasY} ) ) {
          const legendItem = swatchedLegendItems[i];
          legendItem.swatchSelected = true;
          const cp = viewer.colorPicker;
          if (!cp.visible) {
            legend.setColorPickerPosition(cp);
          }
          cp.onChange = function(color) {
            // legendItem.swatchColor = color.rgbaString;
            legendItem.update({swatchColor: color.rgbaString});
            viewer.drawFast();
            // viewer.trigger('legend-swatch-change', legendItem);
          };
          cp.onClose = function() {
            legendItem.swatchSelected = false;
            viewer.drawFull();
            legend.draw();
          };
          cp.setColor(legendItem._swatchColor.rgba);
          cp.open(legendItem);
          break;
        }
      }
    });
  }

  _legendSwatchMouseOver() {
    const viewer = this.viewer;
    this.events.on('mousemove.swatch', (e) => {
      const legend = viewer.legend;
      if (!legend.visible) return;
      const swatchedLegendItems = legend.visibleItems();
      const oldHighlightedItem = legend.highlightedSwatchedItem;
      legend.highlightedSwatchedItem = undefined;
      for (let i = 0, len = swatchedLegendItems.length; i < len; i++) {
        if ( swatchedLegendItems[i]._swatchContainsPoint( {x: e.canvasX, y: e.canvasY} ) ) {
          const legendItem = swatchedLegendItems[i];
          legendItem.swatchHighlighted = true;
          this.canvas.cursor = 'pointer';
          legend.draw();
          break;
        }
      }
      // No swatch selected
      if (oldHighlightedItem && !legend.highlightedSwatchedItem) {
        this.canvas.cursor = 'auto';
        legend.draw();
      }
    });
  }

}

export default EventMonitor;