Source: controller.js

define([
    'jquery',
    'underscore',
    'contextmenu',
    'three',
    'view',
    'scene3d',
    'colorviewcontroller',
    'visibilitycontroller',
    'opacityviewcontroller',
    'shapecontroller',
    'axescontroller',
    'scaleviewcontroller',
    'animationscontroller',
    'filesaver',
    'viewcontroller',
    'svgrenderer',
    'draw',
    'canvasrenderer',
    'canvastoblob',
    'multi-model',
    'uistate'
], function($, _, contextMenu, THREE, DecompositionView, ScenePlotView3D,
            ColorViewController, VisibilityController, OpacityViewController,
            ShapeController, AxesController, ScaleViewController,
            AnimationsController, FileSaver, viewcontroller, SVGRenderer, Draw,
            CanvasRenderer, canvasToBlob, MultiModel, UIStateInit) {

  var EmperorAttributeABC = viewcontroller.EmperorAttributeABC;

  var TAB_ORDER = ['color', 'visibility', 'opacity', 'scale',
                   'shape', 'axes', 'animations'];

  var controllerConstructors = {
      'color': ColorViewController,
      'visibility': VisibilityController,
      'opacity': OpacityViewController,
      'scale': ScaleViewController,
      'shape': ShapeController,
      'axes': AxesController,
      'animations': AnimationsController
    };

  /**
   *
   * @class EmperorController
   *       This is the application controller
   *
   * The application controller, contains all the information on how the model
   * is being presented to the user.
   *
   * @param {DecompositionModel} scatter A decomposition object that represents
   * the scatter-represented objects.
   * @param {DecompositionModel} biplot An optional decomposition object that
   * represents the arrow-represented objects. Can be null or undefined.
   * @param {string} divId The element id where the controller should
   * instantiate itself.
   * @param {node} [webglcanvas = undefined] the canvas to use to render the
   * information. This parameter is optional, and should rarely be set. But is
   * useful for external applications like SAGE2.
   *
   * @return {EmperorController}
   * @constructs EmperorController
   *
   */
  function EmperorController(scatter, biplot, divId, webglcanvas) {

    /**
     * The state shared across one instance of the UI
     * @type {UIState}
     */
    this.UIState = new UIStateInit();
    this.UIState.setProperty('view.usesPointCloud', scatter.length > 20000);

    var scope = this;
    /**
     * Scaling constant for grid dimensions (read only).
     * @type {float}
     */
    this.GRID_SCALE = 0.97;

    /**
     * Scaling constant for scene plot view dimensions
     * @type {float}
     */
    this.SCENE_VIEW_SCALE = 0.5;
    /**
     * jQuery object where the object lives in.
     * @type {node}
     */
    this.$divId = $('#' + divId);
    /**
     * Width of the object.
     * @type {float}
     */
    this.width = this.$divId.width();
    /**
     * Height of the object.
     * @type {float}
     */
    this.height = this.$divId.height();

    var decModelMap = {'scatter': scatter};
    if (biplot)
      decModelMap['biplot'] = biplot;

    /**
     * MultiModel object containing all DecompositionModels
     *
     * @type {MultiModel}
     */
    this.decModels = new MultiModel(decModelMap);

    /**
     * Object with all the available decomposition views.
     *
     * @type {object}
     */
    this.decViews = {'scatter':
                        new DecompositionView(this.decModels,
                                              'scatter',
                                              this.UIState)};

    if (biplot) {
      this.decViews.biplot = new DecompositionView(this.decModels,
                                                   'biplot',
                                                   this.UIState);
    }

    /**
     * Keep track of whether or not the biplot labels should be hidden.
     *
     * @type {Bool}
     * @private
     */
    this._hideBiplotLabels = false;

    /**
     * List of the scene plots views being rendered.
     * @type {ScenePlotView3D[]}
     */
    this.sceneViews = [];

    /**
     * Internal div where the menus live in (jQuery object).
     * @type {node}
     */
    this.$plotSpace = $("<div class='emperor-plot-wrapper'></div>");

    /**
     * Div with the number of visible samples
     * @type {node}
     */
    this.$plotBanner = $('<label>Loading ...</label>');
    this.$plotBanner.css({'padding': '2px',
                          'font-style': '9pt helvetica',
                          'color': 'white',
                          'border': '1px solid',
                          'border-color': 'white',
                          'position': 'absolute'});

    // add the sample count to the plot space
    this.$plotSpace.append(this.$plotBanner);

    /**
     * Internal div where the plots live in (jQuery object).
     * @type {node}
     */
    this.$plotMenu = $("<div class='emperor-plot-menu'></div>");
    this.$plotMenu.attr('title', 'Right click on the plot for more options, ' +
                        ' click on a sample to reveal its name, or ' +
                        'double-click on a sample to copy its name to the ' +
                        'clipboard');

    this.$divId.append(this.$plotSpace);
    this.$divId.append(this.$plotMenu);

    /**
     * @type {Function}
     * Callback to execute when all the view controllers have been successfully
     * loaded.
     */
    this.ready = null;

    /**
     * Holds a reference to all the tabs (view controllers) in the `$plotMenu`.
     * @type {object}
     */
    this.controllers = {};

    /**
     * Object in charge of doing the rendering of the scenes.
     * @type {THREE.Renderer}
     */
    this.renderer = null;
    if (webglcanvas !== undefined) {
        this.renderer = new THREE.WebGLRenderer({canvas: webglcanvas,
                                                 antialias: true});
    }
    else {
        this.renderer = new THREE.WebGLRenderer({antialias: true});
    }

    this.renderer.setSize(this.width, this.height);
    this.renderer.autoClear = false;
    this.renderer.sortObjects = true;
    this.$plotSpace.append(this.renderer.domElement);


    /**
     * The number of tabs that we expect to see. This attribute is updated by
     * the addTab method, and is only releveant during the initialization
     * process.
     * @private
     */
    this._expected = 0;

    /**
     * The number of tabs that have finished initalization. This attribute is
     * only relevant during the initialization process.
     * @private
     */
    this._seen = 0;

    /**
     * Menu tabs containers, note that we need them in this format to have
     * jQuery's UI tabs work properly. All the view controllers will be added
     * to this container, see the addTab method
     * @see EmperorController.addTab
     * @type {node}
     * @private
     */
    this._$tabsContainer = $("<div name='emperor-tabs-container'></div>");
    this._$tabsContainer.css('background-color', '#EEEEEE');
    this._$tabsContainer.addClass('unselectable');
    /**
     * List of available tabs, lives inside `_$tabsContainer`.
     * @type {node}
     * @private
     */
    this._$tabsList = $("<ul name='emperor-tabs-list'></ul>");

    // These will both live in the menu space. As of the writing of this code
    // there's nothing else but tabs on the menu, but this may change in the
    // future, that's why we are creating the extra "tabsContainer" div
    this.$plotMenu.append(this._$tabsContainer);
    this._$tabsContainer.append(this._$tabsList);

    /**
     * @type {Node}
     * jQuery object To show the context menu (as an alternative to
     * right-clicking on the plot).
     *
     * The context menu that this button shows is created in the _buildUI
     * method.
     */
    this.$optionsButton = $('<button name="options-button">&nbsp;</button>');
    this.$optionsButton.css({
      'position': 'absolute',
      'z-index': '3',
      'top': '5px',
      'right': '5px'
    }).attr('title', 'More Options').on('click', function(event) {
      // add offset to avoid overlapping the button with the menu
      scope.$plotSpace.contextMenu({x: event.pageX, y: event.pageY + 5});
    });
    this.$plotSpace.append(this.$optionsButton);

    // default decomposition view uses the full window
    this.addSceneView();

    $(function() {
      // setup the jquery properties of the button
      scope.$optionsButton.button({text: false,
                                   icons: {primary: ' ui-icon-gear'}});

      scope._buildUI();
      // Hide the loading splashscreen
      scope.$divId.find('.loading').hide();

      // The next few lines setup the space/menu resizing logic. Specifically,
      // we only enable the "west' handle, set double-click toggle behaviour
      // and add a tooltip to the handle.
      scope.$plotMenu.resizable({
        handles: 'w',
        helper: 'plot-space-resizable-helper',
        stop: function(event, ui) {
          var percent = (ui.size.width / scope.width) * 100;

          scope.$plotSpace.width((100 - percent) + '%');
          scope.$plotMenu.css({'width': percent + '%', 'left': 0});

          // The scrollbars randomly appear on the window while showing the
          // helper, with this small delay we give them enough time to
          // disappear.
          setTimeout(function() {
            scope.resize(scope.width, scope.height);
          }, 50);
        }
      });

      scope.$plotMenu.find('.ui-resizable-handle').dblclick(function() {
        var percent = (scope.$plotSpace.width() / scope.width) * 100;

        // allow for a bit of leeway
        if (percent >= 98) {
          scope.$plotSpace.css({'width': '73%'});
          scope.$plotMenu.css({'width': '27%', 'left': 0});
        }
        else {
          scope.$plotSpace.css({'width': '99%'});
          scope.$plotMenu.css({'width': '1%', 'left': 0});
        }
        scope.resize(scope.width, scope.height);
      }).attr('title', 'Drag to resize or double click to toggle visibility');

    });

    // once the object finishes loading, resize the contents so everything fits
    // nicely
    $(this).ready(function() {
      scope.resize(scope.$divId.width(), scope.$divId.height());
    });

    this.UIState.registerProperty('view.viewType', function(evt) {
      toDisable = ['scale', 'shape', 'animations'];

      for (controllerName in scope.controllers) {
        var c = scope.controllers[controllerName];
        selector = "li[aria-controls='" + c.identifier + "']";
        //jquery effects are less jarring, but also remind me of people who add
        //effects to slide transitions in powerpoint.  I'd still prefer css
        //to gray out the tab...
        //effects list at https://api.jqueryui.com/category/effects/

        if (toDisable.includes(controllerName)) {
          if (evt.newVal === 'parallel-plot')
            $(selector).hide('blind');
          else if (evt.newVal === 'scatter')
            $(selector).show('blind');
        }
      }
    });
  };

  /**
   *
   * Add a new decomposition view
   *
   * @param {String} key New name for the decomposition view.
   * @param {DecompositionView} value The decomposition view that will be
   * added.
   *
   * @throws Error if `key` already exists, or if `value` is not a
   * decomposition view.
   *
   */
  EmperorController.prototype.addDecompositionView = function(key, value) {
    if (!(value instanceof DecompositionView)) {
      console.error('The value is not a decomposition view');
    }

    if (_.contains(_.keys(this.decViews), key)) {
      throw Error('A decomposition view named "' + key + '" already exists,' +
                  'cannot add an already existing decomposition.');
    }

    this.decViews[key] = value;

    _.each(this.controllers, function(controller) {
      if (controller instanceof EmperorAttributeABC) {
        controller.refreshMetadata();
      }
    });

    _.each(this.sceneViews, function(sv) {
      sv.addDecompositionsToScene();
    });
  };

  /**
   *
   * Helper method to add additional ScenePlotViews (i.e. another plot)
   *
   */
  EmperorController.prototype.addSceneView = function() {
    if (this.sceneViews.length > 4) {
      throw Error('Cannot add another scene plot view');
    }

    var spv = new ScenePlotView3D(this.UIState,
                                  this.renderer,
                                  this.decViews,
                                  this.decModels,
                                  this.$plotSpace, 0, 0,
                                  this.width, this.height);
    this.sceneViews.push(spv);

    // this will setup the appropriate sizes and widths
    this.resize(this.width, this.height);
  };

  /**
   *
   * Helper method to resize the plots
   *
   * @param {width} the width of the entire plotting space
   * @param {height} the height of the entire plotting space
   *
   */
  EmperorController.prototype.resize = function(width, height) {
    // update the available space we have
    this.width = width;
    this.height = height;

    this.$plotSpace.height(height);
    this.$plotMenu.height(height);

    this._$tabsContainer.height(height);

    // the area we have to present the plot is smaller than the total
    var plotWidth = this.$plotSpace.width();

    // TODO: The below will need refactoring
    // This is addressed in issue #405
    if (this.sceneViews.length === 1) {
      this.sceneViews[0].resize(0, 0, plotWidth, this.height);
    }
    else if (this.sceneViews.length === 2) {
      this.sceneViews[0].resize(0, 0, this.SCENE_VIEW_SCALE * plotWidth,
          this.height);
      this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0,
          this.SCENE_VIEW_SCALE * plotWidth, this.height);
    }
    else if (this.sceneViews.length === 3) {
      this.sceneViews[0].resize(0, 0,
          this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
      this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0,
          this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
      this.sceneViews[2].resize(0, this.SCENE_VIEW_SCALE * this.height,
          plotWidth, this.SCENE_VIEW_SCALE * this.height);
    }
    else if (this.sceneViews.length === 4) {
      this.sceneViews[0].resize(0, 0, this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
      this.sceneViews[1].resize(this.SCENE_VIEW_SCALE * plotWidth, 0,
          this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
      this.sceneViews[2].resize(0, this.SCENE_VIEW_SCALE * this.height,
          this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
      this.sceneViews[3].resize(this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height,
          this.SCENE_VIEW_SCALE * plotWidth,
          this.SCENE_VIEW_SCALE * this.height);
    }
    else {
      throw Error('More than four views are currently not supported');
    }

    this.renderer.setSize(plotWidth, this.height);

    /* Resizing the tabs (view controllers) */

    // resize the grid according to the size of the container, since we are
    // inside the tabs we have to account for that lost space.
    var tabHeight = this.$plotMenu.height() * this.GRID_SCALE;

    // the tab list at the top takes up a variable amount of space and
    // without this, the table displayed below will have an odd scrolling
    // behaviour
    tabHeight -= this._$tabsList.height();

    // for each controller, we need to (1) trigger the resize method, and (2)
    // resize the height of the containing DIV tag (we don't need to resize the
    // width as this is already taken care of since it just has to fit the
    // available space).
    _.each(this.controllers, function(controller, index) {
      if (controller !== undefined) {
        $('#' + controller.identifier).height(tabHeight);

        var w = $('#' + controller.identifier).width(),
            h = $('#' + controller.identifier).height();

        controller.resize(w, h);
      }
    });

    //Set all scenes to needing update
    for (var i = 0; i < this.sceneViews.length; i++) {
      this.sceneViews[i].needsUpdate = true;
    }
  };

  /**
   *
   * Helper method to render sceneViews, gets called every time the browser
   * indicates we can render a new frame, however it only triggers the
   * appropriate rendering functions if something has changed since the last
   * frame.
   *
   */
  EmperorController.prototype.render = function() {
    var scope = this;

    if (this.controllers.animations !== undefined) {
      this.controllers.animations.drawFrame();
    }

    $.each(this.sceneViews, function(i, sv) {
      requiredActions = sv.checkUpdate();
      if (requiredActions &
          ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH) {
        //loop over controllers and update
        for (controllerKey in scope.controllers) {
          scope.controllers[controllerKey].forceRefresh();
        }
      }
      if (requiredActions &
          ScenePlotView3D.prototype.NEEDS_RENDER) {
        scope.renderer.setViewport(0, 0, scope.width, scope.height);
        scope.renderer.clear();
        sv.render();

        // if there's a change for the scene view update the counts
        scope.updatePlotBanner();
      }
    });

  };

  /**
   *
   * Updates the plot banner based on the number of visible elements and the
   * scene's background color.
   *
   */
  EmperorController.prototype.updatePlotBanner = function() {
    var color = this.sceneViews[0].scene.background.clone(), visible = 0,
        total = 0, message = '';

    // invert the color so it's visible regardless of the background
    color.setRGB((Math.floor(color.r * 255) ^ 0xFF) / 255,
                 (Math.floor(color.g * 255) ^ 0xFF) / 255,
                 (Math.floor(color.b * 255) ^ 0xFF) / 255);
    color = color.getStyle();

    _.each(this.decViews, function(decomposition) {
      // computing this with every update requires traversin all elements,
      // however it seems as the only reliable way to get this number right
      // without depending on the view controllers (an anti-pattern)
      visible += decomposition.getVisibleCount();
      total += decomposition.count;
    });

    this.$plotBanner.css({'color': color, 'border-color': color});

    if (visible !== total) {
      message = ' <br> WARNING: hiding samples in an ordination can be ' +
                'misleading';
    }

    this.$plotBanner.html(visible.toLocaleString() + ' / ' +
                          total.toLocaleString() + ' visible' + message);
  };

  EmperorController.prototype.getPlotBanner = function(text) {
    return this.$plotBanner.text();
  };

  /**
   *
   * Helper method to check if all the view controllers have finished loading.
   * Relies on the fact that each view controller announces when it is ready.
   *
   * @private
   *
   */
  EmperorController.prototype._controllerHasFinishedLoading = function() {
    this._seen += 1;

    if (this._seen >= this._expected) {
      if (this.ready !== null) {
        this.ready();
      }
    }
  };

  /**
   *
   * Helper method to assemble UI, completely independent of HTML template.
   * This method is called when the object is constructed.
   *
   * @private
   *
   */
  EmperorController.prototype._buildUI = function() {
    var scope = this, isLargeDataset = this.UIState['view.usesPointCloud'];

    for (var index in TAB_ORDER) {
      var item = TAB_ORDER[index];
      if (item === 'shape' && isLargeDataset)
        continue;
      scope.controllers[item] = scope.addTab(scope.sceneViews[0].decViews,
                                             controllerConstructors[item]);
    }

    // We are tabifying this div, I don't know man.
    this._$tabsContainer.tabs({heightStyle: 'fill',
                               // The tabs on the plot space only get resized
                               // when they are visible, thus we subscribe to
                               // the event that's fired after a user selects a
                               // tab.  If you don't do this, the width and
                               // height of each of the view controllers will
                               // be wrong.  We also found that subscribing to
                               // document.ready() wouldn't work either as the
                               // resize callback couldn't be executed on a tab
                               // that didn't exist yet.
                               activate: function(event, ui) {
                                 scope.resize(scope.$divId.width(),
                                              scope.$divId.height());
                               }});

    // Set up the context menu
    this.$contextMenu = $.contextMenu({
      // only tie this selector to our own container div, otherwise with
      // multiple plots on the same screen, this callback gets confused
      selector: '#' + scope.$divId.attr('id') + ' .emperor-plot-wrapper',
      trigger: 'none',
      items: {
        'recenterCamera': {
          name: 'Recenter camera',
          icon: 'home',
          callback: function(key, opts) {
            _.each(scope.sceneViews, function(scene) {
              scene.recenterCamera();
            });
          }
        },
        'toggleAutorotate': {
          name: 'Toggle autorotation',
          icon: 'rotate-left',
          callback: function(key, opts) {
            _.each(scope.sceneViews, function(scene) {
              scene.control.autoRotate = scene.control.autoRotate ^ true;
            });
          },
          disabled: function(key, opts) {
            return scope.UIState['view.viewType'] === 'parallel-plot';
          }
        },
        'labels' : {
          name: 'Toggle label visibility',
          visible: scope.decViews.biplot !== undefined,
          icon: 'font',
          callback: function() {
            scope._hideBiplotLabels = Boolean(scope._hideBiplotLabels ^ true);
            scope.decViews.biplot.toggleLabelVisibility();
          }
        },
        'sep0': '----------------',
        'saveState': {
          name: 'Save current settings',
          icon: 'save',
          callback: function(key, opts) {
            scope.saveConfig();
          }
        },
        'loadState': {
          name: 'Load saved settings',
          icon: 'folder-open-o',
          callback: function(key, opts) {
            if (!FileReader) {
              alert('Your browser does not support file loading. We ' +
                    'recommend using Google Chrome for full functionality.');
              return;
            }
            var file = $('<input type="file">');
            file.on('change', function(evt) {
              var f = evt.target.files[0];
              // With help from
              // http://www.htmlgoodies.com/beyond/javascript/read-text-files-using-the-javascript-filereader.html
              var r = new FileReader();
              r.onload = function(e) {
                try {
                  var json = JSON.parse(e.target.result);
                } catch (err) {
                  alert('File given is not a JSON parsable file.');
                  return;
                }
                try {
                  scope.loadConfig(json);
                } catch (err) {
                  alert('Error loading settings from file: ' + err.message);
                  return;
                }
              };
              r.readAsText(f);
            });
            file.click();
          }
        },
        'sep1': '---------',
        // With large datasets we can't save to SVG. The PNG file will not be
        // high resolution.
        'fold1': {
            'name': 'Save Image',
            icon: 'file-picture-o',
            'items': {
              'saveImagePNG': {
                name: 'PNG' + (isLargeDataset ? '' : ' (high resolution)'),
                callback: function(key, opts) {
                  scope.screenshot('png');
                }
              },
              'saveImageSVG': {
                name: 'SVG + labels' + (isLargeDataset ?
                      ' (not supported for large datasets)' : '') ,
                callback: function(key, opts) {
                  scope.screenshot('svg');
                },
                disabled: function(key, opt) {
                  return isLargeDataset ||
                         (scope.UIState['view.viewType'] === 'parallel-plot');
                }
              }
            }
        },
        fold2: {
          name: 'Experimental',
          disabled: function(key, opt) {
            // Only enable if this is a "vanilla" plot
            if (scope.UIState['view.viewType'] === 'scatter' &&
                scope.decViews.scatter.lines.left === null &&
                scope.decViews.scatter.lines.right === null &&
                scope.decViews.biplot === undefined) {
              return false;
            }
            return true;
          },
          icon: 'warning',
          items: {
            openInVegaEditor: {
              name: 'Open in Vega Editor',
              callback: function(key, opts) {
                scope.exportToVega();
              }
            }
          }
        }
      }
    });

    // The context menu is only shown if there's a single right click. We
    // intercept the clicking event and if it's followed by mouseup event then
    // the context menu is shown, otherwise the event is sent to the THREE.js
    // orbit controls callback. See: http://stackoverflow.com/a/20831728
    this.$plotSpace.on('mousedown', function(evt) {
      scope.$plotSpace.on('mouseup mousemove', function handler(evt) {
        if (evt.type === 'mouseup') {
          // 3 is the right click
          if (evt.which === 3) {
            var contextDiv = $('#' + scope.$divId.attr('id') +
                               ' .emperor-plot-wrapper');
            contextDiv.contextMenu({x: evt.pageX, y: evt.pageY});
          }
        }
        scope.$plotSpace.off('mouseup mousemove', handler);
      });
    });
  };

  /**
   *
   * Save the current canvas view to a new window
   *
   * @param {string} [type = png] Format to save the file as: ('png', 'svg')
   *
   */
  EmperorController.prototype.screenshot = function(type) {
    var img, renderer, factor = 5;
    type = type || 'png';

    if (type === 'png') {
      var pngRenderer;

      // Point clouds can't be rendered by the CanvasRenderer, therefore we
      // have to use the WebGLRenderer and can't increase the image size.
      if (this.UIState['view.usesPointCloud'] ||
          this.UIState['view.viewType'] === 'parallel-plot') {
        pngRenderer = this.sceneViews[0].renderer;
      }
      else {
        pngRenderer = new THREE.CanvasRenderer({
          antialias: true,
          preserveDrawingBuffer: true
        });

        pngRenderer.autoClear = true;
        pngRenderer.sortObjects = true;
        pngRenderer.setSize(this.$plotSpace.width() * factor,
                            this.$plotSpace.height() * factor);
        pngRenderer.setPixelRatio(window.devicePixelRatio);
      }
      pngRenderer.render(this.sceneViews[0].scene, this.sceneViews[0].camera);

      // toBlob is only available in some browsers, that's why we use
      // canvas-toBlob
      pngRenderer.domElement.toBlob(function(blob) {
        saveAs(blob, 'emperor.png');
      });
    }
    else if (type === 'svg') {
      // confirm box based on number of samples: better safe than sorry
      if (this.decViews.scatter.decomp.length >= 9000) {
        if (confirm('This number of samples could take a long time and in ' +
           'some computers the browser will crash. If this happens we ' +
           'suggest to use the png implementation. Do you want to ' +
           'continue?') === false) {
          return;
        }
      }

      // generating SVG image
      var svgRenderer = new THREE.SVGRenderer({antialias: true,
                                               preserveDrawingBuffer: true});
      svgRenderer.setSize(this.$plotSpace.width(), this.$plotSpace.height());
      svgRenderer.render(this.sceneViews[0].scene, this.sceneViews[0].camera);
      svgRenderer.sortObjects = true;

      // converting svgRenderer to string: http://stackoverflow.com/a/17415624
      var XMLS = new XMLSerializer();
      var svgfile = XMLS.serializeToString(svgRenderer.domElement);

      // some browsers (Chrome) will add the namespace, some won't. Make sure
      // that if it's not there, you add it to make sure the file can be opened
      // in tools like Adobe Illustrator or in browsers like Safari or FireFox
      if (svgfile.indexOf('xmlns="http://www.w3.org/2000/svg"') === -1) {
        // adding xmlns header to open in the browser
        svgfile = svgfile.replace('viewBox=',
                                  'xmlns="http://www.w3.org/2000/svg" ' +
                                  'viewBox=');
      }

      // hacking the background color by adding a rectangle
      var index = svgfile.indexOf('viewBox="') + 9;
      var viewBox = svgfile.substring(index,
                                      svgfile.indexOf('"', index)).split(' ');
      var background = '<rect id="background" height="' + viewBox[3] +
                       '" width="' + viewBox[2] + '" y="' + viewBox[1] +
                       '" x="' + viewBox[0] +
                       '" stroke-width="0" stroke="#000000" fill="#' +
                       this.sceneViews[0].scene.background.getHexString() +
                       '"/>';
      index = svgfile.indexOf('>', index) + 1;
      svgfile = svgfile.substr(0, index) + background + svgfile.substr(index);

      var blob = new Blob([svgfile], {type: 'image/svg+xml'});
      saveAs(blob, 'emperor-image.svg');

      // generating legend
      var names = [], colors = [], legend;

      if (this.controllers.color.isColoringContinuous()) {
        legend = XMLS.serializeToString(this.controllers.color.$colorScale[0]);
      }
      else {
        _.each(this.controllers.color.getSlickGridDataset(), function(element) {
          names.push(element.category);
          colors.push(element.value);
        });

        legend = Draw.formatSVGLegend(names, colors);
      }
      blob = new Blob([legend], {type: 'image/svg+xml'});
      saveAs(blob, 'emperor-image-labels.svg');
    } else {
      console.error('Screenshot type not implemented');
    }

    // re-render everything, sometimes after saving objects, the colors change
    this.sceneViews.forEach(function(view) {
      view.needsUpdate = true;
    });
  };

  /**
   *
   * Write settings file for the current controller settings
   *
   * The format is as follows: a javascript object with the camera position
   * stored in the 'cameraPosition' key and the quaternion in the
   * 'cameraQuaternion' key. Each controller in this.controllers is then saved
   * by calling toJSON on them, and the resulting object saved under the same
   * key as the controllers object.
   *
   */
  EmperorController.prototype.saveConfig = function() {
    var saveinfo = {};
    // Assuming single sceneview for now
    sceneview = this.sceneViews[0];
    saveinfo.cameraPosition = sceneview.camera.position;
    saveinfo.cameraQuaternion = sceneview.camera.quaternion;
    saveinfo.hideBiplotLabels = this._hideBiplotLabels;

    // Save settings for each controller in the view
     _.each(this.controllers, function(controller, index) {
      if (controller !== undefined) {
        saveinfo[index] = controller.toJSON();
      }
    });

    // Save the file
    var blob = new Blob([JSON.stringify(saveinfo)], {type: 'text/json'});
    saveAs(blob, 'emperor-settings.json');
   };

  /**
   *
   * Load a settings file and set all controller variables.
   *
   * This method will trigger a rendering callback.
   *
   * @param {object} json Information about the emperor session to load.
   *
   */
  EmperorController.prototype.loadConfig = function(json) {
    //still assuming one sceneview for now
    var sceneview = this.sceneViews[0];

    if (json.cameraPosition !== undefined) {
      sceneview.camera.position.set(json.cameraPosition.x,
                                    json.cameraPosition.y,
                                    json.cameraPosition.z);
    }
    if (json.cameraQuaternion !== undefined) {
      sceneview.camera.quaternion.set(json.cameraQuaternion._x,
                                      json.cameraQuaternion._y,
                                      json.cameraQuaternion._z,
                                      json.cameraQuaternion._w);
    }
    if (json.hideBiplotLabels !== undefined) {
      /*
       * The controller only needs to toggle the visibility if the saved state
       * is different from the current state.
       *
       * saved | current || result
       * =========================
       * false | false   || no-op
       * false | true    || toggle
       * true  | false   || toggle
       * true  | true    || no-op
       *
       * The table above represents a logical XOR.
       */
      if (json.hideBiplotLabels ^ this._hideBiplotLabels) {
        this.decViews.biplot.toggleLabelVisibility();
      }
      this._hideBiplotLabels = json.hideBiplotLabels;
    }

    //must call updates to reset for camera move
    sceneview.camera.updateProjectionMatrix();
    sceneview.control.update();

    //load the rest of the controller settings
    _.each(this.controllers, function(controller, index) {
      if (controller !== undefined && json[index] !== undefined) {
        // wrap everything inside this "ready" call to prevent problems with
        // the jQuery elements not being loaded yet
        $(function() {
          controller.fromJSON(json[index]);
        });
      }
    });

    sceneview.needsUpdate = true;
   };

  /**
   *
   * Helper method to add tabs to the controller.
   *
   * @param {DecompositionView[]} dvdict Dictionary of DecompositionViews.
   * @param {EmperorViewControllerABC} viewConstructor Constructor of the view
   * controller.
   *
   */
  EmperorController.prototype.addTab = function(dvdict, viewConstructor) {
    var scope = this;
    this._expected += 1;

    // nothing but a temporary id
    var id = (Math.round(1000000 * Math.random())).toString(), $li;

    this._$tabsContainer.append("<div id='" + id +
                                "' class='emperor-tab-div' ></div>");
    $('#' + id).height(this.$plotMenu.height() - this._$tabsList.height());

    // dynamically instantiate the controller, see:
    // http://stackoverflow.com/a/8843181
    var params = [null, this.UIState, '#' + id, dvdict];
    var obj = new (Function.prototype.bind.apply(viewConstructor, params));

    obj.ready = function() {
      scope._controllerHasFinishedLoading();
    };

    // set the identifier of the div to the one defined by the object
    $('#' + id).attr('id', obj.identifier);

    // now add the list element linking to the container div with the proper
    // title
    $li = $("<li><a href='#" + obj.identifier + "'>" + obj.title + '</a></li>');
    $li.attr('title', obj.description);
    this._$tabsList.append($li);

    return obj;
  };

  /**
   *
   * Helper that posts messages between browser tabs
   *
   * @private
   *
   */
  _postMessage = function(url, payload) {
    // Shamelessly pulled from https://github.com/vega/vega-embed/
    var editor = window.open(url);
    var wait = 10000;
    var step = 250;
    var count = ~~(wait / step);

    function listen(e) {
      if (e.source === editor) {
        count = 0;
        window.removeEventListener('message', listen, false);
      }
    }

    window.addEventListener('message', listen, false);

    function send() {
      if (count <= 0) {
        return;
      }
      editor.postMessage(payload, '*');
      setTimeout(send, step);
      count -= 1;
    }
    setTimeout(send, step);
  };

  /**
   *
   * Open in Vega editor
   *
   */
  EmperorController.prototype.exportToVega = function() {
    var url = 'https://vega.github.io/editor/';
    var spec = this.decViews.scatter._buildVegaSpec();
    var payload = {
      mode: 'vega',
      renderer: 'canvas',
      spec: JSON.stringify(spec)
    };
    _postMessage(url, payload);
  };

  return EmperorController;
});