define([
'jquery',
'underscore',
'view',
'viewcontroller',
'animationdirector',
'draw',
'color-editor',
'colorviewcontroller'
], function($, _, DecompositionView, ViewControllers, AnimationDirector,
draw, Color, ColorViewController) {
var EmperorViewController = ViewControllers.EmperorViewController;
var drawTrajectoryLineStatic = draw.drawTrajectoryLineStatic;
var drawTrajectoryLineDynamic = draw.drawTrajectoryLineDynamic;
var disposeTrajectoryLineStatic = draw.disposeTrajectoryLineStatic;
var disposeTrajectoryLineDynamic = draw.disposeTrajectoryLineDynamic;
var updateStaticTrajectoryDrawRange = draw.updateStaticTrajectoryDrawRange;
var ColorEditor = Color.ColorEditor, ColorFormatter = Color.ColorFormatter;
/**
* @class AnimationsController
*
* Controls the axes that are displayed on screen as well as their
* orientation.
*
* @param {UIState} uiState The shared state
* @param {Node} container Container node to create the controller in.
* @param {Object} decompViewDict This is object is keyed by unique
* identifiers and the values are DecompositionView objects referring to a
* set of objects presented on screen. This dictionary will usually be shared
* by all the tabs in the application. This argument is passed by reference.
*
* @return {AnimationsController}
* @constructs AnimationsController
* @extends EmperorViewController
*/
function AnimationsController(uiState, container, decompViewDict) {
var helpmenu = 'Animate trajectories connecting samples in your data';
var title = 'Animations';
var scope = this, dm, label, gradientTooltip, trajectoryTooltip;
EmperorViewController.call(this, uiState, container, title, helpmenu,
decompViewDict);
trajectoryTooltip = 'Category to group samples';
gradientTooltip = 'Category to sort samples';
dm = this.getView().decomp;
this.$gradientSelect = $("<select class='emperor-tab-drop-down'>");
this.$trajectorySelect = $("<select class='emperor-tab-drop-down'>");
// http://stackoverflow.com/a/6602002
// prepend an empty string so dropdown shows the "tooltip string"
_.each([''].concat(dm.md_headers), function(header) {
scope.$gradientSelect.append(
$('<option>').attr('value', header).text(header));
scope.$trajectorySelect.append(
$('<option>').attr('value', header).text(header));
});
// add a label to the chosen drop downs
label = $('<label>').text('Gradient').append(this.$gradientSelect);
label.attr('title', gradientTooltip);
this.$header.append(label);
label = $('<label>').text('Trajectory').append(this.$trajectorySelect);
label.attr('title', trajectoryTooltip);
this.$header.append(label);
// container of the sliders and buttons
this._$mediaContainer = $('<div name="media-controls-container"></div>');
this._$mediaContainer.css({'padding-top': '10px',
'width': 'inherit',
'text-align': 'center'});
this.$body.append(this._$mediaContainer);
this.$rewind = $('<button></button>');
this._$mediaContainer.append(this.$rewind);
this.$play = $('<button></button>');
this._$mediaContainer.append(this.$play);
this.$pause = $('<button></button>');
this._$mediaContainer.append(this.$pause);
this._colors = {};
// make the buttons squared
this._$mediaContainer.find('button').css({'width': '30px',
'height': '30px',
'margin': '0 auto',
'margin-left': '10px',
'margin-right': '10px'});
this._$mediaContainer.append($('<hr>'));
this._$speedLabel = $('<text name="speed">Speed: 1x</text>');
this._$speedLabel.attr('title', 'Speed at which the traces animate');
this._$mediaContainer.append(this._$speedLabel);
this.$speed = $('<div></div>').css('margin-top', '10px');
this.$speed.attr('title', 'Speed at which the traces animate');
this._$mediaContainer.append(this.$speed);
this._$radiusLabel = $('<text name="radius">Radius: 1</text>');
this._$radiusLabel.attr('title', 'Radius of the traces');
this._$mediaContainer.append(this._$radiusLabel);
this.$radius = $('<div></div>').css('margin-top', '10px');
this.$radius.attr('title', 'Radius of the animated traces');
this._$mediaContainer.append(this.$radius);
this.$gridDiv = $('<div name="emperor-grid-div"></div>');
this.$gridDiv.css('margin', '0 auto');
this.$gridDiv.css('width', 'inherit');
this.$gridDiv.css('height', '100%');
this.$gridDiv.attr('title', 'Change the color of the animated traces.');
this.$body.append(this.$gridDiv);
this.director = null;
this.playing = false;
/**
* @type {Slick.Grid}
* Container that lists the trajectories and their colors
*/
this._grid = null;
// initialize interface elements here
$(this).ready(function() {
scope.$speed.slider({'min': 0.01,
'max': 10,
'step': 0.05,
'value': 1,
'range': 'max',
'slide': function(event, ui) {
scope._$speedLabel.text('Speed: ' + ui.value +
'x');
},
'change': function(event, ui) {
scope._$speedLabel.text('Speed: ' + ui.value +
'x');
}});
scope.$speed.css('background', '#70caff');
scope.$radius.slider({'min': 0.01,
'max': 10,
'step': 0.05,
'value': 1,
'range': 'max',
'slide': function(event, ui) {
scope._$radiusLabel.text('Radius: ' + ui.value);
},
'change': function(event, ui) {
scope._$radiusLabel.text('Radius: ' + ui.value);
}});
scope.$radius.css('background', '#70caff');
// once this element is ready, it is safe to execute the "ready" callback
// if a subclass needs to wait on other elements, this attribute should
// be changed to null so this callback is effectively cancelled, for an
// example see the constructor of ColorViewController
scope.$trajectorySelect.on('chosen:ready', function() {
if (scope.ready !== null) {
scope.ready();
}
});
// setup chosen
scope.$gradientSelect.chosen({
width: '100%',
search_contains: true,
placeholder_text_single: gradientTooltip
});
scope.$trajectorySelect.chosen({
width: '100%',
search_contains: true,
placeholder_text_single: trajectoryTooltip
});
scope.$gradientSelect.chosen().change(function(e, p) {
scope._gradientChanged(e, p);
});
scope.$trajectorySelect.chosen().change(function(e, p) {
scope._trajectoryChanged(e, p);
});
scope.$rewind.button({icons: {primary: 'ui-icon-seek-first'}});
scope.$rewind.attr('title', 'Restart the animation');
scope.$rewind.on('click', function() {
scope._rewindButtonClicked();
});
scope.$play.button({icons: {primary: 'ui-icon-play'}});
scope.$play.attr('title', 'Start the animation');
scope.$play.on('click', function() {
scope._playButtonClicked();
});
scope.$pause.button({icons: {primary: 'ui-icon-pause'}});
scope.$pause.attr('title', 'Pause the animation');
scope.$pause.on('click', function() {
scope._pauseButtonClicked();
});
scope._buildGrid();
scope.setEnabled(false);
//Note that we can't do this before the buttons are ready.
scope.UIState.registerProperty('view.viewType',
scope._viewTypeChanged.bind(scope));
});
return this;
}
AnimationsController.prototype = Object.create(
EmperorViewController.prototype);
AnimationsController.prototype.constructor = EmperorViewController;
/**
* Get the colors for the trajectories
*
* @return {Object} Returns the object mapping trajectories to colors.
*/
AnimationsController.prototype.getColors = function() {
return this._colors;
};
/**
* Set the colors of the trajectories
*
* @param {Object} colors Mapping between trajectories and colors.
*/
AnimationsController.prototype.setColors = function(colors) {
this._colors = colors;
var data = [];
for (var value in colors) {
data.push({category: value, value: colors[value]});
}
this._grid.setData(data);
this._grid.invalidate();
this._grid.render();
};
/**
* Callback when a row's color changes
*
* See _buildGrid for information about the arguments.
*
* @private
*/
AnimationsController.prototype._colorChanged = function(e, args) {
this._colors[args.item.category] = args.item.value;
};
/**
* Helper method to create a grid and set it up for the traces
*
* @private
*/
AnimationsController.prototype._buildGrid = function() {
var scope = this, columns, gridOptions;
columns = [
{id: 'title', name: '', field: 'value', sortable: false, maxWidth: 25,
minWidth: 25, editor: ColorEditor, formatter: ColorFormatter},
{id: 'field1', name: '', field: 'category'}
];
// autoEdit enables one-click editor trigger on the entire grid, instead
// of requiring users to click twice on a widget.
gridOptions = {editable: true, enableAddRow: false, autoEdit: true,
enableCellNavigation: true, forceFitColumns: true,
enableColumnReorder: false};
this._grid = new Slick.Grid(this.$gridDiv, [], columns, gridOptions);
// hide the header row of the grid
// http://stackoverflow.com/a/29827664/379593
this.$body.find('.slick-header').css('display', 'none');
// subscribe to events when a cell is changed
this._grid.onCellChange.subscribe(function(e, args) {
scope._colorChanged(e, args);
});
};
/**
* Sets whether or not the tab can be modified or accessed.
*
* @param {Boolean} trulse option to enable tab.
*/
AnimationsController.prototype.setEnabled = function(trulse) {
EmperorViewController.prototype.setEnabled.call(this, trulse);
this._updateButtons();
};
/**
* Resizes the container and the individual elements.
*
* Note, the consumer of this class, likely the main controller should call
* the resize function any time a resizing event happens.
*
* @param {Float} width the container width.
* @param {Float} height the container height.
*/
AnimationsController.prototype.resize = function(width, height) {
// call super, most of the header and body resizing logic is done there
EmperorViewController.prototype.resize.call(this, width, height);
this.$body.height(this.$canvas.height() - this.$header.height());
this.$body.width(this.$canvas.width());
var grid = this.$canvas.height();
grid -= this.$header.height() + this._$mediaContainer.height();
this.$gridDiv.height(grid);
// the whole code is asynchronous, so there may be situations where
// _grid doesn't exist yet, so check before trying to modify the object
if (this._grid !== null) {
// make the columns fit the available space whenever the window resizes
// http://stackoverflow.com/a/29835739
this._grid.setColumns(this._grid.getColumns());
// Resize the slickgrid canvas for the new body size.
this._grid.resizeCanvas();
}
};
/**
*
* Helper method to update what media buttons should be enabled
*
* @private
*/
AnimationsController.prototype._updateButtons = function() {
var play, pause, speed, rewind;
/*
*
* The behavior of the media buttons is a bit complicated. It is explained
* by the following truth table where the variables are "director",
* "playing" and "enabled". Each output's value determines if the button
* should be enabled. Note that we negate the values when we make the
* assignment because jQuery only has a "disabled" method.
*
* ||----------|---------|---------||-------|-------|-------|--------|
* || director | playing | enabled || Play | Speed | Pause | Rewind |
* || | | || | Radius| | |
* || | | || | Colors| | |
* ||----------|---------|---------||-------|-------|-------|--------|
* || FALSE | FALSE | FALSE || FALSE | FALSE | FALSE | FALSE |
* || FALSE | FALSE | TRUE || TRUE | TRUE | FALSE | FALSE |
* || FALSE | TRUE | FALSE || FALSE | FALSE | FALSE | FALSE |
* || FALSE | TRUE | TRUE || FALSE | FALSE | FALSE | FALSE |
* || TRUE | FALSE | FALSE || FALSE | FALSE | FALSE | FALSE |
* || TRUE | FALSE | TRUE || TRUE | FALSE | FALSE | TRUE |
* || TRUE | TRUE | FALSE || FALSE | FALSE | FALSE | FALSE |
* || TRUE | TRUE | TRUE || FALSE | FALSE | TRUE | TRUE |
* ||----------|---------|---------||-------|-------|-------|--------|
*
*/
play = ((this.enabled && this.director === null && !this.playing) ||
(this.enabled && this.director !== null && !this.playing));
pause = this.director !== null && this.enabled && this.playing;
speed = this.director === null && !this.playing && this.enabled;
rewind = this.director !== null && this.enabled;
this.$speed.slider('option', 'disabled', !speed);
this.$radius.slider('option', 'disabled', !speed);
// jquery ui requires a manual refresh of to the UI after state changes
this.$play.prop('disabled', !play).button('refresh');
this.$pause.prop('disabled', !pause).button('refresh');
this.$rewind.prop('disabled', !rewind).button('refresh');
this._grid.setOptions({editable: speed});
};
/**
*
* Helper method to update a grid.
*
* @private
*/
AnimationsController.prototype._updateGrid = function() {
var category = this.getTrajectoryCategory(), colors, values;
values = this.getView().decomp.getUniqueValuesByCategory(category);
colors = ColorViewController.getColorList(values,
'discrete-coloring-qiime',
true, false)[0];
this.setColors(colors);
this.resize();
};
/**
*
* Callback method executed when the Gradient menu changes.
*
* @private
*/
AnimationsController.prototype._gradientChanged = function(evt, params) {
if (this.getGradientCategory() !== '' &&
this.getTrajectoryCategory() !== '' &&
this.UIState['view.viewType'] === 'scatter') {
this.setEnabled(true);
this._updateGrid();
}
else if (this.getGradientCategory() === '' ||
this.getTrajectoryCategory() === '' ||
this.UIState['view.viewType'] !== 'scatter') {
this.setEnabled(false);
}
};
/**
*
* Callback method executed when the Trajectory menu changes.
*
* @private
*/
AnimationsController.prototype._trajectoryChanged = function(evt, params) {
if (this.getGradientCategory() !== '' &&
this.getTrajectoryCategory() !== '' &&
this.UIState['view.viewType'] === 'scatter') {
this.setEnabled(true);
this._updateGrid();
}
else if (this.getGradientCategory() === '' ||
this.getTrajectoryCategory() === '' ||
this.UIState['view.viewType'] !== 'scatter') {
this.setColors({});
this.setEnabled(false);
}
};
/**
*
* Callback method executed when the UIState view.viewType changes.
*
* @private
*/
AnimationsController.prototype._viewTypeChanged = function(evt) {
if (this.getGradientCategory() !== '' &&
this.getTrajectoryCategory() !== '' &&
this.UIState['view.viewType'] === 'scatter') {
this.setEnabled(true);
this._updateGrid();
}
else if (this.getGradientCategory() === '' ||
this.getTrajectoryCategory() === '' ||
this.UIState['view.viewType'] !== 'scatter') {
this.setEnabled(false);
}
};
/**
*
* Callback method executed when the Rewind button is clicked.
*
* @private
*/
AnimationsController.prototype._rewindButtonClicked = function(evt, params) {
var view = this.getView();
this.playing = false;
this.director = null;
view.staticTubes.forEach(function(tube) {
if (tube !== null && tube.parent !== null) {
tube.parent.remove(tube);
disposeTrajectoryLineStatic(tube);
}
});
view.dynamicTubes.forEach(function(tube) {
if (tube !== null && tube.parent !== null) {
tube.parent.remove(tube);
disposeTrajectoryLineDynamic(tube);
}
});
view.staticTubes = [];
view.dynamicTubes = [];
view.needsUpdate = true;
this._updateButtons();
};
/**
*
* Callback method when the Pause button is clicked.
*
* @private
*/
AnimationsController.prototype._pauseButtonClicked = function(evt, params) {
if (this.playing) {
this.playing = false;
}
this._updateButtons();
};
/**
*
* Callback method when the Play button is clicked.
*
* @private
*/
AnimationsController.prototype._playButtonClicked = function(evt, params) {
if (this.playing === false && this.director !== null) {
this.playing = true;
this._updateButtons();
return;
}
var headers, data = {}, positions = {}, gradient, trajectory, decomp, p;
var view, marker, pos, speed;
view = this.getView();
decomp = this.getView().decomp;
headers = decomp.md_headers;
// get the current visible dimensions
var x = view.visibleDimensions[0], y = view.visibleDimensions[1],
z = view.visibleDimensions[2];
var is2D = (z === null || z === undefined);
gradient = this.$gradientSelect.val();
trajectory = this.$trajectorySelect.val();
speed = this.getSpeed();
for (var i = 0; i < decomp.plottable.length; i++) {
p = decomp.plottable[i];
data[p.name] = p.metadata;
// get the view's position, not the metadata's position
positions[p.name] = {
'name': p.name, 'color': 0,
'x': p.coordinates[x] * view.axesOrientation[0],
'y': p.coordinates[y] * view.axesOrientation[1],
'z': is2D ? 0 : (p.coordinates[z] * view.axesOrientation[2])
};
}
this.director = new AnimationDirector(headers, data, positions, gradient,
trajectory, speed);
this.director.updateFrame();
this.playing = true;
this._updateButtons();
};
/**
*
* Update the portion of the trajectory that needs to be drawn.
*
* If the animation is not playing (because it was paused or it has finished)
* or a director hasn't been instantiated, no action is taken. Otherwise,
* trajectories are updated on screen.
*
*/
AnimationsController.prototype.drawFrame = function() {
if (this.director === null || this.director.animationCycleFinished() ||
!this.playing) {
return;
}
var view = this.getView(), tube, scope = this, color;
var radius = view.getGeometryFactor();
radius *= 0.45 * this.getRadius();
for (var i = 0; i < this.director.trajectories.length; i++) {
var trajectory = this.director.trajectories[i];
//Ensure static tubes are constructed
if (view.staticTubes[i] === null || view.staticTubes[i] === undefined)
{
var color = this._colors[trajectory.metadataCategoryName] || 'red';
view.staticTubes[i] = drawTrajectoryLineStatic(trajectory,
color,
radius);
}
//Ensure static tube draw ranges are set to visible segment
updateStaticTrajectoryDrawRange(trajectory,
this.director.currentFrame,
view.staticTubes[i]);
}
//Remove any old dynamic tubes from the scene
view.dynamicTubes.forEach(function(tube) {
if (tube === undefined || tube === null) {
return;
}
if (tube.parent !== null) {
tube.parent.remove(tube);
disposeTrajectoryLineDynamic(tube);
}
});
if (this.UIState['view.viewType'] !== 'parallel-plot') {
//Construct new dynamic tubes containing necessary
//interpolated segment for the current frame
view.dynamicTubes = this.director.trajectories.map(function(trajectory) {
var color = scope._colors[trajectory.metadataCategoryName] || 'red';
var tube = drawTrajectoryLineDynamic(trajectory,
scope.director.currentFrame,
color,
radius);
return tube;
});
}
view.needsUpdate = true;
this.director.updateFrame();
if (this.director.animationCycleFinished()) {
this.director = null;
this.playing = false;
// When the animation cycle finishes, update the state of the media
// buttons and re-enable the rewind button so users can clear the
// screen.
this._updateButtons();
this.$rewind.prop('disabled', false).button('refresh');
}
};
/**
*
* Setter for the gradient category
*
* Represents how samples are ordered in each trajectory.
*
* @param {String} category The name of the category to set in the menu.
*/
AnimationsController.prototype.setGradientCategory = function(category) {
if (!this.hasMetadataField(category)) {
category = '';
}
this.$gradientSelect.val(category);
this.$gradientSelect.trigger('chosen:updated');
this.$gradientSelect.change();
};
/**
*
* Getter for the gradient category
*
* Represents how samples are ordered in each trajectory.
*
* @return {String} The name of the gradient category in the menu.
*/
AnimationsController.prototype.getGradientCategory = function() {
return this.$gradientSelect.val();
};
/**
*
* Setter for the trajectory category
*
* Represents how samples are grouped together.
*
* @param {String} category The name of the category to set in the menu.
*/
AnimationsController.prototype.setTrajectoryCategory = function(category) {
if (!this.hasMetadataField(category)) {
category = '';
}
this.$trajectorySelect.val(category);
this.$trajectorySelect.trigger('chosen:updated');
this.$trajectorySelect.change();
};
/**
*
* Getter for the trajectory category
*
* Represents how samples are grouped together.
*
* @return {String} The name of the trajectory category in the menu.
*/
AnimationsController.prototype.getTrajectoryCategory = function() {
return this.$trajectorySelect.val();
};
/**
*
* Setter for the speed of the animation.
*
* @param {Float} speed Speed at which the animation is played.
* @throws {Error} If the radius value is lesser than or equal to 0 or
* greater than 10.
*/
AnimationsController.prototype.setSpeed = function(speed) {
if (speed <= 0 || speed > 10) {
throw new Error('The speed must be greater than 0 and lesser than 10');
}
this.$speed.slider('option', 'value', speed);
};
/**
*
* Getter for the speed of the animation.
*
* @return {Float} Speed at which the animation is played.
*/
AnimationsController.prototype.getSpeed = function() {
return this.$speed.slider('option', 'value');
};
/**
*
* Setter for the radius of the animation.
*
* @param {Float} radius Radius of the traces in the animations.
* @throws {Error} If the radius value is lesser than or equal to 0 or
* greater than 10.
*/
AnimationsController.prototype.setRadius = function(radius) {
if (radius <= 0 || radius > 10) {
throw new Error('The radius must be greater than 0 and lesser than 10');
}
this.$radius.slider('option', 'value', radius);
};
/**
*
* Getter for the radius of the traces in the animation.
*
* @return {Float} Radius of the traces in the animation
*/
AnimationsController.prototype.getRadius = function() {
return this.$radius.slider('option', 'value');
};
/**
* Converts the current instance into a JSON string.
*
* @return {Object} JSON ready representation of self.
*/
AnimationsController.prototype.toJSON = function() {
var json = {};
json.gradientCategory = this.getGradientCategory();
json.trajectoryCategory = this.getTrajectoryCategory();
json.speed = this.getSpeed();
json.radius = this.getRadius();
json.colors = this.getColors();
return json;
};
/**
* Decodes JSON string and modifies its own instance variables accordingly.
*
* @param {Object} Parsed JSON string representation of self.
*/
AnimationsController.prototype.fromJSON = function(json) {
this._rewindButtonClicked();
this.setGradientCategory(json.gradientCategory);
this.setTrajectoryCategory(json.trajectoryCategory);
this.setSpeed(json.speed);
this.setRadius(json.radius);
this.setColors(json.colors);
};
return AnimationsController;
});