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: '', scope: { 'dataset': '=', 'hoveredItem': '=', diff --git a/app/views/timeline.html b/app/views/timeline.html index 40ce0cb..af17282 100644 --- a/app/views/timeline.html +++ b/app/views/timeline.html @@ -26,7 +26,11 @@ dataset="timeline.dataset" hovered-item="timeline.hoveredItem" selected-item="timeline.selectedItem" - preselect="timeline.preselect"> + preselect="timeline.preselect"> + + + +