Files
stackviz/app/js/directives/timeline.js
Tim Buckley 72a3317656 Add canvas timeline implementation
This adds a new timeline renderer using a plain canvas element,
replacing the d3 and svg-based renderer. This new implementation
greatly improves timeline performance, especially on less powerful
graphics cards, and more than doubles the resulting framerate while
panning the view by aggressively caching previously rendered chart
regions. Additionally, worst-case memory usage is greatly reduced
during heavier periods of user interaction.

This new implementation also removes several previous limitations.
Due to cached rendering a nearly unlimited number of objects can be
shown at any time with no performance impact, so small objects never
need to be hidden from view at any zoom level. Also, view transitions
can now be smoothly animated, and reach a stable framerate even on
older mobile devices.

Change-Id: Ib883e056270eff688b4b4a0c340eaea20cceb181
2016-04-06 19:03:31 -06:00

475 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);
self.dstat = {
entries: raw.entries.slice(minIndex, maxIndex),
minimums: raw.minimums,
maximums: raw.maximums
};
$scope.$broadcast('dstatLoaded', self.dstat);
};
$scope.$watch('dataset', function(dataset) {
if (!dataset) {
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.raw(dataset).then(function(response) {
progressService.set(0.33);
initData(response.data);
return datasetService.dstat(dataset);
}).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: {
'dataset': '=',
'hoveredItem': '=',
'selectedItem': '=',
'preselect': '='
},
link: link
};
}
directivesModule.directive('timeline', timeline);