stackviz/app/js/directives/timeline-dstat.js
Tim Buckley 965e723004 Use d3 modules to reduce build size
This switches the codebase to use only specific d3 (version 4) modules
rather than the entire d3 (v3) package, resulting in a decent shrink
in build size (~75KB reduction).

Change-Id: I9f6a5d039d6340cc28115337bdfb891d15af7057
2016-05-28 15:36:34 -06:00

451 lines
14 KiB
JavaScript

'use strict';
var directivesModule = require('./_index.js');
var d3Ease = require('d3-ease');
var d3Interpolate = require('d3-interpolate');
var d3Scale = require('d3-scale');
var arrayUtil = require('../util/array-util');
var parseDstat = require('../util/dstat-parse');
var getDstatLanes = function(data, mins, maxes) {
if (!data || !data.length) {
return [];
}
var row = data[0];
var lanes = [];
if ('total_cpu_usage_usr' in row && 'total_cpu_usage_sys' in row) {
lanes.push([{
scale: d3Scale.scaleLinear().domain([0, 100]),
value: function(d) {
return d.total_cpu_usage_wai;
},
color: "rgba(224, 188, 188, 1)",
text: "CPU wait"
}, {
scale: d3Scale.scaleLinear().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: d3Scale.scaleLinear().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: d3Scale.scaleLinear().domain([0, maxes.net_total_recv]),
value: function(d) { return d.net_total_recv; },
color: "rgba(224, 188, 188, 1)",
text: "Net Down"
}, {
scale: d3Scale.scaleLinear().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: d3Scale.scaleLinear().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: d3Scale.scaleLinear().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($document, $window) {
var link = function(scope, el, attrs, timelineController) {
// local display variables
var margin = timelineController.margin;
var height = 140;
var laneDefs = [];
var laneHeight = 30;
var loaded = false;
// axes and dstat-global variables
var absolute = timelineController.axes.absolute;
var xSelected = timelineController.axes.selection;
var y = d3Scale.scaleLinear();
// animation variables
var currentViewExtents = null;
var viewInterpolator = null;
var easeOutCubic = d3Ease.easeCubicOut;
var easeStartTimestamp = null;
var easeDuration = 500;
// canvases and layers
var regions = [];
var lanes = timelineController.createCanvas(null, height);
var main = timelineController.createCanvas(null, height, false);
el.append(main.canvas);
/**
* Generate the list of active regions or "chunks". These regions span a
* fixed area of the timeline's full "virtual" width, and contain only a
* small subset of data points that fall within the area. This function only
* initializes a list of regions, but does not actually attempt to draw
* anything. Drawing can be handled lazily and will only occur when a
* region's 'dirty' property is set. If a list of regions already exists,
* it will be thrown away and replaced with a new list; this should occur
* any time the full "virtual" timeline width changes (such as a extent
* resize), or if the view extents no longer fall within the generated list
* of regions.
*
* This function will limit the number of generated regions. If this is not
* sufficient to cover the entire area spanned by the timeline's virtual
* width, regions will be generated around the user's current viewport.
*
* Note that individual data points will exist within multiple regions if
* they span region borders. In this case, each containing region will have
* a unique rect instance pointing to the same data point.
*/
function createRegions() {
regions = [];
var fullWidth = absolute(timelineController.timeExtents[1]);
var chunkWidth = 500;
var chunks = Math.ceil(fullWidth / chunkWidth);
var offset = 0;
// avoid creating lots of chunks - cap and only generate around the
// current view
// if we scroll out of bounds of the chunks we *do* have, we can throw
// away our regions + purge regions in memory
if (chunks > 30) {
var startX = absolute(timelineController.viewExtents[0]);
var endX = absolute(timelineController.viewExtents[1]);
var midX = startX + (endX - startX) / 2;
chunks = 50;
offset = Math.max(0, midX - (chunkWidth * 15));
}
for (var i = 0; i < chunks; i++) {
// for each desired chunk, find the bounds and managed data points
// then, calculate positions for each data point
var w = Math.min(fullWidth - offset, chunkWidth);
var min = absolute.invert(offset);
var max = absolute.invert(offset + w);
var data = timelineController.dstatInBounds(min, max);
regions.push({
x: offset, width: w, min: min, max: max,
data: data,
c: null,
dirty: true,
index: regions.length
});
offset += w;
}
}
/**
* Finds all regions falling within the given minimum and maximum absolute
* x coordinates.
* @param {number} minX the minimum x coordinate (exclusive)
* @param {number} maxX the maximum x coording (exclusive)
* @return {object[]} a list of matching regions
*/
function getContainedRegions(minX, maxX) {
return regions.filter(function(region) {
return (region.x + region.width) > minX && region.x < maxX;
});
}
/**
* Draw lane labels into the offscreen lanes canvas.
*/
function drawLanes() {
// make sure the canvas is the correct size and clear it
lanes.resize(timelineController.width + margin.left + margin.right);
lanes.ctx.clearRect(0, 0, lanes.canvas.width, lanes.canvas.height);
lanes.ctx.strokeStyle = 'lightgray';
lanes.ctx.textAlign = 'end';
lanes.ctx.textBaseline = 'middle';
lanes.ctx.font = '10px sans-serif';
// draw lanes for each worker
var laneHeight = 0.8 * y(1);
for (var i = 0; i < laneDefs.length; i++) {
var laneDef = laneDefs[i];
var yPos = y(i + 0.5);
var dy = 0;
for (var pathIndex = 0; pathIndex < laneDef.length; pathIndex++) {
var pathDef = laneDef[pathIndex];
pathDef.scale.range([laneHeight, 0]);
// draw labels right-aligned to the left of each lane
if ('text' in pathDef) {
lanes.ctx.fillStyle = pathDef.color;
lanes.ctx.fillText(
pathDef.text,
margin.left - margin.right, yPos + dy,
margin.left - 10);
dy += 10;
}
}
}
}
/**
* Draw the given region into its own canvas. The region will only be drawn
* if it is marked as dirty. If its canvas has not yet been created, it will
* be initialized automatically. Note that this does not actually draw
* anything to the screen (i.e. main canvas), as this result only populates
* each region's local offscreen image with content. drawAll() will actually
* draw to the screen (and implicitly calls this function as well).
* @param {object} region the region to draw
*/
function drawRegion(region) {
if (!region.dirty) {
// only redraw if dirty
return;
}
if (!region.c) {
// create the actual image buffer lazily - don't waste memory if it will
// never be seen
region.c = timelineController.createCanvas(region.width, height);
}
var ctx = region.c.ctx;
ctx.clearRect(0, 0, region.width, height);
ctx.strokeStyle = 'rgb(175, 175, 175)';
ctx.lineWidth = 1;
for (var laneIndex = 0; laneIndex < laneDefs.length; laneIndex++) {
var laneDef = laneDefs[laneIndex];
var bottom = y(laneIndex) + laneHeight;
for (var pathIndex = 0; pathIndex < laneDef.length; pathIndex++) {
if (!region.data.length) {
continue;
}
var pathDef = laneDef[pathIndex];
var line = pathDef.type === 'line';
ctx.strokeStyle = pathDef.color;
ctx.fillStyle = pathDef.color;
var first = region.data[0];
ctx.beginPath();
ctx.moveTo(
absolute(+first.system_time) - region.x,
y(laneIndex) + pathDef.scale(pathDef.value(first)));
for (var i = 1; i < region.data.length; i++) {
var d = region.data[i];
ctx.lineTo(
absolute(+d.system_time) - region.x,
y(laneIndex) + pathDef.scale(pathDef.value(d)));
}
if (line) {
ctx.stroke();
} else {
var last = region.data[region.data.length - 1];
ctx.lineTo(absolute(+last.system_time) - region.x, bottom);
ctx.lineTo(absolute(+first.system_time) - region.x, bottom);
ctx.fill();
}
}
}
region.dirty = false;
}
/**
* Draw all layers and visible regions on the screen.
*/
function drawAll() {
if (!currentViewExtents) {
currentViewExtents = timelineController.viewExtents;
}
// update size of main canvas
var w = timelineController.width + margin.left + margin.right;
var e = angular.element(main.canvas);
main.resize(w);
var s = function(v) {
return v * main.ratio;
};
main.ctx.clearRect(0, 0, main.canvas.width, main.canvas.height);
main.ctx.drawImage(lanes.canvas, 0, 0);
// draw all visible regions
var startX = absolute(currentViewExtents[0]);
var endX = absolute(currentViewExtents[1]);
var viewRegions = getContainedRegions(startX, endX);
var effectiveWidth = 0;
viewRegions.forEach(function(region) {
effectiveWidth += region.width;
});
if (effectiveWidth < timelineController.width) {
// we had to cap the region generation previously, but moved outside of
// the generated area, so regenerate regions around the current view
createRegions();
viewRegions = getContainedRegions(startX, endX);
}
viewRegions.forEach(function(region) {
drawRegion(region);
// calculate the cropping area and offsets needed to place the region
// in the main canvas
var sx1 = Math.max(0, startX - region.x);
var sx2 = Math.min(region.width, endX - region.x);
var sw = sx2 - sx1;
var dx = Math.max(0, startX - region.x);
if (Math.floor(sw) === 0) {
return;
}
main.ctx.drawImage(
region.c.canvas,
s(sx1), 0, Math.floor(s(sw)), s(height),
s(margin.left + region.x - startX + sx1), 0, s(sw), s(height));
});
}
timelineController.animateCallbacks.push(function(timestamp) {
if (!loaded) {
return false;
}
if (viewInterpolator) {
// start the animation
var currentSize = currentViewExtents[1] - currentViewExtents[0];
var newSize = timelineController.viewExtents[1] - timelineController.viewExtents[0];
var diffSize = currentSize - newSize;
var diffTime = timestamp - easeStartTimestamp;
var pct = diffTime / easeDuration;
// interpolate the current view bounds according to the easing method
currentViewExtents = viewInterpolator(easeOutCubic(pct));
if (Math.abs(diffSize) > 1) {
// size has changed, recalculate regions
createRegions();
}
drawAll();
if (pct >= 1) {
// finished, clear the state vars
easeStartTimestamp = null;
viewInterpolator = null;
return false;
} else {
// request more frames until finished
return true;
}
} else {
// if there is no view interpolator function, just do a plain redraw
drawAll();
return false;
}
});
scope.$on('dstatLoaded', function(event, dstat) {
laneDefs = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
laneHeight = height / (laneDefs.length + 1);
y.domain([0, laneDefs.length]).range([0, height]);
drawLanes();
createRegions();
loaded = true;
});
scope.$on('update', function() {
if (!loaded) {
return;
}
drawLanes();
createRegions();
timelineController.animate();
});
scope.$on('updateViewSize', function() {
if (!loaded) {
return;
}
if (currentViewExtents) {
// if we know where the view is already, try to animate the transition
viewInterpolator = d3Interpolate.interpolateArray(
currentViewExtents,
timelineController.viewExtents);
easeStartTimestamp = performance.now();
} else {
// otherwise, move directly to the new location/size (we will need to
// rebuild regions)
createRegions();
}
timelineController.animate();
});
scope.$on('updateViewPosition', function() {
if (!loaded) {
return;
}
if (currentViewExtents) {
// if we know where the view is already, try to animate the transition
viewInterpolator = d3Interpolate.interpolateArray(
currentViewExtents,
timelineController.viewExtents);
easeStartTimestamp = performance.now();
}
timelineController.animate();
});
};
return {
restrict: 'E',
require: '^timeline',
scope: true,
link: link
};
}
directivesModule.directive('timelineDstat', timelineDstat);