Source: controller.js

define([
    'jquery',
    'underscore',
    'contextmenu',
    'three',
    'view',
    'scene3d',
    'colorviewcontroller',
    'visibilitycontroller',
    'shapecontroller',
    'axescontroller',
    'scaleviewcontroller',
    'filesaver'
], function($, _, contextMenu, THREE, DecompositionView, ScenePlotView3D,
             ColorViewController, VisibilityController, ShapeController,
             AxesController, ScaleViewController, FileSaver) {

  /**
   *
   * @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} dm An object that will be represented on
   * screen.
   * @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(dm, divId, webglcanvas) {
    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();

    /**
     * Ordination data being plotted.
     * @type {DecompositionModel}
     */
    this.dm = dm;
    /**
     * 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>");
    /**
     * Internal div where the plots live in (jQuery object).
     * @type {node}
     */
    this.$plotMenu = $("<div class='emperor-plot-menu'></div>");

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

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

    /**
     * Background color of the scene.
     * @type {THREE.Color}
     * @default 0x00000000
     */
    this.rendererBackgroundColor = new THREE.Color();
    this.rendererBackgroundColor.setHex('0x000000');

    /**
     * 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.setClearColor(this.rendererBackgroundColor);
    this.renderer.autoClear = false;
    this.renderer.sortObjects = true;
    this.$plotSpace.append(this.renderer.domElement);

    /**
     * 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');
    /**
     * 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);

    /**
     * Object with all the available decomposition views.
     *
     * FIXME: This is a hack to go around the fact that the constructor takes
     * a single decomposition model instead of a dictionary
     *
     * @type {object}
     */
    this.decViews = {'scatter': new DecompositionView(this.dm)};

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

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

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

  };

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

    var spv = new ScenePlotView3D(this.renderer, this.decViews,
                                  this.$plotSpace, 0, 0, 0, 0);
    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;
    $.each(this.sceneViews, function(i, sv) {
      if (sv.checkUpdate()) {
        scope.renderer.setViewport(0, 0, scope.width, scope.height);
        scope.renderer.clear();
        sv.render();
      }
    });
  };

  /**
   *
   * 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;

    //FIXME: This only works for 1 scene plot view
    this.controllers.color = this.addTab(this.sceneViews[0].decViews,
                                         ColorViewController);
    this.controllers.visibility = this.addTab(this.sceneViews[0].decViews,
                                              VisibilityController);
    this.controllers.shape = this.addTab(this.sceneViews[0].decViews,
                                         ShapeController);
    this.controllers.axes = this.addTab(this.sceneViews[0].decViews,
                                        AxesController);
    this.controllers.scale = this.addTab(this.sceneViews[0].decViews,
        ScaleViewController);

    // 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: {
        'saveState': {
          name: 'Save current settings',
          icon: 'edit',
          callback: function(key, opts) {
            scope.saveConfig();
          }
        },
        'loadState': {
          name: 'Load saved settings',
          icon: 'paste',
          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': '---------',
        'saveImage': {
          name: 'Save Image (PNG)',
          icon: 'edit',
          callback: function(key, opts) {
            scope.screenshot();
          }
        }
      }
    });

    // Add shift+right click as the trigger for the context menu
    this.$plotSpace.on('contextmenu', function(e) {
      if (e.shiftKey) {
        var contextDiv = $('#' + scope.$divId.attr('id') +
                           ' .emperor-plot-wrapper');
        contextDiv.contextMenu({x: e.pageX, y: e.pageY});
      }
    });
  };

  /**
   *
   * Save the current canvas view to a new window
   *
   * @param {string} [type = png] What format to save the file as.
   *
   */
  EmperorController.prototype.screenshot = function(type) {
    type = type || 'png';
    // Render all scenes so it's rendered in same context as save
    for (var i = 0; i < this.sceneViews.length; i++) {
      this.sceneViews[i].render();
    }
    var c = this.renderer.domElement.toDataURL('image/' + type);
    // Create DOM-less download link and click it to start download
    var download = $('<a href="' + c + '" download="emperor.' + type + '">');
    download.get(0).click();
  };

  /**
   *
   * 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;
    // 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];

    sceneview.camera.position.set(json.cameraPosition.x,
                                  json.cameraPosition.y,
                                  json.cameraPosition.z);
    sceneview.camera.quaternion.set(json.cameraQuaternion._x,
                                    json.cameraQuaternion._y,
                                    json.cameraQuaternion._z,
                                    json.cameraQuaternion._w);

    //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) {
        controller.fromJSON(json[index]);
      }
    });
    sceneview.needsUpdate = true;
   };

  /**
   *
   * Helper method to resize the plots.
   *
   * @param {DecompositionView[]} dvdict Dictionary of DecompositionViews.
   * @param {EmperorViewControllerABC} viewConstructor Constructor of the view
   * controller.
   *
   */
  EmperorController.prototype.addTab = function(dvdict, viewConstructor) {
    // nothing but a temporary id
    var id = (Math.round(1000000 * Math.random())).toString();

    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, '#' + id, dvdict];
    var obj = new (Function.prototype.bind.apply(viewConstructor, params));

    // 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
    this._$tabsList.append("<li><a href='#" + obj.identifier + "'>" +
                           obj.title + '</a></li>');

    return obj;
  };

  return EmperorController;
});