Add canvas timeline implementation
This adds a new timeline renderer using a plain canvas element, replacing the d3 and svg-based renderer. This new implementation greatly improves timeline performance, especially on less powerful graphics cards, and more than doubles the resulting framerate while panning the view by aggressively caching previously rendered chart regions. Additionally, worst-case memory usage is greatly reduced during heavier periods of user interaction. This new implementation also removes several previous limitations. Due to cached rendering a nearly unlimited number of objects can be shown at any time with no performance impact, so small objects never need to be hidden from view at any zoom level. Also, view transitions can now be smoothly animated, and reach a stable framerate even on older mobile devices. Change-Id: Ib883e056270eff688b4b4a0c340eaea20cceb181
This commit is contained in:
@@ -75,120 +75,360 @@ var getDstatLanes = function(data, mins, maxes) {
|
||||
return lanes;
|
||||
};
|
||||
|
||||
function timelineDstat() {
|
||||
function timelineDstat($document, $window) {
|
||||
var link = function(scope, el, attrs, timelineController) {
|
||||
// local display variables
|
||||
var margin = timelineController.margin;
|
||||
var height = 140;
|
||||
var lanes = [];
|
||||
var laneDefs = [];
|
||||
var laneHeight = 30;
|
||||
var loaded = false;
|
||||
|
||||
var chart = d3.select(el[0])
|
||||
.append('svg')
|
||||
.attr('width', timelineController.width + margin.left + margin.right)
|
||||
.attr('height', height)
|
||||
.style('display', 'none');
|
||||
|
||||
var main = chart.append('g')
|
||||
.attr('transform', 'translate(' + margin.left + ',0)');
|
||||
|
||||
// axes and dstat-global variables
|
||||
var absolute = timelineController.axes.absolute;
|
||||
var xSelected = timelineController.axes.selection;
|
||||
var y = d3.scale.linear();
|
||||
|
||||
var update = function() {
|
||||
if (lanes.length === 0) {
|
||||
// animation variables
|
||||
var currentViewExtents = null;
|
||||
var viewInterpolator = null;
|
||||
var easeOutCubic = d3.ease('cubic-out');
|
||||
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;
|
||||
}
|
||||
|
||||
var extent = timelineController.viewExtents;
|
||||
var minExtent = extent[0];
|
||||
var maxExtent = extent[1];
|
||||
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 entries = timelineController.dstat.entries;
|
||||
var timeFunc = function(d) { return d.system_time; };
|
||||
var ctx = region.c.ctx;
|
||||
ctx.clearRect(0, 0, region.width, height);
|
||||
ctx.strokeStyle = 'rgb(175, 175, 175)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
var visibleEntries = entries.slice(
|
||||
arrayUtil.binaryMinIndex(minExtent, entries, timeFunc),
|
||||
arrayUtil.binaryMaxIndex(maxExtent, entries, timeFunc)
|
||||
);
|
||||
for (var laneIndex = 0; laneIndex < laneDefs.length; laneIndex++) {
|
||||
var laneDef = laneDefs[laneIndex];
|
||||
var bottom = y(laneIndex) + laneHeight;
|
||||
|
||||
// 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);
|
||||
});
|
||||
for (var pathIndex = 0; pathIndex < laneDef.length; pathIndex++) {
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
var initLane = function(lane, i) {
|
||||
var laneGroup = main.append('g');
|
||||
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);
|
||||
}
|
||||
|
||||
var text = laneGroup.append('text')
|
||||
.attr('y', y(i + 0.5))
|
||||
.attr('dy', '0.5ex')
|
||||
.attr('text-anchor', 'end')
|
||||
.style('font', '10px sans-serif');
|
||||
viewRegions.forEach(function(region) {
|
||||
drawRegion(region);
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
pathDef.path
|
||||
.style('stroke', pathDef.color)
|
||||
.style('stroke-width', '1.5px')
|
||||
.style('fill', 'none');
|
||||
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 {
|
||||
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);
|
||||
// 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) {
|
||||
lanes = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
|
||||
laneHeight = height / (lanes.length + 1);
|
||||
laneDefs = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
|
||||
laneHeight = height / (laneDefs.length + 1);
|
||||
y.domain([0, laneDefs.length]).range([0, height]);
|
||||
drawLanes();
|
||||
createRegions();
|
||||
|
||||
y.domain([0, lanes.length]).range([0, height]);
|
||||
|
||||
lanes.forEach(initLane);
|
||||
|
||||
chart.style('display', 'block');
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
scope.$on('update', function() {
|
||||
chart.attr('width', timelineController.width + margin.left + margin.right);
|
||||
update(timelineController.dstat);
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawLanes();
|
||||
createRegions();
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('updateView', function() {
|
||||
update(timelineController.dstat);
|
||||
scope.$on('updateViewSize', function() {
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentViewExtents) {
|
||||
// if we know where the view is already, try to animate the transition
|
||||
viewInterpolator = d3.interpolate(
|
||||
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 = d3.interpolate(
|
||||
currentViewExtents,
|
||||
timelineController.viewExtents);
|
||||
easeStartTimestamp = performance.now();
|
||||
}
|
||||
|
||||
timelineController.animate();
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -4,71 +4,39 @@ var directivesModule = require('./_index.js');
|
||||
|
||||
var d3 = require('d3');
|
||||
|
||||
function timelineOverview() {
|
||||
function timelineOverview($document, $window) {
|
||||
var link = function(scope, el, attrs, timelineController) {
|
||||
// local display variables
|
||||
var margin = timelineController.margin;
|
||||
var height = 80;
|
||||
var laneHeight = 10;
|
||||
var loaded = false;
|
||||
|
||||
// scales and extents
|
||||
var x = timelineController.axes.x;
|
||||
var y = d3.scale.linear();
|
||||
var brushExtent = [0, 0];
|
||||
var handleSize = 3;
|
||||
|
||||
var brush = null;
|
||||
// input variables
|
||||
var dragOffsetStart = null;
|
||||
var dragType = null; // left, right, position, null
|
||||
|
||||
var chart = d3.select(el[0])
|
||||
.append('svg')
|
||||
.attr('width', timelineController.width + margin.left + margin.right)
|
||||
.attr('height', height);
|
||||
var rects = [];
|
||||
var lanes = timelineController.createCanvas(timelineController.width, height);
|
||||
var main = timelineController.createCanvas(null, height, false);
|
||||
main.canvas.unselectable = 'on';
|
||||
main.canvas.onselectstart = function() { return false; };
|
||||
main.canvas.style.userSelect = 'none';
|
||||
el.append(main.canvas);
|
||||
|
||||
var groups = chart.append('g')
|
||||
.attr('transform', 'translate(' + margin.left + ',0)');
|
||||
|
||||
var brushGroup = chart.append('g')
|
||||
.attr('transform', 'translate(' + margin.left + ',0)');
|
||||
|
||||
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];
|
||||
})
|
||||
.attr('class', function(d) {
|
||||
if (timelineController.filterFunction) {
|
||||
if (timelineController.filterFunction(d)) {
|
||||
return 'filter-hit';
|
||||
} else {
|
||||
return 'filter-miss';
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
rects.exit().remove();
|
||||
lanes.exit().remove();
|
||||
};
|
||||
|
||||
var centerViewport = function(date) {
|
||||
/**
|
||||
* Centers the viewport on a given date. If the date is not within the
|
||||
* bounds of the data, no changes are made and false is returned.
|
||||
* @param {Date} date the date to center on
|
||||
* @return {boolean} true if the view was centered, false if not
|
||||
*/
|
||||
function centerViewport(date) {
|
||||
// explicitly center the viewport on a date
|
||||
var timeExtents = timelineController.timeExtents;
|
||||
var start = timeExtents[0];
|
||||
@@ -85,18 +53,22 @@ function timelineOverview() {
|
||||
targetStart = Math.min(targetStart, end.getTime() - size);
|
||||
var targetEnd = begin + extentSize;
|
||||
|
||||
brush.extent([targetStart, targetEnd]);
|
||||
brushGroup.select('.brush').call(brush);
|
||||
updateBrush();
|
||||
brushExtent = [targetStart, targetEnd];
|
||||
timelineController.setViewExtents(brushExtent);
|
||||
timelineController.animate();
|
||||
|
||||
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
|
||||
/**
|
||||
* Shift the viewport left or right to fit a data rect. If the item already
|
||||
* fits inside the current view bounds, no changes are made and false is
|
||||
* returned. If not, the view will shift as much as is needed to fit the
|
||||
* item fully into view.
|
||||
* @param {object} item the item to fit into the viewport
|
||||
* @return {boolean} true if the view was moved, false if not
|
||||
*/
|
||||
function shiftViewport(item) {
|
||||
var timeExtents = timelineController.timeExtents;
|
||||
var start = timeExtents[0];
|
||||
var end = timeExtents[1];
|
||||
@@ -125,49 +97,378 @@ function timelineOverview() {
|
||||
return false;
|
||||
}
|
||||
|
||||
brush.extent([targetStart, targetEnd]);
|
||||
brushGroup.select('.brush').call(brush);
|
||||
updateBrush();
|
||||
brushExtent = [targetStart, targetEnd];
|
||||
timelineController.setViewExtents(brushExtent);
|
||||
timelineController.animate();
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates rects from a list of data points, placing them along the primary
|
||||
* x axis and within their appropriate lanes.
|
||||
* @param {object[]]} data a list of data points.
|
||||
* @return {object[]]} a list of rects
|
||||
*/
|
||||
function createRects(data) {
|
||||
var rects = [];
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var d = data[i];
|
||||
rects.push({
|
||||
x: x(d.startDate),
|
||||
y: y(d.worker + 0.5) - 5,
|
||||
width: x(d.endDate) - x(d.startDate),
|
||||
height: laneHeight,
|
||||
entry: d
|
||||
});
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a single rect to the off-screen lanes canvas. By default, this does
|
||||
* not clear any part of the canvas; however, if `clear` is set to `true`,
|
||||
* the area for the rect will be cleared before drawing.
|
||||
* @param {object} rect the rect to draw
|
||||
* @param {boolean} clear if true, clear the area first
|
||||
*/
|
||||
function drawSingleRect(rect, clear) {
|
||||
var ctx = lanes.ctx;
|
||||
ctx.fillStyle = timelineController.statusColorMap[rect.entry.status];
|
||||
ctx.strokeStyle = 'rgb(200, 200, 200)';
|
||||
|
||||
if (clear) {
|
||||
ctx.clearRect(rect.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
|
||||
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all rects into the off-screen lanes canvas. This will fully clear
|
||||
* the canvas before drawing any rects. To redraw only a single rect,
|
||||
* `drawSingleRect()` can be used instead.
|
||||
*/
|
||||
function drawRects() {
|
||||
lanes.resize(timelineController.width);
|
||||
lanes.ctx.clearRect(0, 0, lanes.canvas.width, lanes.canvas.height);
|
||||
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
drawSingleRect(rects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the brush onto the main (on-screen) canvas. The relevant canvas
|
||||
* area should already be cleared and should only contain a rendered lanes
|
||||
* image.
|
||||
*/
|
||||
function drawBrush() {
|
||||
var r = main.ratio;
|
||||
var ctx = main.ctx;
|
||||
ctx.fillStyle = 'dodgerblue';
|
||||
ctx.globalAlpha = 0.365;
|
||||
|
||||
var brushX = (r * margin.left) + (r * x(brushExtent[0]));
|
||||
var brushWidth = r * (x(brushExtent[1]) - x(brushExtent[0]));
|
||||
|
||||
ctx.fillRect(brushX, 0, brushWidth, main.canvas.height);
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the pre-rendered lanes image and brush onto the main canvas. This
|
||||
* is suitable for calling on every frame for a normal update, but it will
|
||||
* not update any rects in the lanes image. If this is needed, `drawRects()`
|
||||
* should be called first.
|
||||
*/
|
||||
function drawAll() {
|
||||
var r = main.ratio;
|
||||
var w = timelineController.width + margin.left + margin.right;
|
||||
main.resize(w);
|
||||
|
||||
main.ctx.clearRect(0, 0, main.canvas.width, main.canvas.height);
|
||||
main.ctx.drawImage(lanes.canvas, r * margin.left, 0);
|
||||
|
||||
drawBrush();
|
||||
}
|
||||
|
||||
timelineController.animateCallbacks.push(function() {
|
||||
drawAll();
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the canvas-local mouse point for the given mouse event, accounting
|
||||
* for all relevant offsets and margins. The returned object will include an
|
||||
* additional `inBounds` property indicating whether or not the point falls
|
||||
* within the bounds of the overview canvas.
|
||||
* @param {MouseEvent} evt the mouse event
|
||||
* @return {object} a point object
|
||||
*/
|
||||
function getMousePoint(evt) {
|
||||
var r = main.canvas.getBoundingClientRect();
|
||||
var ret = {
|
||||
xRaw: evt.clientX - r.left,
|
||||
x: evt.clientX - r.left - margin.left,
|
||||
y: evt.clientY - r.top,
|
||||
radius: evt.radiusX || (3 * main.ratio)
|
||||
};
|
||||
|
||||
ret.inBounds = ret.x > 0 &&
|
||||
ret.x < timelineController.width &&
|
||||
ret.y > 0 && ret.y < height;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given `point` falls within `size` pixels of the given
|
||||
* x coordinate. The pixel size is automatically computed from the touch
|
||||
* radius (if available) or a reasonable value, scaled based on the current
|
||||
* device pixel ratio.
|
||||
* @param {object} point the point to check against
|
||||
* @param {number} x the x coordinate
|
||||
* @return {boolean} true if the point is in bounds, false otherwise
|
||||
*/
|
||||
function withinPx(point, x) {
|
||||
return point.inBounds && Math.abs(x - point.x) <= point.radius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the given drag type string: "left" becomes "right" and "right"
|
||||
* becomes "left". The input string is returned if it isn't either "left"
|
||||
* or "right".
|
||||
* @param {string} side the drag type string to flip
|
||||
* @return {string} the opposite value, or the input if invalid
|
||||
*/
|
||||
function flip(side) {
|
||||
if (side === 'left') {
|
||||
return 'right';
|
||||
} else if (side === 'right') {
|
||||
return 'left';
|
||||
} else {
|
||||
return side;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the closest matching extent satisfying the desired left and right
|
||||
* pixel values within the timeline's overall time extents. If the given
|
||||
* type is "position", this will attempt to preserve the size of the extent
|
||||
* by adjusting the opposite value to fit when an edge is reached;
|
||||
* otherwise, the extent may have one value capped at the timeline's minium
|
||||
* or maximum edges. The returned object will contain the resulting drag
|
||||
* type (as it may have been flipped) as well as the computed valid extents
|
||||
* in an array.
|
||||
*
|
||||
* Note that order of the left and right parameters does not technically
|
||||
* matter, as they will be flipped automatically if necessary.
|
||||
* @param {number} desiredLeft the preferred left end of the extent
|
||||
* @param {number} desiredRight the preferred right end of the extent
|
||||
* @param {string} type the drag type, e.g. "left" or "position"
|
||||
* @return {object} an object with the new drag type and the
|
||||
* computed extents array
|
||||
*/
|
||||
function smartExtent(desiredLeft, desiredRight, type) {
|
||||
desiredLeft = x.invert(desiredLeft);
|
||||
desiredRight = x.invert(desiredRight);
|
||||
if (desiredLeft > desiredRight) {
|
||||
type = flip(type);
|
||||
}
|
||||
var l = Math.min(desiredLeft, desiredRight);
|
||||
var r = Math.max(desiredLeft, desiredRight);
|
||||
|
||||
if (type === 'position') {
|
||||
// plain translation, don't allow size to change if possible
|
||||
var size = r - l;
|
||||
if (l < timelineController.timeExtents[0]) {
|
||||
l = +timelineController.timeExtents[0];
|
||||
r = Math.min(+timelineController.timeExtents[1], l + size);
|
||||
} else if (r > timelineController.timeExtents[1]) {
|
||||
r = +timelineController.timeExtents[1];
|
||||
l = Math.max(+timelineController.timeExtents[0], r - size);
|
||||
}
|
||||
} else {
|
||||
// cap at left and right time extents
|
||||
if (l < timelineController.timeExtents[0]) {
|
||||
l = timelineController.timeExtents[0];
|
||||
}
|
||||
|
||||
if (r > timelineController.timeExtents[1]) {
|
||||
r = timelineController.timeExtents[1];
|
||||
}
|
||||
}
|
||||
|
||||
return { extent: [l, r], type: type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a mouse press on the canvas at the given point. If the point is
|
||||
* within range of a handle (either left or right), it will begin a drag
|
||||
* operation for that side. If the click is otherwise within the existing
|
||||
* selection, a position drag will be started. Otherwise, the click will
|
||||
* start a new selection at the current position with a left drag.
|
||||
*
|
||||
* Note that this function should only be called for element-level events,
|
||||
* and not window-level events.
|
||||
* @param {object} p the mouse point
|
||||
*/
|
||||
function handleMouseDown(p) {
|
||||
var brushLeft = x(brushExtent[0]);
|
||||
var brushRight = x(brushExtent[1]);
|
||||
|
||||
if (withinPx(p, brushLeft)) {
|
||||
dragType = 'left';
|
||||
} else if (withinPx(p, brushRight)) {
|
||||
dragType = 'right';
|
||||
} else if (p.x > brushLeft && p.x < brushRight) {
|
||||
dragType = 'position';
|
||||
dragOffsetStart = p.x - brushLeft;
|
||||
} else {
|
||||
// start a new selection
|
||||
brushExtent = [x.invert(p.x), x.invert(p.x)];
|
||||
dragType = 'left';
|
||||
timelineController.animate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a mouse move at the given point. If a drag is in progress, this
|
||||
* will perform the necessary resizing of the brush and start an animate
|
||||
* task. If no drag is in process, the mouse cursor will be updated as
|
||||
* necessary.
|
||||
*
|
||||
* Note that this function should be used to handle all mouse events at the
|
||||
* window level so that dragging doesn't need to occur strictly in bounds
|
||||
* of the canvas element. In most browsers, window-level events will even
|
||||
* allow the drag to continue when the mouse leaves the browser window
|
||||
* entirely.
|
||||
* @param {object} p the mouse point
|
||||
* @return {boolean} true if the triggering event's preventDefault() should
|
||||
* be called, false otherwise
|
||||
*/
|
||||
function handleMouseMove(p) {
|
||||
var brushLeft = x(brushExtent[0]);
|
||||
var brushRight = x(brushExtent[1]);
|
||||
var e;
|
||||
|
||||
if (dragType !== null) {
|
||||
// handle the drag
|
||||
if (dragType === 'left') {
|
||||
e = smartExtent(p.x, brushRight, dragType);
|
||||
dragType = e.type;
|
||||
brushExtent = e.extent;
|
||||
} else if (dragType === 'right') {
|
||||
e = smartExtent(brushLeft, p.x, dragType);
|
||||
dragType = e.type;
|
||||
brushExtent = e.extent;
|
||||
} else {
|
||||
var size = brushRight - brushLeft;
|
||||
var left = p.x - dragOffsetStart;
|
||||
|
||||
brushExtent = smartExtent(left, left + size, dragType).extent;
|
||||
}
|
||||
|
||||
timelineController.setViewExtents(brushExtent);
|
||||
timelineController.animate();
|
||||
return false;
|
||||
} else {
|
||||
// just update the cursor as needed - show drag arrows over left & right
|
||||
// brush edges
|
||||
if (withinPx(p, brushLeft)) {
|
||||
main.canvas.style.cursor = 'ew-resize';
|
||||
} else if (withinPx(p, brushRight)) {
|
||||
main.canvas.style.cursor = 'ew-resize';
|
||||
} else if (p.inBounds && p.x > brushLeft && p.x < brushRight) {
|
||||
main.canvas.style.cursor = 'move';
|
||||
} else {
|
||||
main.canvas.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a mouse up event.
|
||||
*
|
||||
* This should handle all events at the window level so that drags that
|
||||
* don't complete strictly within the window are ended properly. Most
|
||||
* browsers will allow window-level mouseup events to trigger for drags even
|
||||
* if the cursor is outside of the window entirely.
|
||||
*/
|
||||
function handleMouseUp() {
|
||||
dragType = null;
|
||||
dragOffsetStart = null;
|
||||
main.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
main.canvas.addEventListener('mousedown', function(evt) {
|
||||
// listen on the actual element so we only get element events
|
||||
evt.preventDefault();
|
||||
handleMouseDown(getMousePoint(evt));
|
||||
});
|
||||
|
||||
main.canvas.addEventListener('touchstart', function(evt) {
|
||||
evt.preventDefault();
|
||||
for (var i = 0; i < evt.changedTouches.length; i++) {
|
||||
var touch = evt.changedTouches[i];
|
||||
handleMouseDown(getMousePoint(touch));
|
||||
}
|
||||
});
|
||||
|
||||
$window.addEventListener('mousemove', function(evt) {
|
||||
// listen on the window - this lets us get drag events for the whole page
|
||||
// and (depending on browser) even outside of the window
|
||||
if (!handleMouseMove(getMousePoint(evt))) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$window.addEventListener('touchmove', function(evt) {
|
||||
for (var i = 0; i < evt.changedTouches.length; i++) {
|
||||
var touch = evt.changedTouches[i];
|
||||
if (!handleMouseMove(getMousePoint(touch))) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$window.addEventListener('mouseup', handleMouseUp);
|
||||
$window.addEventListener('touchend', handleMouseUp);
|
||||
$window.addEventListener('touchcancel', handleMouseUp);
|
||||
|
||||
scope.$on('dataLoaded', function(event, data) {
|
||||
laneHeight = height / (data.length + 1);
|
||||
y.domain([0, data.length]).range([0, height]);
|
||||
rects = createRects(timelineController.dataRaw);
|
||||
|
||||
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 = brushGroup.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());
|
||||
brushExtent = [start, reducedEnd];
|
||||
timelineController.setViewExtents(brushExtent);
|
||||
|
||||
loaded = true;
|
||||
|
||||
drawRects();
|
||||
});
|
||||
|
||||
scope.$on('update', function() {
|
||||
chart.attr('width', timelineController.width + margin.left + margin.right);
|
||||
updateItems(timelineController.data);
|
||||
rects = createRects(timelineController.dataRaw);
|
||||
drawRects();
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('updateView', function() {
|
||||
updateItems(timelineController.data);
|
||||
brushExtent = timelineController.viewExtents;
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('select', function(event, selection) {
|
||||
@@ -178,7 +479,7 @@ function timelineOverview() {
|
||||
|
||||
scope.$on('filter', function() {
|
||||
if (loaded) {
|
||||
updateItems(timelineController.data);
|
||||
drawRects();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -1,304 +1,597 @@
|
||||
'use strict';
|
||||
|
||||
var directivesModule = require('./_index.js');
|
||||
var arrayUtil = require('../util/array-util');
|
||||
|
||||
var d3 = require('d3');
|
||||
|
||||
/**
|
||||
* @ngInject
|
||||
*/
|
||||
function timelineViewport($document) {
|
||||
function timelineViewport($document, $window) {
|
||||
var link = function(scope, el, attrs, timelineController) {
|
||||
// local display variables
|
||||
var margin = timelineController.margin;
|
||||
var statusColorMap = timelineController.statusColorMap;
|
||||
var height = 200;
|
||||
var loaded = false;
|
||||
|
||||
// axes and timeline-global variables
|
||||
var y = d3.scale.linear();
|
||||
var absolute = timelineController.axes.absolute;
|
||||
var xSelected = timelineController.axes.selection;
|
||||
var cursorTimeFormat = d3.time.format('%X');
|
||||
var tickFormat = timelineController.axes.x.tickFormat();
|
||||
|
||||
var statusColorMap = timelineController.statusColorMap;
|
||||
// animation variables
|
||||
var currentViewExtents = null;
|
||||
var viewInterpolator = null;
|
||||
var easeOutCubic = d3.ease('cubic-out');
|
||||
var easeStartTimestamp = null;
|
||||
var easeDuration = 500;
|
||||
|
||||
var chart = d3.select(el[0])
|
||||
.append('svg')
|
||||
.attr('width', timelineController.width + margin.left + margin.right)
|
||||
.attr('height', height + margin.top + margin.bottom);
|
||||
// selection and hover variables
|
||||
var mousePoint = null;
|
||||
var selection = null;
|
||||
var hover = null;
|
||||
|
||||
var defs = chart.append('defs')
|
||||
.append('clipPath')
|
||||
.attr('id', 'clip')
|
||||
.append('rect')
|
||||
.attr('width', timelineController.width);
|
||||
// canvases and layers
|
||||
var lanes = timelineController.createCanvas();
|
||||
var regions = [];
|
||||
var cursor = timelineController.createCanvas();
|
||||
var main = timelineController.createCanvas(null, null, false);
|
||||
el.append(main.canvas);
|
||||
|
||||
var main = chart.append('g')
|
||||
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
||||
/**
|
||||
* Initializes rects from a list of parsed subunit log entries, setting
|
||||
* initial sizes and positions based on the current view extents.
|
||||
* @param {Array} data A list of parsed subunit log entries
|
||||
*/
|
||||
function createRects(data) {
|
||||
var rects = [];
|
||||
|
||||
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 cursorItemText = cursorGroup.append('text')
|
||||
.attr('x', 0)
|
||||
.attr('y', -22)
|
||||
.attr('dy', '-.5ex')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('font', '12px sans-serif')
|
||||
.style('font-weight', 'bold');
|
||||
|
||||
var format = d3.time.format('%H:%M');
|
||||
var axis = d3.svg.axis()
|
||||
.scale(xSelected)
|
||||
.tickSize(5)
|
||||
.tickFormat(function(f) { return format(new Date(f)); })
|
||||
.orient('bottom');
|
||||
|
||||
var axisGroup = chart.append('g')
|
||||
.attr('class', 'axis')
|
||||
.attr('transform', 'translate(' + margin.left + ',' + (height + margin.top) + ')')
|
||||
.attr('clip-path', 'url(#clip)')
|
||||
.call(axis);
|
||||
|
||||
var selectedRect = null;
|
||||
|
||||
var color = function(rect, color) {
|
||||
if (!rect.attr('data-old-fill')) {
|
||||
rect.attr('data-old-fill', rect.attr('fill'));
|
||||
var h = 0.8 * y(1);
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var entry = data[i];
|
||||
var start = absolute(+entry.startDate);
|
||||
rects.push({
|
||||
x: start,
|
||||
y: y(entry.worker),
|
||||
width: absolute(+entry.endDate) - start,
|
||||
height: h,
|
||||
entry: entry
|
||||
});
|
||||
}
|
||||
|
||||
rect.attr('fill', color);
|
||||
};
|
||||
return rects;
|
||||
}
|
||||
|
||||
var uncolor = function(rect) {
|
||||
if (!$document[0].contains(rect[0][0])) {
|
||||
// we lost the original colored rect so we can't unset its color,
|
||||
// force a full reload
|
||||
updateItems(timelineController.data);
|
||||
/**
|
||||
* 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.dataInBounds(min, max);
|
||||
var rects = createRects(data);
|
||||
|
||||
regions.push({
|
||||
x: offset, width: w, min: min, max: max,
|
||||
data: data, rects: rects,
|
||||
c: null,
|
||||
dirty: true,
|
||||
index: regions.length
|
||||
});
|
||||
|
||||
offset += w;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all regions as dirty so they can be redrawn for the next frame.
|
||||
*/
|
||||
function markAllDirty() {
|
||||
regions.forEach(function(region) {
|
||||
region.dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all regions containing the given data point.
|
||||
* @param {object} entry the datapoint
|
||||
* @return {object[]} a list of regions containing this entry
|
||||
*/
|
||||
function getRegionsForEntry(entry) {
|
||||
var min = absolute(entry.startDate);
|
||||
var max = absolute(entry.endDate);
|
||||
return getContainedRegions(min, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rect corresponding to the given entry within a particular region.
|
||||
* @param {object} region the region to search in
|
||||
* @param {object} entry the entry to search for
|
||||
* @return {object|null} the matching rect, if any
|
||||
*/
|
||||
function getRectForEntry(region, entry) {
|
||||
return region.rects.find(function(r) {
|
||||
return r.entry === entry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the region managing the given canvas-local X coordinate. If the x
|
||||
* value is outside of the actual canvas area where regions are rendered,
|
||||
* this may return null. Rarely, null may also be returned if, while
|
||||
* animating, the view moves to a position outside of capped view bounds
|
||||
* (i.e. when the view extents are small); if this happens, it can be
|
||||
* ignored and createRegions() will generate the necessary area when the
|
||||
* animation finishes.
|
||||
* @param {number} screenX the canvas-local x coordinate
|
||||
* @return {object|null} the matching region or null
|
||||
*/
|
||||
function getRegionAt(screenX) {
|
||||
if (screenX < margin.left || screenX > main.canvas.width - margin.right) {
|
||||
return null;
|
||||
}
|
||||
var absX = absolute(currentViewExtents[0]) + (screenX - margin.left);
|
||||
return regions.find(function(r) {
|
||||
return absX >= r.x && absX <= (r.x + r.width);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rect at the given canvas-local coordinates, if any exists. This
|
||||
* function has the same limitations as getRegionAt() and will return null
|
||||
* when the coordinates are out of bounds or rarely when animating, but also
|
||||
* returns null when no rect exists at the given coords.
|
||||
*
|
||||
* Note that this will only search within the region containing the
|
||||
* coordinates so it should be fairly performant, though it will only return
|
||||
* one of possibly many matching rects.
|
||||
* @param {number} screenX the canvas-local x coordinate
|
||||
* @param {number} screenY the canvas-local y coordinate
|
||||
* @return {object|null} the matching rect in the region or null
|
||||
*/
|
||||
function getRectAt(screenX, screenY) {
|
||||
if (screenY < margin.top || screenY > main.canvas.height - margin.bottom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var region = getRegionAt(screenX);
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// find the absolute coords in rect-space
|
||||
var absX = absolute(currentViewExtents[0]) + (screenX - margin.left);
|
||||
var absY = screenY - margin.top;
|
||||
|
||||
for (var i = 0; i < region.rects.length; i++) {
|
||||
var rect = region.rects[i];
|
||||
|
||||
if (absX >= rect.x && absX <= (rect.x + rect.width) &&
|
||||
absY >= rect.y && absY <= (rect.y + rect.height)) {
|
||||
// make sure the point is contained inside the rect
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw lane lines and their 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.textBaseline = 'middle';
|
||||
lanes.ctx.font = '14px Arial';
|
||||
|
||||
// draw lanes for each worker
|
||||
var laneHeight = y(1);
|
||||
for (var worker = 0; worker < timelineController.data.length; worker++) {
|
||||
var yPos = margin.top + y(worker - 0.1);
|
||||
|
||||
// draw horizontal lines between lanes
|
||||
lanes.ctx.beginPath();
|
||||
lanes.ctx.moveTo(margin.left, yPos);
|
||||
lanes.ctx.lineTo(margin.left + timelineController.width, yPos);
|
||||
lanes.ctx.stroke();
|
||||
|
||||
// draw labels middle-aligned to the left of each lane
|
||||
lanes.ctx.fillText(
|
||||
'Worker #' + worker,
|
||||
5, yPos + (laneHeight / 2),
|
||||
margin.left - 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single rect within a region. This may be called independently of
|
||||
* drawRegion() to update only a single rect, if needed.
|
||||
* @param {object} region the region to draw within
|
||||
* @param {object} rect the rect to draw
|
||||
* @param {boolean} [clear] if true, clear the rect first (default: false)
|
||||
*/
|
||||
function drawSingleRect(region, rect, clear) {
|
||||
var ctx = region.c.ctx;
|
||||
|
||||
if (rect.entry === selection) {
|
||||
ctx.fillStyle = statusColorMap.selected;
|
||||
} else if (rect.entry === hover) {
|
||||
ctx.fillStyle = statusColorMap.hover;
|
||||
} else {
|
||||
ctx.fillStyle = statusColorMap[rect.entry.status];
|
||||
}
|
||||
|
||||
if (clear) {
|
||||
ctx.clearRect(rect.x - region.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
|
||||
var filter = timelineController.filterFunction;
|
||||
if (!filter || filter(rect.entry)) {
|
||||
ctx.globalAlpha = 1.0;
|
||||
} else {
|
||||
ctx.globalAlpha = 0.15;
|
||||
}
|
||||
|
||||
ctx.fillRect(rect.x - region.x, rect.y, rect.width, rect.height);
|
||||
ctx.strokeRect(rect.x - region.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw all matching rects among all regions that contain this entry.
|
||||
* @param {object} entry the entry to redraw
|
||||
*/
|
||||
function drawAllForEntry(entry) {
|
||||
getRegionsForEntry(entry).forEach(function(region) {
|
||||
if (!region.c) {
|
||||
return;
|
||||
}
|
||||
|
||||
var r = getRectForEntry(region, entry);
|
||||
if (r) {
|
||||
drawSingleRect(region, r, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 call this function as well.
|
||||
* @param {object} region the region to draw
|
||||
*/
|
||||
function drawRegion(region) {
|
||||
if (!region.dirty) {
|
||||
// only redraw if dirty
|
||||
return;
|
||||
}
|
||||
|
||||
if (rect.attr('data-old-fill')) {
|
||||
rect.attr('fill', rect.attr('data-old-fill'));
|
||||
rect.attr('data-old-fill', null);
|
||||
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 + margin.bottom);
|
||||
}
|
||||
};
|
||||
|
||||
var rectMouseOver = function(d) {
|
||||
timelineController.setHover(d);
|
||||
scope.$apply();
|
||||
var ctx = region.c.ctx;
|
||||
ctx.clearRect(0, 0, region.width, height);
|
||||
ctx.strokeStyle = 'rgb(175, 175, 175)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
if (!timelineController.selection ||
|
||||
d !== timelineController.selection.item) {
|
||||
color(d3.select(this), statusColorMap.hover);
|
||||
for (var i = 0; i < region.rects.length; i++) {
|
||||
var rect = region.rects[i];
|
||||
drawSingleRect(region, rect);
|
||||
}
|
||||
};
|
||||
|
||||
var rectMouseOut = function(d) {
|
||||
timelineController.clearHover();
|
||||
scope.$apply();
|
||||
// draw axis ticks + labels
|
||||
// main axis line -- offset y by 0.5 to draw crisp lines
|
||||
ctx.strokeStyle = 'lightgray';
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height + 0.5);
|
||||
ctx.lineTo(region.width, height + 0.5);
|
||||
ctx.stroke();
|
||||
|
||||
if (!timelineController.selection ||
|
||||
d !== timelineController.selection.item) {
|
||||
var self = d3.select(this);
|
||||
uncolor(d3.select(this));
|
||||
// make a scale for the position of this region, but shrink it slightly so
|
||||
// no labels overlap region boundaries and get cut off
|
||||
var tickScale = d3.time.scale().domain([
|
||||
absolute.invert(region.x + 10),
|
||||
absolute.invert(region.x + region.width - 10)
|
||||
]);
|
||||
|
||||
// 1 tick per 125px
|
||||
var ticks = tickScale.ticks(Math.floor(region.width / 125));
|
||||
|
||||
for (var tickIndex = 0; tickIndex < ticks.length; tickIndex++) {
|
||||
var tick = ticks[tickIndex];
|
||||
var tickX = Math.floor(absolute(tick) - region.x) + 0.5;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tickX, height);
|
||||
ctx.lineTo(tickX, height + 6);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillText(tickFormat(tick), tickX, height + 7);
|
||||
}
|
||||
};
|
||||
|
||||
var rectClick = function(d) {
|
||||
timelineController.selectItem(d);
|
||||
scope.$apply();
|
||||
};
|
||||
ctx.strokeStyle = 'rgb(175, 175, 175)';
|
||||
region.dirty = false;
|
||||
}
|
||||
|
||||
var updateLanes = function(data) {
|
||||
var lines = laneLines.selectAll('.laneLine')
|
||||
.data(data, function(d) { return d.key; });
|
||||
function drawCursor() {
|
||||
if (!mousePoint || !mousePoint.inBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.enter().append('line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', timelineController.width)
|
||||
.attr('stroke', 'lightgray')
|
||||
.attr('class', 'laneLine');
|
||||
var r = main.ratio;
|
||||
var ctx = main.ctx;
|
||||
ctx.scale(main.ratio, main.ratio);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = 'dimgrey';
|
||||
ctx.strokeStyle = 'blue';
|
||||
|
||||
lines.attr('y1', function(d, i) { return y(i - 0.1); })
|
||||
.attr('y2', function(d, i) { return y(i - 0.1); });
|
||||
// draw the cursor line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mousePoint.x, margin.top);
|
||||
ctx.lineTo(mousePoint.x, margin.top + height);
|
||||
ctx.stroke();
|
||||
|
||||
lines.exit().remove();
|
||||
// draw the time label
|
||||
ctx.font = '9px sans-serif';
|
||||
var date = new Date(xSelected.invert(mousePoint.x - margin.left));
|
||||
ctx.fillText(cursorTimeFormat(date), mousePoint.x, 16);
|
||||
|
||||
var labels = laneLabels.selectAll('.laneLabel')
|
||||
.data(data, function(d) { return d.key; });
|
||||
// draw the hovered item info
|
||||
if (hover) {
|
||||
var leftEdge = margin.left;
|
||||
var rightEdge = leftEdge + timelineController.width;
|
||||
|
||||
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');
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
var name = hover.name.split('.').pop();
|
||||
var tw = ctx.measureText(name).width;
|
||||
|
||||
labels.attr('y', function(d, i) { return y(i + 0.5); });
|
||||
labels.exit().remove();
|
||||
var cx = mousePoint.x;
|
||||
if (mousePoint.x + (tw / 2) > rightEdge) {
|
||||
cx -= mousePoint.x - (rightEdge - tw / 2);
|
||||
} else if (mousePoint.x - (tw / 2) < leftEdge) {
|
||||
cx += (leftEdge + tw / 2) - mousePoint.x;
|
||||
}
|
||||
ctx.fillText(name, cx, 1);
|
||||
}
|
||||
|
||||
cursor.attr('y2', y(data.length - 0.1));
|
||||
};
|
||||
// reset scale
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
var updateItems = function(data) {
|
||||
var extent = timelineController.viewExtents;
|
||||
var minExtent = extent[0];
|
||||
var maxExtent = extent[1];
|
||||
/**
|
||||
* Draw all layers and visible regions on the screen.
|
||||
*/
|
||||
function drawAll() {
|
||||
// update size of main canvas
|
||||
var w = timelineController.width + margin.left + margin.right;
|
||||
var e = angular.element(main.canvas);
|
||||
main.resize(w);
|
||||
|
||||
// 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 (timelineController.hidden(e)) {
|
||||
return false;
|
||||
}
|
||||
var s = function(v) {
|
||||
return v * main.ratio;
|
||||
};
|
||||
|
||||
if (e.startDate > maxExtent || e.endDate < minExtent) {
|
||||
return false;
|
||||
}
|
||||
main.ctx.clearRect(0, 0, main.canvas.width, main.canvas.height);
|
||||
main.ctx.drawImage(lanes.canvas, 0, 0);
|
||||
|
||||
return true;
|
||||
})
|
||||
};
|
||||
// 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;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
})
|
||||
.attr('class', function(d) {
|
||||
if (timelineController.filterFunction) {
|
||||
if (timelineController.filterFunction(d)) {
|
||||
return 'filter-hit';
|
||||
} else {
|
||||
return 'filter-miss';
|
||||
}
|
||||
} 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);
|
||||
|
||||
axisGroup.call(axis);
|
||||
};
|
||||
|
||||
var select = function(rect) {
|
||||
if (selectedRect) {
|
||||
uncolor(selectedRect);
|
||||
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);
|
||||
}
|
||||
|
||||
selectedRect = rect;
|
||||
viewRegions.forEach(function(region) {
|
||||
drawRegion(region);
|
||||
|
||||
if (rect !== null) {
|
||||
color(rect, statusColorMap.selected);
|
||||
// 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 + margin.bottom),
|
||||
s(margin.left + region.x - startX + sx1), s(margin.top),
|
||||
s(sw), s(height + margin.bottom));
|
||||
});
|
||||
|
||||
drawCursor();
|
||||
}
|
||||
|
||||
timelineController.animateCallbacks.push(function(timestamp) {
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
chart.on('mouseout', function() {
|
||||
cursorGroup.style('opacity', 0);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
chart.on('mousemove', function() {
|
||||
var pos = d3.mouse(this);
|
||||
var px = pos[0];
|
||||
var py = pos[1];
|
||||
/**
|
||||
* Gets the canvas-local mouse point for the given mouse event, accounting
|
||||
* for all relevant offsets and margins. The returned object will include an
|
||||
* additional `inBounds` property indicating whether or not the point falls
|
||||
* within the bounds of the main canvas.
|
||||
* @param {MouseEvent} evt the mouse event
|
||||
* @return {object} a point object
|
||||
*/
|
||||
function getMousePoint(evt) {
|
||||
var r = main.canvas.getBoundingClientRect();
|
||||
var ret = {
|
||||
xRaw: evt.clientX - r.left,
|
||||
x: evt.clientX - r.left,
|
||||
y: evt.clientY - r.top
|
||||
};
|
||||
|
||||
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));
|
||||
ret.inBounds = ret.x > margin.left &&
|
||||
ret.x < (margin.left + timelineController.width) &&
|
||||
ret.y > margin.top && ret.y < (margin.top + height);
|
||||
|
||||
cursorGroup
|
||||
.style('opacity', '0.5')
|
||||
.attr('transform', 'translate(' + relX + ', 0)');
|
||||
return ret;
|
||||
}
|
||||
|
||||
cursorText.text(d3.time.format('%X')(currentTime));
|
||||
main.canvas.addEventListener('mousedown', function(evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
if (timelineController.hover) {
|
||||
var name = timelineController.hover.name.split('.').pop();
|
||||
cursorItemText.text(name);
|
||||
|
||||
var width = cursorItemText.node().getComputedTextLength();
|
||||
var leftEdge = margin.left;
|
||||
var rightEdge = timelineController.width + margin.left;
|
||||
|
||||
if (px + (width / 2) > rightEdge) {
|
||||
cursorItemText.attr('dx', -(px - (rightEdge - width / 2)));
|
||||
} else if (px - (width / 2) < leftEdge) {
|
||||
cursorItemText.attr('dx', (leftEdge + width / 2) - px);
|
||||
} else {
|
||||
cursorItemText.attr('dx', 0);
|
||||
}
|
||||
} else {
|
||||
cursorItemText.text('');
|
||||
cursorItemText.attr('dx', 0);
|
||||
}
|
||||
mousePoint = getMousePoint(evt);
|
||||
var rect = getRectAt(mousePoint.x, mousePoint.y);
|
||||
if (rect) {
|
||||
timelineController.selectItem(rect.entry);
|
||||
scope.$apply();
|
||||
}
|
||||
});
|
||||
|
||||
main.canvas.addEventListener('mousemove', function(evt) {
|
||||
mousePoint = getMousePoint(evt);
|
||||
var rect = getRectAt(mousePoint.x, mousePoint.y);
|
||||
var oldHover = hover;
|
||||
if (rect && rect.entry !== hover) {
|
||||
main.canvas.style.cursor = 'pointer';
|
||||
hover = rect.entry;
|
||||
|
||||
drawAllForEntry(rect.entry);
|
||||
if (oldHover) {
|
||||
drawAllForEntry(oldHover);
|
||||
}
|
||||
} else if (!rect && hover) {
|
||||
main.canvas.style.cursor = 'default';
|
||||
hover = null;
|
||||
drawAllForEntry(oldHover);
|
||||
}
|
||||
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
main.canvas.addEventListener('mouseout', function(evt) {
|
||||
mousePoint = null;
|
||||
main.canvas.style.cursor = 'default';
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('dataLoaded', function(event, data) {
|
||||
y.domain([0, data.length]).range([0, height]);
|
||||
|
||||
defs.attr('height', height);
|
||||
cursor.attr('y1', y(-0.1));
|
||||
createRegions();
|
||||
drawLanes();
|
||||
|
||||
loaded = true;
|
||||
});
|
||||
@@ -308,42 +601,73 @@ function timelineViewport($document) {
|
||||
return;
|
||||
}
|
||||
|
||||
chart.attr('width', timelineController.width + margin.left + margin.right);
|
||||
defs.attr('width', timelineController.width);
|
||||
|
||||
update(timelineController.data);
|
||||
createRegions();
|
||||
drawLanes();
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('updateView', function() {
|
||||
scope.$on('updateViewSize', function() {
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
update(timelineController.data);
|
||||
if (currentViewExtents) {
|
||||
// if we know where the view is already, try to animate the transition
|
||||
viewInterpolator = d3.interpolate(
|
||||
currentViewExtents,
|
||||
timelineController.viewExtents);
|
||||
easeStartTimestamp = performance.now();
|
||||
} else {
|
||||
// otherwise, move directly to the new location/size
|
||||
currentViewExtents = timelineController.viewExtents;
|
||||
createRegions();
|
||||
}
|
||||
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('postSelect', function(event, selection) {
|
||||
if (selection) {
|
||||
if (timelineController.hidden(selection.item)) {
|
||||
if (selectedRect) {
|
||||
uncolor(selectedRect);
|
||||
}
|
||||
} else {
|
||||
// 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);
|
||||
scope.$on('updateViewPosition', function() {
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentViewExtents) {
|
||||
// if we know where the view is already, try to animate the transition
|
||||
viewInterpolator = d3.interpolate(
|
||||
currentViewExtents,
|
||||
timelineController.viewExtents);
|
||||
easeStartTimestamp = performance.now();
|
||||
} else {
|
||||
// otherwise, move directly to the new location
|
||||
currentViewExtents = timelineController.viewExtents;
|
||||
}
|
||||
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('postSelect', function(event, newSelection) {
|
||||
var old = selection;
|
||||
if (newSelection) {
|
||||
selection = newSelection.item;
|
||||
} else {
|
||||
selection = null;
|
||||
}
|
||||
|
||||
if (old) {
|
||||
drawAllForEntry(old);
|
||||
}
|
||||
|
||||
if (selection) {
|
||||
drawAllForEntry(selection);
|
||||
}
|
||||
|
||||
timelineController.animate();
|
||||
});
|
||||
|
||||
scope.$on('filter', function() {
|
||||
if (loaded) {
|
||||
update();
|
||||
markAllDirty();
|
||||
timelineController.animate();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -29,7 +29,7 @@ var parseWorker = function(tags) {
|
||||
/**
|
||||
* @ngInject
|
||||
*/
|
||||
function timeline($log, datasetService, progressService) {
|
||||
function timeline($window, $log, datasetService, progressService) {
|
||||
|
||||
/**
|
||||
* @ngInject
|
||||
@@ -46,11 +46,37 @@ function timeline($log, datasetService, progressService) {
|
||||
self.width = 0;
|
||||
self.height = 550 - this.margin.top - this.margin.bottom;
|
||||
|
||||
/**
|
||||
* The date extents of all chart entries.
|
||||
*/
|
||||
self.timeExtents = [0, 0];
|
||||
|
||||
/**
|
||||
* The date extents of the current viewport.
|
||||
*/
|
||||
self.viewExtents = [0, 0];
|
||||
self.axes = {
|
||||
/**
|
||||
* The primary axis mapping date to on-screen x. The lower time bound maps
|
||||
* to x=0, while the upper time bound maps to x=width.
|
||||
*/
|
||||
x: d3.time.scale(),
|
||||
selection: d3.scale.linear()
|
||||
|
||||
/**
|
||||
* The selection axis, mapping date to on-screen x, depending on the size
|
||||
* and position of the user selection. `selection(viewExtents[0]) = 0`,
|
||||
* while `selection(viewExtents[1]) = width`
|
||||
*/
|
||||
selection: d3.scale.linear(),
|
||||
|
||||
/**
|
||||
* The absolute x axis mapping date to virtual x, depending only on the
|
||||
* size (but not position) of the user selection.
|
||||
* `absolute(timeExtents[0]) = 0`, while `absolute(timeExtents[1])` will
|
||||
* be the total width at the current scale, spanning as many
|
||||
* viewport-widths as necessary.
|
||||
*/
|
||||
absolute: d3.scale.linear()
|
||||
};
|
||||
|
||||
self.selectionName = null;
|
||||
@@ -58,6 +84,9 @@ function timeline($log, datasetService, progressService) {
|
||||
self.hover = null;
|
||||
self.filterFunction = null;
|
||||
|
||||
self.animateId = null;
|
||||
self.animateCallbacks = [];
|
||||
|
||||
self.setViewExtents = function(extents) {
|
||||
if (angular.isNumber(extents[0])) {
|
||||
extents[0] = new Date(extents[0]);
|
||||
@@ -67,9 +96,26 @@ function timeline($log, datasetService, progressService) {
|
||||
extents[1] = new Date(extents[1]);
|
||||
}
|
||||
|
||||
var oldSize = self.viewExtents[1] - self.viewExtents[0];
|
||||
var newSize = extents[1] - extents[0];
|
||||
|
||||
self.viewExtents = extents;
|
||||
self.axes.selection.domain(extents);
|
||||
|
||||
// slight hack: d3 extrapolates by default, and these scales are identical
|
||||
// when the lower bound is zero, so just keep absolute's domain at
|
||||
// [0, selectionWidth]
|
||||
self.axes.absolute.domain([
|
||||
+self.timeExtents[0],
|
||||
+self.timeExtents[0] + newSize
|
||||
]);
|
||||
|
||||
if (Math.abs(oldSize - newSize) > 1) {
|
||||
$scope.$broadcast('updateViewSize');
|
||||
} else {
|
||||
$scope.$broadcast('updateViewPosition');
|
||||
}
|
||||
|
||||
$scope.$broadcast('updateView');
|
||||
};
|
||||
|
||||
@@ -172,6 +218,117 @@ function timeline($log, datasetService, progressService) {
|
||||
return hidden;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all raw data that at least partially fall within the given bounds;
|
||||
* that is, data points with an end date greater than the minimum bound, and
|
||||
* an end date less than the maximum bound. Note that returned data will
|
||||
* be a flat array, i.e. not grouped by worker.
|
||||
* @param {Date} min the lower date bound
|
||||
* @param {Date} max the upper date bound
|
||||
* @return {Array} all matching data points
|
||||
*/
|
||||
self.dataInBounds = function(min, max) {
|
||||
return self.dataRaw.filter(function(d) {
|
||||
return (+d.endDate) > (+min) && (+d.startDate) < (+max);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets all dstat entries within the given bounds.
|
||||
* @param {Date} min the lower time bound
|
||||
* @param {Date} max the upper time bound
|
||||
* @return {Array} a list of dstat entries within the given bounds
|
||||
*/
|
||||
self.dstatInBounds = function(min, max) {
|
||||
var entries = self.dstat.entries;
|
||||
var timeFunc = function(d) { return d.system_time; };
|
||||
return entries.slice(
|
||||
arrayUtil.binaryMinIndex(min, entries, timeFunc),
|
||||
arrayUtil.binaryMaxIndex(max, entries, timeFunc) + 1
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an empty canvas with the specified width and height, returning
|
||||
* the element and its 2d context. The element will not be appended to the
|
||||
* document and may be used for offscreen rendering.
|
||||
* @param {number} [w] the canvas width in px, or null
|
||||
* @param {number} [h] the canvas height in px, or null
|
||||
* @return {object} an object containing the canvas and its 2d context
|
||||
*/
|
||||
self.createCanvas = function(w, h, scale) {
|
||||
w = w || self.width + self.margin.left + self.margin.right;
|
||||
h = h || 200 + self.margin.top + self.margin.bottom;
|
||||
if (typeof scale === 'undefined') {
|
||||
scale = true;
|
||||
}
|
||||
|
||||
/** @type {HTMLCanvasElement} */
|
||||
var canvas = angular.element('<canvas>')[0];
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
var ctx = canvas.getContext('2d');
|
||||
var devicePixelRatio = $window.devicePixelRatio || 1;
|
||||
var backingStoreRatio = ctx.webkitBackingStorePixelRatio ||
|
||||
ctx.mozBackingStorePixelRatio ||
|
||||
ctx.msBackingStorePixelRatio ||
|
||||
ctx.oBackingStorePixelRatio ||
|
||||
ctx.backingStorePixelRatio || 1;
|
||||
var ratio = devicePixelRatio / backingStoreRatio;
|
||||
|
||||
canvas.width = w * ratio;
|
||||
canvas.height = h * ratio;
|
||||
|
||||
if (scale) {
|
||||
ctx.scale(ratio, ratio);
|
||||
}
|
||||
|
||||
var resize = function(w) {
|
||||
canvas.width = w * ratio;
|
||||
canvas.style.width = w + 'px';
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
if (scale) {
|
||||
ctx.scale(ratio, ratio);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canvas: canvas, ctx: ctx,
|
||||
scale: scale, ratio: ratio, resize: resize
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request an animation frame from the browser, and call all regsitered
|
||||
* animation callbacks when it occurs. If an animation has already been
|
||||
* requested but has not completed, this method will return immediately.
|
||||
*/
|
||||
self.animate = function() {
|
||||
if (self.animateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
var _animate = function(timestamp) {
|
||||
var again = false;
|
||||
|
||||
for (var i = 0; i < self.animateCallbacks.length; i++) {
|
||||
if (self.animateCallbacks[i](timestamp)) {
|
||||
again = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (again) {
|
||||
self.animateId = requestAnimationFrame(_animate);
|
||||
} else {
|
||||
self.animateId = null;
|
||||
}
|
||||
};
|
||||
|
||||
self.animateId = requestAnimationFrame(_animate);
|
||||
};
|
||||
|
||||
var initData = function(raw) {
|
||||
self.dataRaw = raw;
|
||||
|
||||
@@ -263,6 +420,7 @@ function timeline($log, datasetService, progressService) {
|
||||
$scope.$watch(function() { return self.width; }, function(width) {
|
||||
self.axes.x.range([0, width]);
|
||||
self.axes.selection.range([0, width]);
|
||||
self.axes.absolute.range([0, width]);
|
||||
|
||||
$scope.$broadcast('update');
|
||||
});
|
||||
|
Reference in New Issue
Block a user