Refactor the timeline directive.

Presently the timeline directive is a single gigantic directive
containing all of the timeline code. Given the amount of code
involved, this complicates maintenance and makes it excessively
difficult to improve.

This refactors the single timeline directive into four separate
collaborating directives: a controller, an overview, a viewport,
and dstat charts. Each child directive is functionally separate
from the others, but can communicate with other chart components
via the controller. This separation should greatly improve future
maintenance, and has already led to several (included) bug fixes.

Change-Id: Id11d15a34466e42c5ebc9808717d52b468245e3a
This commit is contained in:
Tim Buckley 2015-12-23 17:24:11 -07:00
parent fe6f75a271
commit e7473f7f01
6 changed files with 828 additions and 529 deletions

View File

@ -8,6 +8,7 @@ var controllersModule = require('./_index');
function MainCtrl($window, $scope) {
$window.addEventListener('resize', function () {
$scope.$broadcast('windowResize');
$scope.$apply();
});
}

View File

@ -0,0 +1,202 @@
'use strict';
var directivesModule = require('./_index.js');
var arrayUtil = require('../util/array-util');
var parseDstat = require('../util/dstat-parse');
var d3 = require('d3');
var getDstatLanes = function(data, mins, maxes) {
if (!data) {
return [];
}
var row = data[0];
var lanes = [];
if ('total_cpu_usage_usr' in row && 'total_cpu_usage_sys' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, 100]),
value: function(d) {
return d.total_cpu_usage_wai;
},
color: "rgba(224, 188, 188, 1)",
text: "CPU wait"
}, {
scale: d3.scale.linear().domain([0, 100]),
value: function(d) {
return d.total_cpu_usage_usr + d.total_cpu_usage_sys;
},
color: "rgba(102, 140, 178, 0.75)",
text: "CPU (user+sys)"
}]);
}
if ('memory_usage_used' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.memory_usage_used]),
value: function(d) { return d.memory_usage_used; },
color: "rgba(102, 140, 178, 0.75)",
text: "Memory"
}]);
}
if ('net_total_recv' in row && 'net_total_send' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.net_total_recv]),
value: function(d) { return d.net_total_recv; },
color: "rgba(224, 188, 188, 1)",
text: "Net Down"
}, {
scale: d3.scale.linear().domain([0, maxes.net_total_send]),
value: function(d) { return d.net_total_send; },
color: "rgba(102, 140, 178, 0.75)",
text: "Net Up",
type: "line"
}]);
}
if ('dsk_total_read' in row && 'dsk_total_writ' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.dsk_total_read]),
value: function(d) { return d.dsk_total_read; },
color: "rgba(224, 188, 188, 1)",
text: "Disk Read",
type: "line"
}, {
scale: d3.scale.linear().domain([0, maxes.dsk_total_writ]),
value: function(d) { return d.dsk_total_writ; },
color: "rgba(102, 140, 178, 0.75)",
text: "Disk Write",
type: "line"
}]);
}
return lanes;
};
function timelineDstat() {
var link = function(scope, el, attrs, timelineController) {
var margin = timelineController.margin;
var height = 140;
var lanes = [];
var laneHeight = 30;
var chart = d3.select(el[0])
.append('svg')
.attr('width', timelineController.width + margin.left + margin.right)
.attr('height', height);
var main = chart.append('g')
.attr('transform', 'translate(' + margin.left + ',0)')
.attr('width', timelineController.width);
var xSelected = timelineController.axes.selection;
var y = d3.scale.linear();
var update = function() {
if (lanes.length === 0) {
return;
}
var extent = timelineController.viewExtents;
var minExtent = extent[0];
var maxExtent = extent[1];
var entries = timelineController.dstat.entries;
var timeFunc = function(d) { return d.system_time; };
var visibleEntries = entries.slice(
arrayUtil.binaryMinIndex(minExtent, entries, timeFunc),
arrayUtil.binaryMaxIndex(maxExtent, entries, timeFunc)
);
// apply the current dataset (visibleEntries) to each dstat path
lanes.forEach(function(lane) {
lane.forEach(function(pathDef) {
pathDef.path
.datum(visibleEntries)
.attr("d", pathDef.area);
});
});
};
var initLane = function(lane, i) {
var laneGroup = main.append('g');
var text = laneGroup.append('text')
.attr('y', y(i + 0.5))
.attr('dy', '0.5ex')
.attr('text-anchor', 'end')
.style('font', '10px sans-serif');
var dy = 0;
lane.forEach(function(pathDef) {
var laneHeight = 0.8 * y(1);
pathDef.scale.range([laneHeight, 0]);
if ('text' in pathDef) {
text.append('tspan')
.attr('x', -margin.right)
.attr('dy', dy)
.text(pathDef.text)
.attr('fill', pathDef.color);
dy += 10;
}
pathDef.path = laneGroup.append('path');
if (pathDef.type === 'line') {
pathDef.area = d3.svg.line()
.x(function(d) { return xSelected(d.system_time); })
.y(function(d) {
return y(i) + pathDef.scale(pathDef.value(d));
});
pathDef.path
.style('stroke', pathDef.color)
.style('stroke-width', '1.5px')
.style('fill', 'none');
} else {
pathDef.area = d3.svg.area()
.x(function(d) { return xSelected(d.system_time); })
.y0(y(i) + laneHeight)
.y1(function(d) {
return y(i) + pathDef.scale(pathDef.value(d));
});
pathDef.path.style('fill', pathDef.color);
}
});
};
scope.$on('dstatLoaded', function(event, dstat) {
lanes = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
laneHeight = height / (lanes.length + 1);
y.domain([0, lanes.length]).range([0, height]);
lanes.forEach(initLane);
});
scope.$on('update', function() {
chart.style('width', timelineController.width + margin.left + margin.right);
main.style('width', timelineController.width);
update(timelineController.dstat);
});
scope.$on('updateView', function() {
update(timelineController.dstat);
});
};
return {
restrict: 'E',
require: '^timeline',
scope: true,
link: link
};
}
directivesModule.directive('timelineDstat', timelineDstat);

View File

@ -0,0 +1,173 @@
'use strict';
var directivesModule = require('./_index.js');
var d3 = require('d3');
function timelineOverview() {
var link = function(scope, el, attrs, timelineController) {
var margin = timelineController.margin;
var height = 80;
var laneHeight = 10;
var x = timelineController.axes.x;
var y = d3.scale.linear();
var brush = null;
var chart = d3.select(el[0])
.append('svg')
.attr('height', height)
.style('position', 'relative')
.style('width', timelineController.width)
.style('left', margin.left)
.style('right', margin.right);
var groups = chart.append('g');
var updateBrush = function() {
timelineController.setViewExtents(brush.extent());
};
var updateItems = function(data) {
var lanes = groups
.selectAll('g')
.data(data, function(d) { return d.key; });
lanes.enter().append('g');
var rects = lanes.selectAll('rect').data(
function(d) { return d.values; },
function(d) { return d.name; });
rects.enter().append('rect')
.attr('y', function(d) { return y(d.worker + 0.5) - 5; })
.attr('height', laneHeight);
rects.attr('x', function(d) { return x(d.startDate); })
.attr('width', function(d) { return x(d.endDate) - x(d.startDate); })
.attr('stroke', 'rgba(100, 100, 100, 0.25)')
.attr('fill', function(d) {
return timelineController.statusColorMap[d.status];
});
rects.exit().remove();
lanes.exit().remove();
};
var centerViewport = function(date) {
// explicitly center the viewport on a date
var timeExtents = timelineController.timeExtents;
var start = timeExtents[0];
var end = timeExtents[1];
if (date < start || date > end) {
return false;
}
var viewExtents = timelineController.viewExtents;
var size = viewExtents[1] - viewExtents[0];
var targetStart = math.max(start.getTime(), date - (size / 2));
targetStart = Math.min(targetStart, end.getTime() - size);
var targetEnd = begin + extentSize;
brush.extent([targetStart, targetEnd]);
chart.select('.brush').call(brush);
updateBrush();
return true;
};
var shiftViewport = function(item) {
// shift the viewport left/right to fit an item
// unlike centerViewport() this only moves the view extents far enough to
// make an item fit entirely in the view, but will not center it
// if the item is already fully contained in the view, this does nothing
var timeExtents = timelineController.timeExtents;
var start = timeExtents[0];
var end = timeExtents[1];
var viewExtents = timelineController.viewExtents;
var viewStart = viewExtents[0];
var viewEnd = viewExtents[1];
if (item.startDate >= viewStart && item.endDate <= viewEnd) {
return false;
}
var size = viewEnd - viewStart;
var currentMid = viewStart.getTime() + (size / 2);
var targetMid = item.startDate.getTime() + (item.endDate - item.startDate) / 2;
var targetStart, targetEnd;
if (targetMid > currentMid) {
// move right - anchor item end to view right
targetEnd = item.endDate.getTime();
targetStart = Math.max(start.getTime(), targetEnd - size);
} else if (targetMid < currentMid) {
// move left - anchor item start to view left
targetStart = item.startDate.getTime();
targetEnd = Math.min(end.getTime(), targetStart + size);
} else {
return false;
}
brush.extent([targetStart, targetEnd]);
chart.select('.brush').call(brush);
updateBrush();
return true;
};
scope.$on('dataLoaded', function(event, data) {
laneHeight = height / (data.length + 1);
var timeExtents = timelineController.timeExtents;
var start = timeExtents[0];
var end = timeExtents[1];
var reducedEnd = new Date(start.getTime() + (end - start) / 8);
y.domain([0, data.length]).range([0, height]);
brush = d3.svg.brush()
.x(timelineController.axes.x)
.extent([start, reducedEnd])
.on('brush', updateBrush);
var brushElement = chart.append('g')
.attr('class', 'brush')
.call(brush)
.selectAll('rect')
.attr('y', 1)
.attr('fill', 'dodgerblue')
.attr('fill-opacity', 0.365)
.attr('height', height - 1);
timelineController.setViewExtents(brush.extent());
});
scope.$on('update', function() {
chart.style('width', timelineController.width);
updateItems(timelineController.data);
});
scope.$on('updateView', function() {
updateItems(timelineController.data);
});
scope.$on('select', function(event, selection) {
if (selection) {
shiftViewport(selection.item);
}
});
};
return {
restrict: 'E',
require: '^timeline',
scope: true,
link: link
};
}
directivesModule.directive('timelineOverview', timelineOverview);

View File

@ -0,0 +1,298 @@
'use strict';
var directivesModule = require('./_index.js');
var d3 = require('d3');
/**
* @ngInject
*/
function timelineViewport($document) {
var link = function(scope, el, attrs, timelineController) {
var margin = timelineController.margin;
var height = 200;
var loaded = false;
var y = d3.scale.linear();
var xSelected = timelineController.axes.selection;
var statusColorMap = timelineController.statusColorMap;
var chart = d3.select(el[0])
.append('svg')
.attr('width', timelineController.width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
var defs = chart.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', timelineController.width);
var main = chart.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
.attr('width', timelineController.width);
var laneLines = main.append('g');
var laneLabels = main.append('g');
var itemGroups = main.append('g');
var cursorGroup = main.append('g')
.style('opacity', 0)
.style('pointer-events', 'none');
var cursor = cursorGroup.append('line')
.attr('x1', 0)
.attr('x2', 0)
.attr('stroke', 'blue');
var cursorText = cursorGroup.append('text')
.attr('x', 0)
.attr('y', -10)
.attr('dy', '-.5ex')
.style('text-anchor', 'middle')
.style('font', '9px sans-serif');
var selectedRect = null;
var color = function(rect, color) {
if (!rect.attr('data-old-fill')) {
rect.attr('data-old-fill', rect.attr('fill'));
}
rect.attr('fill', color);
};
var uncolor = function(rect) {
if (!$document[0].contains(rect[0][0])) {
// we load the original colored rect so we can't unset its color,
// force a full reload
updateItems(timelineController.data);
return;
}
if (rect.attr('data-old-fill')) {
rect.attr('fill', rect.attr('data-old-fill'));
rect.attr('data-old-fill', null);
}
};
var rectMouseOver = function(d) {
if (selectedRect !== null) {
return;
}
timelineController.setHover(d);
scope.$apply();
color(d3.select(this), statusColorMap.hover);
};
var rectMouseOut = function(d) {
if (selectedRect !== null) {
return;
}
timelineController.clearHover();
scope.$apply();
var self = d3.select(this);
uncolor(d3.select(this));
};
var rectClick = function(d) {
timelineController.selectItem(d);
scope.$apply();
};
var updateLanes = function(data) {
var lines = laneLines.selectAll('.laneLine')
.data(data, function(d) { return d.key; });
lines.enter().append('line')
.attr('x1', 0)
.attr('x2', timelineController.width)
.attr('stroke', 'lightgray')
.attr('class', 'laneLine');
lines.attr('y1', function(d, i) { return y(i - 0.1); })
.attr('y2', function(d, i) { return y(i - 0.1); });
lines.exit().remove();
var labels = laneLabels.selectAll('.laneLabel')
.data(data, function(d) { return d.key; });
labels.enter().append('text')
.text(function(d) { return 'Worker #' + d.key; })
.attr('x', -margin.right)
.attr('dy', '.5ex')
.attr('text-anchor', 'end')
.attr('class', 'laneLabel');
labels.attr('y', function(d, i) { return y(i + 0.5); });
labels.exit().remove();
cursor.attr('y2', y(data.length - 0.1));
};
var updateItems = function(data) {
var extent = timelineController.viewExtents;
var minExtent = extent[0];
var maxExtent = extent[1];
// filter visible items to include only those within the current extent
// additionally prune extremely small values to improve performance
var visibleItems = data.map(function(group) {
return {
key: group.key,
values: group.values.filter(function(e) {
if (xSelected(e.endDate) - xSelected(e.startDate) < 2) {
return false;
}
if (e.startDate > maxExtent || e.endDate < minExtent) {
return false;
}
return true;
})
};
});
var groups = itemGroups.selectAll("g")
.data(visibleItems, function(d) { return d.key; });
groups.enter().append("g");
var rects = groups.selectAll("rect")
.data(function(d) { return d.values; }, function(d) { return d.name; });
rects.enter().append("rect")
.attr('y', function(d) { return y(d.worker); })
.attr('height', 0.8 * y(1))
.attr('stroke', 'rgba(100, 100, 100, 0.25)')
.attr('clip-path', 'url(#clip)');
rects
.attr('x', function(d) {
return xSelected(d.startDate);
})
.attr('width', function(d) {
return xSelected(d.endDate) - xSelected(d.startDate);
})
.attr('fill', function(d) {
if (timelineController.selectionName === d.name) {
return statusColorMap.selected;
} else {
return statusColorMap[d.status];
}
})
.attr('data-old-fill', function(d) {
if (timelineController.selectionName === d.name) {
return statusColorMap[d.status];
} else {
return null;
}
})
.on("mouseover", rectMouseOver)
.on('mouseout', rectMouseOut)
.on('click', rectClick);
rects.exit().remove();
groups.exit().remove();
};
var update = function(data) {
updateItems(timelineController.data);
updateLanes(timelineController.data);
};
var select = function(rect) {
if (selectedRect) {
uncolor(selectedRect);
}
selectedRect = rect;
if (rect !== null) {
color(rect, statusColorMap.selected);
}
};
chart.on('mouseout', function() {
cursorGroup.style('opacity', 0);
});
chart.on('mousemove', function() {
var pos = d3.mouse(this);
var px = pos[0];
var py = pos[1];
if (px >= margin.left && px < (timelineController.width + margin.left) &&
py > margin.top && py < (height + margin.top)) {
var relX = px - margin.left;
var currentTime = new Date(xSelected.invert(relX));
cursorGroup
.style('opacity', '0.5')
.attr('transform', 'translate(' + relX + ', 0)');
cursorText.text(d3.time.format('%X')(currentTime));
}
});
scope.$on('dataLoaded', function(event, data) {
y.domain([0, data.length]).range([0, height]);
defs.attr('height', height);
main.attr('height', height);
cursor.attr('y1', y(-0.1));
loaded = true;
});
scope.$on('update', function() {
if (!loaded) {
return;
}
chart.style('width', timelineController.width + margin.left + margin.right);
defs.attr('width', timelineController.width);
main.attr('width', timelineController.width);
update(timelineController.data);
});
scope.$on('updateView', function() {
if (!loaded) {
return;
}
update(timelineController.data);
});
scope.$on('postSelect', function(event, selection) {
if (selection) {
// iterate over all rects to find match
itemGroups.selectAll('rect').each(function(d) {
if (d.name === selection.item.name) {
select(d3.select(this));
}
});
} else {
select(null);
}
});
};
return {
restrict: 'E',
require: '^timeline',
scope: true,
link: link
};
}
directivesModule.directive('timelineViewport', timelineViewport);

View File

@ -7,485 +7,140 @@ var parseDstat = require('../util/dstat-parse');
var d3 = require('d3');
var statusColorMap = {
"success": "LightGreen",
"fail": "Crimson",
"skip": "DodgerBlue"
'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")) {
if (!tags[i].startsWith('worker')) {
continue;
}
return parseInt(tags[i].split("-")[1]);
return parseInt(tags[i].split('-')[1]);
}
return null;
};
var getDstatLanes = function(data, mins, maxes) {
if (!data) {
return [];
}
var row = data[0];
var lanes = [];
if ('total_cpu_usage_usr' in row && 'total_cpu_usage_sys' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, 100]),
value: function(d) {
return d.total_cpu_usage_wai;
},
color: "rgba(224, 188, 188, 1)",
text: "CPU wait"
}, {
scale: d3.scale.linear().domain([0, 100]),
value: function(d) {
return d.total_cpu_usage_usr + d.total_cpu_usage_sys;
},
color: "rgba(102, 140, 178, 0.75)",
text: "CPU (user+sys)"
}]);
}
if ('memory_usage_used' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.memory_usage_used]),
value: function(d) { return d.memory_usage_used; },
color: "rgba(102, 140, 178, 0.75)",
text: "Memory"
}]);
}
if ('net_total_recv' in row && 'net_total_send' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.net_total_recv]),
value: function(d) { return d.net_total_recv; },
color: "rgba(224, 188, 188, 1)",
text: "Net Down"
}, {
scale: d3.scale.linear().domain([0, maxes.net_total_send]),
value: function(d) { return d.net_total_send; },
color: "rgba(102, 140, 178, 0.75)",
text: "Net Up",
type: "line"
}]);
}
if ('dsk_total_read' in row && 'dsk_total_writ' in row) {
lanes.push([{
scale: d3.scale.linear().domain([0, maxes.dsk_total_read]),
value: function(d) { return d.dsk_total_read; },
color: "rgba(224, 188, 188, 1)",
text: "Disk Read",
type: "line"
}, {
scale: d3.scale.linear().domain([0, maxes.dsk_total_writ]),
value: function(d) { return d.dsk_total_writ; },
color: "rgba(102, 140, 178, 0.75)",
text: "Disk Write",
type: "line"
}]);
}
return lanes;
};
/**
* @ngInject
*/
function timeline($log, datasetService) {
var link = function(scope, el, attrs) {
var data = [];
var dstat = {};
var timeExtents = [];
var controller = function($scope) {
var self = this;
self.statusColorMap = statusColorMap;
var margin = { top: 20, right: 10, bottom: 10, left: 80 };
var width = el.parent()[0].clientWidth - margin.left - margin.right;
var height = 550 - margin.top - margin.bottom;
self.data = [];
self.dataRaw = [];
self.dstat = [];
var miniHeight = 0;
var dstatHeight = 0;
var mainHeight = 0;
self.margin = { top: 20, right: 10, bottom: 10, left: 80 };
self.width = 0;
self.height = 550 - this.margin.top - this.margin.bottom;
// primary x axis, maps time -> screen x
var x = d3.time.scale().range([0, width]);
// secondary x axis, maps time (in selected range) -> screen x
var xSelected = d3.scale.linear().range([0, width]);
// y axis for lane positions within main
var yMain = d3.scale.linear();
// y axis for dstat lane positions
var yDstat = d3.scale.linear();
// y axis for lane positions within mini
var yMini = d3.scale.linear();
var chart = d3.select(el[0])
.append('svg')
.attr('width', '100%')
.attr('height', height + margin.top + margin.bottom);
var defs = chart.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width);
var main = chart.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
.attr('width', width);
var laneLines = main.append('g');
var laneLabels = main.append('g');
var itemGroups = main.append('g');
var dstatLanes = [];
var dstatGroup = chart.append('g').attr('width', width);
var mini = chart.append('g').attr('width', width);
var miniGroups = mini.append('g');
// delay init of the brush until we know the extents, otherwise it won't
// init properly
var brush = null;
var selectedRect = null;
var cursorGroup = main.append('g')
.style('opacity', 0)
.style('pointer-events', 'none');
var cursor = cursorGroup.append('line')
.attr('x1', 0)
.attr('x2', 0)
.attr('stroke', 'blue');
var cursorText = cursorGroup.append('text')
.attr('x', 0)
.attr('y', -10)
.attr('dy', '-.5ex')
.style('text-anchor', 'middle')
.style('font', '9px sans-serif');
var updateLanes = function() {
var lines = laneLines.selectAll('.laneLine')
.data(data, function(d) { return d.key; });
lines.enter().append('line')
.attr('x1', 0)
.attr('x2', width)
.attr('stroke', 'lightgray')
.attr('class', 'laneLine');
lines.attr('y1', function(d, i) { return yMain(i - 0.1); })
.attr('y2', function(d, i) { return yMain(i - 0.1); });
lines.exit().remove();
var labels = laneLabels.selectAll('.laneLabel')
.data(data, function(d) { return d.key; });
labels.enter().append('text')
.text(function(d) { return 'Worker #' + d.key; })
.attr('x', -margin.right)
.attr('dy', '.5ex')
.attr('text-anchor', 'end')
.attr('class', 'laneLabel');
labels.attr('y', function(d, i) { return yMain(i + 0.5); });
labels.exit().remove();
cursor.attr('y2', yMain(data.length - 0.1));
self.timeExtents = [0, 0];
self.viewExtents = [0, 0];
self.axes = {
x: d3.time.scale(),
selection: d3.scale.linear()
};
var updateItems = function() {
var minExtent = brush.extent()[0];
var maxExtent = brush.extent()[1];
self.selectionName = null;
self.selection = null;
self.hover = null;
// filter visible items to include only those within the current extent
// additionally prune extremely small values to improve performance
var visibleItems = data.map(function(group) {
return {
key: group.key,
values: group.values.filter(function(e) {
if (xSelected(e.endDate) - xSelected(e.startDate) < 2) {
return false;
}
self.setViewExtents = function(extents) {
if (angular.isNumber(extents[0])) {
extents[0] = new Date(extents[0]);
}
if (e.startDate > maxExtent || e.endDate < minExtent) {
return false;
}
if (angular.isNumber(extents[1])) {
extents[1] = new Date(extents[1]);
}
return true;
})
self.viewExtents = extents;
self.axes.selection.domain(extents);
$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
};
});
var groups = itemGroups.selectAll("g")
.data(visibleItems, function(d) { return d.key; });
groups.enter().append("g");
var rects = groups.selectAll("rect")
.data(function(d) { return d.values; }, function(d) { return d.name; });
rects.enter().append("rect")
.attr('y', function(d) { return yMain(parseWorker(d.tags)); })
.attr('height', 0.8 * yMain(1))
.attr('stroke', 'rgba(100, 100, 100, 0.25)')
.attr('clip-path', 'url(#clip)');
rects
.attr('x', function(d) {
return xSelected(d.startDate);
})
.attr('width', function(d) {
return xSelected(d.endDate) - xSelected(d.startDate);
})
.attr('fill', function(d) { return statusColorMap[d.status]; })
.on("mouseover", function(d) {
if (selectedRect !== null) {
return;
}
scope.hoveredItem = d;
scope.$apply();
var self = d3.select(this);
if (!self.attr('data-old-fill')) {
self.attr('data-old-fill', self.attr('fill'));
}
self.attr('fill', 'darkturquoise');
})
.on('mouseout', function(d) {
if (selectedRect !== null) {
return;
}
scope.hoveredItem = null;
scope.$apply();
var self = d3.select(this);
if (self.attr('data-old-fill')) {
self.attr('fill', self.attr('data-old-fill'));
self.attr('data-old-fill', null);
}
})
.on('click', function(d) {
var self = d3.select(this);
selectItem(self, d);
scope.$apply();
});
rects.exit().remove();
groups.exit().remove();
};
var updateDstat = function() {
if (dstatLanes.length === 0) {
return;
$scope.selectedItem = item;
}
var minExtent = brush.extent()[0];
var maxExtent = brush.extent()[1];
var timeFunc = function(d) { return d.system_time; };
var visibleEntries = dstat.entries.slice(
arrayUtil.binaryMinIndex(minExtent, dstat.entries, timeFunc),
arrayUtil.binaryMaxIndex(maxExtent, dstat.entries, timeFunc)
);
// apply the current dataset (visibleEntries) to each dstat path
dstatLanes.forEach(function(lane) {
lane.forEach(function(pathDef) {
pathDef.path
.datum(visibleEntries)
.attr("d", pathDef.area);
});
});
// 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);
};
var update = function() {
if (!data) {
return;
}
self.selectItem = function(item) {
var workerItems = self.data[item.worker].values;
var index = -1;
xSelected.domain(brush.extent());
updateLanes();
updateItems();
updateDstat();
};
var updateMiniItems = function() {
var groups = miniGroups.selectAll("g")
.data(data, function(d) { return d.key; });
groups.enter().append("g");
var rects = groups.selectAll("rect").data(
function(d) { return d.values; },
function(d) { return d.name; });
rects.enter().append("rect")
.attr("y", function(d) { return yMini(parseWorker(d.tags) + 0.5) - 5; })
.attr("height", 10);
rects.attr("x", function(d) { return x(d.startDate); })
.attr("width", function(d) { return x(d.endDate) - x(d.startDate); })
.attr("stroke", 'rgba(100, 100, 100, 0.25)')
.attr("fill", function(d) { return statusColorMap[d.status]; });
rects.exit().remove();
groups.exit().remove();
};
var selectItem = function(element, datum) {
if (selectedRect) {
if (selectedRect.attr('data-old-fill')) {
selectedRect.attr('fill', selectedRect.attr('data-old-fill'));
selectedRect.attr('data-old-fill', null);
workerItems.forEach(function(d, i) {
if (d.name === item.name) {
index = i;
}
if (scope.selectedItem.name === datum.name) {
scope.selectedItem = null;
selectedRect = null;
return;
}
}
scope.selectedItem = datum;
selectedRect = element;
if (!element.attr('data-old-fill')) {
element.attr('data-old-fill', element.attr('fill'));
}
element.attr('fill', 'goldenrod');
};
var initChart = function() {
// determine lanes available based on current data
dstatLanes = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
// determine region sizes that depend on available datasets
miniHeight = data.length * 12 + 30;
dstatHeight = dstatLanes.length * 30 + 30;
mainHeight = height - miniHeight - dstatHeight - 10;
// update scales based on data and calculated heights
x.domain(timeExtents);
yMain.domain([0, data.length]).range([0, mainHeight]);
yDstat.domain([0, dstatLanes.length]).range([0, dstatHeight]);
yMini.domain([0, data.length]).range([0, miniHeight]);
// apply calculated heights to group sizes and transforms
defs.attr('height', mainHeight);
main.attr('height', mainHeight);
cursor.attr('y1', yMain(-0.1));
var dstatOffset = margin.top + mainHeight;
dstatGroup
.attr('height', dstatHeight)
.attr('transform', 'translate(' + margin.left + ',' + dstatOffset + ')');
var miniOffset = margin.top + mainHeight + dstatHeight;
mini.attr('height', mainHeight)
.attr('transform', 'translate(' + margin.left + ',' + miniOffset + ')');
// set initial selection extents to 1/8 the total size
// this helps with performance issues in some browsers when displaying
// large datasets (e.g. recent Firefox on Linux)
var start = timeExtents[0];
var end = timeExtents[1];
var reducedEnd = new Date(start.getTime() + (end - start) / 8);
brush = d3.svg.brush()
.x(x)
.extent([start, reducedEnd])
.on('brush', update);
var brushElement = mini.append('g')
.attr('class', 'brush')
.call(brush)
.selectAll('rect')
.attr('y', 1)
.attr('fill', 'dodgerblue')
.attr('fill-opacity', 0.365);
brushElement.attr('height', miniHeight - 1);
// init dstat lanes
dstatLanes.forEach(function(lane, i) {
var laneGroup = dstatGroup.append('g');
var text = laneGroup.append('text')
.attr('y', yDstat(i + 0.5))
.attr('dy', '0.5ex')
.attr('text-anchor', 'end')
.style('font', '10px sans-serif');
var dy = 0;
lane.forEach(function(pathDef) {
var laneHeight = 0.8 * yDstat(1);
pathDef.scale.range([laneHeight, 0]);
if ('text' in pathDef) {
text.append('tspan')
.attr('x', -margin.right)
.attr('dy', dy)
.text(pathDef.text)
.attr('fill', pathDef.color);
dy += 10;
}
pathDef.path = laneGroup.append('path');
if (pathDef.type === 'line') {
pathDef.area = d3.svg.line()
.x(function(d) { return xSelected(d.system_time); })
.y(function(d) {
return yDstat(i) + pathDef.scale(pathDef.value(d));
});
pathDef.path
.style('stroke', pathDef.color)
.style('stroke-width', '1.5px')
.style('fill', 'none');
} else {
pathDef.area = d3.svg.area()
.x(function(d) { return xSelected(d.system_time); })
.y0(yDstat(i) + laneHeight)
.y1(function(d) {
return yDstat(i) + pathDef.scale(pathDef.value(d));
});
pathDef.path.style('fill', pathDef.color);
}
});
});
// finalize chart init
updateMiniItems();
update();
if (index === -1) {
return false;
}
self.setSelection(index, item);
return true;
};
var initData = function(raw, dstatRaw) {
// find data extents
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);
};
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;
@ -496,127 +151,93 @@ function timeline($log, datasetService) {
maxEnd = d.endDate;
}
if (scope.preselect && d.name === scope.preselect) {
if ($scope.preselect && d.name === $scope.preselect) {
preselect = d;
}
});
// define a nested data structure with groups by worker, and fill using
// entries w/ duration > 0
data = d3.nest()
.key(function(d) { return parseWorker(d.tags); })
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; }));
var accessor = function(d) { return d.system_time; };
var minIndex = arrayUtil.binaryMinIndex(minStart, dstatRaw.entries, accessor);
var maxIndex = arrayUtil.binaryMaxIndex(maxEnd, dstatRaw.entries, accessor);
self.axes.x.domain(self.timeExtents);
dstat = {
entries: dstatRaw.entries.slice(minIndex, maxIndex),
minimums: dstatRaw.minimums,
maximums: dstatRaw.maximums
};
timeExtents = [ minStart, maxEnd ];
initChart();
$scope.$broadcast('dataLoaded', self.data);
if (preselect) {
// determine desired viewport and item sizes to center view on
var extentSize = (maxEnd - minStart) / 8;
var itemLength = (preselect.endDate - preselect.startDate);
var itemMid = preselect.startDate.getTime() + (itemLength / 2);
// find real begin, end values, but don't exceed min/max time extents
var begin = Math.max(minStart.getTime(), itemMid - (extentSize / 2));
begin = Math.min(begin, maxEnd.getTime() - extentSize);
var end = begin + extentSize;
// update the brush extent and redraw
brush.extent([ begin, end ]);
mini.select('.brush').call(brush);
// update items to reflect the new viewport bounds
update();
// find + select the actual dom element
itemGroups.selectAll('rect').each(function(d) {
if (d.name === preselect.name) {
selectItem(d3.select(this), d);
}
});
self.selectItem(preselect);
}
};
chart.on('mouseout', function() {
cursorGroup.style('opacity', 0);
});
var initDstat = function(raw) {
var min = self.timeExtents[0];
var max = self.timeExtents[1];
chart.on('mousemove', function() {
var pos = d3.mouse(this);
var px = pos[0];
var py = pos[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 (px >= margin.left && px < (width + margin.left) &&
py > margin.top && py < (mainHeight + margin.top)) {
var relX = px - margin.left;
var currentTime = new Date(xSelected.invert(relX));
self.dstat = {
entries: raw.entries.slice(minIndex, maxIndex),
minimums: raw.minimums,
maximums: raw.maximums
};
cursorGroup
.style('opacity', '0.5')
.attr('transform', 'translate(' + relX + ', 0)');
$scope.$broadcast('dstatLoaded', self.dstat);
};
cursorText.text(d3.time.format('%X')(currentTime));
}
});
scope.$on('windowResize', function() {
var extent = brush.extent();
width = el.parent()[0].clientWidth - margin.left - margin.right;
x.range([0, width]);
xSelected.range([0, width]);
chart.attr('width', el.parent()[0].clientWidth);
defs.attr('width', width);
main.attr('width', width);
mini.attr('width', width);
laneLines.selectAll('.laneLine').attr('x2', width);
brush.extent(extent);
updateMiniItems();
update();
});
scope.$watch('dataset', function(dataset) {
$scope.$watch('dataset', function(dataset) {
if (!dataset) {
return;
}
var raw = null;
var dstat = null;
// load both datasets
// 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) {
raw = response.data;
initData(response.data);
return datasetService.dstat(dataset);
}).then(function(response) {
var firstDate = new Date(raw[0].timestamps[0]);
dstat = parseDstat(response.data, firstDate.getYear());
}).finally(function() {
// display as much as we were able to load
// (dstat may not exist, but that's okay)
initData(raw, dstat);
var firstDate = new Date(self.dataRaw[0].timestamps[0]);
var raw = parseDstat(response.data, firstDate.getYear());
initDstat(raw);
$scope.$broadcast('update');
}).catch(function(ex) {
$log.error(ex);
});
});
$scope.$watch(function() { return self.width; }, function(width) {
self.axes.x.range([0, width]);
self.axes.selection.range([0, width]);
$scope.$broadcast('update');
});
};
var link = function(scope, el, attrs, ctrl) {
var updateWidth = function() {
ctrl.width = el.parent()[0].clientWidth -
ctrl.margin.left -
ctrl.margin.right;
};
scope.$on('windowResize', updateWidth);
updateWidth();
};
return {
controller: controller,
controllerAs: 'timeline',
restrict: 'EA',
transclude: true,
template: '<ng-transclude></ng-transclude>',
scope: {
'dataset': '=',
'hoveredItem': '=',

View File

@ -26,7 +26,11 @@
dataset="timeline.dataset"
hovered-item="timeline.hoveredItem"
selected-item="timeline.selectedItem"
preselect="timeline.preselect"></timeline>
preselect="timeline.preselect">
<timeline-viewport></timeline-viewport>
<timeline-dstat></timeline-dstat>
<timeline-overview></timeline-overview>
</timeline>
</div>
</div>
<div class="col-lg-4">