lumenize
Version:
Illuminating the forest AND the trees in your data.
369 lines (327 loc) • 15.8 kB
JavaScript
// Generated by CoffeeScript 1.10.0
(function() {
var OLAPCube, Time, Timeline, TransitionsCalculator, ref, utils;
OLAPCube = require('./OLAPCube').OLAPCube;
ref = require('tztime'), utils = ref.utils, Time = ref.Time, Timeline = ref.Timeline;
TransitionsCalculator = (function() {
/*
@class TransitionsCalculator
Used to accumlate counts and sums about transitions.
Let's say that you want to create a throughput or velocity chart where each column on the chart represents the
number of work items that cross over from one state into another state in a given month/week/quarter/etc. You would
send a transitions to a temporal data store like Rally's Lookback API specifying both the current values and the
previous values. For instance, the work items crossing from "In Progress" to "Completed" could be found
with this query clause `"_PreviousValues.ScheduleState": {"$lte": "In Progress"}, "ScheduleState": {"$gt": "In Progress"}`
{TransitionsCalculator, Time} = require('../')
snapshots = [
{ id: 1, from: '2011-01-03T00:00:00.000Z', PlanEstimate: 10 },
{ id: 1, from: '2011-01-05T00:00:00.000Z', PlanEstimate: 10 },
{ id: 2, from: '2011-01-04T00:00:00.000Z', PlanEstimate: 20 },
{ id: 3, from: '2011-01-10T00:00:00.000Z', PlanEstimate: 30 },
{ id: 4, from: '2011-01-11T00:00:00.000Z', PlanEstimate: 40 },
{ id: 5, from: '2011-01-17T00:00:00.000Z', PlanEstimate: 50 },
{ id: 6, from: '2011-02-07T00:00:00.000Z', PlanEstimate: 60 },
{ id: 7, from: '2011-02-08T00:00:00.000Z', PlanEstimate: 70 },
]
But that's not the entire story. What if something crosses over into "Completed" and beyond but crosses back. It could
do this several times and get counted multiple times. That would be bad. The way we deal with this is to also
look for the list of snapshots that pass backwards across the boundary and subract thier impact on the final calculations.
One can think of alternative aproaches for avoiding this double counting. You could, for instance, only count the last
transition for each unique work item. The problem with this approach is that the backward moving transition might
occur in a different time period from the forward moving one. A later snapshot could invalidate an earlier calculation
which is bad for incremental calculation and caching. To complicate matters, the field values being summed by the
calculator might have changed between subsequent forward/backward transitions. The chosen algorithm is the only way I know to
preserve the idempotency and cachable incremental calculation properties.
snapshotsToSubtract = [
{ id: 1, from: '2011-01-04T00:00:00.000Z', PlanEstimate: 10 },
{ id: 7, from: '2011-02-09T00:00:00.000Z', PlanEstimate: 70 },
]
The calculator will keep track of the count of items automatically (think throughput), but if you want to sum up a
particular field (think velocity), you can specify that with the 'fieldsToSum' config property.
fieldsToSum = ['PlanEstimate']
Now let's build our config object.
config =
asOf: '2011-02-10' # Leave this off if you want it to continuously update to today
granularity: Time.MONTH
tz: 'America/Chicago'
validFromField: 'from'
validToField: 'to'
uniqueIDField: 'id'
fieldsToSum: fieldsToSum
asterixToDateTimePeriod: true # Set to false or leave off if you are going to reformat the timePeriod
In most cases, you'll want to leave off the `asOf` configuration property so the data can be continuously updated
with new snapshots as they come in. We include it in this example so the output stays stable. If we hadn't, then
the rows would continue to grow to encompass today.
startOn = '2011-01-02T00:00:00.000Z'
endBefore = '2011-02-27T00:00:00.000Z'
calculator = new TransitionsCalculator(config)
calculator.addSnapshots(snapshots, startOn, endBefore, snapshotsToSubtract)
console.log(calculator.getResults())
* [ { timePeriod: '2011-01', count: 5, PlanEstimate: 150 },
* { timePeriod: '2011-02*', count: 1, PlanEstimate: 60 } ]
The asterix on the last row in the results is to indicate that it is a to-date value. As more snapshots come in, this
last row will change. The caching and incremental calcuation capability of this Calculator are designed to take
this into account.
Now, let's use the same data but aggregate in granularity of weeks.
config.granularity = Time.WEEK
calculator = new TransitionsCalculator(config)
calculator.addSnapshots(snapshots, startOn, endBefore, snapshotsToSubtract)
console.log(calculator.getResults())
* [ { timePeriod: '2010W52', count: 1, PlanEstimate: 10 },
* { timePeriod: '2011W01', count: 2, PlanEstimate: 50 },
* { timePeriod: '2011W02', count: 2, PlanEstimate: 90 },
* { timePeriod: '2011W03', count: 0, PlanEstimate: 0 },
* { timePeriod: '2011W04', count: 0, PlanEstimate: 0 },
* { timePeriod: '2011W05', count: 1, PlanEstimate: 60 },
* { timePeriod: '2011W06*', count: 0, PlanEstimate: 0 } ]
Remember, you can easily convert weeks to other granularities for display.
weekStartingLabel = 'week starting ' + new Time('2010W52').inGranularity(Time.DAY).toString()
console.log(weekStartingLabel)
* week starting 2010-12-27
If you want to display spinners while the chart is rendering, you can read this calculator's upToDateISOString property and
compare it directly to the getResults() row's timePeriod property using code like this. Yes, this works eventhough
upToDateISOString is an ISOString.
row = {timePeriod: '2011W07'}
if calculator.upToDateISOString < row.timePeriod
console.log("#{row.timePeriod} not yet calculated.")
* 2011W07 not yet calculated.
*/
function TransitionsCalculator(config) {
/*
@constructor
@param {Object} config
@cfg {String} tz The timezone for analysis in the form like `America/New_York`
@cfg {String} [validFromField = "_ValidFrom"]
@cfg {String} [validToField = "_ValidTo"]
@cfg {String} [uniqueIDField = "_EntityID"] Not used right now but when drill-down is added it will be
@cfg {String} granularity 'month', 'week', 'quarter', etc. Use Time.MONTH, Time.WEEK, etc.
@cfg {String[]} [fieldsToSum=[]] It will track the count automatically but it can keep a running sum of other fields also
@cfg {Boolean} [asterixToDateTimePeriod=false] If set to true, then the still-in-progress last time period will be asterixed
*/
var cubeConfig, dimensions, f, i, len, metrics, ref1, ref2;
this.config = utils.clone(config);
if (this.config.validFromField == null) {
this.config.validFromField = "_ValidFrom";
}
if (this.config.validToField == null) {
this.config.validToField = "_ValidTo";
}
if (this.config.uniqueIDField == null) {
this.config.uniqueIDField = "_EntityID";
}
if (this.config.fieldsToSum == null) {
this.config.fieldsToSum = [];
}
if (this.config.asterixToDateTimePeriod == null) {
this.config.asterixToDateTimePeriod = false;
}
utils.assert(this.config.tz != null, "Must provide a timezone to this calculator.");
utils.assert(this.config.granularity != null, "Must provide a granularity to this calculator.");
if ((ref1 = this.config.granularity) === Time.HOUR || ref1 === Time.MINUTE || ref1 === Time.SECOND || ref1 === Time.MILLISECOND) {
throw new Error("Transitions calculator is not designed to work on granularities finer than 'day'");
}
dimensions = [
{
field: 'timePeriod'
}
];
metrics = [
{
field: 'count',
f: 'sum'
}
];
ref2 = this.config.fieldsToSum;
for (i = 0, len = ref2.length; i < len; i++) {
f = ref2[i];
metrics.push({
field: f,
f: 'sum'
});
}
cubeConfig = {
dimensions: dimensions,
metrics: metrics
};
this.cube = new OLAPCube(cubeConfig);
this.upToDateISOString = null;
this.lowestTimePeriod = null;
if (this.config.asOf != null) {
this.maxTimeString = new Time(this.config.asOf, Time.MILLISECOND).getISOStringInTZ(this.config.tz);
} else {
this.maxTimeString = Time.getISOStringFromJSDate();
}
this.virgin = true;
}
TransitionsCalculator.prototype.addSnapshots = function(snapshots, startOn, endBefore, snapshotsToSubtract) {
var filteredSnapshots, filteredSnapshotsToSubstract, startOnString;
if (snapshotsToSubtract == null) {
snapshotsToSubtract = [];
}
/*
@method addSnapshots
Allows you to incrementally add snapshots to this calculator.
@chainable
@param {Object[]} snapshots An array of temporal data model snapshots.
@param {String} startOn A ISOString (e.g. '2012-01-01T12:34:56.789Z') indicating the time start of the period of
interest. On the second through nth call, this should equal the previous endBefore.
@param {String} endBefore A ISOString (e.g. '2012-01-01T12:34:56.789Z') indicating the moment just past the time
period of interest.
@return {TransitionsCalculator}
*/
if (this.upToDateISOString != null) {
utils.assert(this.upToDateISOString === startOn, "startOn (" + startOn + ") parameter should equal endBefore of previous call (" + this.upToDateISOString + ") to addSnapshots.");
}
this.upToDateISOString = endBefore;
startOnString = new Time(startOn, this.config.granularity, this.config.tz).toString();
if (this.lowestTimePeriod != null) {
if (startOnString < this.lowestTimePeriod) {
this.lowestTimePeriod = startOnString;
}
} else {
this.lowestTimePeriod = startOnString;
}
filteredSnapshots = this._filterSnapshots(snapshots);
this.cube.addFacts(filteredSnapshots);
filteredSnapshotsToSubstract = this._filterSnapshots(snapshotsToSubtract, -1);
this.cube.addFacts(filteredSnapshotsToSubstract);
this.virgin = false;
return this;
};
TransitionsCalculator.prototype._filterSnapshots = function(snapshots, sign) {
var f, filteredSnapshots, fs, i, j, len, len1, ref1, s;
if (sign == null) {
sign = 1;
}
filteredSnapshots = [];
for (i = 0, len = snapshots.length; i < len; i++) {
s = snapshots[i];
if (s[this.config.validFromField] <= this.maxTimeString) {
if (s.count != null) {
throw new Error('Snapshots passed into a TransitionsCalculator cannot have a `count` field.');
}
if (s.timePeriod != null) {
throw new Error('Snapshots passed into a TransitionsCalculator cannot have a `timePeriod` field.');
}
fs = utils.clone(s);
fs.count = sign * 1;
fs.timePeriod = new Time(s[this.config.validFromField], this.config.granularity, this.config.tz).toString();
ref1 = this.config.fieldsToSum;
for (j = 0, len1 = ref1.length; j < len1; j++) {
f = ref1[j];
fs[f] = sign * s[f];
}
filteredSnapshots.push(fs);
}
}
return filteredSnapshots;
};
TransitionsCalculator.prototype.getResults = function() {
/*
@method getResults
Returns the current state of the calculator
@return {Object[]} Returns an Array of Maps like `{timePeriod: '2012-12', count: 10, otherField: 34}`
*/
var cell, config, f, filter, i, j, k, len, len1, len2, out, outRow, ref1, ref2, t, timeLine, timePeriods, tp;
if (this.virgin) {
return [];
}
out = [];
this.highestTimePeriod = new Time(this.maxTimeString, this.config.granularity, this.config.tz).toString();
config = {
startOn: this.lowestTimePeriod,
endBefore: this.highestTimePeriod,
granularity: this.config.granularity
};
timeLine = new Timeline(config);
timePeriods = (function() {
var i, len, ref1, results;
ref1 = timeLine.getAllRaw();
results = [];
for (i = 0, len = ref1.length; i < len; i++) {
t = ref1[i];
results.push(t.toString());
}
return results;
})();
timePeriods.push(this.highestTimePeriod);
for (i = 0, len = timePeriods.length; i < len; i++) {
tp = timePeriods[i];
filter = {};
filter['timePeriod'] = tp;
cell = this.cube.getCell(filter);
outRow = {};
outRow.timePeriod = tp;
if (cell != null) {
outRow.count = cell.count_sum;
ref1 = this.config.fieldsToSum;
for (j = 0, len1 = ref1.length; j < len1; j++) {
f = ref1[j];
outRow[f] = cell[f + '_sum'];
}
} else {
outRow.count = 0;
ref2 = this.config.fieldsToSum;
for (k = 0, len2 = ref2.length; k < len2; k++) {
f = ref2[k];
outRow[f] = 0;
}
}
out.push(outRow);
}
if (this.config.asterixToDateTimePeriod) {
out[out.length - 1].timePeriod += '*';
}
return out;
};
TransitionsCalculator.prototype.getStateForSaving = function(meta) {
/*
@method getStateForSaving
Enables saving the state of this calculator. See TimeInStateCalculator documentation for a detailed example.
@param {Object} [meta] An optional parameter that will be added to the serialized output and added to the meta field
within the deserialized calculator.
@return {Object} Returns an Ojbect representing the state of the calculator. This Object is suitable for saving to
to an object store. Use the static method `newFromSavedState()` with this Object as the parameter to reconstitute
the calculator.
*/
var out;
out = {
config: this.config,
cubeSavedState: this.cube.getStateForSaving(),
upToDateISOString: this.upToDateISOString,
maxTimeString: this.maxTimeString,
lowestTimePeriod: this.lowestTimePeriod,
virgin: this.virgin
};
if (meta != null) {
out.meta = meta;
}
return out;
};
TransitionsCalculator.newFromSavedState = function(p) {
/*
@method newFromSavedState
Deserializes a previously saved calculator and returns a new calculator. See TimeInStateCalculator for a detailed example.
@static
@param {String/Object} p A String or Object from a previously saved state
@return {TransitionsCalculator}
*/
var calculator;
if (utils.type(p) === 'string') {
p = JSON.parse(p);
}
calculator = new TransitionsCalculator(p.config);
calculator.cube = OLAPCube.newFromSavedState(p.cubeSavedState);
calculator.upToDateISOString = p.upToDateISOString;
calculator.maxTimeString = p.maxTimeString;
calculator.lowestTimePeriod = p.lowestTimePeriod;
calculator.virgin = p.virgin;
if (p.meta != null) {
calculator.meta = p.meta;
}
return calculator;
};
return TransitionsCalculator;
})();
exports.TransitionsCalculator = TransitionsCalculator;
}).call(this);