      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