stackviz/app/js/directives/timeline.js
Tim Buckley d27c01fb6a Add new configuration file format
This enables a new "artifact"-based configuration file format,
intended to work natively with the deployer and to aid future efforts
to visualize additional data sources.

Among other tweaks, dataset indices are no longer used as the primary
differentiator between data files, and instead artifact names (such as
`testrepository.subunit`) are used to group related artfacts of various
types, such as 'subunit', 'subunit-stats', and 'subunit-details'.

Additionally, datasets and artifacts now have access to substantially
more metadata about the job that generated the output data. In future
patches, this metadata will be used to display and link to additional
information about visualized data. This metadata is made available
automatically by the deployer, and can be optionally gathered from
environment variables when using `stackviz-export` via a new `--env`
flag.

Change-Id: I3e16cc314624a1b7b4f6bf43fa4d5cdeedcdba0c
2016-04-21 16:21:52 -06:00

478 lines
13 KiB
JavaScript

'use strict';
var directivesModule = require('./_index.js');
var arrayUtil = require('../util/array-util');
var parseDstat = require('../util/dstat-parse');
var d3 = require('d3');
var statusColorMap = {
'success': 'LightGreen',
'fail': 'Crimson',
'skip': 'DodgerBlue',
'selected': 'GoldenRod',
'hover': 'DarkTurquoise'
};
var parseWorker = function(tags) {
for (var i = 0; i < tags.length; i++) {
if (!tags[i].startsWith('worker')) {
continue;
}
return parseInt(tags[i].split('-')[1], 10);
}
return null;
};
/**
* @ngInject
*/
function timeline($window, $log, datasetService, progressService) {
/**
* @ngInject
*/
var controller = function($scope) {
var self = this;
self.statusColorMap = statusColorMap;
self.data = [];
self.dataRaw = [];
self.dstat = [];
self.margin = { top: 40, right: 10, bottom: 20, left: 80 };
self.width = 0;
self.height = 550 - this.margin.top - this.margin.bottom;
/**
* The date extents of all chart entries.
*/
self.timeExtents = [0, 0];
/**
* The date extents of the current viewport.
*/
self.viewExtents = [0, 0];
self.axes = {
/**
* The primary axis mapping date to on-screen x. The lower time bound maps
* to x=0, while the upper time bound maps to x=width.
*/
x: d3.time.scale(),
/**
* The selection axis, mapping date to on-screen x, depending on the size
* and position of the user selection. `selection(viewExtents[0]) = 0`,
* while `selection(viewExtents[1]) = width`
*/
selection: d3.scale.linear(),
/**
* The absolute x axis mapping date to virtual x, depending only on the
* size (but not position) of the user selection.
* `absolute(timeExtents[0]) = 0`, while `absolute(timeExtents[1])` will
* be the total width at the current scale, spanning as many
* viewport-widths as necessary.
*/
absolute: d3.scale.linear()
};
self.selectionName = null;
self.selection = null;
self.hover = null;
self.filterFunction = null;
self.animateId = null;
self.animateCallbacks = [];
self.setViewExtents = function(extents) {
if (angular.isNumber(extents[0])) {
extents[0] = new Date(extents[0]);
}
if (angular.isNumber(extents[1])) {
extents[1] = new Date(extents[1]);
}
var oldSize = self.viewExtents[1] - self.viewExtents[0];
var newSize = extents[1] - extents[0];
self.viewExtents = extents;
self.axes.selection.domain(extents);
// slight hack: d3 extrapolates by default, and these scales are identical
// when the lower bound is zero, so just keep absolute's domain at
// [0, selectionWidth]
self.axes.absolute.domain([
+self.timeExtents[0],
+self.timeExtents[0] + newSize
]);
if (Math.abs(oldSize - newSize) > 1) {
$scope.$broadcast('updateViewSize');
} else {
$scope.$broadcast('updateViewPosition');
}
$scope.$broadcast('updateView');
};
self.setHover = function(item) {
self.hover = item;
$scope.hoveredItem = item;
};
self.clearHover = function() {
self.hover = null;
$scope.hoveredItem = null;
};
self.setSelection = function(index, item) {
if (self.selection && self.selection.item.name === item.name) {
self.selectionName = null;
self.selection = null;
$scope.selectedItem = null;
} else {
self.selectionName = item.name;
self.selection = {
item: item,
index: index
};
$scope.selectedItem = item;
}
// selection in the viewport depends on the overview setting the view
// extents & makings sure there is a visible rect to select
// the postSelect event makes sure that this is handled in the correct
// sequence
$scope.$broadcast('select', self.selection);
$scope.$broadcast('postSelect', self.selection);
};
self.setFilterFunction = function(fn) {
self.filterFunction = fn;
$scope.$broadcast('filter', fn);
};
self.selectItem = function(item) {
var workerItems = self.data[item.worker].values;
var index = -1;
workerItems.forEach(function(d, i) {
if (d.name === item.name) {
index = i;
}
});
if (index === -1) {
return false;
}
self.setSelection(index, item);
return true;
};
self.selectIndex = function(worker, index) {
var item = self.data[worker].values[index];
self.setSelection(index, item);
return true;
};
self.clearSelection = function() {
self.selection = null;
$scope.$broadcast('select', null);
};
self.selectNextItem = function() {
if (self.selection) {
var worker = self.selection.item.worker;
if (self.selection.index < self.data[worker].values.length - 1) {
self.selectIndex(worker, (self.selection.index) + 1);
return true;
}
}
return false;
};
self.selectPreviousItem = function() {
if (self.selection) {
var worker = self.selection.item.worker;
if (self.selection.index > 0) {
self.selectIndex(worker, (self.selection.index) - 1);
return true;
}
}
return false;
};
self.hidden = function(item) {
var width = self.axes.selection(item.endDate) -
self.axes.selection(item.startDate);
var hidden = width < 2;
item.hidden = hidden;
return hidden;
};
/**
* Get all raw data that at least partially fall within the given bounds;
* that is, data points with an end date greater than the minimum bound, and
* an end date less than the maximum bound. Note that returned data will
* be a flat array, i.e. not grouped by worker.
* @param {Date} min the lower date bound
* @param {Date} max the upper date bound
* @return {Array} all matching data points
*/
self.dataInBounds = function(min, max) {
return self.dataRaw.filter(function(d) {
return (+d.endDate) > (+min) && (+d.startDate) < (+max);
});
};
/**
* Gets all dstat entries within the given bounds.
* @param {Date} min the lower time bound
* @param {Date} max the upper time bound
* @return {Array} a list of dstat entries within the given bounds
*/
self.dstatInBounds = function(min, max) {
var entries = self.dstat.entries;
var timeFunc = function(d) { return d.system_time; };
return entries.slice(
arrayUtil.binaryMinIndex(min, entries, timeFunc),
arrayUtil.binaryMaxIndex(max, entries, timeFunc) + 1
);
};
/**
* Creates an empty canvas with the specified width and height, returning
* the element and its 2d context. The element will not be appended to the
* document and may be used for offscreen rendering.
* @param {number} [w] the canvas width in px, or null
* @param {number} [h] the canvas height in px, or null
* @return {object} an object containing the canvas and its 2d context
*/
self.createCanvas = function(w, h, scale) {
w = w || self.width + self.margin.left + self.margin.right;
h = h || 200 + self.margin.top + self.margin.bottom;
if (typeof scale === 'undefined') {
scale = true;
}
/** @type {HTMLCanvasElement} */
var canvas = angular.element('<canvas>')[0];
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
/** @type {CanvasRenderingContext2D} */
var ctx = canvas.getContext('2d');
var devicePixelRatio = $window.devicePixelRatio || 1;
var backingStoreRatio = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1;
var ratio = devicePixelRatio / backingStoreRatio;
canvas.width = w * ratio;
canvas.height = h * ratio;
if (scale) {
ctx.scale(ratio, ratio);
}
var resize = function(w) {
canvas.width = w * ratio;
canvas.style.width = w + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (scale) {
ctx.scale(ratio, ratio);
}
};
return {
canvas: canvas, ctx: ctx,
scale: scale, ratio: ratio, resize: resize
};
};
/**
* Request an animation frame from the browser, and call all regsitered
* animation callbacks when it occurs. If an animation has already been
* requested but has not completed, this method will return immediately.
*/
self.animate = function() {
if (self.animateId) {
return;
}
var _animate = function(timestamp) {
var again = false;
for (var i = 0; i < self.animateCallbacks.length; i++) {
if (self.animateCallbacks[i](timestamp)) {
again = true;
}
}
if (again) {
self.animateId = requestAnimationFrame(_animate);
} else {
self.animateId = null;
}
};
self.animateId = requestAnimationFrame(_animate);
};
var initData = function(raw) {
self.dataRaw = raw;
var minStart = null;
var maxEnd = null;
var preselect = null;
// parse date strings and determine extents
raw.forEach(function(d) {
d.worker = parseWorker(d.tags);
d.startDate = new Date(d.timestamps[0]);
if (minStart === null || d.startDate < minStart) {
minStart = d.startDate;
}
d.endDate = new Date(d.timestamps[1]);
if (maxEnd === null || d.endDate > maxEnd) {
maxEnd = d.endDate;
}
if ($scope.preselect && d.name === $scope.preselect) {
preselect = d;
}
});
self.timeExtents = [ minStart, maxEnd ];
self.data = d3.nest()
.key(function(d) { return d.worker; })
.sortKeys(d3.ascending)
.entries(raw.filter(function(d) { return d.duration > 0; }));
self.axes.x.domain(self.timeExtents);
$scope.$broadcast('dataLoaded', self.data);
if (preselect) {
self.selectItem(preselect);
}
};
var initDstat = function(raw) {
var min = self.timeExtents[0];
var max = self.timeExtents[1];
var accessor = function(d) { return d.system_time; };
var minIndex = arrayUtil.binaryMinIndex(min, raw.entries, accessor);
var maxIndex = arrayUtil.binaryMaxIndex(max, raw.entries, accessor);
if (minIndex < 0) {
minIndex = 0;
}
self.dstat = {
entries: raw.entries.slice(minIndex, maxIndex),
minimums: raw.minimums,
maximums: raw.maximums
};
$scope.$broadcast('dstatLoaded', self.dstat);
};
$scope.$watch('artifactName', function(artifactName) {
if (!artifactName) {
return;
}
progressService.start({ parent: 'timeline .panel-body' });
// load dataset details (raw log entries and dstat) sequentially
// we need to determine the initial date from the subunit data to parse
// dstat
datasetService.artifact(artifactName, 'subunit').then(function(response) {
progressService.set(0.33);
initData(response.data);
return datasetService.artifact('dstat');
}).then(function(response) {
progressService.set(0.66);
var firstDate = new Date(self.dataRaw[0].timestamps[0]);
var raw = parseDstat(response.data, firstDate.getYear());
initDstat(raw);
}).catch(function(ex) {
$log.warn(ex);
}).finally(function() {
$scope.$broadcast('update');
progressService.done();
});
});
$scope.$watch(function() { return self.width; }, function(width) {
self.axes.x.range([0, width]);
self.axes.selection.range([0, width]);
self.axes.absolute.range([0, width]);
$scope.$broadcast('update');
});
};
var link = function(scope, el, attrs, ctrl) {
var updateWidth = function() {
var body = el[0].querySelector('div.panel div.panel-body');
var style = getComputedStyle(body);
ctrl.width = body.clientWidth -
ctrl.margin.left -
ctrl.margin.right -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight);
};
scope.$on('windowResize', updateWidth);
updateWidth();
d3.select(window)
.on("keydown", function() {
var code = d3.event.keyCode;
if (code === 37) {
ctrl.selectPreviousItem();
}
if (code === 39) {
ctrl.selectNextItem();
}
scope.$apply();
});
};
return {
controller: controller,
controllerAs: 'timeline',
restrict: 'EA',
transclude: true,
templateUrl: 'directives/timeline.html',
scope: {
'artifactName': '=',
'hoveredItem': '=',
'selectedItem': '=',
'preselect': '='
},
link: link
};
}
directivesModule.directive('timeline', timeline);