370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
'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 registered
|
|
* 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);
|