Source: trajectory.js

/** @module trajectory */
define([
    'underscore'
], function(_) {
  /**
   *
   * @class TrajectoryOfSamples
   *
   * Represents an ordered set of samples and their position in PCoA space.
   *
   * @param {string[]} sampleNames Array of sample identifiers.
   * @param {string} metadataCategoryName The name of the category in the
   * mapping file used to generate this trajectory.
   * @param {float[]} gradientPoints The position of the samples in the
   * gradient.
   * @param {Object[]} coordinates Array of objects with x, y and z properties
   * where each corresponds to the position of a sample in PCoA space.
   * @param {float} minimumDelta Minimum differential between the ordered
   * gradientPoints this value must be non-zero. Note that this value should be
   * computed taking into account all the other trajectories that will be
   * animated together, usually by an AnimationDirector object.
   * @param {integer} [suppliedN = 5] Determines how many points should be found
   * in the the trajectory.
   * @param {integer} [maxN = 10] Maximum number of samples allowed per
   * interpolation interval.
   *
   * @return {TrajectoryOfSamples} An instance of TrajectoryOfSamples
   * @constructs TrajectoryOfSamples
   **/
  function TrajectoryOfSamples(sampleNames, metadataCategoryName,
                               gradientPoints, coordinates, minimumDelta,
                               suppliedN, maxN) {
    /**
     * Sample identifiers
     * @type {string[]}
     */
    this.sampleNames = sampleNames;
    /**
     * The name of the category in the mapping file used to generate this
     * trajectory.
     * @type {string}
     */
    this.metadataCategoryName = metadataCategoryName;

    // array of the values that samples have through the gradient
    this.gradientPoints = gradientPoints;

    // the first three axes of the data points
    this.coordinates = coordinates;

    /**
     * Minimum differential between samples in the trajectory; the value is
     * computed using the gradientPoints array.
     * @type {float}
     */
    this.minimumDelta = minimumDelta;

    /**
     * Minimum number of frames a distance will have in the gradient.
     * This value determines how fast the animation will run.
     * For now we use 5 as a good default value; 60 was way too slow.
     * @type {float}
     * @default 5
     */
    this.suppliedN = suppliedN !== undefined ? suppliedN : 5;
    /**
     * Maximum number of samples allowed per interpolation interval.
     * @type {float}
     * @default 10
     */
    this.maxN = maxN !== undefined ? maxN : 10;

    if (this.coordinates.length != this.gradientPoints.length) {
      throw new Error('The number of coordinate points and gradient points is' +
          'different, make sure these values are consistent.');
    }

    // initialize as an empty array but fill it up upon request
    /**
     * Array of objects with the corresponding interpolated x, y and z values.
     * The interpolation operation takes place between subsequent samples.
     * @type {Object[]}
     */
    this.interpolatedCoordinates = null;
    this._generateInterpolatedCoordinates();

    return this;
  }

  /**
   *
   * Helper method to iterate over all the coordinates and generate
   * interpolated arrays.
   * @private
   *
   */
  TrajectoryOfSamples.prototype._generateInterpolatedCoordinates = function() {
    var pointsPerStep = 0, delta = 0;
    var interpolatedCoordinatesBuffer = new Array(),
    intervalBuffer = new Array();
    var currInterpolation;

    // iterate over the gradient points to compute the interpolated distances
    for (var index = 0; index < this.gradientPoints.length - 1; index++) {

      // calculate the absolute difference of the current pair of points
      delta = Math.abs(Math.abs(this.gradientPoints[index]) - Math.abs(
            this.gradientPoints[index + 1]));

      pointsPerStep = this.calculateNumberOfPointsForDelta(delta);
      if (pointsPerStep > this.maxN) {
        pointsPerStep = this.maxN;
      }

      currInterpolation = linearInterpolation(this.coordinates[index]['x'],
          this.coordinates[index]['y'],
          this.coordinates[index]['z'],
          this.coordinates[index + 1]['x'],
          this.coordinates[index + 1]['y'],
          this.coordinates[index + 1]['z'],
          pointsPerStep);

      // extend to include these interpolated points, do not include the last
      // element of the array to avoid repeating the number per interval
      interpolatedCoordinatesBuffer = interpolatedCoordinatesBuffer.concat(
          currInterpolation.slice(0, -1));

      // extend the interval buffer
      for (var i = 0; i < pointsPerStep; i++) {
        intervalBuffer.push(index);
      }
    }

    // add the last point to make sure the trajectory is closed
    this.interpolatedCoordinates = interpolatedCoordinatesBuffer.concat(
        currInterpolation.slice(-1));
    this._intervalValues = intervalBuffer;

    return;
  };

  /**
   *
   * Helper method to calculate the number of points that there should be for a
   * differential.
   *
   * @param {float} delta Value for which to determine the required number of
   * points.
   *
   * @return {integer} The number of suggested frames for the differential
   *
   */
  TrajectoryOfSamples.prototype.calculateNumberOfPointsForDelta =
      function(delta) {
    return Math.floor((delta * this.suppliedN) / this.minimumDelta);
  };

  /**
   *
   * Retrieve the representative coordinates needed for a trajectory to be
   * drawn.
   *
   ** Note that this implementation is naive and will return points that lay on
   * a rect line if these were part of the original set of coordinates.
   *
   * @param {integer} idx Value for which to determine the required number of
   * points.
   *
   * @return {Array[]} Array containing the representative float x, y, z
   * coordinates needed to draw a trajectory at the given index.
   */
  TrajectoryOfSamples.prototype.representativeCoordinatesAtIndex =
      function(idx) {

    if (idx === 0) {
      return [this.coordinates[0]];
    }

    // we only need to show the edges and none of the interpolated points
    if (this.interpolatedCoordinates.length - 1 <= idx) {
      return this.coordinates;
    }

    var output = this.coordinates.slice(0, this._intervalValues[idx] + 1);
    output = output.concat(this.interpolatedCoordinates[idx]);

    return output;
  };

  /**
   *
   * Grab only the interpolated portion of representativeCoordinatesAtIndex.
   *
   * @param {integer} idx Value for which to determine the required number of
   * points.
   *
   * @return {Array[]} Array containing the representative float x, y, z
   * coordinates needed to draw the interpolated portion of a trajectory at the
   * given index.
   */
  TrajectoryOfSamples.prototype.representativeInterpolatedCoordinatesAtIndex =
  function(idx) {
    if (idx === 0)
      return null;
    if (this.interpolatedCoordinates.length - 1 <= idx)
      return null;

    lastStaticPoint = this.coordinates[this._intervalValues[idx]];
    interpPoint = this.interpolatedCoordinates[idx];
    if (lastStaticPoint.x === interpPoint.x &&
      lastStaticPoint.y === interpPoint.y &&
      lastStaticPoint.z === interpPoint.z) {
      return null; //Shouldn't pass on a zero length segment
    }

    return [lastStaticPoint, interpPoint];
  };

  /**
   *
   * Function to interpolate a certain number of steps between two three
   * dimensional points.
   *
   * This code is based on the function found in:
   *     http://snipplr.com/view.php?codeview&id=47206
   *
   * @param {float} x_1 Initial value of a position in the first dimension
   * @param {float} y_1 Initial value of a position in the second dimension
   * @param {float} z_1 Initial value of a position in the third dimension
   * @param {float} x_2 Final value of a position in the first dimension
   * @param {float} y_2 Final value of a position in the second dimension
   * @param {float} z_2 Final value of a position in the third dimension
   * @param {integer} steps Number of steps that we want the interpolation to
   * run
   *
   * @return {Object[]} Array of objects that have the x, y and z attributes
   * @function linearInterpolation
   *
   */

  function linearInterpolation(x_1, y_1, z_1, x_2, y_2, z_2, steps) {
    var xAbs = Math.abs(x_1 - x_2);
    var yAbs = Math.abs(y_1 - y_2);
    var zAbs = Math.abs(z_1 - z_2);
    var xDiff = x_2 - x_1;
    var yDiff = y_2 - y_1;
    var zDiff = z_2 - z_1;

    // and apparetnly this makes takes no effect whatsoever
    var length = Math.sqrt(xAbs * xAbs + yAbs * yAbs + zAbs * zAbs);
    var xStep = xDiff / steps;
    var yStep = yDiff / steps;
    var zStep = zDiff / steps;

    var newx = 0;
    var newy = 0;
    var newz = 0;
    var result = new Array();

    for (var s = 0; s <= steps; s++) {
      newx = x_1 + (xStep * s);
      newy = y_1 + (yStep * s);
      newz = z_1 + (zStep * s);

      result.push({'x': newx, 'y': newy, 'z': newz});
    }

    return result;
  }

  /**
   *
   * Function to compute the distance between two three dimensional points.
   *
   * This code is based on the function found in:
   *     {@link http://snipplr.com/view.php?codeview&id=47207}
   *
   * @param {float} x_1 Initial value of a position in the first dimension
   * @param {float} y_1 Initial value of a position in the second dimension
   * @param {float} z_1 Initial value of a position in the third dimension
   * @param {float} x_2 Final value of a position in the first dimension
   * @param {float} y_2 Final value of a position in the second dimension
   * @param {float} z_2 Final value of a position in the third dimension
   *
   * @return {float} Value of the distance between the two points
   * @function distanceBetweenPoints
   *
   */
  function distanceBetweenPoints(x_1, y_1, z_1, x_2, y_2, z_2) {
    var xs = 0;
    var ys = 0;
    var zs = 0;

    // Math.pow is faster than simple multiplication
    xs = Math.pow(Math.abs(x_2 - x_1), 2);
    ys = Math.pow(Math.abs(y_2 - y_1), 2);
    zs = Math.pow(Math.abs(z_2 - z_1), 2);

    return Math.sqrt(xs + ys + zs);
  }

  /**
   *
   * Helper data wrangling function, takes as inputs a mapping file and the
   * coordinates to synthesize the information into an array. Mainly used by
   * the AnimationDirector object.
   *
   * @param {string[]} mappingFileHeaders The metadata mapping file headers.
   * @param {Array[]} mappingFileData An Array where the indices are sample
   * identifiers and each of the contained elements is an Array of strings where
   * the first element corresponds to the first data for the first column in the
   * mapping file (`mappingFileHeaders`).
   * @param {Object[]} coordinatesData An Array of Objects where the indices are
   * the sample identifiers and each of the objects has the following
   * properties: x, y, z, name, color, P1, P2, P3, ... PN where N is the number
   * of dimensions in this dataset.
   * @param {string} trajectoryCategory a string with the name of the mapping
   * file header where the data that groups the samples is contained, this will
   * usually be BODY_SITE, HOST_SUBJECT_ID, etc..
   * @param {string} gradientCategory a string with the name of the mapping file
   * header where the data that spreads the samples over a gradient is
   * contained, usually time or days_since_epoch. Note that this should be an
   * all numeric category.
   *
   * @return {Object[]} An Array with the contained data indexed by the sample
   * identifiers.
   * @throws {Error} Any of the following:
   *  * gradientIndex === -1
   *  * trajectoryIndex === -1
   * @function getSampleNamesAndDataForSortedTrajectories
   *
   */
  function getSampleNamesAndDataForSortedTrajectories(mappingFileHeaders,
      mappingFileData,
      coordinatesData,
      trajectoryCategory,
      gradientCategory) {
    var gradientIndex = mappingFileHeaders.indexOf(gradientCategory);
    var trajectoryIndex = mappingFileHeaders.indexOf(trajectoryCategory);

    var processedData = {}, out = {};
    var trajectoryBuffer = null, gradientBuffer = null;

    // this is the most utterly annoying thing ever
    if (gradientIndex === -1) {
      throw new Error('Gradient category not found in mapping file header');
    }
    if (trajectoryIndex === -1) {
      throw new Error('Trajectory category not found in mapping file header');
    }

    for (var sampleId in mappingFileData) {

      trajectoryBuffer = mappingFileData[sampleId][trajectoryIndex];
      gradientBuffer = mappingFileData[sampleId][gradientIndex];

      // check if there's already an element for this trajectory, if not
      // initialize a new array for this element of the processed data
      if (processedData[trajectoryBuffer] === undefined) {
        processedData[trajectoryBuffer] = [];
      }
      processedData[trajectoryBuffer].push({'name': sampleId,
        'value': gradientBuffer, 'x': coordinatesData[sampleId]['x'],
        'y': coordinatesData[sampleId]['y'],
        'z': coordinatesData[sampleId]['z']});
    }

    // we need this custom sorting function to make the values be sorted in
    // ascending order but accounting for the data structure that we just built
    var sortingFunction = function(a, b) {
      return parseFloat(a['value']) - parseFloat(b['value']);
    };

    // sort all the values using the custom anonymous function
    for (var key in processedData) {
      processedData[key].sort(sortingFunction);
    }

    // Don't add a trajectory unless it has more than one sample in the
    // gradient. For example, there's no reason why we should animate a
    // trajectory that has 3 samples at timepoint 0 ([0, 0, 0]) or a trajectory
    // that has just one sample at timepoint 0 ([0])
    for (key in processedData) {
      var uniqueValues = _.uniq(processedData[key], false, function(sample) {
        return sample.value;
      });

      if (uniqueValues.length > 1 && processedData[key].length >= 1) {
        out[key] = processedData[key];
      }
    }

    // we need a placeholder object as we filter trajectories below
    processedData = out;
    out = {};

    // note that min finds the trajectory with the oldest sample, once found
    // we get the first sample and the first point in the gradient
    var earliestSample = _.min(processedData, function(trajectory) {
      return parseInt(trajectory[0].value);
    })[0].value;

    // Left-pad all trajectories so they start at the same time, but they are
    // not visibly different.
    //
    // Note: THREE.js won't display geometries with overlapping vertices,
    // therefore we add a small amount of noise in the Z coordinate.
    _.each(processedData, function(value, key) {
      out[key] = processedData[key];
      var first = processedData[key][0];
      if (first.value !== earliestSample) {
        out[key].unshift({'name': first.name, 'value': earliestSample,
                          'x': first.x, 'y': first.y, 'z': first.z + 0.0001});
      }
    });

    return out;
  }

  /**
   *
   * Function to calculate the minimum delta from an array of wrangled data by
   * getSampleNamesAndDataForSortedTrajectories.
   *
   * This function will not take into account as a minimum delta zero values
   * i.e. the differential between two samples that lie at the same position in
   * the gradient.
   *
   * @param {Object[]} sampleData An Array as computed from mapping file data
   * and coordinates by getSampleNamesAndDataForSortedTrajectories.
   *
   * @return {float} The minimum difference between two samples across the
   * defined gradient.
   *
   * @throws {Error} Input data is undefined.
   * @function getMinimumDelta
   */
  function getMinimumDelta(sampleData) {
    if (sampleData === undefined) {
      throw new Error('The sample data cannot be undefined');
    }

    var bufferArray = new Array(), deltasArray = new Array();

    // go over all the data and compute the deltas for all trajectories
    for (var key in sampleData) {
      for (var index = 0; index < sampleData[key].length; index++) {
        bufferArray.push(sampleData[key][index]['value']);
      }
      for (var index = 0; index < bufferArray.length - 1; index++) {
        deltasArray.push(Math.abs(bufferArray[index + 1] - bufferArray[index]));
      }

      // clean buffer array
      bufferArray.length = 0;
    }

    // remove all the deltas of zero so we don't skew our results
    deltasArray = _.filter(deltasArray, function(num) { return num !== 0; });

    // return the minimum of these values
    return _.min(deltasArray);
  }

  return {'TrajectoryOfSamples': TrajectoryOfSamples,
    'getMinimumDelta': getMinimumDelta,
    'getSampleNamesAndDataForSortedTrajectories':
      getSampleNamesAndDataForSortedTrajectories,
    'distanceBetweenPoints': distanceBetweenPoints,
    'linearInterpolation': linearInterpolation};
});