1 define([
  2     'jquery',
  3     'underscore',
  4     'util'
  5 ],
  6 function($, _, util) {
  7   var naturalSort = util.naturalSort;
  8   /**
  9    *
 10    * @class Plottable
 11    *
 12    * Represents a sample and the associated metadata in the ordination space.
 13    *
 14    * @param {string} name A string indicating the name of the sample.
 15    * @param {string[]} metadata An Array of strings with the metadata values.
 16    * @param {float[]} coordinates An Array of floats indicating the position in
 17    * space where this sample is located.
 18    * @param {integer} [idx = -1] An integer representing the index where the
 19    * object is located in a DecompositionModel.
 20    * @param {float[]} [ci = []] An array of floats indicating the confidence
 21    * intervals in each dimension.
 22    *
 23    * @return {Plottable}
 24    * @constructs Plottable
 25    *
 26    **/
 27   function Plottable(name, metadata, coordinates, idx, ci) {
 28     /**
 29      * Sample name.
 30      * @type {string}
 31      */
 32     this.name = name;
 33     /**
 34      * Metadata values for the sample.
 35      * @type {string[]}
 36      */
 37     this.metadata = metadata;
 38     /**
 39      * Position of the sample in the N-dimensional space.
 40      * @type {float[]}
 41      */
 42     this.coordinates = coordinates;
 43 
 44     /**
 45      * The index of the sample in the array of meshes.
 46      * @type {integer}
 47      */
 48     this.idx = idx === undefined ? -1 : idx;
 49     /**
 50      * Confidence intervals.
 51      * @type {float[]}
 52      */
 53     this.ci = ci === undefined ? [] : ci;
 54 
 55     if (this.ci.length !== 0) {
 56       if (this.ci.length !== this.coordinates.length) {
 57         throw new Error("The number of confidence intervals doesn't match " +
 58                         'with the number of dimensions in the coordinates ' +
 59                         'attribute. coords: ' + this.coordinates.length +
 60                         ' ci: ' + this.ci.length);
 61       }
 62     }
 63   };
 64 
 65   /**
 66    *
 67    * Helper method to convert a Plottable into a string.
 68    *
 69    * @return {string} A string describing the Plottable object.
 70    *
 71    */
 72   Plottable.prototype.toString = function() {
 73     var ret = 'Sample: ' + this.name + ' located at: (' +
 74               this.coordinates.join(', ') + ') metadata: [' +
 75               this.metadata.join(', ') + ']';
 76 
 77     if (this.idx === -1) {
 78       ret = ret + ' without index';
 79     }
 80     else {
 81       ret = ret + ' at index: ' + this.idx;
 82     }
 83 
 84     if (this.ci.length === 0) {
 85       ret = ret + ' and without confidence intervals.';
 86     }
 87     else {
 88       ret = ret + ' and with confidence intervals at (' + this.ci.join(', ') +
 89         ').';
 90     }
 91 
 92     return ret;
 93   };
 94 
 95   /**
 96    * @class DecompositionModel
 97    *
 98    * Models all the ordination data to be plotted.
 99    *
100    * @param {object} data An object with the following attributes (keys):
101    * - `name` A string containing the abbreviated name of the
102    *   ordination method.
103    * - `ids` An array of strings where each string is a sample
104    *   identifier
105    * - `coords` A 2D Array of floats where each row contains the
106    *   coordinates of a sample. The rows are in ids order.
107    * - `names` A 1D Array of strings where each element is the name of one of
108    *   the dimensions in the model.
109    * - `pct_var` An Array of floats where each position contains
110    *   the percentage explained by that axis
111    * - `low` A 1D Array of floats where each row contains the
112    *   coordinates of a sample. The rows are in ids order.
113    * - `high` A 1D Array of floats where each row contains the
114    *   coordinates of a sample. The rows are in ids order.
115    * @param {float[]} md_headers An Array of string where each string is a
116    * metadata column header
117    * @param {string[]} metadata A 2D Array of strings where each row contains
118    * the metadata values for a given sample. The rows are in ids order. The
119    * columns are in `md_headers` order.
120    *
121    * @throws {Error} In any of the following cases:
122    * - The number of coordinates does not match the number of samples.
123    * - If there's a coordinate in `coords` that doesn't have the same length as
124    *   the rest.
125    * - The number of samples is different than the rows provided as metadata.
126    * - Not all metadata rows have the same number of fields.
127    *
128    * @return {DecompositionModel}
129    * @constructs DecompositionModel
130    *
131    */
132   function DecompositionModel(data, md_headers, metadata, type) {
133     var coords = data.coordinates, ci = data.ci || [];
134 
135     /**
136      *
137      * Model's type of the data, can be either 'scatter' or 'arrow'
138      * @type {string}
139      *
140      */
141     this.type = type || 'scatter';
142 
143     var num_coords;
144     /**
145      * Abbreviated name of the ordination method used to create the data.
146      * @type {string}
147      */
148     this.abbreviatedName = data.name || '';
149     /**
150      * List of sample name identifiers.
151      * @type {string[]}
152      */
153     this.ids = data.sample_ids;
154     /**
155      * Percentage explained by each of the axes in the ordination.
156      * @type {float[]}
157      */
158     this.percExpl = data.percents_explained;
159     /**
160      * Column names for the metadata in the samples.
161      * @type {string[]}
162      */
163     this.md_headers = md_headers;
164 
165     if (coords === undefined) {
166       throw new Error('Coordinates are required to initialize this object.');
167     }
168 
169     /*
170       Check that the number of coordinates set provided are the same as the
171       number of samples
172     */
173     if (this.ids.length !== coords.length) {
174       throw new Error('The number of coordinates differs from the number of ' +
175                       'samples. Coords: ' + coords.length + ' samples: ' +
176                       this.ids.length);
177     }
178 
179     /*
180       Check that all the coords set have the same number of coordinates
181     */
182     num_coords = coords[0].length;
183     var res = _.find(coords, function(c) {return c.length !== num_coords;});
184     if (res !== undefined) {
185       throw new Error('Not all samples have the same number of coordinates');
186     }
187 
188     /*
189       Check that we have the percentage explained values for all coordinates
190     */
191     if (this.percExpl.length !== num_coords) {
192       throw new Error('The number of percentage explained values does not ' +
193                       'match the number of coordinates. Perc expl: ' +
194                       this.percExpl.length + ' Num coord: ' + num_coords);
195     }
196 
197     /*
198       Check that we have the metadata for all samples
199     */
200     if (this.ids.length !== metadata.length) {
201       throw new Error('The number of metadata rows and the the number of ' +
202                       'samples do not match. Samples: ' + this.ids.length +
203                       ' Metadata rows: ' + metadata.length);
204     }
205 
206     /*
207       Check that we have all the metadata categories in all rows
208     */
209     res = _.find(metadata, function(m) {
210                   return m.length !== md_headers.length;
211     });
212     if (res !== undefined) {
213       throw new Error('Not all metadata rows have the same number of values');
214     }
215 
216     this.plottable = new Array(this.ids.length);
217     for (i = 0; i < this.ids.length; i++) {
218       // note that ci[i] can be empty
219       this.plottable[i] = new Plottable(this.ids[i], metadata[i], coords[i], i,
220                                         ci[i]);
221     }
222 
223     // use slice to make a copy of the array so we can modify it
224     /**
225      * Minimum and maximum values for each axis in the ordination. More
226      * concretely this object has a `min` and a `max` attributes, each with a
227      * list of floating point arrays that describe the minimum and maximum for
228      * each axis.
229      * @type {Object}
230      */
231     this.dimensionRanges = {'min': coords[0].slice(),
232                             'max': coords[0].slice()};
233     this.dimensionRanges = _.reduce(this.plottable,
234                                     DecompositionModel._minMaxReduce,
235                                     this.dimensionRanges);
236 
237     /**
238      * Number of plottables in this decomposition model
239      * @type {integer}
240      */
241     this.length = this.plottable.length;
242 
243     /**
244      * Number of dimensions in this decomposition model
245      * @type {integer}
246      */
247     this.dimensions = this.dimensionRanges.min.length;
248 
249     /**
250      * Names of the axes in the ordination
251      * @type {string[]}
252      */
253     this.axesNames = data.axes_names === undefined ? [] : data.axes_names;
254     // We call this after all the other attributes have been initialized so we
255     // can use that information safely. Fixes a problem with the ordination
256     // file format, see https://github.com/biocore/emperor/issues/562
257     this._fixAxesNames();
258 
259     /**
260      * Array of pairs of Plottable objects.
261      * @type {Array[]}
262      */
263     this.edges = this._processEdgeList(data.edges || []);
264   }
265 
266   /**
267    *
268    * Whether or not the plottables have confidence intervals
269    *
270    * @return {Boolean} `true` if the plottables have confidence intervals,
271    * `false` otherwise.
272    *
273    */
274   DecompositionModel.prototype.hasConfidenceIntervals = function() {
275     if (this.plottable.length <= 0) {
276       return false;
277     }
278     else if (this.plottable[0].ci.length > 0) {
279       return true;
280     }
281     return false;
282   };
283 
284   /**
285    *
286    * Retrieve the plottable object with the given id.
287    *
288    * @param {string} id A string with the plottable.
289    *
290    * @return {Plottable} The plottable object for the given id.
291    *
292    */
293   DecompositionModel.prototype.getPlottableByID = function(id) {
294     idx = this.ids.indexOf(id);
295     if (idx === -1) {
296       throw new Error(id + ' is not found in the Decomposition Model ids');
297     }
298     return this.plottable[idx];
299   };
300 
301   /**
302    *
303    * Retrieve all the plottable objects with the given ids.
304    *
305    * @param {integer[]} idArray an Array of strings where each string is a
306    * plottable id.
307    *
308    * @return {Plottable[]} An Array of plottable objects for the given ids.
309    *
310    */
311   DecompositionModel.prototype.getPlottableByIDs = function(idArray) {
312     dm = this;
313     return _.map(idArray, function(id) {return dm.getPlottableByID(id);});
314   };
315 
316   /**
317    *
318    * Helper function that returns the index of a given metadata category.
319    *
320    * @param {string} category A string with the metadata header.
321    *
322    * @return {integer} An integer representing the index of the metadata
323    * category in the `md_headers` array.
324    *
325    */
326   DecompositionModel.prototype._getMetadataIndex = function(category) {
327     var md_idx = this.md_headers.indexOf(category);
328     if (md_idx === -1) {
329       throw new Error('The header ' + category +
330                       ' is not found in the metadata categories');
331     }
332     return md_idx;
333   };
334 
335   /**
336    *
337    * Retrieve all the plottable objects under the metadata header value.
338    *
339    * @param {string} category A string with the metadata header.
340    * @param {string} value A string with the value under the metadata category.
341    *
342    * @return {Plottable[]} An Array of plottable objects for the given category
343    * value pair.
344    *
345    */
346   DecompositionModel.prototype.getPlottablesByMetadataCategoryValue = function(
347       category, value) {
348 
349     var md_idx = this._getMetadataIndex(category);
350     var res = _.filter(this.plottable, function(pl) {
351       return pl.metadata[md_idx] === value;
352     });
353 
354     if (res.length === 0) {
355       throw new Error('The value ' + value +
356                       ' is not found in the metadata category ' + category);
357     }
358     return res;
359   };
360 
361   /**
362    *
363    * Retrieve the available values for a given metadata category
364    *
365    * @param {string} category A string with the metadata header.
366    *
367    * @return {string[]} An array of the available values for the given metadata
368    * header sorted first alphabetically and then numerically.
369    *
370    */
371   DecompositionModel.prototype.getUniqueValuesByCategory = function(category) {
372     var md_idx = this._getMetadataIndex(category);
373     var values = _.map(this.plottable, function(pl) {
374       return pl.metadata[md_idx];
375     });
376 
377     return naturalSort(_.uniq(values));
378   };
379 
380   /**
381    *
382    * Method to determine if this is an arrow decomposition
383    *
384    */
385   DecompositionModel.prototype.isArrowType = function() {
386     return this.type === 'arrow';
387   };
388 
389   /**
390    *
391    * Method to determine if this is a scatter decomposition
392    *
393    */
394   DecompositionModel.prototype.isScatterType = function() {
395     return this.type === 'scatter';
396   };
397 
398   /**
399    *
400    * Executes the provided `func` passing all the plottables as parameters.
401    *
402    * @param {function} func The function to call for each plottable. It should
403    * accept a single parameter which will be the plottable.
404    *
405    * @return {Object[]} An array with the results of executing func over all
406    * plottables.
407    *
408    */
409   DecompositionModel.prototype.apply = function(func) {
410     return _.map(this.plottable, func);
411   };
412 
413   /**
414    *
415    * Transform observation names into plottable objects.
416    *
417    * @return {Array[]} An array of plottable pairs.
418    * @private
419    *
420    */
421   DecompositionModel.prototype._processEdgeList = function(edges) {
422     if (edges.length === 0) {
423       return edges;
424     }
425 
426     var u, v, scope = this;
427     edges = edges.map(function(edge) {
428       if (edge[0] === edge[1]) {
429         throw new Error('Cannot create edge between two identical nodes (' +
430                         edge[0] + ' and ' + edge[1] + ')');
431       }
432 
433       u = scope.getPlottableByID(edge[0]);
434       v = scope.getPlottableByID(edge[1]);
435 
436       return [u, v];
437     });
438 
439     return edges;
440   };
441 
442   /**
443    *
444    * Helper function used to find the minimum and maximum values every
445    * dimension in the plottable objects. This function is used with
446    * underscore.js' reduce function (_.reduce).
447    *
448    * @param {Object} accumulator An object with a "min" and "max" arrays that
449    * store the minimum and maximum values over all the plottables.
450    * @param {Plottable} plottable A plottable object to compare with.
451    *
452    * @return {Object} An updated version of accumulator, integrating the ranges
453    * of the newly seen plottable object.
454    * @private
455    *
456    */
457   DecompositionModel._minMaxReduce = function(accumulator, plottable) {
458 
459     // iterate over every dimension
460     _.each(plottable.coordinates, function(value, index) {
461       if (value > accumulator.max[index]) {
462         accumulator.max[index] = value;
463       }
464       else if (value < accumulator.min[index]) {
465         accumulator.min[index] = value;
466       }
467     });
468 
469     return accumulator;
470   };
471 
472   /**
473    *
474    * Fix the names of the axes.
475    *
476    * Account for missing axes names, and for uninformative names produced by
477    * scikit-bio. In both cases, if we have an abbreviated name, we will use
478    * that string as a prefix for the axes names.
479    *
480    * @private
481    *
482    */
483   DecompositionModel.prototype._fixAxesNames = function() {
484     var expected = [], replacement = [], prefix, names, cast, i;
485 
486     if (this.abbreviatedName === '') {
487       prefix = 'Axis ';
488     }
489     else {
490       prefix = this.abbreviatedName + ' ';
491     }
492 
493     if (this.axesNames.length === 0) {
494       for (i = 0; i < this.dimensions; i++) {
495         replacement.push(prefix + (i + 1));
496       }
497       this.axesNames = replacement;
498     }
499     else {
500       names = util.splitNumericValues(this.axesNames);
501 
502       for (i = 0; i < names.numeric.length; i++) {
503         expected.push(i);
504 
505         // don't zero-index, doesn't make sense for displaying purposes
506         replacement.push(prefix + (i + 1));
507       }
508 
509       // to truly match scikit-bio's format, all the numeric names should come
510       // after the non-numeric names, and the numeric names should match the
511       // array of expected values.
512       if (_.isEqual(expected, names.numeric) &&
513           _.isEqual(this.axesNames, names.nonNumeric.concat(names.numeric))) {
514         this.axesNames = names.nonNumeric.concat(replacement);
515       }
516     }
517     this._buildAxesLabels();
518   };
519 
520   /**
521    *
522    * Helper method to build labels for all axes
523    *
524    */
525   DecompositionModel.prototype._buildAxesLabels = function() {
526     var axesLabels = [], index, text;
527     for (index = 0; index < this.axesNames.length; index++) {
528       // when the labels get too long, it's a bit hard to look at
529       if (this.axesNames[index].length > 25) {
530         text = this.axesNames[index].slice(0, 20) + '...';
531       }
532       else {
533         text = this.axesNames[index];
534       }
535 
536       // account for custom axes (their percentage explained will be -1 to
537       // indicate that this attribute is not meaningful).
538       if (this.percExpl[index] >= 0) {
539         text += ' (' + this.percExpl[index].toPrecision(4) + ' %)';
540       }
541 
542       axesLabels.push(text);
543     }
544     this.axesLabels = axesLabels;
545   };
546 
547   /**
548    *
549    * Helper method to convert a DecompositionModel into a string.
550    *
551    * @return {string} String representation describing the Decomposition
552    * object.
553    *
554    */
555   DecompositionModel.prototype.toString = function() {
556     return 'name: ' + this.abbreviatedName + '\n' +
557       'Metadata headers: [' + this.md_headers.join(', ') + ']\n' +
558       'Plottables:\n' + _.map(this.plottable, function(plt) {
559         return plt.toString();
560       }).join('\n');
561   };
562 
563   return { 'DecompositionModel': DecompositionModel,
564            'Plottable': Plottable};
565 });
566