1 define([ 2 'jquery', 3 'underscore', 4 'contextmenu', 5 'three', 6 'view', 7 'scene3d', 8 'colorviewcontroller', 9 'visibilitycontroller', 10 'opacityviewcontroller', 11 'shapecontroller', 12 'axescontroller', 13 'scaleviewcontroller', 14 'animationscontroller', 15 'filesaver', 16 'viewcontroller', 17 'svgrenderer', 18 'draw', 19 'canvasrenderer', 20 'canvastoblob', 21 'multi-model', 22 'uistate' 23 ], function($, _, contextMenu, THREE, DecompositionView, ScenePlotView3D, 24 ColorViewController, VisibilityController, OpacityViewController, 25 ShapeController, AxesController, ScaleViewController, 26 AnimationsController, FileSaver, viewcontroller, SVGRenderer, Draw, 27 CanvasRenderer, canvasToBlob, MultiModel, UIStateInit) { 28 29 var EmperorAttributeABC = viewcontroller.EmperorAttributeABC; 30 31 var TAB_ORDER = ['color', 'visibility', 'opacity', 'scale', 32 'shape', 'axes', 'animations']; 33 34 var controllerConstructors = { 35 'color': ColorViewController, 36 'visibility': VisibilityController, 37 'opacity': OpacityViewController, 38 'scale': ScaleViewController, 39 'shape': ShapeController, 40 'axes': AxesController, 41 'animations': AnimationsController 42 }; 43 44 /** 45 * 46 * @class EmperorController 47 * This is the application controller 48 * 49 * The application controller, contains all the information on how the model 50 * is being presented to the user. 51 * 52 * @param {DecompositionModel} scatter A decomposition object that represents 53 * the scatter-represented objects. 54 * @param {DecompositionModel} biplot An optional decomposition object that 55 * represents the arrow-represented objects. Can be null or undefined. 56 * @param {string} divId The element id where the controller should 57 * instantiate itself. 58 * @param {node} [webglcanvas = undefined] the canvas to use to render the 59 * information. This parameter is optional, and should rarely be set. But is 60 * useful for external applications like SAGE2. 61 * 62 * @return {EmperorController} 63 * @constructs EmperorController 64 * 65 */ 66 function EmperorController(scatter, biplot, divId, webglcanvas, info) { 67 68 /** 69 * The state shared across one instance of the UI 70 * @type {UIState} 71 */ 72 this.UIState = new UIStateInit(); 73 this.UIState.setProperty('view.usesPointCloud', scatter.length > 20000); 74 75 var scope = this; 76 /** 77 * Scaling constant for grid dimensions (read only). 78 * @type {float} 79 */ 80 this.GRID_SCALE = 0.97; 81 82 /** 83 * Scaling constant for scene plot view dimensions 84 * @type {float} 85 */ 86 this.SCENE_VIEW_SCALE = 0.5; 87 /** 88 * jQuery object where the object lives in. 89 * @type {node} 90 */ 91 this.$divId = $('#' + divId); 92 /** 93 * Width of the object. 94 * @type {float} 95 */ 96 this.width = this.$divId.width(); 97 /** 98 * Height of the object. 99 * @type {float} 100 */ 101 this.height = this.$divId.height(); 102 103 /** 104 * Information to be displayed in the plot banner. 105 * @type {string} 106 */ 107 this.info = info; 108 109 var decModelMap = {'scatter': scatter}; 110 if (biplot) 111 decModelMap['biplot'] = biplot; 112 113 /** 114 * MultiModel object containing all DecompositionModels 115 * 116 * @type {MultiModel} 117 */ 118 this.decModels = new MultiModel(decModelMap); 119 120 /** 121 * Object with all the available decomposition views. 122 * 123 * @type {object} 124 */ 125 this.decViews = {'scatter': 126 new DecompositionView(this.decModels, 127 'scatter', 128 this.UIState)}; 129 130 if (biplot) { 131 this.decViews.biplot = new DecompositionView(this.decModels, 132 'biplot', 133 this.UIState); 134 } 135 136 /** 137 * Keep track of whether or not the biplot labels should be hidden. 138 * 139 * @type {Bool} 140 * @private 141 */ 142 this._hideBiplotLabels = false; 143 144 /** 145 * List of the scene plots views being rendered. 146 * @type {ScenePlotView3D[]} 147 */ 148 this.sceneViews = []; 149 150 /** 151 * Internal div where the menus live in (jQuery object). 152 * @type {node} 153 */ 154 this.$plotSpace = $("<div class='emperor-plot-wrapper'></div>"); 155 156 /** 157 * Div with the number of visible samples 158 * @type {node} 159 */ 160 this.$plotBanner = $('<label>Loading ...</label>'); 161 this.$plotBanner.css({'padding': '2px', 162 'font-style': '9pt helvetica', 163 'color': 'white', 164 'border': '1px solid', 165 'border-color': 'white', 166 'position': 'absolute', 167 '-webkit-user-select': 'none', 168 '-moz-user-select': 'none', 169 '-ms-user-select': 'none', 170 'user-select': 'none'}); 171 172 // add the sample count to the plot space 173 this.$plotSpace.append(this.$plotBanner); 174 175 /** 176 * Internal div where the plots live in (jQuery object). 177 * @type {node} 178 */ 179 this.$plotMenu = $("<div class='emperor-plot-menu'></div>"); 180 this.$plotMenu.attr('title', 'Right click on the plot for more options, ' + 181 ' click on a sample to reveal its name, or ' + 182 'double-click on a sample to copy its name to the ' + 183 'clipboard'); 184 185 this.$divId.append(this.$plotSpace); 186 this.$divId.append(this.$plotMenu); 187 188 /** 189 * @type {Function} 190 * Callback to execute when all the view controllers have been successfully 191 * loaded. 192 */ 193 this.ready = null; 194 195 /** 196 * Holds a reference to all the tabs (view controllers) in the `$plotMenu`. 197 * @type {object} 198 */ 199 this.controllers = {}; 200 201 /** 202 * Object in charge of doing the rendering of the scenes. 203 * @type {THREE.Renderer} 204 */ 205 this.renderer = null; 206 if (webglcanvas !== undefined) { 207 this.renderer = new THREE.WebGLRenderer({canvas: webglcanvas, 208 antialias: true}); 209 } 210 else { 211 this.renderer = new THREE.WebGLRenderer({antialias: true}); 212 } 213 214 this.renderer.setSize(this.width, this.height); 215 this.renderer.autoClear = false; 216 this.renderer.sortObjects = true; 217 this.$plotSpace.append(this.renderer.domElement); 218 219 220 /** 221 * The number of tabs that we expect to see. This attribute is updated by 222 * the addTab method, and is only releveant during the initialization 223 * process. 224 * @private 225 */ 226 this._expected = 0; 227 228 /** 229 * The number of tabs that have finished initalization. This attribute is 230 * only relevant during the initialization process. 231 * @private 232 */ 233 this._seen = 0; 234 235 /** 236 * Menu tabs containers, note that we need them in this format to have 237 * jQuery's UI tabs work properly. All the view controllers will be added 238 * to this container, see the addTab method 239 * @see EmperorController.addTab 240 * @type {node} 241 * @private 242 */ 243 this._$tabsContainer = $("<div name='emperor-tabs-container'></div>"); 244 this._$tabsContainer.css('background-color', '#EEEEEE'); 245 this._$tabsContainer.addClass('unselectable'); 246 /** 247 * List of available tabs, lives inside `_$tabsContainer`. 248 * @type {node} 249 * @private 250 */ 251 this._$tabsList = $("<ul name='emperor-tabs-list'></ul>"); 252 253 // These will both live in the menu space. As of the writing of this code 254 // there's nothing else but tabs on the menu, but this may change in the 255 // future, that's why we are creating the extra "tabsContainer" div 256 this.$plotMenu.append(this._$tabsContainer); 257 this._$tabsContainer.append(this._$tabsList); 258 259 /** 260 * @type {Node} 261 * jQuery object To show the context menu (as an alternative to 262 * right-clicking on the plot). 263 * 264 * The context menu that this button shows is created in the _buildUI 265 * method. 266 */ 267 this.$optionsButton = $('<button name="options-button"> </button>'); 268 this.$optionsButton.css({ 269 'position': 'absolute', 270 'z-index': '3', 271 'top': '5px', 272 'right': '5px' 273 }).attr('title', 'More Options').on('click', function(event) { 274 // add offset to avoid overlapping the button with the menu 275 scope.$plotSpace.contextMenu({x: event.pageX, y: event.pageY + 5}); 276 }); 277 this.$plotSpace.append(this.$optionsButton); 278 279 // default decomposition view uses the full window 280 this.addSceneView(); 281 282 $(function() { 283 // setup the jquery properties of the button 284 scope.$optionsButton.button({text: false, 285 icons: {primary: ' ui-icon-gear'}}); 286 287 scope._buildUI(); 288 // Hide the loading splashscreen 289 scope.$divId.find('.loading').hide(); 290 291 // The next few lines setup the space/menu resizing logic. Specifically, 292 // we only enable the "west' handle, set double-click toggle behaviour 293 // and add a tooltip to the handle. 294 scope.$plotMenu.resizable({ 295 handles: 'w', 296 helper: 'plot-space-resizable-helper', 297 stop: function(event, ui) { 298 var percent = (ui.size.width / scope.width) * 100; 299 300 scope.$plotSpace.width((100 - percent) + '%'); 301 scope.$plotMenu.css({'width': percent + '%', 'left': 0}); 302 303 // The scrollbars randomly appear on the window while showing the 304 // helper, with this small delay we give them enough time to 305 // disappear. 306 setTimeout(function() { 307 scope.resize(scope.width, scope.height); 308 }, 50); 309 } 310 }); 311 312 scope.$plotMenu.find('.ui-resizable-handle').dblclick(function() { 313 var percent = (scope.$plotSpace.width() / scope.width) * 100; 314 315 // allow for a bit of leeway 316 if (percent >= 98) { 317 scope.$plotSpace.css({'width': '73%'}); 318 scope.$plotMenu.css({'width': '27%', 'left': 0}); 319 } 320 else { 321 scope.$plotSpace.css({'width': '99%'}); 322 scope.$plotMenu.css({'width': '1%', 'left': 0}); 323 } 324 scope.resize(scope.width, scope.height); 325 }).attr('title', 'Drag to resize or double click to toggle visibility'); 326 327 }); 328 329 // once the object finishes loading, resize the contents so everything fits 330 // nicely 331 $(this).ready(function() { 332 scope.resize(scope.$divId.width(), scope.$divId.height()); 333 }); 334 335 this.UIState.registerProperty('view.viewType', function(evt) { 336 toDisable = ['scale', 'shape', 'animations']; 337 338 for (controllerName in scope.controllers) { 339 var c = scope.controllers[controllerName]; 340 selector = "li[aria-controls='" + c.identifier + "']"; 341 //jquery effects are less jarring, but also remind me of people who add 342 //effects to slide transitions in powerpoint. I'd still prefer css 343 //to gray out the tab... 344 //effects list at https://api.jqueryui.com/category/effects/ 345 346 if (toDisable.includes(controllerName)) { 347 if (evt.newVal === 'parallel-plot') 348 $(selector).hide('blind'); 349 else if (evt.newVal === 'scatter') 350 $(selector).show('blind'); 351 } 352 } 353 }); 354 }; 355 356 /** 357 * 358 * Add a new decomposition view 359 * 360 * @param {String} key New name for the decomposition view. 361 * @param {DecompositionView} value The decomposition view that will be 362 * added. 363 * 364 * @throws Error if `key` already exists, or if `value` is not a 365 * decomposition view. 366 * 367 */ 368 EmperorController.prototype.addDecompositionView = function(key, value) { 369 if (!(value instanceof DecompositionView)) { 370 console.error('The value is not a decomposition view'); 371 } 372 373 if (_.contains(_.keys(this.decViews), key)) { 374 throw Error('A decomposition view named "' + key + '" already exists,' + 375 'cannot add an already existing decomposition.'); 376 } 377 378 this.decViews[key] = value; 379 380 _.each(this.controllers, function(controller) { 381 if (controller instanceof EmperorAttributeABC) { 382 controller.refreshMetadata(); 383 } 384 }); 385 386 _.each(this.sceneViews, function(sv) { 387 sv.addDecompositionsToScene(); 388 }); 389 }; 390 391 /** 392 * 393 * Helper method to add additional ScenePlotViews (i.e. another plot) 394 * 395 */ 396 EmperorController.prototype.addSceneView = function() { 397 if (this.sceneViews.length > 4) { 398 throw Error('Cannot add another scene plot view'); 399 } 400 401 var spv = new ScenePlotView3D(this.UIState, 402 this.renderer, 403 this.decViews, 404 this.decModels, 405 this.$plotSpace, 0, 0, 406 this.width, this.height); 407 this.sceneViews.push(spv); 408 409 // this will setup the appropriate sizes and widths 410 this.resize(this.width, this.height); 411 }; 412 413 /** 414 * 415 * Helper method to resize the plots 416 * 417 * @param {width} the width of the entire plotting space 418 * @param {height} the height of the entire plotting space 419 * 420 */ 421 EmperorController.prototype.resize = function(width, height) { 422 // update the available space we have 423 this.width = width; 424 this.height = height; 425 426 this.$plotSpace.height(height); 427 this.$plotMenu.height(height); 428 429 this._$tabsContainer.height(height); 430 431 // the area we have to present the plot is smaller than the total 432 var plotWidth = this.$plotSpace.width(); 433 434 // TODO: The below will need refactoring 435 // This is addressed in issue #405 436 if (this.sceneViews.length === 1) { 437 this.sceneViews[0].resize(0, 0, plotWidth, this.height); 438 } 439 else if (this.sceneViews.length === 2) { 440 this.sceneViews[0].resize(0, 0, this.SCENE_VIEW_SCALE * plotWidth, 441 this.height); 442 this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0, 443 this.SCENE_VIEW_SCALE * plotWidth, this.height); 444 } 445 else if (this.sceneViews.length === 3) { 446 this.sceneViews[0].resize(0, 0, 447 this.SCENE_VIEW_SCALE * plotWidth, 448 this.SCENE_VIEW_SCALE * this.height); 449 this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0, 450 this.SCENE_VIEW_SCALE * plotWidth, 451 this.SCENE_VIEW_SCALE * this.height); 452 this.sceneViews[2].resize(0, this.SCENE_VIEW_SCALE * this.height, 453 plotWidth, this.SCENE_VIEW_SCALE * this.height); 454 } 455 else if (this.sceneViews.length === 4) { 456 this.sceneViews[0].resize(0, 0, this.SCENE_VIEW_SCALE * plotWidth, 457 this.SCENE_VIEW_SCALE * this.height); 458 this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0, 459 this.SCENE_VIEW_SCALE * plotWidth, 460 this.SCENE_VIEW_SCALE * this.height); 461 this.sceneViews[2].resize(0, this.SCENE_VIEW_SCALE * this.height, 462 this.SCENE_VIEW_SCALE * plotWidth, 463 this.SCENE_VIEW_SCALE * this.height); 464 this.sceneViews[3].resize(this.SCENE_VIEW_SCALE * plotWidth, 465 this.SCENE_VIEW_SCALE * this.height, 466 this.SCENE_VIEW_SCALE * plotWidth, 467 this.SCENE_VIEW_SCALE * this.height); 468 } 469 else { 470 throw Error('More than four views are currently not supported'); 471 } 472 473 this.renderer.setSize(plotWidth, this.height); 474 475 /* Resizing the tabs (view controllers) */ 476 477 // resize the grid according to the size of the container, since we are 478 // inside the tabs we have to account for that lost space. 479 var tabHeight = this.$plotMenu.height() * this.GRID_SCALE; 480 481 // the tab list at the top takes up a variable amount of space and 482 // without this, the table displayed below will have an odd scrolling 483 // behaviour 484 tabHeight -= this._$tabsList.height(); 485 486 // for each controller, we need to (1) trigger the resize method, and (2) 487 // resize the height of the containing DIV tag (we don't need to resize the 488 // width as this is already taken care of since it just has to fit the 489 // available space). 490 _.each(this.controllers, function(controller, index) { 491 if (controller !== undefined) { 492 $('#' + controller.identifier).height(tabHeight); 493 494 var w = $('#' + controller.identifier).width(), 495 h = $('#' + controller.identifier).height(); 496 497 controller.resize(w, h); 498 } 499 }); 500 501 //Set all scenes to needing update 502 for (var i = 0; i < this.sceneViews.length; i++) { 503 this.sceneViews[i].needsUpdate = true; 504 } 505 }; 506 507 /** 508 * 509 * Helper method to render sceneViews, gets called every time the browser 510 * indicates we can render a new frame, however it only triggers the 511 * appropriate rendering functions if something has changed since the last 512 * frame. 513 * 514 */ 515 EmperorController.prototype.render = function() { 516 var scope = this; 517 518 if (this.controllers.animations !== undefined) { 519 this.controllers.animations.drawFrame(); 520 } 521 522 $.each(this.sceneViews, function(i, sv) { 523 requiredActions = sv.checkUpdate(); 524 if (requiredActions & 525 ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH) { 526 //loop over controllers and update 527 for (controllerKey in scope.controllers) { 528 scope.controllers[controllerKey].forceRefresh(); 529 } 530 } 531 if (requiredActions & 532 ScenePlotView3D.prototype.NEEDS_RENDER) { 533 scope.renderer.setViewport(0, 0, scope.width, scope.height); 534 scope.renderer.clear(); 535 sv.render(); 536 537 // if there's a change for the scene view update the counts 538 scope.updatePlotBanner(); 539 } 540 }); 541 542 }; 543 544 /** 545 * 546 * Updates the plot banner based on the number of visible elements and the 547 * scene's background color. 548 * 549 */ 550 EmperorController.prototype.updatePlotBanner = function() { 551 var color = this.sceneViews[0].scene.background.clone(), visible = 0, 552 total = 0, message = ''; 553 554 // invert the color so it's visible regardless of the background 555 color.setRGB((Math.floor(color.r * 255) ^ 0xFF) / 255, 556 (Math.floor(color.g * 255) ^ 0xFF) / 255, 557 (Math.floor(color.b * 255) ^ 0xFF) / 255); 558 color = color.getStyle(); 559 560 _.each(this.decViews, function(decomposition) { 561 // computing this with every update requires traversin all elements, 562 // however it seems as the only reliable way to get this number right 563 // without depending on the view controllers (an anti-pattern) 564 visible += decomposition.getVisibleCount(); 565 total += decomposition.count; 566 }); 567 568 this.$plotBanner.css({'color': color, 'border-color': color}); 569 if (this.info) { 570 message += '<br>' + this.info; 571 } 572 if (visible !== total) { 573 message += ' <br> WARNING: hiding samples in an ordination can be ' + 574 'misleading'; 575 } 576 577 this.$plotBanner.html(visible.toLocaleString() + ' / ' + 578 total.toLocaleString() + ' visible' + message); 579 }; 580 581 EmperorController.prototype.getPlotBanner = function(text) { 582 return this.$plotBanner.text(); 583 }; 584 585 /** 586 * 587 * Helper method to check if all the view controllers have finished loading. 588 * Relies on the fact that each view controller announces when it is ready. 589 * 590 * @private 591 * 592 */ 593 EmperorController.prototype._controllerHasFinishedLoading = function() { 594 this._seen += 1; 595 596 if (this._seen >= this._expected) { 597 if (this.ready !== null) { 598 this.ready(); 599 } 600 } 601 }; 602 603 /** 604 * 605 * Helper method to assemble UI, completely independent of HTML template. 606 * This method is called when the object is constructed. 607 * 608 * @private 609 * 610 */ 611 EmperorController.prototype._buildUI = function() { 612 var scope = this, isLargeDataset = this.UIState['view.usesPointCloud']; 613 614 for (var index in TAB_ORDER) { 615 var item = TAB_ORDER[index]; 616 if (item === 'shape' && isLargeDataset) 617 continue; 618 scope.controllers[item] = scope.addTab(scope.sceneViews[0].decViews, 619 controllerConstructors[item]); 620 } 621 622 // We are tabifying this div, I don't know man. 623 this._$tabsContainer.tabs({heightStyle: 'fill', 624 // The tabs on the plot space only get resized 625 // when they are visible, thus we subscribe to 626 // the event that's fired after a user selects a 627 // tab. If you don't do this, the width and 628 // height of each of the view controllers will 629 // be wrong. We also found that subscribing to 630 // document.ready() wouldn't work either as the 631 // resize callback couldn't be executed on a tab 632 // that didn't exist yet. 633 activate: function(event, ui) { 634 scope.resize(scope.$divId.width(), 635 scope.$divId.height()); 636 }}); 637 638 // Set up the context menu 639 this.$contextMenu = $.contextMenu({ 640 // only tie this selector to our own container div, otherwise with 641 // multiple plots on the same screen, this callback gets confused 642 selector: '#' + scope.$divId.attr('id') + ' .emperor-plot-wrapper', 643 trigger: 'none', 644 items: { 645 'recenterCamera': { 646 name: 'Recenter camera', 647 icon: 'home', 648 callback: function(key, opts) { 649 _.each(scope.sceneViews, function(scene) { 650 scene.recenterCamera(); 651 }); 652 } 653 }, 654 'toggleAutorotate': { 655 name: 'Toggle autorotation', 656 icon: 'rotate-left', 657 callback: function(key, opts) { 658 _.each(scope.sceneViews, function(scene) { 659 scene.control.autoRotate = scene.control.autoRotate ^ true; 660 }); 661 }, 662 disabled: function(key, opts) { 663 return scope.UIState['view.viewType'] === 'parallel-plot'; 664 } 665 }, 666 'labels' : { 667 name: 'Toggle label visibility', 668 visible: scope.decViews.biplot !== undefined, 669 icon: 'font', 670 callback: function() { 671 scope._hideBiplotLabels = Boolean(scope._hideBiplotLabels ^ true); 672 scope.decViews.biplot.toggleLabelVisibility(); 673 } 674 }, 675 'sep0': '----------------', 676 'saveState': { 677 name: 'Save current settings', 678 icon: 'save', 679 callback: function(key, opts) { 680 scope.saveConfig(); 681 } 682 }, 683 'loadState': { 684 name: 'Load saved settings', 685 icon: 'folder-open-o', 686 callback: function(key, opts) { 687 if (!FileReader) { 688 alert('Your browser does not support file loading. We ' + 689 'recommend using Google Chrome for full functionality.'); 690 return; 691 } 692 var file = $('<input type="file">'); 693 file.on('change', function(evt) { 694 var f = evt.target.files[0]; 695 // With help from 696 // http://www.htmlgoodies.com/beyond/javascript/read-text-files-using-the-javascript-filereader.html 697 var r = new FileReader(); 698 r.onload = function(e) { 699 try { 700 var json = JSON.parse(e.target.result); 701 } catch (err) { 702 alert('File given is not a JSON parsable file.'); 703 return; 704 } 705 try { 706 scope.loadConfig(json); 707 } catch (err) { 708 alert('Error loading settings from file: ' + err.message); 709 return; 710 } 711 }; 712 r.readAsText(f); 713 }); 714 file.click(); 715 } 716 }, 717 'sep1': '---------', 718 // With large datasets we can't save to SVG. The PNG file will not be 719 // high resolution. 720 'fold1': { 721 'name': 'Save Image', 722 icon: 'file-picture-o', 723 'items': { 724 'saveImagePNG': { 725 name: 'PNG' + (isLargeDataset ? '' : ' (high resolution)'), 726 callback: function(key, opts) { 727 scope.screenshot('png'); 728 } 729 }, 730 'saveImageSVG': { 731 name: 'SVG + labels' + (isLargeDataset ? 732 ' (not supported for large datasets)' : '') , 733 callback: function(key, opts) { 734 scope.screenshot('svg'); 735 }, 736 disabled: function(key, opt) { 737 return isLargeDataset || 738 (scope.UIState['view.viewType'] === 'parallel-plot'); 739 } 740 } 741 } 742 }, 743 fold2: { 744 name: 'Experimental', 745 disabled: function(key, opt) { 746 // Only enable if this is a "vanilla" plot 747 if (scope.UIState['view.viewType'] === 'scatter' && 748 scope.decViews.scatter.lines.left === null && 749 scope.decViews.scatter.lines.right === null && 750 scope.decViews.biplot === undefined) { 751 return false; 752 } 753 return true; 754 }, 755 icon: 'warning', 756 items: { 757 openInVegaEditor: { 758 name: 'Open in Vega Editor', 759 callback: function(key, opts) { 760 scope.exportToVega(); 761 } 762 } 763 } 764 } 765 } 766 }); 767 768 // The context menu is only shown if there's a single right click. We 769 // intercept the clicking event and if it's followed by mouseup event then 770 // the context menu is shown, otherwise the event is sent to the THREE.js 771 // orbit controls callback. See: http://stackoverflow.com/a/20831728 772 this.$plotSpace.on('mousedown', function(evt) { 773 scope.$plotSpace.on('mouseup mousemove', function handler(evt) { 774 if (evt.type === 'mouseup') { 775 // 3 is the right click 776 if (evt.which === 3) { 777 var contextDiv = $('#' + scope.$divId.attr('id') + 778 ' .emperor-plot-wrapper'); 779 contextDiv.contextMenu({x: evt.pageX, y: evt.pageY}); 780 } 781 } 782 scope.$plotSpace.off('mouseup mousemove', handler); 783 }); 784 }); 785 }; 786 787 /** 788 * 789 * Save the current canvas view to a new window 790 * 791 * @param {string} [type = png] Format to save the file as: ('png', 'svg') 792 * 793 */ 794 EmperorController.prototype.screenshot = function(type) { 795 var img, renderer, factor = 5; 796 type = type || 'png'; 797 798 if (type === 'png') { 799 var pngRenderer; 800 801 // Point clouds can't be rendered by the CanvasRenderer, therefore we 802 // have to use the WebGLRenderer and can't increase the image size. 803 if (this.UIState['view.usesPointCloud'] || 804 this.UIState['view.viewType'] === 'parallel-plot') { 805 pngRenderer = this.sceneViews[0].renderer; 806 } 807 else { 808 pngRenderer = new THREE.CanvasRenderer({ 809 antialias: true, 810 preserveDrawingBuffer: true 811 }); 812 813 pngRenderer.autoClear = true; 814 pngRenderer.sortObjects = true; 815 pngRenderer.setSize(this.$plotSpace.width() * factor, 816 this.$plotSpace.height() * factor); 817 pngRenderer.setPixelRatio(window.devicePixelRatio); 818 } 819 pngRenderer.render(this.sceneViews[0].scene, this.sceneViews[0].camera); 820 821 // toBlob is only available in some browsers, that's why we use 822 // canvas-toBlob 823 pngRenderer.domElement.toBlob(function(blob) { 824 saveAs(blob, 'emperor.png'); 825 }); 826 } 827 else if (type === 'svg') { 828 // confirm box based on number of samples: better safe than sorry 829 if (this.decViews.scatter.decomp.length >= 9000) { 830 if (confirm('This number of samples could take a long time and in ' + 831 'some computers the browser will crash. If this happens we ' + 832 'suggest to use the png implementation. Do you want to ' + 833 'continue?') === false) { 834 return; 835 } 836 } 837 838 // generating SVG image 839 var svgRenderer = new THREE.SVGRenderer({antialias: true, 840 preserveDrawingBuffer: true}); 841 svgRenderer.setSize(this.$plotSpace.width(), this.$plotSpace.height()); 842 svgRenderer.render(this.sceneViews[0].scene, this.sceneViews[0].camera); 843 svgRenderer.sortObjects = true; 844 845 // converting svgRenderer to string: http://stackoverflow.com/a/17415624 846 var XMLS = new XMLSerializer(); 847 var svgfile = XMLS.serializeToString(svgRenderer.domElement); 848 849 // some browsers (Chrome) will add the namespace, some won't. Make sure 850 // that if it's not there, you add it to make sure the file can be opened 851 // in tools like Adobe Illustrator or in browsers like Safari or FireFox 852 if (svgfile.indexOf('xmlns="http://www.w3.org/2000/svg"') === -1) { 853 // adding xmlns header to open in the browser 854 svgfile = svgfile.replace('viewBox=', 855 'xmlns="http://www.w3.org/2000/svg" ' + 856 'viewBox='); 857 } 858 859 // hacking the background color by adding a rectangle 860 var index = svgfile.indexOf('viewBox="') + 9; 861 var viewBox = svgfile.substring(index, 862 svgfile.indexOf('"', index)).split(' '); 863 var background = '<rect id="background" height="' + viewBox[3] + 864 '" width="' + viewBox[2] + '" y="' + viewBox[1] + 865 '" x="' + viewBox[0] + 866 '" stroke-width="0" stroke="#000000" fill="#' + 867 this.sceneViews[0].scene.background.getHexString() + 868 '"/>'; 869 index = svgfile.indexOf('>', index) + 1; 870 svgfile = svgfile.substr(0, index) + background + svgfile.substr(index); 871 872 var blob = new Blob([svgfile], {type: 'image/svg+xml'}); 873 saveAs(blob, 'emperor-image.svg'); 874 875 // generating legend 876 var names = [], colors = [], legend; 877 878 if (this.controllers.color.isColoringContinuous()) { 879 legend = XMLS.serializeToString(this.controllers.color.$colorScale[0]); 880 } 881 else { 882 _.each(this.controllers.color.getSlickGridDataset(), function(element) { 883 names.push(element.category); 884 colors.push(element.value); 885 }); 886 887 legend = Draw.formatSVGLegend(names, colors); 888 } 889 blob = new Blob([legend], {type: 'image/svg+xml'}); 890 saveAs(blob, 'emperor-image-labels.svg'); 891 } else { 892 console.error('Screenshot type not implemented'); 893 } 894 895 // re-render everything, sometimes after saving objects, the colors change 896 this.sceneViews.forEach(function(view) { 897 view.needsUpdate = true; 898 }); 899 }; 900 901 /** 902 * 903 * Write settings file for the current controller settings 904 * 905 * The format is as follows: a javascript object with the camera position 906 * stored in the 'cameraPosition' key and the quaternion in the 907 * 'cameraQuaternion' key. Each controller in this.controllers is then saved 908 * by calling toJSON on them, and the resulting object saved under the same 909 * key as the controllers object. 910 * 911 */ 912 EmperorController.prototype.saveConfig = function() { 913 var saveinfo = {}; 914 // Assuming single sceneview for now 915 sceneview = this.sceneViews[0]; 916 saveinfo.cameraPosition = sceneview.camera.position; 917 saveinfo.cameraQuaternion = sceneview.camera.quaternion; 918 saveinfo.hideBiplotLabels = this._hideBiplotLabels; 919 920 // Save settings for each controller in the view 921 _.each(this.controllers, function(controller, index) { 922 if (controller !== undefined) { 923 saveinfo[index] = controller.toJSON(); 924 } 925 }); 926 927 // Save the file 928 var blob = new Blob([JSON.stringify(saveinfo)], {type: 'text/json'}); 929 saveAs(blob, 'emperor-settings.json'); 930 }; 931 932 /** 933 * 934 * Load a settings file and set all controller variables. 935 * 936 * This method will trigger a rendering callback. 937 * 938 * @param {object} json Information about the emperor session to load. 939 * 940 */ 941 EmperorController.prototype.loadConfig = function(json) { 942 //still assuming one sceneview for now 943 var sceneview = this.sceneViews[0]; 944 945 if (json.cameraPosition !== undefined) { 946 sceneview.camera.position.set(json.cameraPosition.x, 947 json.cameraPosition.y, 948 json.cameraPosition.z); 949 } 950 if (json.cameraQuaternion !== undefined) { 951 sceneview.camera.quaternion.set(json.cameraQuaternion._x, 952 json.cameraQuaternion._y, 953 json.cameraQuaternion._z, 954 json.cameraQuaternion._w); 955 } 956 if (json.hideBiplotLabels !== undefined) { 957 /* 958 * The controller only needs to toggle the visibility if the saved state 959 * is different from the current state. 960 * 961 * saved | current || result 962 * ========================= 963 * false | false || no-op 964 * false | true || toggle 965 * true | false || toggle 966 * true | true || no-op 967 * 968 * The table above represents a logical XOR. 969 */ 970 if (json.hideBiplotLabels ^ this._hideBiplotLabels) { 971 this.decViews.biplot.toggleLabelVisibility(); 972 } 973 this._hideBiplotLabels = json.hideBiplotLabels; 974 } 975 976 //must call updates to reset for camera move 977 sceneview.camera.updateProjectionMatrix(); 978 sceneview.control.update(); 979 980 //load the rest of the controller settings 981 _.each(this.controllers, function(controller, index) { 982 if (controller !== undefined && json[index] !== undefined) { 983 // wrap everything inside this "ready" call to prevent problems with 984 // the jQuery elements not being loaded yet 985 $(function() { 986 controller.fromJSON(json[index]); 987 }); 988 } 989 }); 990 991 sceneview.needsUpdate = true; 992 }; 993 994 /** 995 * 996 * Helper method to add tabs to the controller. 997 * 998 * @param {DecompositionView[]} dvdict Dictionary of DecompositionViews. 999 * @param {EmperorViewControllerABC} viewConstructor Constructor of the view 1000 * controller. 1001 * 1002 */ 1003 EmperorController.prototype.addTab = function(dvdict, viewConstructor) { 1004 var scope = this; 1005 this._expected += 1; 1006 1007 // nothing but a temporary id 1008 var id = (Math.round(1000000 * Math.random())).toString(), $li; 1009 1010 this._$tabsContainer.append("<div id='" + id + 1011 "' class='emperor-tab-div' ></div>"); 1012 $('#' + id).height(this.$plotMenu.height() - this._$tabsList.height()); 1013 1014 // dynamically instantiate the controller, see: 1015 // http://stackoverflow.com/a/8843181 1016 var params = [null, this.UIState, '#' + id, dvdict]; 1017 var obj = new (Function.prototype.bind.apply(viewConstructor, params)); 1018 1019 obj.ready = function() { 1020 scope._controllerHasFinishedLoading(); 1021 }; 1022 1023 // set the identifier of the div to the one defined by the object 1024 $('#' + id).attr('id', obj.identifier); 1025 1026 // now add the list element linking to the container div with the proper 1027 // title 1028 $li = $("<li><a href='#" + obj.identifier + "'>" + obj.title + '</a></li>'); 1029 $li.attr('title', obj.description); 1030 this._$tabsList.append($li); 1031 1032 return obj; 1033 }; 1034 1035 /** 1036 * 1037 * Helper that posts messages between browser tabs 1038 * 1039 * @private 1040 * 1041 */ 1042 _postMessage = function(url, payload) { 1043 // Shamelessly pulled from https://github.com/vega/vega-embed/ 1044 var editor = window.open(url); 1045 var wait = 10000; 1046 var step = 250; 1047 var count = ~~(wait / step); 1048 1049 function listen(e) { 1050 if (e.source === editor) { 1051 count = 0; 1052 window.removeEventListener('message', listen, false); 1053 } 1054 } 1055 1056 window.addEventListener('message', listen, false); 1057 1058 function send() { 1059 if (count <= 0) { 1060 return; 1061 } 1062 editor.postMessage(payload, '*'); 1063 setTimeout(send, step); 1064 count -= 1; 1065 } 1066 setTimeout(send, step); 1067 }; 1068 1069 /** 1070 * 1071 * Open in Vega editor 1072 * 1073 */ 1074 EmperorController.prototype.exportToVega = function() { 1075 var url = 'https://vega.github.io/editor/'; 1076 var spec = this.decViews.scatter._buildVegaSpec(); 1077 var payload = { 1078 mode: 'vega', 1079 renderer: 'canvas', 1080 spec: JSON.stringify(spec) 1081 }; 1082 _postMessage(url, payload); 1083 }; 1084 1085 return EmperorController; 1086 }); 1087