facet/continuous-transform.js

/**
 * ContinuousTransfrom defines a transformation on continuous (nummerical) data.
 * Currently linear interpolation between a set of control points is implemented.
 *
 * @class ContinuousTransform
 */
var AmpersandModel = require('ampersand-model');
var Collection = require('ampersand-collection');
var misval = require('../util/misval');

var ControlPoint = require('./control-point');
var ControlPoints = Collection.extend({
  model: ControlPoint
});

/**
 * Apply piecewise linear transformation
 * The function is constant outside the range spanned by the control points;
 * there it is set to value of the first, or the last, control points.
 *
 * @function
 * @memberof! ContinuousTransform
 * @param {number} x
 * @returns {number} fx
 */
function transform (cps, x) {
  if (x === misval) {
    return misval;
  }

  var ncps = cps.models.length;
  if (x <= cps.models[0].x) {
    // outside range on left side
    return cps.models[0].fx;
  } else if (x >= cps.models[ncps - 1].x) {
    // outside range on right side
    return cps.models[ncps - 1].fx;
  } else {
    // inside range
    var i = 0;
    while (x > cps.models[i].x) {
      i = i + 1;
    }

    // linear interpolate between fx_i and fx_(i+1)
    var xm = cps.models[i].x;
    var xp = cps.models[i + 1].x;
    var fxm = cps.models[i].fx;
    var fxp = cps.models[i + 1].fx;
    if (xp === xm) {
      return 0.5 * (fxm + fxp);
    } else {
      return fxm + (x - xm) * (fxp - fxm) / (xp - xm);
    }
  }
}

/**
 * The inverse of the transform
 *
 * @function
 * @memberof! ContinuousTransform
 * @param {number} fx
 * @returns {number} x
 */
function inverse (cps, fx) {
  if (fx === misval) {
    return misval;
  }

  var ncps = cps.models.length;
  if (fx <= cps.models[0].fx) {
    // outside range on left side
    return cps.models[0].x;
  } else if (fx >= cps.models[ncps - 1].fx) {
    // outside range on right side
    return cps.models[ncps - 1].x;
  } else {
    // inside range
    var i = 0;
    while (fx > cps.models[i].fx) {
      i = i + 1;
    }

    // linear interpolate between fx_i and fx_(i+1)
    var xm = cps.models[i].x;
    var xp = cps.models[i + 1].x;
    var fxm = cps.models[i].fx;
    var fxp = cps.models[i + 1].fx;
    if (fxp === fxm) {
      return 0.5 * (xm + xp);
    } else {
      return xm + (fx - fxm) * (xp - xm) / (fxp - fxm);
    }
  }
}

module.exports = AmpersandModel.extend({
  props: {
    /**
     * The type of continuous transform, can be none, or percentiles
     * Use isNone, or isPercentiles, check for transform type
     * @memberof! ContinuousTransform
     */
    type: {
      type: 'string',
      required: true,
      default: 'none',
      values: ['none', 'percentiles']
    },
    transformedType: {
      type: 'string',
      required: true,
      default: 'continuous',
      values: ['continuous']
    }
  },
  derived: {
    isNone: {
      deps: ['type'],
      fn: function () {
        return this.type === 'none';
      }
    },
    isPercentiles: {
      deps: ['type'],
      fn: function () {
        return this.type === 'percentiles';
      }
    },
    /**
     * The minimum value this facet can take, after the transformation has been applied
     * @type {number}
     * @memberof! ContinuousTransform
     */
    transformedMin: {
      deps: ['type'],
      fn: function () {
        if (this.isPercentiles) {
          return 0;
        } else if (this.isNone) {
          return this.parent.minval;
        } else {
          console.error('Invalid continuous transform');
        }
      },
      cache: false
    },
    /**
     * The maximum value this facet can take, after the transformation has been applied
     * @type {number}
     * @memberof! ContinuousTransform
     */
    transformedMax: {
      deps: ['type'],
      fn: function () {
        if (this.isPercentiles) {
          return 100;
        } else if (this.isNone) {
          return this.parent.maxval;
        } else {
          console.error('Invalid continuous transform');
        }
      },
      cache: false
    },
    /**
     * The minimum value this facet can take, after the transformation has been applied
     *
     * @type {string}
     * @memberof! ContinuousTransform
     */
    transformedMinAsText: {
      deps: ['transformedMin', 'transformedType'],
      fn: function () {
        var minval = this.transformedMin;
        if (this.transformedType === 'datetime') {
          return minval.format();
        } else {
          return minval.toString();
        }
      },
      cache: false
    },
    /**
     * The maximum value this facet can take, after the transformation has been applied
     *
     * @type {string}
     * @memberof! ContinuousTransform
     */
    transformedMaxAsText: {
      deps: ['transformedMax', 'transformedType'],
      fn: function () {
        var maxval = this.transformedMax;
        if (this.transformedType === 'datetime') {
          return maxval.format();
        } else {
          return maxval.toString();
        }
      },
      cache: false
    }
  },
  collections: {
    cps: ControlPoints
  },
  transform: function (x) {
    return transform(this.cps, x);
  },
  inverse: function (fx) {
    return inverse(this.cps, fx);
  },
  reset: function () {
    this.type = 'none';
    this.cps.reset();
  }
});