stackviz/app/js/directives/timeline-overview.js

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);