Add base utilities for canvas charts

This commit adds support for our own custom canvas-based charts.
By avoiding SVG and rendering directly to a 2D canvas context, these
charts should greatly reduce memory usage and improve page
performance, particularly when scrolling or on browsers and platforms
without good hardware acceleration. Lag while scrolling or hovering
over data points should be almost entirely eliminated, especially on
data-heavy pages like the home page with large periods or certain test
pages.

This commit adds only the components shared between all charts. New
implementations of each specific chart type will be added in follow-up
patches.

Change-Id: I5aff9c647095d879982c9b4d6080eafc497368d6
This commit is contained in:
Tim Buckley 2016-08-31 14:45:09 -06:00
parent c03378f034
commit 38c9528eb1
5 changed files with 972 additions and 2 deletions

View File

@ -0,0 +1,323 @@
'use strict';
var d3Scale = require('d3-scale');
var d3Format = require('d3-format');
var d3TimeFormat = require('d3-time-format');
var directivesModule = require('./_index.js');
function mapperFromPath(path) {
return function(object) {
if (path.startsWith('.')) {
path = path.substring(1);
}
var parts = path.split('.');
var current = object;
for (var i = 0; i < parts.length; i++) {
current = current[parts[i]];
}
return current;
};
}
// amounts (in scaled pixels) to extend the in-canvas padding depending on
// axis alignment. these are hand-selected 'reasonable' values for now, but
// could be computed automatically in the future by measuring tick text lenths
var paddingSizes = {
top: { top: 15, bottom: 10 },
bottom: { bottom: 15, top: 10 },
left: { left: 25, right: 20 },
right: { right: 25, left: 20 }
};
var textAlignMap = {
top: 'center',
bottom: 'center',
left: 'end',
right: 'start'
};
var textBaselineMap = {
top: 'bottom',
bottom: 'top',
left: 'middle',
right: 'middle'
};
// directional unit vectors used to compute axis offsets for label placement
var orthoVectors = {
top: [0, -1],
bottom: [0, 1],
left: [-1, 0],
right: [1, 0]
};
var oppositeAlignMap = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left'
};
function screenCoordsForAxis(orient, align, self, opposite) {
var selfRange = self.range();
var oppositeRange = opposite.range();
var norm = align === 'left' || align === 'top';
var vertical = orient === 'vertical';
var oppositeIndex = vertical ? norm ? 0 : 1 : norm ? 1 : 0;
var oppositeCoord = oppositeRange[oppositeIndex];
var start = [selfRange[norm ? 1 : 0], oppositeCoord];
var end = [selfRange[norm ? 0 : 1], oppositeCoord];
// for vertical axes, start and end map to (y, x) and should be flipped
if (vertical) {
start.reverse();
end.reverse();
}
var tick = function(value) {
var selfCoord = self(value);
var direction = norm;
if (!vertical) {
direction = !direction;
}
var tickStart = [selfCoord, oppositeRange[direction ? 0 : 1]];
var tickEnd = [selfCoord, oppositeRange[direction ? 1 : 0]];
if (vertical) {
tickStart.reverse();
tickEnd.reverse();
}
return {
start: tickStart, end: tickEnd
};
};
return { start: start, end: end, tick: tick };
}
function labelOffset(point, align, amount) {
var vec = orthoVectors[align];
return [point[0] + amount * vec[0], point[1] + amount * vec[1]];
}
function clamp(point, axis, extent, size) {
point = point.slice(); // don't modify original
var min = Math.min(extent[0], extent[1]);
var max = Math.max(extent[0], extent[1]);
var half = size / 2;
var index = (axis === 'x') ? 0 : 1;
if (point[index] - half < min) {
point[index] = min + half;
} else if (point[index] + half > max) {
point[index] = max - half;
}
return point;
}
function formatter(type, specifier) {
// d3's formatters aren't as lenient as printf() and only format a single
// value. we want to be able to show a suffix (for units, etc) so we'll add
// our own using '|' as a field separator
var tokens = specifier.split('|', 2);
var suffix = '';
if (tokens.length == 2) {
suffix = tokens[1];
}
var func = (type === 'time' ? d3TimeFormat.timeFormat : d3Format.format);
var format = func(tokens[0]);
return function(input) {
return format(input) + suffix;
};
}
/**
* @ngInject
*/
function chartAxis() {
var link = function(scope, el, attrs, ctrl) { // eslint-disable-line complexity
var background = ctrl.createCanvas(ctrl.width, ctrl.height, false);
var scale = null;
if (scope.type === 'linear') {
scale = d3Scale.scaleLinear();
} else if (scope.type === 'time') {
scale = d3Scale.scaleTime();
} else {
throw new Error('Unsupported scale type: ' + scope.type);
}
if (!scope.mapper && !scope.path) {
throw new Error('A mapper function or path is required!');
}
var orient = scope.orient;
var mapper = scope.mapper || mapperFromPath(scope.path);
var grid = typeof scope.grid === 'undefined' ? true : scope.grid;
var draw = typeof scope.draw === 'undefined' ? false : scope.draw;
var labels = typeof scope.labels === 'undefined' ? true : scope.labels;
// canvas renderers aggressively anti-alias by default making straight lines
// blurry, offsetting points by 0.5 works around the issue
var crispy = function(d) { return Math.floor(d) + 0.5; };
var coarseFormat = null;
if (scope.coarseFormat) {
coarseFormat = formatter(scope.type, scope.coarseFormat);
}
var granularFormat = null;
if (scope.granularFormat) {
granularFormat = formatter(scope.type, scope.granularFormat);
}
var align;
if (scope.align) {
align = scope.align;
} else if (orient === 'horizontal') {
align = 'left';
} else { // orient === 'vertical'
align = 'bottom';
}
ctrl.setAxis(
scope.name, scale, mapper, orient, scope.domain,
coarseFormat, granularFormat);
ctrl.pushPadding(paddingSizes[align]);
var font = Math.floor(10 * background.ratio) + 'px sans-serif';
function renderBackground() {
background.resize(ctrl.width, ctrl.height);
var axis = ctrl.axes[scope.name];
var ticks = axis.scale.ticks(scope.ticks || 5);
var opposite = ctrl.axes[scope.opposes];
var tickFormat = coarseFormat || axis.scale.tickFormat();
var point = screenCoordsForAxis(orient, align, axis.scale, opposite.scale);
var ctx = background.ctx;
ctx.strokeStyle = scope.stroke || 'black';
ctx.font = font;
if (draw) {
ctx.lineWidth = scope.lineWidth || 1;
ctx.beginPath();
ctx.moveTo.apply(ctx, point.start.map(crispy));
ctx.lineTo.apply(ctx, point.end.map(crispy));
ctx.stroke();
}
// not going to bother DPI scaling the line, it (subjectively) seems
// nicer to keep it 1 pixel wide
ctx.lineWidth = 1;
ticks.forEach(function(tick) {
var tickPoint = point.tick(tick);
if (grid) {
ctx.globalAlpha = 0.1;
ctx.beginPath();
ctx.moveTo.apply(ctx, tickPoint.start.map(crispy));
ctx.lineTo.apply(ctx, tickPoint.end.map(crispy));
ctx.stroke();
ctx.globalAlpha = 1;
}
if (labels) {
var pos = labelOffset(tickPoint.start, align, 5).map(crispy);
ctx.textBaseline = textBaselineMap[align];
ctx.textAlign = textAlignMap[align];
ctx.fillText(tickFormat(tick), pos[0], pos[1]);
}
});
}
scope.$on('update', renderBackground);
scope.$on('renderBackground', function(event, canvas) {
canvas.ctx.drawImage(background.canvas, 0, 0);
});
scope.$on('renderOverlay', function(event, canvas) {
if (ctrl.mousePoint && ctrl.mousePoint.inBounds) {
var axis = ctrl.axes[scope.name];
var opposite = ctrl.axes[scope.opposes];
var mouseAxis = (orient === 'horizontal') ? 'x' : 'y';
var value = axis.scale.invert(ctrl.mousePoint[mouseAxis]);
var flipped = oppositeAlignMap[align];
var point = screenCoordsForAxis(orient, flipped, axis.scale, opposite.scale);
var tick = point.tick(value);
var ctx = canvas.ctx;
ctx.lineWidth = 1;
ctx.strokeStyle = scope.stroke || 'black';
ctx.globalAlpha = 0.25;
ctx.font = font;
ctx.beginPath();
ctx.moveTo.apply(ctx, tick.start.map(crispy));
ctx.lineTo.apply(ctx, tick.end.map(crispy));
ctx.stroke();
ctx.globalAlpha = 1.0;
var format = granularFormat || axis.scale.tickFormat();
var labelPos = labelOffset(tick.start, flipped, 5);
var formatted = format(value);
var size;
if (orient === 'horizontal') {
size = ctx.measureText(formatted).width;
} else {
size = ctx.measureText('m').width; // meh
}
var metricsAxis = (orient === 'horizontal') ? 'width' : 'height';
labelPos = clamp(labelPos, mouseAxis, axis.scale.range(), size);
ctx.textBaseline = textBaselineMap[flipped];
ctx.textAlign = textAlignMap[flipped];
ctx.fillText(format(value), labelPos[0], labelPos[1]);
}
});
};
return {
restrict: 'E',
require: '^chart',
link: link,
scope: {
name: '@',
opposes: '@',
type: '@',
domain: '=',
range: '=',
path: '@',
mapper: '=',
orient: '@',
align: '@',
lineWidth: '=',
ticks: '=',
stroke: '@',
grid: '=',
draw: '=',
labels: '=',
coarseFormat: '@',
granularFormat: '@'
}
};
}
directivesModule.directive('chartAxis', chartAxis);

View File

@ -0,0 +1,31 @@
'use strict';
var d3Scale = require('d3-scale');
var directivesModule = require('./_index.js');
/**
* @ngInject
*/
function chartDataset() {
var link = function(scope, el, attrs, ctrl) {
scope.$watch('data', function(data) {
if (data) {
ctrl.setDataset(scope.name, scope.title, data);
}
});
};
return {
restrict: 'E',
require: '^chart',
link: link,
scope: {
name: '@',
title: '@',
data: '='
}
};
}
directivesModule.directive('chartDataset', chartDataset);

View File

@ -0,0 +1,242 @@
'use strict';
var d3Array = require('d3-array');
var directivesModule = require('./_index.js');
function capToRange(preferredPosition, size, axis) {
var range = axis.range();
var axisMin = Math.min(range[0], range[1]);
var axisMax = Math.max(range[0], range[1]);
if (preferredPosition < axisMin) {
return axisMin;
} else if (preferredPosition + size > axisMax) {
return axisMax - size;
} else {
return preferredPosition;
}
}
function fitsInAxis(choice) {
var range = choice.axis.range();
var axisMin = Math.min(range[0], range[1]);
var axisMax = Math.max(range[0], range[1]);
return choice.pos >= axisMin && choice.pos + choice.size < axisMax;
}
function decideOrientation(gravity, width, height, point, hAxis, vAxis) {
var padding = 15;
var choices = {
left: { pos: point.x - padding - width, size: width, axis: hAxis },
right: { pos: point.x + padding, size: width, axis: hAxis },
top: { pos: point.y - padding - height, size: height, axis: vAxis },
bottom: { pos: point.y + padding, size: height, axis: vAxis }
};
if (fitsInAxis(choices[gravity])) {
return choices[gravity];
} else {
// this would be better if it explicitly picked the axis opposite the
// requested gravity, but we'll pick the first acceptable alignment for
// simplicity
var valid = Object.keys(choices).find(function(choiceName) {
return fitsInAxis(choices[choiceName]);
});
if (valid) {
return choices[valid];
} else {
return choices.right;
}
}
}
function computePosition(gravity, width, height, point, hAxis, vAxis) {
// attempt to center the tooltip relative to the mouse along the opposite axis
// if the rect falls outside the axes we can move it without worrying about
// the cursor getting in the way
var centerX = capToRange(point.x - width / 2, width, hAxis);
var centerY = capToRange(point.y - height / 2, height, vAxis);
// attempt to position along the primary axis based on user-specified gravity
// this will be overridden if it won't fit to prevent being covered by the
// cursor
var orient = decideOrientation(gravity, width, height, point, hAxis, vAxis);
if (orient.axis === hAxis) {
return { x: orient.pos, y: centerY };
} else { // orientation.axis === vAxis
return { x: centerX, y: orient.pos };
}
}
/**
* @ngInject
*/
function chartTooltip() {
var link = function(scope, el, attrs, ctrl) {
var overlay = ctrl.createCanvas(ctrl.width, ctrl.height, false);
var overlayDirty = false;
function renderOverlay() {
var r = overlay.ratio;
var ctx = overlay.ctx;
ctx.clearRect(0, 0, overlay.canvas.width, overlay.canvas.height);
var primary = ctrl.axes[scope.primary];
var secondary = ctrl.axes[scope.secondary];
var primaryFormat = primary.granularFormat || primary.scale.tickFormat();
var secondaryFormat = secondary.granularFormat || secondary.scale.tickFormat();
var secondaryValues = [];
var points = [];
ctrl.tooltips.forEach(function(v, k) {
v.points.forEach(function(point) {
points.push(point);
var mapped = secondary.mapper(point);
secondaryValues.push({
text: secondaryFormat(mapped),
datasetTitle: ctrl.datasets[k].title,
style: v.style
});
});
});
if (points.length === 0) {
return;
}
var fontNormal = Math.floor(12 * r) + 'px sans-serif';
var fontBold = 'bold ' + fontNormal;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.font = fontBold;
// measure widths needed for bounding box
var primaryMean = d3Array.mean(points, primary.mapper);
var primaryFormatted = primaryFormat(primaryMean);
var primaryWidth = ctx.measureText(primaryFormatted).width;
ctx.font = fontNormal;
var maxNameWidth = d3Array.max(secondaryValues.map(function(v) {
return ctx.measureText(v.datasetTitle).width;
}));
ctx.font = fontBold;
var maxValueWidth = d3Array.max(secondaryValues.map(function(v) {
return ctx.measureText(v.text).width;
}));
var padding = 5 * r;
var rowSize = 18 * r;
// find the bounding box
var width = Math.floor(Math.max(
2 * padding + primaryWidth,
4 * padding + rowSize + maxNameWidth + maxValueWidth)) + 1;
var height = Math.floor(
(rowSize + padding) * (points.length + 1) + padding) + 1;
var gravity = scope.gravity || 'right';
var hAxis = primary.orient === 'horizontal' ? primary : secondary;
var vAxis = primary.orient === 'vertical' ? primary : secondary;
var pos = computePosition(
gravity, width, height, ctrl.mousePoint,
hAxis.scale, vAxis.scale);
var x = Math.floor(pos.x);
var y = Math.floor(pos.y);
// draw tooltip box
ctx.strokeStyle = 'black';
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.beginPath();
ctx.moveTo(x + 0.5, y + 0.5);
ctx.lineTo(x + width + 0.5, y + 0.5);
ctx.lineTo(x + width + 0.5, y + height + 0.5);
ctx.lineTo(x + 0.5, y + height + 0.5);
ctx.closePath();
ctx.fill();
ctx.stroke();
// draw the header
ctx.fillStyle = 'black';
ctx.font = fontBold;
ctx.fillText(primaryFormatted, x + padding, y + padding + rowSize / 2);
ctx.font = fontNormal;
for (var row = 0; row < points.length; row++) {
var value = secondaryValues[row];
var rowY = y + padding + (rowSize + padding) * (row + 1);
// draw colored square
ctx.fillStyle = value.style;
ctx.beginPath();
ctx.moveTo(x + padding + 0.5, rowY + 0.5);
ctx.lineTo(x + padding + rowSize + 0.5, rowY + 0.5);
ctx.lineTo(x + padding + rowSize + 0.5, rowY + rowSize + 0.5);
ctx.lineTo(x + padding + 0.5, rowY + rowSize + 0.5);
ctx.closePath();
ctx.fill();
ctx.stroke();
// draw dataset label
ctx.font = fontNormal;
ctx.fillStyle = 'black';
ctx.fillText(
value.datasetTitle,
x + 2 * padding + rowSize,
rowY + rowSize / 2);
// draw value label
ctx.font = fontBold;
ctx.fillText(
value.text,
x + 3 * padding + rowSize + maxNameWidth,
rowY + rowSize / 2);
}
overlayDirty = false;
}
scope.$on('renderOverlay', function(event, canvas) {
if (overlayDirty) {
renderOverlay();
}
canvas.ctx.drawImage(overlay.canvas, 0, 0);
});
scope.$on('update', renderOverlay);
scope.$on('resize', function(event, width, height) {
overlay.resize(width, height);
});
scope.$on('mousemove', function() {
overlayDirty = true;
ctrl.render();
});
scope.$on('mouseout', function() {
overlayDirty = true;
ctrl.render();
});
};
return {
restrict: 'E',
require: '^chart',
link: link,
scope: {
primary: '@',
secondary: '@',
gravity: '@'
}
};
}
directivesModule.directive('chartTooltip', chartTooltip);

369
app/js/directives/chart.js Normal file
View File

@ -0,0 +1,369 @@
'use strict';
var d3Array = require('d3-array');
var directivesModule = require('./_index.js');
/**
* @ngInject
*/
function chart($window) {
/**
* @ngInject
*/
var controller = function($scope) {
var self = this;
self.canvas = null;
self.padding = { top: 10, right: 25, bottom: 10, left: 25 };
self.margin = { top: 0, right: 0, bottom: 0, left: 0 };
self.axes = {};
self.datasets = {};
self.tooltips = new Map();
self.linked = false;
self.mousePoint = null;
self.mousePointDirty = false;
self.dragging = false;
/**
* 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
* @param {boolean} scale if true, scale all drawing operations based on
* the current device pixel ratio
* @return {object} an object containing the canvas, its 2d context,
* and other properties
*/
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;
canvas.ratio = devicePixelRatio;
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
if (scale) {
ctx.scale(ratio, devicePixelRatio);
}
var resize = function(w, h) {
canvas.width = w * devicePixelRatio;
canvas.style.width = w + 'px';
if (typeof h !== 'undefined') {
canvas.height = h * devicePixelRatio;
canvas.style.height = h + 'px';
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (scale) {
ctx.scale(devicePixelRatio, devicePixelRatio);
}
};
return {
canvas: canvas, ctx: ctx,
scale: scale, ratio: devicePixelRatio, resize: resize
};
};
/**
* For each (axis, dataset) combination, creates a cache of mapped values
* and calculates extents. If no explicit domain is specified, axes will
* have their domains set to fit the extent of the data and expanded as per
* d3-scale's `scale.nice()`.
*/
self.recalc = function() {
angular.forEach(self.axes, function(axis) {
var mins = [];
var maxes = [];
angular.forEach(self.datasets, function(dataset) {
var mapped = dataset.data.map(axis.mapper);
var extent = d3Array.extent(mapped);
mins.push(extent[0]);
maxes.push(extent[1]);
dataset.mapped[axis.name] = mapped;
dataset.extent[axis.name] = extent;
});
axis.extent = [ d3Array.min(mins), d3Array.max(maxes) ];
if (axis.domain) {
axis.scale.domain(axis.domain);
} else {
axis.scale.domain(axis.extent).nice();
}
});
};
self.setAxis = function(
name, scale, mapper, orient, domain, coarseFormat, granularFormat) {
self.axes[name] = {
name: name,
scale: scale,
mapper: mapper,
orient: orient,
domain: domain,
extent: null,
coarseFormat: coarseFormat,
granularFormat: granularFormat
};
// recalc is called at the end of link, don't do it here if not necessary
if (self.linked) {
self.recalc();
self.update();
}
};
self.setDataset = function(name, title, data) {
self.datasets[name] = {
name: name,
title: title,
data: data,
mapped: {},
extent: {}
};
if (self.linked) {
self.recalc();
self.update();
}
};
/**
* Adds the value of each named prop of `diff` to the current padding.
* For example, a `diff` of `{ top: 10 }` will add 10 to the current
* padding's `top` field.
* @param {object} diff an object containing named values to add
*/
self.pushPadding = function(diff) {
Object.keys(diff).forEach(function(key) {
self.padding[key] += diff[key];
});
};
/**
* Returns data from `datasetName` as mapped for use with `axisName`.
* @param {string} datasetName the name of the dataset
* @param {string} axisName the name of the axis
* @return {number[]} a list of values
*/
self.data = function(datasetName, axisName) {
var dataset = self.datasets[datasetName];
return dataset.mapped[axisName];
};
self.dataNearAxis = function(point, dataset, axisX, radius) {
var xMin = axisX.scale.invert(point.x - radius);
var xMax = axisX.scale.invert(point.x + radius);
var bisectorX = d3Array.bisector(axisX.mapper);
return dataset.data.slice(
bisectorX.left(dataset.data, xMin),
bisectorX.right(dataset.data, xMax)
);
};
self.nearestPoint = function(point, dataset, axisX, axisY, radius) {
// it would be simpler to do an array intersection with two calls to
// dataNearAxis(), but then we'll need to bisect the entire dataset twice
// instead, we can filter the now-filtered list to save some cycles
var nearX = self.dataNearAxis(point, dataset, axisX, radius);
var radiusSq = radius * radius;
var dist = function(d) {
return Math.pow(axisX.scale(axisX.mapper(d)) - point.x, 2) +
Math.pow(axisY.scale(axisY.mapper(d)) - point.y, 2);
};
var candidates = nearX.map(function(d) {
return { datum: d, distanceSq: dist(d) };
}).filter(function(d) {
return d.distanceSq < radiusSq;
}).sort(function(a, b) {
return a.distanceSq - b.distanceSq;
});
if (candidates.length === 0) {
return null;
} else {
return candidates[0].datum;
}
};
self.update = function() {
$scope.$broadcast('update');
self.render();
};
/**
* 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.render = function() {
if (self.renderId) {
return;
}
self.renderId = requestAnimationFrame(function(timestamp) {
if (self.mousePointDirty) {
if (self.mousePoint) {
$scope.$broadcast('mousemove', self.mousePoint);
}
self.mousePointDirty = false;
}
self.canvas.ctx.clearRect(
0, 0,
self.canvas.canvas.width, self.canvas.canvas.height);
$scope.$broadcast('renderBackground', self.canvas, timestamp);
$scope.$broadcast('render', self.canvas, timestamp);
$scope.$broadcast('renderOverlay', self.canvas, timestamp);
self.renderId = null;
});
};
};
var link = function(scope, el, attrs, ctrl) {
el.css('display', 'block');
el.css('width', attrs.width);
el.css('height', attrs.height);
var updateSize = function() {
var style = getComputedStyle(el[0]);
ctrl.width = el[0].clientWidth -
ctrl.margin.left -
ctrl.margin.right -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight);
ctrl.height = el[0].clientHeight -
ctrl.margin.top -
ctrl.margin.bottom -
parseFloat(style.paddingTop) -
parseFloat(style.paddingBottom);
if (ctrl.canvas) {
ctrl.canvas.resize(ctrl.width, ctrl.height);
}
var scale = $window.devicePixelRatio || 1;
angular.forEach(ctrl.axes, function(axis) {
if (axis.orient === 'vertical') {
// swapped for screen y
axis.scale.range([
(ctrl.height - ctrl.padding.bottom) * scale,
ctrl.padding.top * scale
]);
} else if (axis.orient === 'horizontal') {
axis.scale.range([
ctrl.padding.left * scale,
(ctrl.width - ctrl.padding.right) * scale
]);
}
});
scope.$broadcast('resize', ctrl.width, ctrl.height);
};
updateSize();
scope.$on('windowResize', function() {
updateSize();
ctrl.update();
});
var createMouseEvent = function(evt) {
var r = ctrl.canvas.canvas.getBoundingClientRect();
var ratio = ctrl.canvas.ratio;
var ret = {
x: (evt.clientX - r.left) * ratio,
y: (evt.clientY - r.top) * ratio,
dragging: ctrl.dragging
};
ret.inBounds =
ret.x > ratio * ctrl.padding.left &&
ret.x < ratio * (ctrl.width - ctrl.padding.right) &&
ret.y > ratio * ctrl.padding.top &&
ret.y < ratio * (ctrl.height - ctrl.padding.bottom);
return ret;
};
ctrl.canvas = ctrl.createCanvas(ctrl.width, ctrl.height, false);
ctrl.canvas.canvas.unselectable = 'on';
ctrl.canvas.canvas.onselectstart = function() { return false; };
ctrl.canvas.canvas.style.userSelect = 'none';
el.append(ctrl.canvas.canvas);
ctrl.canvas.canvas.addEventListener('mousedown', function(evt) {
evt.preventDefault();
ctrl.dragging = true;
ctrl.mousePoint = createMouseEvent(evt);
scope.$broadcast('mousedown', ctrl.mousePoint);
});
ctrl.canvas.canvas.addEventListener('mouseup', function(evt) {
// note: this may not give correct behavior for off-canvas drags
ctrl.dragging = false;
ctrl.mousePoint = createMouseEvent(evt);
scope.$broadcast('mouseup', ctrl.mousePoint);
});
ctrl.canvas.canvas.addEventListener('mousemove', function(evt) {
// move events can occur more often than redraws, so we'll delay event
// dispatching to the beginning of render(), and call render() for every
// movement event
// note that in some situations this could cause events to be executed
// out-of-order
ctrl.mousePointDirty = true;
ctrl.mousePoint = createMouseEvent(evt);
ctrl.render();
});
ctrl.canvas.canvas.addEventListener('mouseout', function(evt) {
ctrl.mousePoint = null;
scope.$broadcast('mouseout', createMouseEvent(evt));
});
ctrl.linked = true;
ctrl.update();
};
return {
controller: controller,
link: link,
controllerAs: 'chart',
restrict: 'E',
transclude: true,
template: '<ng-transclude></ng-transclude>',
scope: {
width: '@',
height: '@'
}
};
}
directivesModule.directive('chart', chart);

View File

@ -22,8 +22,13 @@
"browserify-shim": "^3.8.10",
"bulk-require": "^0.2.1",
"bulkify": "^1.1.1",
"del": "^0.1.3",
"d3": "^3.5.3",
"d3-array": "^1.0.1",
"d3-format": "^1.0.2",
"d3-interpolate": "^1.1.1",
"d3-scale": "^1.0.3",
"d3-time-format": "^2.0.2",
"del": "^0.1.3",
"eslint": "1.5.1",
"eslint-config-openstack": "1.2.2",
"eslint-plugin-angular": "0.12.0",
@ -60,9 +65,9 @@
"karma-spec-reporter": "0.0.20",
"karma-subunit-reporter": "0.0.4",
"moment": "^2.11.1",
"nvd3": "^1.8.4",
"morgan": "^1.6.1",
"nprogress": "^0.2.0",
"nvd3": "^1.8.4",
"pretty-hrtime": "^1.0.0",
"protractor": "^2.2.0",
"protractor-http-mock": "^0.1.18",