      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