      1 define([
      2     'three',
      3     'orbitcontrols',
      4     'draw',
      5     'underscore',
      6     'selectionbox',
      7     'selectionhelper'
      8 ], function(THREE, OrbitControls, draw, _, SelectionBox, SelectionHelper) {
      9   /** @private */
     10   var makeLine = draw.makeLine;
     11   /** @private */
     12   var makeLabel = draw.makeLabel;
     13
     14   /**
     15    *
     16    * @class ScenePlotView3D
     17    *
     18    * Represents a three dimensional scene in THREE.js.
     19    *
     20    * @param {UIState} uiState shared UIState state object
     21    * @param {THREE.renderer} renderer THREE renderer object.
     22    * @param {Object} decViews dictionary of DecompositionViews shown in this
     23    * scene
     24    * @param {MultiModel} decModels MultiModel of DecompositionModels shown in
     25    * this scene (with extra global data about them)
     26    * @param {Node} container Div where the scene will be rendered.
     27    * @param {Float} xView Horizontal position of the rendered scene in the
     28    * container element.
     29    * @param {Float} yView Vertical position of the rendered scene in the
     30    * container element.
     31    * @param {Float} width The width of the renderer
     32    * @param {Float} height The height of the renderer
     33    *
     34    * @return {ScenePlotView3D} An instance of ScenePlotView3D.
     35    * @constructs ScenePlotView3D
     36    */
     37   function ScenePlotView3D(uiState, renderer, decViews, decModels, container,
     38                            xView, yView, width, height) {
     39     var scope = this;
     40
     41     this.UIState = uiState;
     42
     43     // convert to jquery object for consistency with the rest of the objects
     44     var $container = $(container);
     45     this.decViews = decViews;
     46     this.decModels = decModels;
     47     this.renderer = renderer;
     48     /**
     49      * Horizontal position of the scene.
     50      * @type {Float}
     51      */
     52     this.xView = xView;
     53     /**
     54      * Vertical position of the scene.
     55      * @type {Float}
     56      */
     57     this.yView = yView;
     58     /**
     59      * Width of the scene.
     60      * @type {Float}
     61      */
     62     this.width = width;
     63     /**
     64      * Height of the scene.
     65      * @type {Float}
     66      */
     67     this.height = height;
     68     /**
     69      * Axes color.
     70      * @type {String}
     71      * @default '#FFFFFF' (white)
     72      */
     73     this.axesColor = '#FFFFFF';
     74     /**
     75      * Background color.
     76      * @type {String}
     77      * @default '#000000' (black)
     78      */
     79     this.backgroundColor = '#000000';
     80     /**
     81      * True when changes have occured that require re-rendering of the canvas
     82      * @type {Boolean}
     83      */
     84     this.needsUpdate = true;
     85     /**
     86      * Array of integers indicating the index of the visible dimension at each
     87      * axis ([x, y, z]).
     88      * @type {Integer[]}
     89      */
     90     this.visibleDimensions = _.clone(this.decViews.scatter.visibleDimensions);
     91
     92     // used to name the axis lines/labels in the scene
     93     this._axisPrefix = 'emperor-axis-line-';
     94     this._axisLabelPrefix = 'emperor-axis-label-';
     95
     96     //need to initialize the scene
     97     this.scene = new THREE.Scene();
     98     this.scene.background = new THREE.Color(this.backgroundColor);
     99
    100     /**
    101      * Camera used to display the scatter scene.
    102      * @type {THREE.OrthographicCamera}
    103      */
    104     this.scatterCam = this.buildCamera('scatter');
    105
    106     /**
    107      * Object used to light the scene in scatter mode,
    108      * by default is set to a light and
    109      * transparent color (0x99999999).
    110      * @type {THREE.DirectionalLight}
    111      */
    112     this.light = new THREE.DirectionalLight(0x999999, 2);
    113     this.light.position.set(1, 1, 1).normalize();
    114     this.scatterCam.add(this.light);
    115
    116     /**
    117      * Camera used to display the parallel plot scene.
    118      * @type {THREE.OrthographicCamera}
    119      */
    120     this.parallelCam = this.buildCamera('parallel-plot');
    121
    122     // use $container.get(0) to retrieve the native DOM object
    123     this.scatterController = this.buildCamController('scatter',
    124                                                     this.scatterCam,
    125                                                     $container.get(0));
    126     this.parallelController = this.buildCamController('parallel-plot',
    127                                                      this.parallelCam,
    128                                                      $container.get(0));
    129     this.control = this.scatterController;
    130
    131     this.scene.add(this.scatterCam);
    132     this.scene.add(this.parallelCam);
    133
    134     this._raycaster = new THREE.Raycaster();
    135     this._mouse = new THREE.Vector2();
    136
    137     /**
    138      * Special purpose group for points that are selectable with the
    139      * SelectionBox.
    140      * @type {THREE.Group}
    141      * @private
    142      */
    143     this._selectable = new THREE.Group();
    144     this.scene.add(this._selectable);
    145
    146     /**
    147      * Object to compute bounding boxes from a selection area
    148      *
    149      * Selection is only enabled when the user is holding Shift.
    150      *
    151      * @type {THREE.SelectionBox}
    152      * @private
    153      */
    154     this._selectionBox = new THREE.SelectionBox(this.camera,
    155                                                 this._selectable);
    156
    157     /**
    158      * Helper to view the selection space when the user holds shift
    159      *
    160      * This object is disabled by default, and is only renabled when the user
    161      * holds the shift key.
    162      * @type {THREE.SelectionHelper}
    163      * @private
    164      */
    165     this._selectionHelper = new THREE.SelectionHelper(this._selectionBox,
    166                                                      renderer,
    167                                                      'emperor-selection-area');
    168     this._selectionHelper.enabled = false;
    169
    170     //Swap the camera whenever the view type changes
    171     this.UIState.registerProperty('view.viewType', function(evt) {
    172       if (evt.newVal === 'parallel-plot') {
    173         scope.camera = scope.parallelCam;
    174         scope.control = scope.parallelController;
    175         //Don't let the controller move around when its not the active camera
    176         scope.scatterController.enabled = false;
    177         scope.scatterController.autoRotate = false;
    178         scope.parallelController.enabled = true;
    179         scope._selectionBox.camera = scope.camera;
    180         scope._selectionBox.collection = [];
    181       } else {
    182         scope.camera = scope.scatterCam;
    183         scope.control = scope.scatterController;
    184         //Don't let the controller move around when its not the active camera
    185         scope.scatterController.enabled = true;
    186         scope.parallelController.enabled = false;
    187         scope._selectionBox.camera = scope.camera;
    188         scope._selectionBox.collection = [];
    189       }
    190     });
    191
    192     this.addDecompositionsToScene();
    193
    194     this.updateCameraTarget();
    195     this.control.update();
    196
    197     this.scatterController.addEventListener('change', function() {
    198       scope.needsUpdate = true;
    199     });
    200     this.parallelController.addEventListener('change', function() {
    201       scope.needsUpdate = true;
    202     });
    203
    204     /**
    205      * Object with "min" and "max" attributes each of which is an array with
    206      * the ranges that covers all of the decomposition views.
    207      * @type {Object}
    208      */
    209     this.drawAxesWithColor('#FFFFFF');
    210     this.drawAxesLabelsWithColor('#FFFFFF');
    211
    212     // initialize subscribers for event callbacks
    213     /**
    214      * Events allowed for callbacks. DO NOT EDIT.
    215      * @type {String[]}
    216      */
    217     this.EVENTS = ['click', 'dblclick', 'select'];
    218     /** @private */
    219     this._subscribers = {};
    220
    221     for (var i = 0; i < this.EVENTS.length; i++) {
    222       this._subscribers[this.EVENTS[i]] = [];
    223     }
    224
    225     // Add callback call when sample is clicked
    226     // Double and single click together from: http://stackoverflow.com/a/7845282
    227     var DELAY = 200, clicks = 0, timer = null;
    228     $container.on('mousedown', function(event) {
    229         clicks++;
    230         if (clicks === 1) {
    231             timer = setTimeout(function() {
    232                 scope._eventCallback('click', event);
    233                 clicks = 0;
    234             }, DELAY);
    235         }
    236         else {
    237             clearTimeout(timer);
    238             scope._eventCallback('dblclick', event);
    239             clicks = 0;
    240         }
    241     })
    242     .on('dblclick', function(event) {
    243         event.preventDefault();  //cancel system double-click event
    244     });
    245
    246     // setup the selectionBox and selectionHelper objects and callbacks
    247     this._addSelectionEvents($container);
    248
    249     this.control.update();
    250
    251     // register callback for populating info with clicked sample name
    252     // set the timeout for fading out the info div
    253     var infoDuration = 4000;
    254     var infoTimeout = setTimeout(function() {
    255         scope.$info.fadeOut();
    256       }, infoDuration);
    257
    258     /**
    259      *
    260      * The functions showText and copyToClipboard are used in the 'click',
    261      * 'dblclick', and 'select' events.
    262      *
    263      * When a sample is clicked we show a legend at the bottom left of the
    264      * view. If this legend is clicked, we copy the sample name to the
    265      * clipboard. When a sample is double-clicked we directly copy the sample
    266      * name to the clipboard and add the legend at the bottom left of the view.
    267      *
    268      * When samples are selected we show a message on the bottom left of the
    269      * view, and copy a comma-separated list of samples to the clipboard.
    270      *
    271      */
    272
    273     function showText(n, i) {
    274       clearTimeout(infoTimeout);
    275       scope.$info.text(n);
    276       scope.$info.show();
    277
    278       // reset the timeout for fading out the info div
    279       infoTimeout = setTimeout(function() {
    280         scope.$info.fadeOut();
    281         scope.$info.text('');
    282       }, infoDuration);
    283     }
    284
    285     function copyToClipboard(text) {
    286       var $temp = $('<input>');
    287
    288       // we need an input element to be able to copy to clipboard, taken from
    289       // https://codepen.io/shaikmaqsood/pen/XmydxJ/
    290       $('body').append($temp);
    291       $temp.val(text).select();
    292       document.execCommand('copy');
    293       $temp.remove();
    294     }
    295
    296     //Add info div as bottom of canvas
    297     this.$info = $('<div>').attr('title', 'Click to copy to clipboard');
    298     this.$info.css({'position': 'absolute',
    299                     'bottom': 0,
    300                     'height': 16,
    301                     'width': '50%',
    302                     'padding-left': 10,
    303                     'padding-right': 10,
    304                     'font-size': 12,
    305                     'z-index': 10000,
    306                     'background-color': 'rgb(238, 238, 238)',
    307                     'border': '1px solid black',
    308                     'font-family': 'Verdana,Arial,sans-serif'}).hide();
    309     this.$info.click(function() {
    310       var text = scope.$info.text();
    311
    312       // handle the case where multiple clicks are received
    313       text = text.replace(/\(copied to clipboard\) /g, '');
    314       copyToClipboard(text);
    315
    316       scope.$info.effect('highlight', {}, 500);
    317       scope.$info.text('(copied to clipboard) ' + text);
    318     });
    319     $(this.renderer.domElement).parent().append(this.$info);
    320
    321     // UI callbacks specific to emperor, not to be confused with DOM events
    322     this.on('click', showText);
    323     this.on('dblclick', function(n, i) {
    324       copyToClipboard(n);
    325       showText('(copied to clipboard) ' + n, i);
    326     });
    327     this.on('select', function(names, view) {
    328       if (names.length) {
    329         showText(names.length + ' samples copied to your clipboard.');
    330         copyToClipboard(names.join(','));
    331       }
    332     });
    333
    334     // if a decomposition uses a point cloud, or
    335     // if a decomposition uses a parallel plot,
    336     // update the default raycasting tolerance as
    337     // it is otherwise too large and error-prone
    338     var updateRaycasterLinePrecision = function(evt) {
    339       if (scope.UIState.getProperty('view.viewType') === 'parallel-plot')
    340         scope._raycaster.params.Line.threshold = 0.01;
    341       else
    342         scope._raycaster.params.Line.threshold = 1;
    343     };
    344     var updateRaycasterPointPrecision = function(evt) {
    345       if (scope.UIState.getProperty('view.usesPointCloud'))
    346         scope._raycaster.params.Points.threshold = 0.01;
    347       else
    348         scope._raycaster.params.Points.threshold = 1;
    349     };
    350     this.UIState.registerProperty('view.usesPointCloud',
    351                              updateRaycasterPointPrecision);
    352     this.UIState.registerProperty('view.viewType',
    353                              updateRaycasterLinePrecision);
    354   };
    355
    356   /**
    357    * Builds a camera (for scatter or parallel plot)
    358    */
    359   ScenePlotView3D.prototype.buildCamera = function(viewType) {
    360
    361     var camera;
    362     if (viewType === 'scatter')
    363     {
    364       // Set up the camera
    365       var max = _.max(this.decViews.scatter.decomp.dimensionRanges.max);
    366       var frontFrust = _.min([max * 0.001, 1]);
    367       var backFrust = _.max([max * 100, 100]);
    368
    369       // these are placeholders that are
    370       // later updated in updateCameraAspectRatio
    371       camera = new THREE.OrthographicCamera(-50, 50, 50, -50);
    372       camera.position.set(0, 0, max * 5);
    373       camera.zoom = 0.7;
    374     }
    375     else if (viewType === 'parallel-plot')
    376     {
    377       var w = this.decModels.dimensionRanges.max.length;
    378
    379       // Set up the camera
    380       camera = new THREE.OrthographicCamera(0, w, 1, 0);
    381       camera.position.set(0, 0, 1); //Must set positive Z because near > 0
    382       camera.zoom = 0.7;
    383     }
    384
    385     return camera;
    386   };
    387
    388   /**
    389    * Builds a camera controller (for scatter or parallel plot)
    390    */
    391   ScenePlotView3D.prototype.buildCamController = function(viewType, cam, view) {
    392     /**
    393      * Object used to interact with the scene. By default it uses the mouse.
    394      * @type {THREE.OrbitControls}
    395      */
    396     var control = new THREE.OrbitControls(cam, view);
    397     control.enableKeys = false;
    398     control.rotateSpeed = 1.0;
    399     control.zoomSpeed = 1.2;
    400     control.panSpeed = 0.8;
    401     control.enableZoom = true;
    402     control.enablePan = true;
    403
    404     // don't free panning and rotation for paralle plots
    405     control.screenSpacePanning = (viewType === 'scatter');
    406     control.enableRotate = (viewType === 'scatter');
    407
    408     return control;
    409   };
    410
    411   /**
    412    *
    413    * Adds all the decomposition views to the current scene.
    414    *
    415    */
    416   ScenePlotView3D.prototype.addDecompositionsToScene = function() {
    417     var j, marker, scaling = this.getScalingConstant();
    418
    419     // Note that the internal logic of the THREE.Scene object prevents the
    420     // objects from being re-added so we can simply iterate over all the
    421     // decomposition views.
    422
    423     // Add all the meshes to the scene, iterate through all keys in
    424     // decomposition view dictionary and put points in a separate group
    425     for (var decViewName in this.decViews) {
    426       var isArrowType = this.decViews[decViewName].decomp.isArrowType();
    427
    428       for (j = 0; j < this.decViews[decViewName].markers.length; j++) {
    429         marker = this.decViews[decViewName].markers[j];
    430
    431         // only arrows include text as part of their markers
    432         // arrows are not selectable
    433         if (isArrowType) {
    434           marker.label.scale.set(marker.label.scale.x * scaling,
    435                                  marker.label.scale.y * scaling, 1);
    436           this.scene.add(marker);
    437         }
    438         else {
    439           this._selectable.add(marker);
    440         }
    441       }
    442       for (j = 0; j < this.decViews[decViewName].ellipsoids.length; j++) {
    443         this.scene.add(this.decViews[decViewName].ellipsoids[j]);
    444       }
    445
    446       // if the left lines exist so will the right lines
    447       if (this.decViews[decViewName].lines.left) {
    448         this.scene.add(this.decViews[decViewName].lines.left);
    449         this.scene.add(this.decViews[decViewName].lines.right);
    450       }
    451     }
    452
    453     this.needsUpdate = true;
    454   };
    455
    456   /**
    457    * Calculate a scaling constant for the text in the scene.
    458    *
    459    * It is important that this factor is calculated based on all the elements
    460    * in a scene, and that it is the same for all the text elements in the
    461    * scene. Otherwise, some text will be bigger than other.
    462    *
    463    * @return {Number} The scaling factor to use for labels.
    464    */
    465   ScenePlotView3D.prototype.getScalingConstant = function() {
    466     return (this.decModels.dimensionRanges.max[0] -
    467             this.decModels.dimensionRanges.min[0]) * 0.001;
    468   };
    469
    470   /**
    471    *
    472    * Helper method used to iterate over the ranges of the visible dimensions.
    473    *
    474    * This function that centralizes the pattern followed by drawAxesWithColor
    475    * and drawAxesLabelsWithColor.
    476    *
    477    * @param {Function} action a function that can take up to three arguments
    478    * "start", "end" and "index".  And for each visible dimension the function
    479    * will get the "start" and "end" of the range, and the current "index" of the
    480    * visible dimension.
    481    * @private
    482    *
    483    */
    484   ScenePlotView3D.prototype._dimensionsIterator = function(action) {
    485
    486     this.decModels._unionRanges();
    487
    488     if (this.UIState['view.viewType'] === 'scatter')
    489     {
    490       // shortcut to the index of the visible dimension and the range object
    491       var x = this.visibleDimensions[0], y = this.visibleDimensions[1],
    492           z = this.visibleDimensions[2], range = this.decModels.dimensionRanges,
    493           is2D = (z === null || z === undefined);
    494
    495       // Adds a padding to all dimensions such that samples don't overlap
    496       // with the axes lines. Determined based on the default sphere radius
    497       var axesPadding = 1.07;
    498
    499       /*
    500        * We special case Z when it is a 2D plot, whenever that's the case we set
    501        * the range to be zero so no lines are shown on screen.
    502        */
    503
    504       // this is the "origin" of our ordination
    505       var start = [range.min[x] * axesPadding,
    506                    range.min[y] * axesPadding,
    507                    is2D ? 0 : range.min[z] * axesPadding];
    508
    509       var ends = [
    510         [range.max[x] * axesPadding,
    511          range.min[y] * axesPadding,
    512          is2D ? 0 : range.min[z] * axesPadding],
    513         [range.min[x] * axesPadding,
    514          range.max[y] * axesPadding,
    515          is2D ? 0 : range.min[z] * axesPadding],
    516         [range.min[x] * axesPadding,
    517          range.min[y] * axesPadding,
    518          is2D ? 0 : range.max[z] * axesPadding]
    519       ];
    520
    521       action(start, ends[0], x);
    522       action(start, ends[1], y);
    523
    524       // when transitioning to 2D disable rotation to avoid awkward angles
    525       if (is2D) {
    526         this.control.enableRotate = false;
    527       }
    528       else {
    529         action(start, ends[2], z);
    530         this.control.enableRotate = true;
    531       }
    532     }
    533     else {
    534       //Parallel Plots show all axes
    535       for (var i = 0; i < this.decViews['scatter'].decomp.dimensions; i++)
    536       {
    537         action([i, 0, 0], [i, 1, 0], i);
    538       }
    539     }
    540   };
    541
    542   /**
    543    *
    544    * Draw the axes lines in the plot
    545    *
    546    * @param {String} color A CSS-compatible value that specifies the color
    547    * of each of the axes lines, the length of these lines is determined by the
    548    * global dimensionRanges property computed in decModels.
    549    * If the color value is null the lines will be removed.
    550    *
    551    */
    552   ScenePlotView3D.prototype.drawAxesWithColor = function(color) {
    553     var scope = this, axisLine;
    554
    555     // axes lines are removed if the color is null
    556     this.removeAxes();
    557     if (color === null) {
    558       return;
    559     }
    560
    561     this._dimensionsIterator(function(start, end, index) {
    562       axisLine = makeLine(start, end, color, 3, false);
    563       axisLine.name = scope._axisPrefix + index;
    564
    565       scope.scene.add(axisLine);
    566     });
    567   };
    568
    569   /**
    570    *
    571    * Draw the axes labels for each visible dimension.
    572    *
    573    * The text in the labels is determined using the percentage explained by
    574    * each dimension and the abbreviated name of a single decomposition object.
    575    * Note that we arbitrarily use the first one, as all decomposition objects
    576    * presented in the same scene should have the same percentages explained by
    577    * each axis.
    578    *
    579    * @param {String} color A CSS-compatible value that specifies the color
    580    * of the labels, these labels will be positioned at the end of the axes
    581    * line. If the color value is null the labels will be removed.
    582    *
    583    */
    584   ScenePlotView3D.prototype.drawAxesLabelsWithColor = function(color) {
    585     var scope = this, axisLabel, decomp, firstKey, text, scaling;
    586     scaling = this.getScalingConstant();
    587
    588     // the labels are only removed if the color is null
    589     this.removeAxesLabels();
    590     if (color === null) {
    591       return;
    592     }
    593
    594     // get the first decomposition object, it doesn't really matter which one
    595     // we look at though, as all of them should have the same percentage
    596     // explained on each axis
    597     firstKey = _.keys(this.decViews)[0];
    598     decomp = this.decViews[firstKey].decomp;
    599
    600     this._dimensionsIterator(function(start, end, index) {
    601       text = decomp.axesLabels[index];
    602       axisLabel = makeLabel(end, text, color);
    603
    604       if (scope.UIState['view.viewType'] === 'scatter') {
    605         //Scatter has a 1 to 1 aspect ratio and labels in world size
    606         axisLabel.scale.set(axisLabel.scale.x * scaling,
    607                             axisLabel.scale.y * scaling,
    608                             1);
    609       }
    610       else if (scope.UIState['view.viewType'] === 'parallel-plot') {
    611         //Parallel plot aspect ratio depends on number of dimensions
    612         //We have to correct label size to account for this.
    613         //But we also have to fix label width so that it fits between
    614         //axes, which are exactly 1 apart in world space
    615         var cam = scope.camera;
    616         var labelWPix = axisLabel.scale.x;
    617         var labelHPix = axisLabel.scale.y;
    618         var viewWPix = scope.width;
    619         var viewHPix = scope.height;
    620
    621         //Assuming a camera zoom of 1:
    622         var viewWUnits = cam.right - cam.left;
    623         var viewHUnits = cam.top - cam.bottom;
    624
    625         //These are world sizes of label for a camera zoom of 1
    626         var labelWUnits = labelWPix * viewWUnits / viewWPix;
    627         var labelHUnits = labelHPix * viewHUnits / viewHPix;
    628
    629         //TODO FIXME HACK:  Note that our options here are to scale each
    630         //label to fit in its area, or to scale all labels by the same amount
    631         //We choose to scale all labels by the same amount based on an
    632         //empirical 'nice' label length of ~300
    633         //We could replace this with a max of all label widths, but must note
    634         //that label widths are always powers of 2 in the current version
    635
    636         //Resize to fit labels of width 300 between axes
    637         var scalingFudge = 0.9 / (300 * viewWUnits / viewWPix);
    638
    639         axisLabel.scale.set(labelWUnits * scalingFudge,
    640                             labelHUnits * scalingFudge,
    641                             1);
    642       }
    643
    644       axisLabel.name = scope._axisLabelPrefix + index;
    645       scope.scene.add(axisLabel);
    646     });
    647   };
    648
    649   /**
    650    *
    651    * Helper method to remove objects with some prefix from the view's scene
    652    *
    653    * @param {String} prefix The prefix of object names to remove
    654    *
    655    */
    656   ScenePlotView3D.prototype._removeObjectsWithPrefix = function(prefix) {
    657     var scope = this;
    658     var recursiveRemove = function(rootObj) {
    659       if (rootObj.name != null && rootObj.name.startsWith(prefix)) {
    660         scope.scene.remove(rootObj);
    661       }
    662       else {
    663         // We can't iterate the children array while removing from it,
    664         // So we make a shallow copy.
    665         var childCopy = Array.from(rootObj.children);
    666         for (var child in childCopy) {
    667           recursiveRemove(childCopy[child]);
    668         }
    669       }
    670     };
    671     recursiveRemove(this.scene);
    672   };
    673
    674   /**
    675    *
    676    * Helper method to remove the axis lines from the scene
    677    *
    678    */
    679   ScenePlotView3D.prototype.removeAxes = function() {
    680     this._removeObjectsWithPrefix(this._axisPrefix);
    681   };
    682
    683   /**
    684    *
    685    * Helper method to remove the axis labels from the scene
    686    *
    687    */
    688   ScenePlotView3D.prototype.removeAxesLabels = function() {
    689     this._removeObjectsWithPrefix(this._axisLabelPrefix);
    690   };
    691
    692   /**
    693    *
    694    * Resizes and relocates the scene.
    695    *
    696    * @param {Float} xView New horizontal location.
    697    * @param {Float} yView New vertical location.
    698    * @param {Float} width New scene width.
    699    * @param {Float} height New scene height.
    700    *
    701    */
    702   ScenePlotView3D.prototype.resize = function(xView, yView, width, height) {
    703     this.xView = xView;
    704     this.yView = yView;
    705     this.width = width;
    706     this.height = height;
    707
    708     this.updateCameraAspectRatio();
    709     this.control.update();
    710
    711     //Since parallel plot labels have to correct for aspect ratio, we need
    712     //to redraw when width/height of view is modified.
    713     this.drawAxesLabelsWithColor(this.axesColor);
    714
    715     this.needsUpdate = true;
    716   };
    717
    718   /**
    719    *
    720    * Resets the aspect ratio of the camera according to the current size of the
    721    * plot space.
    722    *
    723    */
    724   ScenePlotView3D.prototype.updateCameraAspectRatio = function() {
    725     if (this.UIState['view.viewType'] === 'scatter')
    726     {
    727       var x = this.visibleDimensions[0], y = this.visibleDimensions[1];
    728
    729       // orthographic cameras operate in space units not in pixel units i.e.
    730       // the width and height of the view is based on the objects not the window
    731       var owidth = this.decModels.dimensionRanges.max[x] -
    732                       this.decModels.dimensionRanges.min[x];
    733       var oheight = this.decModels.dimensionRanges.max[y] -
    734                       this.decModels.dimensionRanges.min[y];
    735
    736       var aspect = this.width / this.height;
    737
    738       // ensure that the camera's aspect ratio is equal to the window's
    739       owidth = oheight * aspect;
    740
    741       this.camera.left = -owidth / 2;
    742       this.camera.right = owidth / 2;
    743       this.camera.top = oheight / 2;
    744       this.camera.bottom = -oheight / 2;
    745
    746       this.camera.aspect = aspect;
    747       this.camera.updateProjectionMatrix();
    748     }
    749     else if (this.UIState['view.viewType'] === 'parallel-plot')
    750     {
    751       var w = this.decModels.dimensionRanges.max.length;
    752       this.camera.left = 0;
    753       this.camera.right = w;
    754       this.camera.top = 1;
    755       this.camera.bottom = 0;
    756       this.camera.updateProjectionMatrix();
    757     }
    758   };
    759
    760   /**
    761    * Updates the target and dimensions of the camera and control
    762    *
    763    * The target of the scene depends on the coordinate space of the data, by
    764    * default it is set to zero, but we need to make sure that the target is
    765    * reasonable for the data.
    766    */
    767   ScenePlotView3D.prototype.updateCameraTarget = function() {
    768     if (this.UIState['view.viewType'] === 'scatter')
    769     {
    770       var x = this.visibleDimensions[0], y = this.visibleDimensions[1];
    771
    772       var owidth = this.decModels.dimensionRanges.max[x] -
    773                       this.decModels.dimensionRanges.min[x];
    774       var oheight = this.decModels.dimensionRanges.max[y] -
    775                       this.decModels.dimensionRanges.min[y];
    776       var xcenter = this.decModels.dimensionRanges.max[x] - (owidth / 2);
    777       var ycenter = this.decModels.dimensionRanges.max[y] - (oheight / 2);
    778
    779       var max = _.max(this.decViews.scatter.decomp.dimensionRanges.max);
    780
    781       this.control.target.set(xcenter, ycenter, 0);
    782       this.camera.position.set(xcenter, ycenter, max * 5);
    783       this.camera.updateProjectionMatrix();
    784
    785       this.light.position.set(xcenter, ycenter, max * 5);
    786
    787       this.updateCameraAspectRatio();
    788
    789       this.control.saveState();
    790
    791       this.needsUpdate = true;
    792     }
    793     else if (this.UIState['view.viewType'] === 'parallel-plot') {
    794       this.control.target.set(0, 0, 1); //Must set positive Z because near > 0
    795       this.camera.position.set(0, 0, 1); //Must set positive Z because near > 0
    796       this.camera.updateProjectionMatrix();
    797       this.updateCameraAspectRatio();
    798       this.control.saveState();
    799       this.needsUpdate = true;
    800     }
    801   };
    802
    803   ScenePlotView3D.prototype.NEEDS_RENDER = 1;
    804   ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH = 2;
    805
    806   /**
    807    *
    808    * Convenience method to check if this or any of the decViews under this need
    809    * rendering
    810    *
    811    */
    812    ScenePlotView3D.prototype.checkUpdate = function() {
    813     var updateDimensions = false, updateColors = false,
    814         currentDimensions, backgroundColor, axesColor, scope = this;
    815
    816     //Check if the view type changed and swap the markers in/out of the scene
    817     //tree.
    818     var anyMarkersSwapped = false, isArrowType;
    819
    820     _.each(this.decViews, function(view) {
    821       if (view.needsSwapMarkers) {
    822         isArrowType = view.decomp.isArrowType();
    823         anyMarkersSwapped = true;
    824
    825         // arrows are in the scene whereas points/markers are in a different
    826         // group used for brush selection
    827         var group = isArrowType ? scope.scene : scope._selectable;
    828         var oldMarkers = view.getAndClearOldMarkers(), marker;
    829
    830         for (var i = 0; i < oldMarkers.length; i++) {
    831           marker = oldMarkers[i];
    832
    833           group.remove(marker);
    834
    835           if (isArrowType) {
    836             marker.dispose();
    837           }
    838           else {
    839             marker.material.dispose();
    840             marker.geometry.dispose();
    841           }
    842         }
    843
    844         // do not show arrows in a parallel plot
    845         var newMarkers = view.markers;
    846         if (isArrowType && scope.UIState['view.viewType'] === 'scatter' ||
    847             view.decomp.isScatterType()) {
    848           var scaling = scope.getScalingConstant();
    849
    850           for (i = 0; i < newMarkers.length; i++) {
    851             marker = newMarkers[i];
    852
    853             // when we re-add arrows we need to re-scale the labels
    854             if (isArrowType) {
    855               marker.label.scale.set(marker.label.scale.x * scaling,
    856                                      marker.label.scale.y * scaling, 1);
    857             }
    858             group.add(marker);
    859           }
    860         }
    861
    862         var lines = view.lines;
    863         var ellipsoids = view.ellipsoids;
    864
    865         if (scope.UIState['view.viewType'] == 'parallel-plot') {
    866           for (i = 0; i < lines.length; i++)
    867             scope.scene.remove(lines[i]);
    868           for (i = 0; i < ellipsoids.length; i++)
    869             scope.scene.remove(ellipsoids[i]);
    870         }
    871         if (scope.UIState['view.viewType'] == 'scatter') {
    872           for (i = 0; i < lines.length; i++)
    873             scope.scene.add(lines[i]);
    874           for (i = 0; i < ellipsoids.length; i++)
    875             scope.scene.add(ellipsoids[i]);
    876         }
    877     }});
    878
    879     if (anyMarkersSwapped) {
    880       this.updateCameraTarget();
    881       this.control.update();
    882     }
    883
    884
    885     // check if any of the decomposition views have changed
    886     var updateData = _.any(this.decViews, function(dv) {
    887       // note that we may be overwriting these variables, but we have a
    888       // guarantee that if one of them changes for one of decomposition views,
    889       // all of them will have changed, so grabbing one should be sufficient to
    890       // perform the comparisons below
    891       currentDimensions = dv.visibleDimensions;
    892       backgroundColor = dv.backgroundColor;
    893       axesColor = dv.axesColor;
    894
    895       return dv.needsUpdate;
    896     });
    897
    898     _.each(this.decViews, function(view) {
    899       view.getTubes().forEach(function(tube) {
    900         if (tube !== null)
    901           scope.scene.add(tube);
    902       });
    903     });
    904
    905     // check if the visible dimensions have changed
    906     if (!_.isEqual(currentDimensions, this.visibleDimensions)) {
    907       // remove the current axes
    908       this.removeAxes();
    909       this.removeAxesLabels();
    910
    911       // get the new dimensions and re-display the data
    912       this.visibleDimensions = _.clone(currentDimensions);
    913       this.drawAxesWithColor(this.axesColor);
    914       this.drawAxesLabelsWithColor(this.axesColor);
    915
    916       this.updateCameraTarget();
    917       this.control.update();
    918
    919       updateDimensions = true;
    920     }
    921
    922     // check if we should change the axes color
    923     if (axesColor !== this.axesColor) {
    924       this.drawAxesWithColor(axesColor);
    925       this.drawAxesLabelsWithColor(axesColor);
    926
    927       this.axesColor = _.clone(axesColor);
    928
    929       updateColors = true;
    930     }
    931
    932     // check if we should change the background color
    933     if (backgroundColor !== this.backgroundColor) {
    934       this.backgroundColor = _.clone(backgroundColor);
    935       this.scene.background = new THREE.Color(this.backgroundColor);
    936
    937       updateColors = true;
    938     }
    939
    940     if (updateData) {
    941       this.drawAxesWithColor(this.axesColor);
    942       this.drawAxesLabelsWithColor(this.axesColor);
    943     }
    944
    945     var retVal = 0;
    946     if (anyMarkersSwapped)
    947       retVal |= ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH;
    948     if (anyMarkersSwapped || this.needsUpdate || updateData ||
    949         updateDimensions || updateColors || this.control.autoRotate)
    950       retVal |= ScenePlotView3D.prototype.NEEDS_RENDER;
    951
    952     // if anything has changed, then trigger an update
    953     return retVal;
    954    };
    955
    956   /**
    957    *
    958    * Convenience method to re-render the contents of the scene.
    959    *
    960    */
    961   ScenePlotView3D.prototype.render = function() {
    962     this.renderer.setViewport(this.xView, this.yView, this.width, this.height);
    963     this.renderer.render(this.scene, this.camera);
    964     var camera = this.camera;
    965
    966     // if autorotation is enabled, then update the controls
    967     if (this.control.autoRotate) {
    968       this.control.update();
    969     }
    970
    971     // Only scatter plots that are not using a point cloud should be pointed
    972     // towards the camera. For arrow types and point clouds doing this will
    973     // results in odd visual effects
    974     if (!this.UIState.getProperty('view.usesPointCloud') &&
    975         this.decViews.scatter.decomp.isScatterType()) {
    976       _.each(this.decViews.scatter.markers, function(element) {
    977         element.quaternion.copy(camera.quaternion);
    978       });
    979     }
    980
    981     this.needsUpdate = false;
    982     $.each(this.decViews, function(key, val) {
    983       val.needsUpdate = false;
    984     });
    985   };
    986
    987   /**
    988    * Helper method to highlight and return selected objects.
    989    *
    990    * This is mostly necessary because depending on the rendering type we will
    991    * have a slightly different way to set and return the highlighting
    992    * attributes. For large plots we return the points geometry together with
    993    * a userData.selected attribute with the selected indices.
    994    *
    995    * Note that we created a group of selectable objects in the constructor so
    996    * we don't have to check for geometry types, etc.
    997    *
    998    * @param {Array} collection An array of objects to highlight
    999    * @param {Integer} color A hexadecimal-encoded color. For shaders we only
    1000    * use the first bit to decide if the marker is rendered in white or rendered
    1001    * with the original color.
    1002    *
    1003    * @return {Array} selected objects (after checking for visibility and
    1004    * opacity).
    1005    *
    1006    * @private
    1007    */
    1008   ScenePlotView3D.prototype._highlightSelected = function(collection, color) {
    1009     var i = 0, j = 0, selected = [];
    1010
    1011     if (this.UIState.getProperty('view.usesPointCloud') ||
    1012         this.UIState.getProperty('view.viewType') === 'parallel-plot') {
    1013       for (i = 0; i < collection.length; i++) {
    1014         // for shaders the emissive attribute is an int
    1015         var indices, emissiveColor = (color > 0) * 1;
    1016
    1017         // if there's no selection then update all the points
    1018         if (collection[i].userData.selected === undefined) {
    1019           indices = _.range(collection[i].geometry.attributes.emissive.count);
    1020         }
    1021         else {
    1022           indices = collection[i].userData.selected;
    1023         }
    1024
    1025         for (j = 0; j < indices.length; j++) {
    1026           if (collection[i].geometry.attributes.visible.getX(indices[j]) &&
    1027               collection[i].geometry.attributes.opacity.getX(indices[j])) {
    1028             collection[i].geometry.attributes.emissive.setX(indices[j],
    1029                                                             emissiveColor);
    1030           }
    1031         }
    1032
    1033         collection[i].geometry.attributes.emissive.needsUpdate = true;
    1034         selected.push(collection[i]);
    1035       }
    1036     }
    1037     else {
    1038       for (i = 0; i < collection.length; i++) {
    1039         var material = collection[i].material;
    1040
    1041         if (material.visible && material.opacity && material.emissive) {
    1042           collection[i].material.emissive.set(color);
    1043           selected.push(collection[i]);
    1044         }
    1045       }
    1046     }
    1047
    1048     return selected;
    1049   };
    1050
    1051   /**
    1052    *
    1053    * Adds the mouse selection events to the current view
    1054    *
    1055    * @param {node} $container The container to add the events to.
    1056    * @private
    1057    */
    1058   ScenePlotView3D.prototype._addSelectionEvents = function($container) {
    1059     var scope = this;
    1060
    1061     // There're three stages to the mouse selection:
    1062     //  mousedown -> mousemove -> mouseup
    1063     //
    1064     // The mousdown event is ignored unless the user is holding Shift. Once
    1065     // selection has started the rotation controls are disabled. The mousemove
    1066     // event continues until the user releases the mouse. Once this happens
    1067     // rotation is re-enabled and the selection box disappears. Selected
    1068     // markers are highlighted by changing the light they emit.
    1069     //
    1070     $container.on('mousedown', function(event) {
    1071       // ignore the selection event if shift is not being held or if parallel
    1072       // plots are being visualized at the moment
    1073       if (!event.shiftKey) {
    1074         return;
    1075       }
    1076
    1077       scope.control.enabled = false;
    1078       scope.scatterController.enabled = false;
    1079       scope.parallelController.enabled = false;
    1080       scope._selectionHelper.enabled = true;
    1081       scope._selectionHelper.onSelectStart(event);
    1082
    1083       // clear up any color setting
    1084       scope._highlightSelected(scope._selectionBox.collection, 0x000000);
    1085
    1086       var element = scope.renderer.domElement;
    1087       var offset = $(element).offset(), i = 0;
    1088
    1089       scope._selectionBox.startPoint.set(
    1090         ((event.clientX - offset.left) / element.width) * 2 - 1,
    1091         -((event.clientY - offset.top) / element.height) * 2 + 1,
    1092         0.5);
    1093     })
    1094     .on('mousemove', function(event) {
    1095       // ignore if the user is not holding the shift key or the orbit control
    1096       // is enabled and he selection disabled
    1097       if (!event.shiftKey ||
    1098           (scope.control.enabled && !scope._selectionHelper.enabled)) {
    1099         return;
    1100       }
    1101
    1102       var element = scope.renderer.domElement, selected;
    1103       var offset = $(element).offset(), i = 0;
    1104
    1105       scope._selectionBox.endPoint.set(
    1106         ((event.clientX - offset.left) / element.width) * 2 - 1,
    1107         - ((event.clientY - offset.top) / element.height) * 2 + 1,
    1108         0.5);
    1109
    1110       // reset everything before updating the selected color
    1111       scope._highlightSelected(scope._selectionBox.collection, 0x000000);
    1112       scope._highlightSelected(scope._selectionBox.select(), 0x8c8c8f);
    1113
    1114       scope.needsUpdate = true;
    1115     })
    1116     .on('mouseup', function(event) {
    1117       // if the user is not already selecting data then ignore
    1118       if (!scope._selectionHelper.enabled || scope.control.enabled) {
    1119         return;
    1120       }
    1121
    1122       // otherwise if shift is being held then keep selecting, otherwise ignore
    1123       if (event.shiftKey) {
    1124         var element = scope.renderer.domElement;
    1125         var offset = $(element).offset(), indices = [], names = [];
    1126         scope._selectionBox.endPoint.set(
    1127           ((event.clientX - offset.left) / element.width) * 2 - 1,
    1128           - ((event.clientY - offset.top) / element.height) * 2 + 1,
    1129           0.5);
    1130
    1131         selected = scope._highlightSelected(scope._selectionBox.select(),
    1132                                             0x8c8c8f);
    1133
    1134         // get the list of sample names from the views
    1135         for (var i = 0; i < selected.length; i++) {
    1136           if (selected[i].isPoints) {
    1137             // this is a list of indices of the selected samples
    1138             indices = selected[i].userData.selected;
    1139
    1140             for (var j = 0; j < indices.length; j++) {
    1141               names.push(scope.decViews.scatter.decomp.ids[indices[j]]);
    1142             }
    1143           }
    1144           else if (selected[i].isLineSegments) {
    1145             var index, viewType, view;
    1146
    1147             view = scope.decViews.scatter;
    1148             viewType = scope.UIState['view.viewType'];
    1149
    1150             // this is a list of indices of the selected samples
    1151             indices = selected[i].userData.selected;
    1152
    1153             for (var k = 0; k < indices.length; k++) {
    1154               index = view.getModelPointIndex(indices[k], viewType);
    1155               names.push(view.decomp.ids[index]);
    1156             }
    1157
    1158             // every segment is labeled the same for each sample
    1159             names = _.unique(names);
    1160           }
    1161           else {
    1162             names.push(selected[i].name);
    1163           }
    1164         }
    1165
    1166         scope._selectCallback(names, scope.decViews.scatter);
    1167       }
    1168
    1169       scope.control.enabled = true;
    1170       scope.scatterController.enabled = true;
    1171       scope.parallelController.enabled = true;
    1172       scope._selectionHelper.enabled = false;
    1173       scope.needsUpdate = true;
    1174     });
    1175   };
    1176
    1177
    1178   /**
    1179    * Handle selection events.
    1180    * @private
    1181    */
    1182   ScenePlotView3D.prototype._selectCallback = function(names, view) {
    1183     var eventType = 'select';
    1184
    1185     for (var i = 0; i < this._subscribers[eventType].length; i++) {
    1186       // keep going if one of the callbacks fails
    1187       try {
    1188         this._subscribers[eventType][i](names, view);
    1189       } catch (e) {
    1190         console.error(e);
    1191       }
    1192       this.needsUpdate = true;
    1193     }
    1194   };
    1195
    1196   /**
    1197    *
    1198    * Helper method that runs functions subscribed to the container's callbacks.
    1199    * @param {String} eventType Event type being called
    1200    * @param {event} event The event from jQuery, with x and y click coords
    1201    * @private
    1202    *
    1203    */
    1204   ScenePlotView3D.prototype._eventCallback = function(eventType, event) {
    1205     event.preventDefault();
    1206     // don't do anything if no subscribers
    1207     if (this._subscribers[eventType].length === 0) {
    1208       return;
    1209     }
    1210
    1211     var element = this.renderer.domElement, scope = this;
    1212     var offset = $(element).offset();
    1213     this._mouse.x = ((event.clientX - offset.left) / element.width) * 2 - 1;
    1214     this._mouse.y = -((event.clientY - offset.top) / element.height) * 2 + 1;
    1215
    1216     this._raycaster.setFromCamera(this._mouse, this.camera);
    1217
    1218     // get a flattened array of markers
    1219     var objects = _.map(this.decViews, function(decomp) {
    1220       return decomp.markers;
    1221     });
    1222     objects = _.reduce(objects, function(memo, value) {
    1223       return memo.concat(value);
    1224     }, []);
    1225     var intersects = this._raycaster.intersectObjects(objects);
    1226
    1227     // Get first intersected item and call callback with it.
    1228     if (intersects && intersects.length > 0) {
    1229       var firstObj = intersects[0].object, intersect;
    1230       /*
    1231        * When the intersect object is a Points object, the raycasting method
    1232        * won't intersect individual mesh objects. Instead it intersects a point
    1233        * and we get the index of the point. This index can then be used to
    1234        * trace the original Plottable object.
    1235        */
    1236       if (firstObj.isPoints || firstObj.isLineSegments) {
    1237         // don't search over invisible things
    1238         intersects = _.filter(intersects, function(marker) {
    1239            return firstObj.geometry.attributes.visible.getX(marker.index) &&
    1240                   firstObj.geometry.attributes.opacity.getX(marker.index);
    1241         });
    1242
    1243         // if there's no hits then finish the execution
    1244         if (intersects.length === 0) {
    1245           return;
    1246         }
    1247
    1248         var meshIndex = intersects[0].index;
    1249         var modelIndex = this.decViews.scatter.getModelPointIndex(meshIndex,
    1250                                                 this.UIState['view.viewType']);
    1251         intersect = this.decViews.scatter.decomp.plottable[modelIndex];
    1252       }
    1253       else {
    1254         intersects = _.filter(intersects, function(marker) {
    1255           return marker.object.visible && marker.object.material.opacity;
    1256         });
    1257
    1258         // if there's no hits then finish the execution
    1259         if (intersects.length === 0) {
    1260           return;
    1261         }
    1262
    1263         intersect = intersects[0].object;
    1264       }
    1265
    1266       for (var i = 0; i < this._subscribers[eventType].length; i++) {
    1267         // keep going if one of the callbacks fails
    1268         try {
    1269           this._subscribers[eventType][i](intersect.name, intersect);
    1270         } catch (e) {
    1271           console.error(e);
    1272         }
    1273         this.needsUpdate = true;
    1274       }
    1275     }
    1276   };
    1277
    1278   /**
    1279    *
    1280    * Interface to subscribe to event types in the canvas, see the EVENTS
    1281    * property.
    1282    *
    1283    * @param {String} eventType The type of event to subscribe to.
    1284    * @param {Function} handler Function to call when `eventType` is triggered,
    1285    * receives two parameters, a string with the name of the object, and the
    1286    * object itself i.e. f(objectName, object).
    1287    *
    1288    * @throws {Error} If the given eventType is unknown.
    1289    *
    1290    */
    1291   ScenePlotView3D.prototype.on = function(eventType, handler) {
    1292     if (this.EVENTS.indexOf(eventType) === -1) {
    1293       throw new Error('Unknown event ' + eventType + '. Known events are: ' +
    1294                       this.EVENTS.join(', '));
    1295     }
    1296
    1297     this._subscribers[eventType].push(handler);
    1298   };
    1299
    1300   /**
    1301    *
    1302    * Interface to unsubscribe a function from an event type, see the EVENTS
    1303    * property.
    1304    *
    1305    * @param {String} eventType The type of event to unsubscribe from.
    1306    * @param {Function} handler Function to remove from the subscribers list.
    1307    *
    1308    * @throws {Error} If the given eventType is unknown.
    1309    *
    1310    */
    1311   ScenePlotView3D.prototype.off = function(eventType, handler) {
    1312     if (this.EVENTS.indexOf(eventType) === -1) {
    1313       throw new Error('Unknown event ' + eventType + '. Known events are ' +
    1314                       this.EVENTS.join(', '));
    1315     }
    1316
    1317     var pos = this._subscribers[eventType].indexOf(handler);
    1318     if (pos !== -1) {
    1319       this._subscribers[eventType].splice(pos, 1);
    1320     }
    1321   };
    1322
    1323   /**
    1324    *
    1325    * Recenter the position of the camera to the initial default.
    1326    *
    1327    */
    1328   ScenePlotView3D.prototype.recenterCamera = function() {
    1329     this.control.reset();
    1330     this.control.update();
    1331
    1332     this.needsUpdate = true;
    1333   };
    1334
    1335   return ScenePlotView3D;
    1336 });
    1337