Files
stackviz/app/js/directives/timeline-overview.js
Tim Buckley 72a3317656 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
2016-04-06 19:03:31 -06:00

496 lines
17 KiB
JavaScript

'use strict';
var directivesModule = require('./_index.js');
var d3 = require('d3');
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;
// input variables
var dragOffsetStart = null;
var dragType = null; // left, right, position, null
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);
/**
* 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];
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;
brushExtent = [targetStart, targetEnd];
timelineController.setViewExtents(brushExtent);
timelineController.animate();
return true;
}
/**
* 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];
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;
}
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);
brushExtent = [start, reducedEnd];
timelineController.setViewExtents(brushExtent);
loaded = true;
drawRects();
});
scope.$on('update', function() {
rects = createRects(timelineController.dataRaw);
drawRects();
timelineController.animate();
});
scope.$on('updateView', function() {
brushExtent = timelineController.viewExtents;
timelineController.animate();
});
scope.$on('select', function(event, selection) {
if (selection) {
shiftViewport(selection.item);
}
});
scope.$on('filter', function() {
if (loaded) {
drawRects();
}
});
};
return {
restrict: 'E',
require: '^timeline',
scope: true,
link: link
};
}
directivesModule.directive('timelineOverview', timelineOverview);