diff --git a/app/js/controllers/main.js b/app/js/controllers/main.js
index 5b983c0..5f4b23b 100644
--- a/app/js/controllers/main.js
+++ b/app/js/controllers/main.js
@@ -8,6 +8,7 @@ var controllersModule = require('./_index');
function MainCtrl($window, $scope) {
$window.addEventListener('resize', function () {
$scope.$broadcast('windowResize');
+ $scope.$apply();
});
}
diff --git a/app/js/directives/timeline-dstat.js b/app/js/directives/timeline-dstat.js
new file mode 100644
index 0000000..e9ba6c8
--- /dev/null
+++ b/app/js/directives/timeline-dstat.js
@@ -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);
diff --git a/app/js/directives/timeline-overview.js b/app/js/directives/timeline-overview.js
new file mode 100644
index 0000000..a4c8669
--- /dev/null
+++ b/app/js/directives/timeline-overview.js
@@ -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);
diff --git a/app/js/directives/timeline-viewport.js b/app/js/directives/timeline-viewport.js
new file mode 100644
index 0000000..67d86f1
--- /dev/null
+++ b/app/js/directives/timeline-viewport.js
@@ -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);
diff --git a/app/js/directives/timeline.js b/app/js/directives/timeline.js
index fc89d90..7056d7d 100644
--- a/app/js/directives/timeline.js
+++ b/app/js/directives/timeline.js
@@ -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: '