define([
'jquery',
'underscore',
'util',
'view',
'viewcontroller',
'color-editor',
'chroma',
'three'
], function($, _, util, DecompositionView, ViewControllers, Color, chroma,
THREE) {
// we only use the base attribute class, no need to get the base class
var EmperorAttributeABC = ViewControllers.EmperorAttributeABC;
var ColorEditor = Color.ColorEditor, ColorFormatter = Color.ColorFormatter;
/**
* @class ColorViewController
*
* Controls the color changing tab in Emperor. Takes care of changes to
* color based on metadata, as well as making colorbars if coloring by a
* numeric metadata category.
*
* @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 {ColorViewController}
* @constructs ColorViewController
* @extends EmperorAttributeABC
*/
function ColorViewController(uiState, container, decompViewDict) {
var helpmenu = 'Change the colors of the attributes on the plot, such as ' +
'spheres, vectors and ellipsoids.';
var title = 'Color';
// Constant for width in slick-grid
var SLICK_WIDTH = 25, scope = this;
var name, value, colorItem;
// Create scale div and checkbox for whether using scalar data or not
/**
* @type {Node}
* jQuery object holding the colorbar div
*/
this.$scaleDiv = $('<div>');
/**
* @type {Node}
* jQuery object holding the SVG colorbar
*/
this.$colorScale = $("<svg width='90%' height='100%' " +
"style='display:block;margin:auto;'></svg>");
this.$scaleDiv.append(this.$colorScale);
this.$scaleDiv.hide();
/**
* @type {Node}
* jQuery object holding the continuous value checkbox
*/
this.$scaled = $("<input type='checkbox'>");
this.$scaled.prop('hidden', true);
/**
* @type {Node}
* jQuery object holding the continuous value label
*/
this.$scaledLabel = $("<label for='scaled'>Continuous values</label>");
this.$scaledLabel.prop('hidden', true);
// this class uses a colormap selector, so populate it before calling super
// because otherwise the categorySelectionCallback will be called before the
// data is populated
/**
* @type {Node}
* jQuery object holding the select box for the colormaps
*/
this.$colormapSelect = $("<select class='emperor-tab-drop-down'>");
var currType = ColorViewController.Colormaps[0].type;
var selectOpts = $('<optgroup>').attr('label', currType);
for (var i = 0; i < ColorViewController.Colormaps.length; i++) {
var colormap = ColorViewController.Colormaps[i];
// Check if we are in a new optgroup
if (colormap.type !== currType) {
currType = colormap.type;
scope.$colormapSelect.append(selectOpts);
selectOpts = $('<optgroup>').attr('label', currType);
}
var colorItem = $('<option>')
.attr('value', colormap.id)
.attr('data-type', currType)
.text(colormap.name);
selectOpts.append(colorItem);
}
scope.$colormapSelect.append(selectOpts);
// Build the options dictionary
var options = {
'valueUpdatedCallback':
function(e, args) {
var val = args.item.category, color = args.item.value;
var group = args.item.plottables;
var element = scope.getView();
scope.setPlottableAttributes(element, color, group);
},
'categorySelectionCallback':
function(evt, params) {
// we re-use this same callback regardless of whether the
// color or the metadata category changed, maybe we can do
// something better about this
var category = scope.getMetadataField();
var discrete = $('option:selected', scope.$colormapSelect)
.attr('data-type') == DISCRETE;
var colorScheme = scope.$colormapSelect.val();
var decompViewDict = scope.getView();
if (discrete) {
var palette = ColorViewController.getPaletteColor(colorScheme);
scope.$scaled.prop('checked', false);
scope.$scaled.prop('hidden', true);
scope.$scaledLabel.prop('hidden', true);
scope.bodyGrid.selectionPalette = palette;
} else {
scope.$scaled.prop('hidden', false);
scope.$scaledLabel.prop('hidden', false);
scope.bodyGrid.selectionPalette = undefined;
}
var scaled = scope.$scaled.is(':checked');
// getting all unique values per categories
var uniqueVals = decompViewDict.decomp.getUniqueValuesByCategory(
category);
// getting color for each uniqueVals
var colorInfo = ColorViewController.getColorList(
uniqueVals, colorScheme, discrete, scaled);
var attributes = colorInfo[0];
// fetch the slickgrid-formatted data
var data = decompViewDict.setCategory(
attributes, scope.setPlottableAttributes, category);
if (scaled) {
scope.$searchBar.prop('hidden', true);
plottables = ColorViewController._nonNumericPlottables(
uniqueVals, data);
// Set SlickGrid for color of non-numeric values and show color bar
// for rest if there are non numeric categories
if (plottables.length > 0) {
scope.setSlickGridDataset(
[{id: 0, category: 'Non-numeric values', value: '#64655d',
plottables: plottables}]);
}
else {
scope.setSlickGridDataset([]);
}
scope.$scaleDiv.show();
scope.$colorScale.html(colorInfo[1]);
}
else {
scope.$searchBar.prop('hidden', false);
scope.setSlickGridDataset(data);
scope.$scaleDiv.hide();
}
// Call resize to update all methods for new shows/hides/resizes
scope.resize();
},
'slickGridColumn': {
id: 'title', name: '', field: 'value',
sortable: false, maxWidth: SLICK_WIDTH,
minWidth: SLICK_WIDTH,
editor: ColorEditor,
formatter: ColorFormatter
}
};
EmperorAttributeABC.call(this, uiState, container, title, helpmenu,
decompViewDict, options);
// the base-class will try to execute the "ready" callback, so we prevent
// that by copying the property and setting the property to undefined.
// This controller is not ready until the colormapSelect has signaled that
// it is indeed ready.
var ready = this.ready;
this.ready = undefined;
// account for the searchbar
this.$colormapSelect.insertAfter(this.$select);
this.$header.append(this.$scaled);
this.$header.append(this.$scaledLabel);
this.$body.prepend(this.$scaleDiv);
// the chosen select can only be set when the document is ready
$(function() {
scope.$colormapSelect.on('chosen:ready', function() {
if (ready !== null) {
ready();
scope.ready = ready;
}
});
scope.$colormapSelect.chosen({width: '100%', search_contains: true});
scope.$colormapSelect.chosen().change(options.categorySelectionCallback);
scope.$scaled.on('change', options.categorySelectionCallback);
});
return this;
}
ColorViewController.prototype = Object.create(EmperorAttributeABC.prototype);
ColorViewController.prototype.constructor = EmperorAttributeABC;
/**
* Helper for building the plottables for non-numeric data
*
* @param {String[]} uniqueVals Array of unique values for the category
* @param {Object} data SlickGrid formatted data from setCategory function
*
* @return {Plottable[]} Array of plottables for all non-numeric values
* @private
*
*/
ColorViewController._nonNumericPlottables = function(uniqueVals, data) {
// Filter down to only non-numeric data
var split = util.splitNumericValues(uniqueVals);
var plotList = data.filter(function(x) {
return $.inArray(x.category, split.nonNumeric) !== -1;
});
// Build list of plottables and return
var plottables = [];
for (var i = 0; i < plotList.length; i++) {
plottables = plottables.concat(plotList[i].plottables);
}
return plottables;
};
/**
* Sets whether or not elements in the tab can be modified.
*
* @param {Boolean} trulse option to enable elements.
*/
ColorViewController.prototype.setEnabled = function(trulse) {
EmperorAttributeABC.prototype.setEnabled.call(this, trulse);
this.$colormapSelect.prop('disabled', !trulse).trigger('chosen:updated');
this.$scaled.prop('disabled', !trulse);
};
/**
*
* Private method to reset the color of all the objects in every
* decomposition view to red.
*
* @extends EmperorAttributeABC
* @private
*
*/
ColorViewController.prototype._resetAttribute = function() {
EmperorAttributeABC.prototype._resetAttribute.call(this);
_.each(this.decompViewDict, function(view) {
view.setColor(0xff0000);
});
};
/**
* Method that returns whether or not the coloring is continuous and the
* values have been scaled.
*
* @return {Boolean} True if the coloring is continuous and the data is
* scaled, false otherwise.
*/
ColorViewController.prototype.isColoringContinuous = function() {
// the bodygrid can have at most one element (NA values)
return (this.$scaled.is(':checked') &&
this.getSlickGridDataset().length <= 1);
};
/**
*
* Wrapper for generating a list of colors that corresponds to all samples
* in the plot by coloring type requested
*
* @param {String[]} values list of objects to generate a color for, usually a
* category in a given metadata column.
* @param {String} [map = {'discrete-coloring-qiime'|'Viridis'}] name of the
* color map to use, see ColorViewController.Colormaps
* @see ColorViewController.Colormaps
* @param {Boolean} discrete Whether to treat colormap requested as a
* discrete set of colors or use interpolation to create gradient of colors
* @param {Boolean} [scaled = false] Whether to use a scaled colormap or
* equidistant colors for each value
* @see ColorViewController.getDiscreteColors
* @see ColorViewController.getInterpolatedColors
* @see ColorViewController.getScaledColors
*
* @return {Object} colors The object containing the hex colors keyed to
* each sample
* @return {String} gradientSVG The SVG string for the scaled data or null
*
*/
ColorViewController.getColorList = function(values, map, discrete, scaled) {
var colors = {}, gradientSVG;
scaled = scaled || false;
if (_.findWhere(ColorViewController.Colormaps, {id: map}) === undefined) {
throw new Error('Could not find ' + map + ' as a colormap.');
}
// 1 color and continuous coloring should return the first element in map
if (values.length == 1 && discrete === false) {
colors[values[0]] = chroma.brewer[map][0];
return [colors, gradientSVG];
}
//Call helper function to create the required colormap type
if (discrete) {
colors = ColorViewController.getDiscreteColors(values, map);
}
else if (scaled) {
try {
var info = ColorViewController.getScaledColors(values, map);
} catch (e) {
alert('Category can not be shown as continuous values. Continuous ' +
'coloration requires at least 2 numeric values in the category.');
throw new Error('non-numeric category');
}
colors = info[0];
gradientSVG = info[1];
}
else {
colors = ColorViewController.getInterpolatedColors(values, map);
}
return [colors, gradientSVG];
};
/**
*
* Retrieve a discrete color set.
*
* @param {String[]} values list of objects to generate a color for, usually a
* category in a given metadata column.
* @param {String} [map = 'discrete-coloring-qiime'] name of the color map to
* use, see ColorViewController.Colormaps
* @see ColorViewController.Colormaps
*
* @return {Object} colors The object containing the hex colors keyed to
* each sample
*
*/
ColorViewController.getDiscreteColors = function(values, map) {
map = ColorViewController.getPaletteColor(map);
var size = map.length;
var colors = {};
for (var i = 0; i < values.length; i++) {
mapIndex = i - (Math.floor(i / size) * size);
colors[values[i]] = map[mapIndex];
}
return colors;
};
/**
*
* Retrieve a whole discrete palette color set.
*
* @param {String} [map = 'discrete-coloring-qiime'] name of the color map to
* use, see ColorViewController.Colormaps
* @see ColorViewController.Colormaps
*
* @return {Object} map for selected color palette
*
*/
ColorViewController.getPaletteColor = function(map) {
map = map || 'discrete-coloring-qiime';
if (map == 'discrete-coloring-qiime') {
map = ColorViewController._qiimeDiscrete;
} else {
map = chroma.brewer[map];
}
return map;
};
/**
*
* Retrieve a scaled color set.
*
* @param {String[]} values Objects to generate a color for, usually a
* category in a given metadata column.
* @param {String} [map = 'Viridis'] name of the discrete color map to use.
* @param {String} [nanColor = '#64655d'] Color to use for non-numeric values.
*
* @return {Object} colors The object containing the hex colors keyed to
* each sample
* @return {String} gradientSVG The SVG string for the scaled data or null
*
*/
ColorViewController.getScaledColors = function(values, map, nanColor) {
map = map || 'Viridis';
nanColor = nanColor || '#64655d';
map = chroma.brewer[map];
// Get list of only numeric values, error if none
var split = util.splitNumericValues(values), numbers;
if (split.numeric.length < 2) {
throw new Error('non-numeric category');
}
// convert objects to numbers so we can map them to a color, we keep a copy
// of the untransformed object so we can search the metadata
numbers = _.map(split.numeric, parseFloat);
min = _.min(numbers);
max = _.max(numbers);
var interpolator = chroma.scale(map).domain([min, max]);
var colors = {};
// Color all the numeric values
_.each(split.numeric, function(element) {
colors[element] = interpolator(+element).hex();
});
//Gray out non-numeric values
_.each(split.nonNumeric, function(element) {
colors[element] = nanColor;
});
//build the SVG showing the gradient of colors for values
var mid = (min + max) / 2;
var step = (max - min) / 100;
var stopColors = [];
for (var s = min; s <= max; s += step) {
stopColors.push(interpolator(s).hex());
}
var gradientSVG = '<defs>';
gradientSVG += '<linearGradient id="Gradient" x1="0" x2="0" y1="1" y2="0">';
for (var pos = 0; pos < stopColors.length; pos++) {
gradientSVG += '<stop offset="' + pos + '%" stop-color="' +
stopColors[pos] + '"/>';
}
gradientSVG += '</linearGradient></defs><rect id="gradientRect" ' +
'width="20" height="95%" fill="url(#Gradient)"/>';
gradientSVG += '<text x="25" y="12px" font-family="sans-serif" ' +
'font-size="12px" text-anchor="start">' + max + '</text>';
gradientSVG += '<text x="25" y="50%" font-family="sans-serif" ' +
'font-size="12px" text-anchor="start">' + mid + '</text>';
gradientSVG += '<text x="25" y="95%" font-family="sans-serif" ' +
'font-size="12px" text-anchor="start">' + min + '</text>';
return [colors, gradientSVG];
};
/**
*
* Retrieve an interpolatd color set.
*
* @param {String[]} values Objects to generate a color for, usually a
* category in a given metadata column.
* @param {String} [map = 'Viridis'] name of the color map to use.
*
* @return {Object} colors The object containing the hex colors keyed to
* each sample.
*
*/
ColorViewController.getInterpolatedColors = function(values, map) {
map = map || 'Viridis';
map = chroma.brewer[map];
var total = values.length;
// Logic here adapted from Colorer.assignOrdinalScaledColors() in Empress'
// codebase
var interpolator = chroma.scale(map).domain([0, values.length - 1]);
var colors = {};
for (var i = 0; i < values.length; i++) {
colors[values[i]] = interpolator(i).hex();
}
return colors;
};
/**
* Converts the current instance into a JSON string.
*
* @return {Object} JSON ready representation of self.
*/
ColorViewController.prototype.toJSON = function() {
var json = EmperorAttributeABC.prototype.toJSON.call(this);
json.colormap = this.$colormapSelect.val();
json.continuous = this.$scaled.is(':checked');
return json;
};
/**
* Decodes JSON string and modifies its own instance variables accordingly.
*
* @param {Object} Parsed JSON string representation of self.
*/
ColorViewController.prototype.fromJSON = function(json) {
var data;
// NOTE: We do not call super here because of the non-numeric values issue
// Order here is important. We want to set all the extra controller
// settings before we load from json, as they can override the JSON when set
this.setMetadataField(json.category);
this.setEnabled(true);
// if the category is null, then there's nothing to set about the state
// of the controller
if (json.category === null) {
return;
}
this.$colormapSelect.val(json.colormap);
this.$colormapSelect.trigger('chosen:updated');
this.$scaled.prop('checked', json.continuous);
this.$scaled.trigger('change');
// Fetch and set the SlickGrid-formatted data
// Need to take into account the existence of the non-numeric values grid
// information from the continuous data.
var decompViewDict = this.getView();
if (this.$scaled.is(':checked')) {
// Get the current SlickGrid data and update with the saved color
data = this.getSlickGridDataset();
data[0].value = json.data['Non-numeric values'];
this.setPlottableAttributes(
decompViewDict, json.data['Non-numeric values'], data[0].plottables);
}
else {
data = decompViewDict.setCategory(
json.data, this.setPlottableAttributes, json.category);
}
if (!_.isEmpty(data)) {
this.setSlickGridDataset(data);
}
};
/**
* 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.
*/
ColorViewController.prototype.resize = function(width, height) {
this.$body.height(this.$canvas.height() - this.$header.height());
this.$body.width(this.$canvas.width());
if (this.$scaled.is(':checked')) {
this.$scaleDiv.css('height', (this.$body.height() / 2) + 'px');
this.$gridDiv.css('height', (this.$body.height() / 2 - 20) + 'px');
}
else {
this.$gridDiv.css('height', '100%');
}
// call super, most of the header and body resizing logic is done there
EmperorAttributeABC.prototype.resize.call(this, width, height);
};
/**
* Helper function to set the color of plottable
*
* @param {scope} object , the scope where the plottables exist
* @param {color} string , hexadecimal representation of a color, which will
* be applied to the plottables
* @param {group} array of objects, list of object that should be changed in
* scope
*/
ColorViewController.prototype.setPlottableAttributes =
function(scope, color, group) {
scope.setColor(color, group);
};
var DISCRETE = 'Discrete';
var SEQUENTIAL = 'Sequential';
var DIVERGING = 'Diverging';
/**
* @type {Object}
* Color maps available, along with what type of colormap they are.
*/
ColorViewController.Colormaps = [
{id: 'discrete-coloring-qiime', name: 'Classic QIIME Colors',
type: DISCRETE},
{id: 'Paired', name: 'Paired', type: DISCRETE},
{id: 'Accent', name: 'Accent', type: DISCRETE},
{id: 'Dark2', name: 'Dark', type: DISCRETE},
{id: 'Set1', name: 'Set1', type: DISCRETE},
{id: 'Set2', name: 'Set2', type: DISCRETE},
{id: 'Set3', name: 'Set3', type: DISCRETE},
{id: 'Pastel1', name: 'Pastel1', type: DISCRETE},
{id: 'Pastel2', name: 'Pastel2', type: DISCRETE},
{id: 'Viridis', name: 'Viridis', type: SEQUENTIAL},
{id: 'Reds', name: 'Reds', type: SEQUENTIAL},
{id: 'RdPu', name: 'Red-Purple', type: SEQUENTIAL},
{id: 'Oranges', name: 'Oranges', type: SEQUENTIAL},
{id: 'OrRd', name: 'Orange-Red', type: SEQUENTIAL},
{id: 'YlOrBr', name: 'Yellow-Orange-Brown', type: SEQUENTIAL},
{id: 'YlOrRd', name: 'Yellow-Orange-Red', type: SEQUENTIAL},
{id: 'YlGn', name: 'Yellow-Green', type: SEQUENTIAL},
{id: 'YlGnBu', name: 'Yellow-Green-Blue', type: SEQUENTIAL},
{id: 'Greens', name: 'Greens', type: SEQUENTIAL},
{id: 'GnBu', name: 'Green-Blue', type: SEQUENTIAL},
{id: 'Blues', name: 'Blues', type: SEQUENTIAL},
{id: 'BuGn', name: 'Blue-Green', type: SEQUENTIAL},
{id: 'BuPu', name: 'Blue-Purple', type: SEQUENTIAL},
{id: 'Purples', name: 'Purples', type: SEQUENTIAL},
{id: 'PuRd', name: 'Purple-Red', type: SEQUENTIAL},
{id: 'PuBuGn', name: 'Purple-Blue-Green', type: SEQUENTIAL},
{id: 'Greys', name: 'Greys', type: SEQUENTIAL},
{id: 'Spectral', name: 'Spectral', type: DIVERGING},
{id: 'RdBu', name: 'Red-Blue', type: DIVERGING},
{id: 'RdYlGn', name: 'Red-Yellow-Green', type: DIVERGING},
{id: 'RdYlBu', name: 'Red-Yellow-Blue', type: DIVERGING},
{id: 'RdGy', name: 'Red-Grey', type: DIVERGING},
{id: 'PiYG', name: 'Pink-Yellow-Green', type: DIVERGING},
{id: 'BrBG', name: 'Brown-Blue-Green', type: DIVERGING},
{id: 'PuOr', name: 'Purple-Orange', type: DIVERGING},
{id: 'PRGn', name: 'Purple-Green', type: DIVERGING}
];
// taken from the qiime/colors.py module; a total of 24 colors
/** @private */
ColorViewController._qiimeDiscrete = ['#ff0000', '#0000ff', '#f27304',
'#008000', '#91278d', '#ffff00', '#7cecf4', '#f49ac2', '#5da09e', '#6b440b',
'#808080', '#f79679', '#7da9d8', '#fcc688', '#80c99b', '#a287bf', '#fff899',
'#c49c6b', '#c0c0c0', '#ed008a', '#00b6ff', '#a54700', '#808000', '#008080'];
return ColorViewController;
});