models/partition.js

/**
 * Partition
 *
 * Describes a partitioning of the data, based on the values a Facet can take.
 * @class Partition
 */
var BaseModel = require('./base');
var Groups = require('./group-collection');
var moment = require('moment-timezone');
var app = require('ampersand-app');
var selection = require('../util-selection');

function setTimeResolution (partition) {
  var start = partition.minval;
  var end = partition.maxval;
  var humanized = end.from(start, true).split(' ');
  var units = humanized[humanized.length - 1];

  if (units === 'minute') {
    units = 'seconds';
  } else if (units === 'hour') {
    units = 'minutes';
  } else if (units === 'day') {
    units = 'hours';
  } else if (units === 'week') {
    units = 'days';
  } else if (units === 'month') {
    units = 'days';
  } else if (units === 'year') {
    units = 'months';
  }
  partition.groupingTimeResolution = units;

  var fmt;
  if (units === 'seconds') {
    fmt = 'mm:ss';
  } else if (units === 'minutes') {
    fmt = 'HH:mm';
  } else if (units === 'hours') {
    fmt = 'HH:00';
  } else if (units === 'days') {
    fmt = 'dddd do';
  } else if (units === 'weeks') {
    fmt = 'wo';
  } else if (units === 'months') {
    fmt = 'YY MMM';
  } else if (units === 'years') {
    fmt = 'YYYY';
  }
  partition.groupingTimeFormat = fmt;
}

/*
 * Setup a grouping based on the `partition.minval`, `partition.maxval`,
 * `partition.groupingTimeResolution` and the `partition.groupingTimeFormat`.
 * @param {Partition} partition
 * @memberof! Partition
 */
function setTimeGroups (partition) {
  setTimeResolution(partition);
  var timeStart = partition.minval;
  var timeEnd = partition.maxval;
  var timeStep = partition.groupingTimeResolution;
  var timeFormat = partition.groupingTimeFormat;

  partition.groups.reset();

  var binned, binStart, binEnd;
  var current = timeStart.clone();
  while (current.isBefore(timeEnd)) {
    binned = current.clone().startOf(timeStep);
    binStart = binned.clone();
    binEnd = binned.clone().add(1, timeStep);

    partition.groups.add({
      min: binStart.format(),
      max: binEnd.format(),
      value: binned,
      label: binned.format(timeFormat)
    });

    current.add(1, timeStep);
  }
}

/*
 * Setup a grouping based on the `partition.groupingContinuous`, `partition.minval`,
 * `partition.maxval`, and the `partition.groupingParam`.
 * @memberof! Partition
 * @param {Partition} partition
 */
function setContinuousGroups (partition) {
  var param = partition.groupingParam;
  var x0, x1, size, nbins;

  if (partition.groupFixedN) {
    // A fixed number of equally sized bins
    nbins = param;
    x0 = partition.minval;
    x1 = partition.maxval;
    size = (x1 - x0) / nbins;
  } else if (partition.groupFixedS) {
    // A fixed bin size
    size = param;
    x0 = Math.floor(partition.minval / size) * size;
    x1 = Math.ceil(partition.maxval / size) * size;
    nbins = (x1 - x0) / size;
  } else if (partition.groupFixedSC) {
    // A fixed bin size, centered on 0
    size = param;
    x0 = (Math.floor(partition.minval / size) - 0.5) * size;
    x1 = (Math.ceil(partition.maxval / size) + 0.5) * size;
    nbins = (x1 - x0) / size;
  } else if (partition.groupLog) {
    // Fixed number of logarithmically (base 10) sized bins
    nbins = param;
    x0 = Math.log(partition.minval) / Math.log(10.0);
    x1 = Math.log(partition.maxval) / Math.log(10.0);
    size = (x1 - x0) / nbins;
  }

  // and update partition.groups
  partition.groups.reset();
  delete partition.groups.comparator; // use as-entered ordering

  function unlog (x) {
    return Math.exp(x * Math.log(10));
  }

  var i;
  for (i = 0; i < nbins; i++) {
    var start = x0 + i * size;
    var end = x0 + (i + 1) * size;
    var mid = 0.5 * (start + end);

    if (partition.groupLog) {
      partition.groups.add({
        min: unlog(start),
        max: unlog(end),
        value: unlog(start),
        label: unlog(end).toPrecision(5)
      });
    } else {
      partition.groups.add({
        min: start,
        max: end,
        value: mid,
        label: mid.toPrecision(5)
      });
    }
  }
}

/*
 * Setup a grouping based on the `partition.categorialTransform`
 * @memberof! Partition
 * @param {Partition} partition
 */
function setCategorialGroups (partition) {
  // and update partition.groups
  partition.groups.reset();

  // use as-entered ordering
  delete partition.groups.comparator;

  if (app && app.me && app.me.dataset) {
    var facet = app.me.dataset.facets.get(partition.facetId);
    facet.categorialTransform.forEach(function (rule) {
      partition.groups.add({
        value: rule.group,
        label: rule.group,
        count: rule.count
      });
    });
  }
}

/**
 * Setup the partition.groups()
 * @memberof! Partition
 * @param {Partition} partition
 */
function setGroups (partition) {
  var type = partition.type;

  if (type === 'categorial') {
    setCategorialGroups(partition);
  } else if (type === 'continuous') {
    setContinuousGroups(partition);
  } else if (type === 'datetime') {
    setTimeGroups(partition);
  } else {
    console.error('Cannot set groups for partition', partition.getId());
  }
}

module.exports = BaseModel.extend({
  dataTypes: {
    'numberOrMoment': {
      set: function (value) {
        if (value === +value) {
          // allow setting a number
          return {
            val: +value,
            type: 'numberOrMoment'
          };
        } else {
          // allow setting something moment understands
          value = moment(value, moment.ISO_8601);
          if (value.isValid()) {
            return {
              val: value,
              type: 'numberOrMoment'
            };
          }
        }
        return {
          val: value,
          type: typeof value
        };
      },
      compare: function (currentVal, newVal) {
        if (currentVal instanceof moment) {
          return currentVal.isSame(newVal);
        } else {
          return +currentVal === +newVal;
        }
      }
    }
  },
  props: {
    /**
     * Type of this partition:
     *  * `constant`        A constant value of "1" for all data items
     *  * `continuous`      The facet takes on real numbers
     *  * `categorial`      The facet is a string, or an array of strings (for labels and tags)
     *  * `datetime`  The facet is a datetime (using momentjs)
     * Determined by the facet, and automatically set on updateSelection()
     * @memberof! Partition
     * @type {string}
     */
    type: {
      type: 'string',
      required: true,
      default: 'categorial',
      values: ['constant', 'continuous', 'categorial', 'datetime']
    },

    /**
     * The ID of the facet to partition over
     * @memberof! Partition
     * @type {string}
     */
    facetId: 'string',

    /**
     * When part of a partitioning, this deterimines the ordering
     * @memberof! Partition
     * @type {number}
     */
    rank: 'number',

    /**
     * For continuous or datetime Facets, the minimum value. Values lower than this are grouped to 'missing'
     * @memberof! Partition
     * @type {number|moment}
     */
    minval: 'numberOrMoment',

    /**
     * For continuous or datetime Facets, the maximum value. Values higher than this are grouped to 'missing'
     * @memberof! Partition
     * @type {number|moment}
     */
    maxval: 'numberOrMoment',

    /**
     * Extra parameter used in the grouping strategy: either the number of bins, or the bin size.
     * @memberof! Partition
     * @type {number}
     */
    groupingParam: ['number', true, 20],

    /**
     * Grouping strategy:
     *  * `fixedn`  fixed number of bins in the interval [minval, maxval]
     *  * `fixedsc` a fixed binsize, centered on zero
     *  * `fixeds`  a fixed binsize, starting at zero
     *  * `log`     fixed number of bins but on a logarithmic scale
     * Don't use directly but check grouping via the groupFixedN, groupFixedSC,
     * groupFixedS, and groupLog properties
     * @memberof! Partition
     * @type {number}
     */
    groupingContinuous: {
      type: 'string',
      required: true,
      default: 'fixedn',
      values: ['fixedn', 'fixedsc', 'fixeds', 'log']
    },

    /**
     * Time is grouped by truncating; the groupingTimeResolution parameter sets the resolution.
     * See [this table](http://momentjs.com/docs/#/durations/creating/) for accpetable values
     * when using a crossfilter dataset.
     * @memberof! Partition
     * @type {string}
     */
    groupingTimeResolution: ['string', true, 'hours'],

    /**
     * Formatting string for displaying of datetimes
     * @memberof! Partition
     * @type {string}
     */
    groupingTimeFormat: ['string', true, 'hours'],

    /**
     * Depending on the type of partition, this can be an array of the selected groups,
     * or a numberic interval [start, end]
     * @memberof! Partition
     * @type {array}
     */
    // NOTE: for categorial facets, contains group.value
    selected: {
      type: 'array',
      required: true,
      default: function () {
        return [];
      }
    }
  },

  collections: {
    /**
     * The (ordered) set of groups this Partition can take, making up this partition.
     * Used for plotting
     * @memberof! Partition
     * @type {Group[]}
     */
    groups: Groups
  },
  derived: {
    // properties for grouping-continuous
    groupFixedN: {
      deps: ['groupingContinuous'],
      fn: function () {
        return this.groupingContinuous === 'fixedn';
      }
    },
    groupFixedSC: {
      deps: ['groupingContinuous'],
      fn: function () {
        return this.groupingContinuous === 'fixedsc';
      }
    },
    groupFixedS: {
      deps: ['groupingContinuous'],
      fn: function () {
        return this.groupingContinuous === 'fixeds';
      }
    },
    groupLog: {
      deps: ['groupingContinuous'],
      fn: function () {
        return this.groupingContinuous === 'log';
      }
    }
  },
  updateSelection: function (group) {
    selection.updateSelection(this, group);
  },
  filterFunction: function () {
    return selection.filterFunction(this);
  },
  setGroups: function () {
    setGroups(this);
  }
});