Source: sceneplotview3d.js

define([
    'three',
    'orbitcontrols',
    'draw'
], function(THREE, OrbitControls, draw) {
  /** @private */
  var makeLine = draw.makeLine;
  /** @private */
  var makeLabel = draw.makeLabel;

  /**
   *
   * @class ScenePlotView3D
   *
   * Represents a three dimensional scene in THREE.js.
   *
   * @param {THREE.renderer} renderer THREE renderer object.
   * @param {Object} decViews dictionary of DecompositionViews shown in this
   * scene
   * @param {Node} container Div where the scene will be rendered.
   * @param {Float} xView Horizontal position of the rendered scene in the
   * container element.
   * @param {Float} yView Vertical position of the rendered scene in the
   * container element.
   * @param {Float} width The width of the renderer
   * @param {Float} height The height of the renderer
   *
   * @return {ScenePlotView3D} An instance of ScenePlotView3D.
   * @constructs ScenePlotView3D
   */
   function ScenePlotView3D(renderer, decViews, container, xView, yView,
                            width, height) {
    var scope = this;

    // convert to jquery object for consistency with the rest of the objects
    var $container = $(container);
    this.decViews = decViews;
    this.renderer = renderer;
    /**
     * Horizontal position of the scene.
     * @type {Float}
     */
    this.xView = xView;
    /**
     * Vertical position of the scene.
     * @type {Float}
     */
    this.yView = yView;
    /**
     * Width of the scene.
     * @type {Float}
     */
    this.width = width;
    /**
     * Height of the scene.
     * @type {Float}
     */
    this.height = height;
    /**
     * Axes color.
     * @type {integer}
     * @default 0xFFFFFF (white)
     */
    this.axesColor = 0xFFFFFF;
    /**
     * Background color.
     * @type {integer}
     * @default 0x000000 (black)
     */
    this.backgroundColor = 0x000000;

    // used to name the axis lines/labels in the scene
    this._axisPrefix = 'emperor-axis-line-';
    this._axisLabelPrefix = 'emperor-axis-label-';

    // Set up the camera
    var max = _.max(decViews.scatter.decomp.dimensionRanges.max);
    var frontFrust = _.min([max * 0.001, 1]);
    var backFrust = _.max([max * 100, 100]);
    /**
     * Camera used to display the scene.
     * @type {THREE.PerspectiveCamera}
     */
    this.camera = new THREE.PerspectiveCamera(35, width / height,
                                              frontFrust, backFrust);
    this.camera.position.set(0, 0, max * 5);

    //need to initialize the scene
    this.scene = new THREE.Scene();
    this.scene.add(this.camera);
    /**
     * Object used to light the scene, by default is set to a light and
     * transparent color (0x99999999).
     * @type {THREE.DirectionalLight}
     */
    this.light = new THREE.DirectionalLight(0x999999, 2);
    this.light.position.set(1, 1, 1).normalize();
    this.camera.add(this.light);

    // Add all the meshes to the scene, iterate through all keys in
    // decomposition view dictionary
    for (var decViewName in this.decViews) {
      for (var j = 0; j < this.decViews[decViewName].markers.length; j++) {
        this.scene.add(this.decViews[decViewName].markers[j]);
      }
    }

    // use get(0) to retrieve the native DOM object
    /**
     * Object used to interact with the scene. By default it uses the mouse.
     * @type {THREE.OrbitControls}
     */
    this.control = new THREE.OrbitControls(this.camera,
                                           $container.get(0));
    this.control.addEventListener('change', function() {
      scope.needsUpdate = true;
    });
    this.control.rotateSpeed = 1.0;
    this.control.zoomSpeed = 1.2;
    this.control.panSpeed = 0.8;
    this.control.enableZoom = true;
    this.control.enablePan = true;
    this.control.enableDamping = true;
    this.control.dampingFactor = 0.3;
    /**
     * True when changes have occured that require re-rendering of the canvas
     * @type {Boolean}
     */
    this.needsUpdate = true;
    /**
     * Array of integers indicating the index of the visible dimension at each
     * axis ([x, y, z]).
     * @type {Integer[]}
     */
    this.visibleDimensions = [0, 1, 2];
    /**
     * Object with "min" and "max" attributes each of which is an array with
     * the ranges that covers all of the decomposition views.
     * @type {Object}
     */
    this.dimensionRanges = {'max': [], 'min': []};
    this.drawAxesWithColor(0xFFFFFF);
    this.drawAxesLabelsWithColor(0xFFFFFF);

    this._raycaster = new THREE.Raycaster();
    this._mouse = new THREE.Vector2();

    // initialize subscribers for event callbacks
    /**
     * Events allowed for callbacks. DO NOT EDIT.
     * @type {String[]}
     */
    this.EVENTS = ['click', 'dblclick'];
    /** @private */
    this._subscribers = {};

    for (var i = 0; i < this.EVENTS.length; i++) {
      this._subscribers[this.EVENTS[i]] = [];
    }

    // Add callback call when sample is clicked
    // Double and single click together from: http://stackoverflow.com/a/7845282
    var DELAY = 200, clicks = 0, timer = null;
    $container.on('click', function(event) {
        clicks++;
        if (clicks === 1) {
            timer = setTimeout(function() {
                scope._eventCallback('click', event);
                clicks = 0;
            }, DELAY);
        }
        else {
            clearTimeout(timer);
            scope._eventCallback('dblclick', event);
            clicks = 0;
        }
    })
    .on('dblclick', function(event) {
        event.preventDefault();  //cancel system double-click event
    });

    //Add info div as bottom of canvas
    this.$info = $('<div>');
    this.$info.css('position', 'absolute')
      .css('bottom', 0)
      .css('height', 16)
      .css('width', '50%')
      .css('padding-left', 10)
      .css('padding-right', 10)
      .css('font-size', 12)
      .css('z-index', 10000)
      .css('background-color', 'rgb(238, 238, 238)')
      .css('border', '1px solid black')
      .css('font-family', 'Verdana,Arial,sans-serif')
      .hide();
    $(this.renderer.domElement).parent().append(this.$info);

    // register callback for populating info with clicked sample name
    // set the timeout for fading out the info div
    var infoDuration = 2500;
    var infoTimeout = setTimeout(function() {
        scope.$info.fadeOut();
      }, infoDuration);

    this.on('click', function(n, i) {
      clearTimeout(infoTimeout);
      scope.$info.text(n);
      scope.$info.show();

      // reset the timeout for fading out the info div
      infoTimeout = setTimeout(function() {
        scope.$info.fadeOut();
        scope.$info.text('');
      }, infoDuration);
    });
  };

  /**
   *
   * Utility method to find the union of the ranges in the decomposition views
   * this method will populate the dimensionRanges attributes.
   * @private
   *
   */
  ScenePlotView3D.prototype._unionRanges = function() {
    var scope = this;

    // means we already have the data, so let's say goodbye
    if (this.dimensionRanges.max.length !== 0) {
      return;
    }

    _.each(this.decViews, function(decView, name) {
      // get each decomposition object
      var decomp = decView.decomp;

      if (scope.dimensionRanges.max.length === 0) {
        scope.dimensionRanges.max = decomp.dimensionRanges.max.slice();
        scope.dimensionRanges.min = decomp.dimensionRanges.min.slice();
      }
      else {
        // when we have more than one decomposition view we need to figure out
        // the absolute largest range that views span over
        _.each(decomp.dimensionRanges.max, function(value, index) {
          var vMax = decomp.dimensionRanges.max[index],
              vMin = decomp.dimensionRanges.min[index];

          if (vMax > scope.dimensionRanges.max[index]) {
            scope.dimensionRanges.max[index] = vMax;
          }
          if (vMin < scope.dimensionRanges.min[index]) {
            scope.dimensionRanges.min[index] = vMin;
          }
        });
      }
    });
  };
  /**
   *
   * Helper method used to iterate over the ranges of the visible dimensions.
   *
   * This function that centralizes the pattern followed by drawAxesWithColor
   * and drawAxesLabelsWithColor.
   *
   * @param {Function} action a function that can take up to three arguments
   * "start", "end" and "index".  And for each visible dimension the function
   * will get the "start" and "end" of the range, and the current "index" of the
   * visible dimension.
   * @private
   *
   */
  ScenePlotView3D.prototype._dimensionsIterator = function(action) {
    this._unionRanges();

    // shortcut to the index of the visible dimension and the range object
    var x = this.visibleDimensions[0], y = this.visibleDimensions[1],
        z = this.visibleDimensions[2], range = this.dimensionRanges;

    // this is the "origin" of our ordination
    var start = [range.min[x], range.min[y], range.min[z]];

    var ends = [
      [range.max[x], range.min[y], range.min[z]],
      [range.min[x], range.max[y], range.min[z]],
      [range.min[x], range.min[y], range.max[z]]
    ];

    action(start, ends[0], x);
    action(start, ends[1], y);
    action(start, ends[2], z);
  };

  /**
   *
   * Draw the axes lines in the plot
   *
   * @param {Integer} color An integer in hexadecimal that specifies the color
   * of each of the axes lines, the length of these lines is determined by the
   * dimensionRanges property.
   *
   */
  ScenePlotView3D.prototype.drawAxesWithColor = function(color) {
    var scope = this, axisLine;

    this.removeAxes();

    this._dimensionsIterator(function(start, end, index) {
      axisLine = makeLine(start, end, color, 3, false);
      axisLine.name = scope._axisPrefix + index;

      scope.scene.add(axisLine);
    });
  };

  /**
   *
   * Draw the axes labels for each visible dimension.
   *
   * The text in the labels is determined using the percentage explained by
   * each dimension and the abbreviated name of a single decomposition object.
   * Note that we arbitrarily use the first one, as all decomposition objects
   * presented in the same scene should have the same percentages explained by
   * each axis.
   *
   * @param {Integer} color An integer in hexadecimal that specifies the color
   * of the labels, these labels will be positioned at the end of the axes line.
   *
   */
  ScenePlotView3D.prototype.drawAxesLabelsWithColor = function(color) {
    var scope = this, axisLabel, decomp, firstKey, text, factor;

    factor = (this.dimensionRanges.max[0] - this.dimensionRanges.min[0]) * 0.9;

    this.removeAxesLabels();

    // get the first decomposition object, it doesn't really mater which one
    // we look at though, as all of them should have the same percentage
    // explained on each axis
    firstKey = _.keys(this.decViews)[0];
    decomp = this.decViews[firstKey].decomp;

    this._dimensionsIterator(function(start, end, index) {

      // construct a label of the format: AbbNam (xx.xx %)
      if (decomp.abbreviatedName !== '') {
        text = decomp.abbreviatedName;
      }
      else {
        // when the labels get too long, it's a bit hard to look at
        if (decomp.axesNames[index].length > 25) {
          text = decomp.axesNames[index].slice(0, 20) + '...';
        }
        else {
          text = decomp.axesNames[index];
        }
      }
      text += ' (' + decomp.percExpl[index].toPrecision(4) + ' %)';

      axisLabel = makeLabel(end, text, color, factor);
      axisLabel.name = scope._axisLabelPrefix + index;

      scope.scene.add(axisLabel);
    });
  };

  /**
   *
   * Helper method to remove objects that match a prefix from the view's scene
   * this method is used by removeAxes and removeAxesLabels. This function
   * iterates "num" times, and for each iteration it finds and removes objects
   * with the name of the form "prefix" + "iteration".
   *
   * @param {String} prefix The label that will prepended to the iterating
   * index.
   *
   */
  ScenePlotView3D.prototype._removeObjectsWithPrefix = function(prefix) {
    var scope = this;
    _.each(this.visibleDimensions, function(i) {
      var axisLine = scope.scene.getObjectByName(prefix + i);
      scope.scene.remove(axisLine);
    });
  };

  /**
   *
   * Helper method to remove the axis lines from the scene
   *
   */
  ScenePlotView3D.prototype.removeAxes = function() {
    this._removeObjectsWithPrefix(this._axisPrefix);
  };

  /**
   *
   * Helper method to remove the axis labels from the scene
   *
   */
  ScenePlotView3D.prototype.removeAxesLabels = function() {
    this._removeObjectsWithPrefix(this._axisLabelPrefix);
  };

  /**
   *
   * Sets the aspect ratio of the camera according to the current size of the
   * scene.
   *
   * @param {Float} winAspect ratio of width to height of the scene.
   *
   */
  ScenePlotView3D.prototype.setCameraAspectRatio = function(winAspect) {
    this.camera.aspect = winAspect;
    this.camera.updateProjectionMatrix();
  };

  /**
   *
   * Resizes and relocates the scene.
   *
   * @param {Float} xView New horizontal location.
   * @param {Float} yView New vertical location.
   * @param {Float} width New scene width.
   * @param {Float} height New scene height.
   *
   */
  ScenePlotView3D.prototype.resize = function(xView, yView, width, height) {
    this.xView = xView;
    this.yView = yView;
    this.width = width;
    this.height = height;
    this.setCameraAspectRatio(width / height);
    this.needsUpdate = true;
  };

  /**
   *
   * Convenience method to check if this or any of the decViews under this need
   * rendering
   *
   */
   ScenePlotView3D.prototype.checkUpdate = function() {
    var updateDimensions = false, updateColors = false,
        currentDimensions, backgroundColor, axesColor;

    // check if any of the decomposition views have changed
    var updateData = _.any(this.decViews, function(dv) {
      // note that we may be overwriting these variables, but we have a
      // guarantee that if one of them changes for one of decomposition views,
      // all of them will have changed, so grabbing one should be sufficient to
      // perform the comparisons below
      currentDimensions = dv.visibleDimensions;
      backgroundColor = dv.backgroundColor;
      axesColor = dv.axesColor;

      return dv.needsUpdate;
    });

    // check if the visible dimensions have changed
    if (!_.isEqual(currentDimensions, this.visibleDimensions)) {
      // remove the current axes
      this.removeAxes();
      this.removeAxesLabels();

      // get the new dimensions and re-display the data
      this.visibleDimensions = _.clone(currentDimensions);
      this.drawAxesWithColor(this.axesColor);
      this.drawAxesLabelsWithColor(this.axesColor);

      updateDimensions = true;
    }

    // check if we should change the axes color
    if (axesColor !== this.axesColor) {
      this.drawAxesWithColor(axesColor);
      this.drawAxesLabelsWithColor(axesColor);

      this.axesColor = _.clone(axesColor);

      updateColors = true;
    }

    // check if we should change the background color
    if (backgroundColor !== this.backgroundColor) {
      this.renderer.setClearColor(new THREE.Color(backgroundColor));
      this.backgroundColor = _.clone(backgroundColor);

      updateColors = true;
    }

    // if anything has changed, then trigger an update
    return this.needsUpdate || updateData || updateDimensions || updateColors;
   };

  /**
   *
   * Convenience method to re-render the contents of the scene.
   *
   */
  ScenePlotView3D.prototype.render = function() {
    this.renderer.setViewport(this.xView, this.yView, this.width, this.height);
    this.renderer.render(this.scene, this.camera);
    var camera = this.camera;
    //point all samples towards the camera
    _.each(this.decViews.scatter.markers, function(element) {
      element.quaternion.copy(camera.quaternion);
    });
    this.needsUpdate = false;
    $.each(this.decViews, function(key, val) {
      val.needsUpdate = false;
    });
  };

  /**
   *
   * Helper method that runs functions subscribed to the container's callbacks.
   * @param {String} eventType Event type being called
   * @param {event} event The event from jQuery, with x and y click coords
   * @private
   *
   */
  ScenePlotView3D.prototype._eventCallback = function(eventType, event) {
    event.preventDefault();
    // don't do anything if no subscribers
    if (this._subscribers[eventType].length === 0) {
      return;
    }

    var element = this.renderer.domElement;
    var offset = $(element).offset();
    this._mouse.x = ((event.clientX - offset.left) / element.width) * 2 - 1;
    this._mouse.y = -((event.clientY - offset.top) / element.height) * 2 + 1;

    this._raycaster.setFromCamera(this._mouse, this.camera);

    var intersects = this._raycaster.intersectObjects(
      this.decViews.scatter.markers);

    // Get first intersected item and call callback with it.
    if (intersects.length > 0) {
      var intersect = intersects[0].object;

      for (var i = 0; i < this._subscribers[eventType].length; i++) {
        // keep going if one of the callbacks fails
        try {
          this._subscribers[eventType][i](intersect.name, intersect);
        } catch (e) {
          console.error(e);
        }
        this.needsUpdate = true;
      }
    }
  };

  /**
   *
   * Interface to subscribe to event types in the canvas, see the EVENTS
   * property.
   *
   * @param {String} eventType The type of event to subscribe to.
   * @param {Function} handler Function to call when `eventType` is triggered,
   * receives two parameters, a string with the name of the object, and the
   * object itself i.e. f(objectName, object).
   *
   * @throws {Error} If the given eventType is unknown.
   *
   */
  ScenePlotView3D.prototype.on = function(eventType, handler) {
    if (this.EVENTS.indexOf(eventType) === -1) {
      throw new Error('Unknown event ' + eventType + '. Known events are: ' +
                      this.EVENTS.join(', '));
    }

    this._subscribers[eventType].push(handler);
  };

  /**
   *
   * Interface to unsubscribe a function from an event type, see the EVENTS
   * property.
   *
   * @param {String} eventType The type of event to unsubscribe from.
   * @param {Function} handler Function to remove from the subscribers list.
   *
   * @throws {Error} If the given eventType is unknown.
   *
   */
  ScenePlotView3D.prototype.off = function(eventType, handler) {
    if (this.EVENTS.indexOf(eventType) === -1) {
      throw new Error('Unknown event ' + eventType + '. Known events are ' +
                      this.EVENTS.join(', '));
    }

    var pos = this._subscribers[eventType].indexOf(handler);
    if (pos !== -1) {
      this._subscribers[eventType].splice(pos, 1);
    }
  };

  return ScenePlotView3D;
});