From 38c9528eb1ad7a4c9b6ce7c33dc5bd8dfa830e5a Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Wed, 31 Aug 2016 14:45:09 -0600 Subject: [PATCH] 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 --- app/js/directives/chart-axis.js | 323 +++++++++++++++++++++++++ app/js/directives/chart-dataset.js | 31 +++ app/js/directives/chart-tooltip.js | 242 +++++++++++++++++++ app/js/directives/chart.js | 369 +++++++++++++++++++++++++++++ package.json | 9 +- 5 files changed, 972 insertions(+), 2 deletions(-) create mode 100644 app/js/directives/chart-axis.js create mode 100644 app/js/directives/chart-dataset.js create mode 100644 app/js/directives/chart-tooltip.js create mode 100644 app/js/directives/chart.js diff --git a/app/js/directives/chart-axis.js b/app/js/directives/chart-axis.js new file mode 100644 index 00000000..5bcbbc82 --- /dev/null +++ b/app/js/directives/chart-axis.js @@ -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); diff --git a/app/js/directives/chart-dataset.js b/app/js/directives/chart-dataset.js new file mode 100644 index 00000000..9f5dc1b2 --- /dev/null +++ b/app/js/directives/chart-dataset.js @@ -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); diff --git a/app/js/directives/chart-tooltip.js b/app/js/directives/chart-tooltip.js new file mode 100644 index 00000000..a0307d52 --- /dev/null +++ b/app/js/directives/chart-tooltip.js @@ -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); diff --git a/app/js/directives/chart.js b/app/js/directives/chart.js new file mode 100644 index 00000000..3321dec3 --- /dev/null +++ b/app/js/directives/chart.js @@ -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('')[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: '', + scope: { + width: '@', + height: '@' + } + }; +} + +directivesModule.directive('chart', chart); diff --git a/package.json b/package.json index 9eee9347..da886ffe 100644 --- a/package.json +++ b/package.json @@ -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",