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