From 7d8afe8ab24398013c2f406c805d5bc25b43dcac Mon Sep 17 00:00:00 2001 From: etimberg Date: Fri, 16 Dec 2016 23:05:23 -0500 Subject: [PATCH] Refactoring to put browser specific code in a new class, BrowserPlatform. BrowserPlatform implements IPlatform. Chart.Platform is the constructor for the platform object that is attached to the chart instance. Plugins are notified about the event using the `onEvent` call. The legend plugin was converted to use onEvent instead of the older private `handleEvent` method. Wrote test to check that plugins are notified about events --- docs/09-Advanced.md | 8 ++ src/chart.js | 4 + src/core/core.controller.js | 158 +++------------------- src/core/core.interaction.js | 31 ++++- src/core/core.legend.js | 22 ++- src/core/core.platform.js | 44 ++++++ src/core/core.tooltip.js | 7 +- src/platforms/platform.browser.js | 214 ++++++++++++++++++++++++++++++ test/core.controller.tests.js | 55 ++++++++ 9 files changed, 385 insertions(+), 158 deletions(-) create mode 100644 src/core/core.platform.js create mode 100644 src/platforms/platform.browser.js diff --git a/docs/09-Advanced.md b/docs/09-Advanced.md index f76ac4b0d97..4c677c904d4 100644 --- a/docs/09-Advanced.md +++ b/docs/09-Advanced.md @@ -410,6 +410,7 @@ Plugins will be called at the following times * After datasets draw * Resize * Before an animation is started +* When an event occurs on the canvas (mousemove, click, etc). This requires the `options.events` property handled Plugins should derive from Chart.PluginBase and implement the following interface ```javascript @@ -437,6 +438,13 @@ Plugins should derive from Chart.PluginBase and implement the following interfac afterDatasetsDraw: function(chartInstance, easing) { }, destroy: function(chartInstance) { } + + /** + * Called when an event occurs on the chart + * @param e {Core.Event} the Chart.js wrapper around the native event. e.native is the original event + * @return {Boolean} true if the chart is changed and needs to re-render + */ + onEvent: function(chartInstance, e) {} } ``` diff --git a/src/chart.js b/src/chart.js index 7c490e7eabc..749d32be6b4 100644 --- a/src/chart.js +++ b/src/chart.js @@ -18,6 +18,10 @@ require('./core/core.title')(Chart); require('./core/core.legend')(Chart); require('./core/core.interaction')(Chart); require('./core/core.tooltip')(Chart); +require('./core/core.platform')(Chart); + +// By default, we only load the browser platform. +Chart.Platform = require('./platforms/platform.browser')(Chart); require('./elements/element.arc')(Chart); require('./elements/element.line')(Chart); diff --git a/src/core/core.controller.js b/src/core/core.controller.js index 4e28773880e..5d33e5e76d4 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -14,140 +14,6 @@ module.exports = function(Chart) { // Controllers available for dataset visualization eg. bar, line, slice, etc. Chart.controllers = {}; - /** - * The "used" size is the final value of a dimension property after all calculations have - * been performed. This method uses the computed style of `element` but returns undefined - * if the computed style is not expressed in pixels. That can happen in some cases where - * `element` has a size relative to its parent and this last one is not yet displayed, - * for example because of `display: none` on a parent node. - * TODO(SB) Move this method in the upcoming core.platform class. - * @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value - * @returns {Number} Size in pixels or undefined if unknown. - */ - function readUsedSize(element, property) { - var value = helpers.getStyle(element, property); - var matches = value && value.match(/(\d+)px/); - return matches? Number(matches[1]) : undefined; - } - - /** - * Initializes the canvas style and render size without modifying the canvas display size, - * since responsiveness is handled by the controller.resize() method. The config is used - * to determine the aspect ratio to apply in case no explicit height has been specified. - * TODO(SB) Move this method in the upcoming core.platform class. - */ - function initCanvas(canvas, config) { - var style = canvas.style; - - // NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it - // returns null or '' if no explicit value has been set to the canvas attribute. - var renderHeight = canvas.getAttribute('height'); - var renderWidth = canvas.getAttribute('width'); - - // Chart.js modifies some canvas values that we want to restore on destroy - canvas._chartjs = { - initial: { - height: renderHeight, - width: renderWidth, - style: { - display: style.display, - height: style.height, - width: style.width - } - } - }; - - // Force canvas to display as block to avoid extra space caused by inline - // elements, which would interfere with the responsive resize process. - // https://github.com/chartjs/Chart.js/issues/2538 - style.display = style.display || 'block'; - - if (renderWidth === null || renderWidth === '') { - var displayWidth = readUsedSize(canvas, 'width'); - if (displayWidth !== undefined) { - canvas.width = displayWidth; - } - } - - if (renderHeight === null || renderHeight === '') { - if (canvas.style.height === '') { - // If no explicit render height and style height, let's apply the aspect ratio, - // which one can be specified by the user but also by charts as default option - // (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2. - canvas.height = canvas.width / (config.options.aspectRatio || 2); - } else { - var displayHeight = readUsedSize(canvas, 'height'); - if (displayWidth !== undefined) { - canvas.height = displayHeight; - } - } - } - - return canvas; - } - - /** - * Restores the canvas initial state, such as render/display sizes and style. - * TODO(SB) Move this method in the upcoming core.platform class. - */ - function releaseCanvas(canvas) { - if (!canvas._chartjs) { - return; - } - - var initial = canvas._chartjs.initial; - ['height', 'width'].forEach(function(prop) { - var value = initial[prop]; - if (value === undefined || value === null) { - canvas.removeAttribute(prop); - } else { - canvas.setAttribute(prop, value); - } - }); - - helpers.each(initial.style || {}, function(value, key) { - canvas.style[key] = value; - }); - - // The canvas render size might have been changed (and thus the state stack discarded), - // we can't use save() and restore() to restore the initial state. So make sure that at - // least the canvas context is reset to the default state by setting the canvas width. - // https://www.w3.org/TR/2011/WD-html5-20110525/the-canvas-element.html - canvas.width = canvas.width; - - delete canvas._chartjs; - } - - /** - * TODO(SB) Move this method in the upcoming core.platform class. - */ - function acquireContext(item, config) { - if (typeof item === 'string') { - item = document.getElementById(item); - } else if (item.length) { - // Support for array based queries (such as jQuery) - item = item[0]; - } - - if (item && item.canvas) { - // Support for any object associated to a canvas (including a context2d) - item = item.canvas; - } - - if (item instanceof HTMLCanvasElement) { - // To prevent canvas fingerprinting, some add-ons undefine the getContext - // method, for example: https://github.com/kkapsner/CanvasBlocker - // https://github.com/chartjs/Chart.js/issues/2807 - var context = item.getContext && item.getContext('2d'); - if (context instanceof CanvasRenderingContext2D) { - initCanvas(item, config); - return context; - } - } - - return null; - } - /** * Initializes the given config with global and chart default values. */ @@ -197,7 +63,9 @@ module.exports = function(Chart) { config = initConfig(config); - var context = acquireContext(item, config); + // Create the platform implementation + me.platform = new Chart.Platform(me); + var context = me.platform.acquireContext(item, config); var canvas = context && context.canvas; var height = canvas && canvas.height; var width = canvas && canvas.width; @@ -696,7 +564,7 @@ module.exports = function(Chart) { helpers.unbindEvents(me, me.events); helpers.removeResizeListener(canvas.parentNode); helpers.clear(me.chart); - releaseCanvas(canvas); + me.platform.releaseCanvas(canvas); me.chart.canvas = null; me.chart.ctx = null; } @@ -742,7 +610,6 @@ module.exports = function(Chart) { eventHandler: function(e) { var me = this; - var legend = me.legend; var tooltip = me.tooltip; var hoverOptions = me.options.hover; @@ -750,9 +617,12 @@ module.exports = function(Chart) { me._bufferedRender = true; me._bufferedRequest = null; - var changed = me.handleEvent(e); - changed |= legend && legend.handleEvent(e); - changed |= tooltip && tooltip.handleEvent(e); + // Create platform agnostic chart event using platform specific code + var chartEvent = me.platform.createEvent(e); + + var changed = me.handleEvent(chartEvent); + changed |= tooltip && tooltip.handleEvent(chartEvent); + changed |= Chart.plugins.notify(me, 'onEvent', [chartEvent]); var bufferedRequest = me._bufferedRequest; if (bufferedRequest) { @@ -776,7 +646,7 @@ module.exports = function(Chart) { /** * Handle an event * @private - * param e {Event} the event to handle + * param e {Core.Event} the event to handle * @return {Boolean} true if the chart needs to re-render */ handleEvent: function(e) { @@ -796,12 +666,14 @@ module.exports = function(Chart) { // On Hover hook if (hoverOptions.onHover) { - hoverOptions.onHover.call(me, e, me.active); + // Need to call with native event here to not break backwards compatibility + hoverOptions.onHover.call(me, e.native, me.active); } if (e.type === 'mouseup' || e.type === 'click') { if (options.onClick) { - options.onClick.call(me, e, me.active); + // Use e.native here for backwards compatibility + options.onClick.call(me, e.native, me.active); } } diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index aacdda19c38..0888fa9591b 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -3,6 +3,23 @@ module.exports = function(Chart) { var helpers = Chart.helpers; + /** + * Helper function to get relative position for an event + * @param e {Event|Core.Event} the event to get the position for + * @param chart {chart} the chart + * @returns {Point} the event position + */ + function getRelativePosition(e, chart) { + if (e.native) { + return { + x: e.x, + y: e.y + }; + } + + return helpers.getRelativePosition(e, chart); + } + /** * Helper function to traverse all of the visible elements in the chart * @param chart {chart} the chart @@ -82,7 +99,7 @@ module.exports = function(Chart) { } function indexMode(chart, e, options) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var distanceMetric = function(pt1, pt2) { return Math.abs(pt1.x - pt2.x); }; @@ -125,7 +142,7 @@ module.exports = function(Chart) { // Helper function for different modes modes: { single: function(chart, e) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var elements = []; parseVisibleItems(chart, function(element) { @@ -166,7 +183,7 @@ module.exports = function(Chart) { * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned */ dataset: function(chart, e, options) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var items = options.intersect ? getIntersectItems(chart, position) : getNearestItems(chart, position, false); if (items.length > 0) { @@ -193,7 +210,7 @@ module.exports = function(Chart) { * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned */ point: function(chart, e) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); return getIntersectItems(chart, position); }, @@ -206,7 +223,7 @@ module.exports = function(Chart) { * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned */ nearest: function(chart, e, options) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var nearestItems = getNearestItems(chart, position, options.intersect); // We have multiple items at the same distance from the event. Now sort by smallest @@ -238,7 +255,7 @@ module.exports = function(Chart) { * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned */ x: function(chart, e, options) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var items = []; var intersectsItem = false; @@ -269,7 +286,7 @@ module.exports = function(Chart) { * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned */ y: function(chart, e, options) { - var position = helpers.getRelativePosition(e, chart.chart); + var position = getRelativePosition(e, chart.chart); var items = []; var intersectsItem = false; diff --git a/src/core/core.legend.js b/src/core/core.legend.js index 4ab51ba5dbf..7ae4d6cd23f 100644 --- a/src/core/core.legend.js +++ b/src/core/core.legend.js @@ -439,7 +439,7 @@ module.exports = function(Chart) { /** * Handle an event * @private - * @param e {Event} the event to handle + * @param e {Core.Event} the event to handle * @return {Boolean} true if a change occured */ handleEvent: function(e) { @@ -460,9 +460,9 @@ module.exports = function(Chart) { return; } - var position = helpers.getRelativePosition(e, me.chart.chart), - x = position.x, - y = position.y; + // Chart event already has relative position in it + var x = e.x, + y = e.y; if (x >= me.left && x <= me.right && y >= me.top && y <= me.bottom) { // See if we are touching one of the dataset boxes @@ -473,11 +473,13 @@ module.exports = function(Chart) { if (x >= hitBox.left && x <= hitBox.left + hitBox.width && y >= hitBox.top && y <= hitBox.top + hitBox.height) { // Touching an element if (type === 'click') { - opts.onClick.call(me, e, me.legendItems[i]); + // use e.native for backwards compatibility + opts.onClick.call(me, e.native, me.legendItems[i]); changed = true; break; } else if (type === 'mousemove') { - opts.onHover.call(me, e, me.legendItems[i]); + // use e.native for backwards compatibility + opts.onHover.call(me, e.native, me.legendItems[i]); changed = true; break; } @@ -523,6 +525,14 @@ module.exports = function(Chart) { Chart.layoutService.removeBox(chartInstance, chartInstance.legend); delete chartInstance.legend; } + }, + onEvent: function(chartInstance, e) { + var legend = chartInstance.legend; + if (legend) { + return legend.handleEvent(e); + } + + return false; } }); }; diff --git a/src/core/core.platform.js b/src/core/core.platform.js new file mode 100644 index 00000000000..f925dd3ff63 --- /dev/null +++ b/src/core/core.platform.js @@ -0,0 +1,44 @@ +'use strict'; + +// Core.Platform abstracts away browser specific APIs from the chart +module.exports = function(Chart) { + var platform = Chart.corePlatform = {}; + + /** + * @name Core.platform.events + * @type Map + */ + platform.events = { + mouseenter: 'mouseenter', + mousemove: 'mousemove', + mouseout: 'mouseout', + mousedown: 'mousedown', + mouseup: 'mouseup', + click: 'click', + dblclick: 'dblclick', + contextmenu: 'contextmenu', + keydown: 'keydown', + keypress: 'keypress', + keyup: 'keyup' + }; + + /** + * @interface IPlatform + * Allows abstracting platform dependencies away from the chart + */ + /** + * Creates a chart.js event from a platform specific event + * @method IPlatform#createEvent + * @param e {Event} : the platform event to translate + * @returns {Core.Event} chart.js event + */ + /** + * @method IPlatform#acquireContext + * @param item {CanvasRenderingContext2D|HTMLCanvasElement} the context or canvas to use + * @param config {ChartOptions} the chart options + */ + /** + * @method IPlatform#releaseCanvas + * @param canvas {HTMLCanvasElement} the canvas to release + */ +}; diff --git a/src/core/core.tooltip.js b/src/core/core.tooltip.js index d99f2302ff5..c1ac7830739 100755 --- a/src/core/core.tooltip.js +++ b/src/core/core.tooltip.js @@ -763,7 +763,7 @@ module.exports = function(Chart) { /** * Handle an event * @private - * @param e {Event} the event to handle + * @param e {Core.Event} the event to handle * @returns {Boolean} true if the tooltip changed */ handleEvent: function(e) { @@ -785,7 +785,10 @@ module.exports = function(Chart) { me._lastActive = me._active; if (options.enabled || options.custom) { - me._eventPosition = helpers.getRelativePosition(e, me._chart); + me._eventPosition = { + x: e.x, + y: e.y + }; var model = me._model; me.update(true); diff --git a/src/platforms/platform.browser.js b/src/platforms/platform.browser.js new file mode 100644 index 00000000000..9e653382132 --- /dev/null +++ b/src/platforms/platform.browser.js @@ -0,0 +1,214 @@ +'use strict'; + +// Chart.Platform implementation for targeting a web browser +module.exports = function(Chart) { + var helpers = Chart.helpers; + var corePlatform = Chart.corePlatform; + var platformEvents = corePlatform.events; + + // Key is the browser event type + var typeMap = { + // Mouse events + mouseenter: platformEvents.mouseenter, + mousedown: platformEvents.mousedown, + mousemove: platformEvents.mousemove, + mouseup: platformEvents.mouseup, + mouseout: platformEvents.mouseout, + mouseleave: platformEvents.mouseout, + click: platformEvents.click, + dblclick: platformEvents.dblclick, + contextmenu: platformEvents.contextmenu, + + // Touch events + touchstart: platformEvents.mousedown, + touchmove: platformEvents.mousemove, + touchend: platformEvents.mouseup, + + // Pointer events + pointerenter: platformEvents.mouseenter, + pointerdown: platformEvents.mousedown, + pointermove: platformEvents.mousemove, + pointerup: platformEvents.mouseup, + pointerleave: platformEvents.mouseout, + pointerout: platformEvents.mouseout, + + // Key events + keydown: platformEvents.keydown, + keypress: platformEvents.keypress, + keyup: platformEvents.keyup, + }; + + /** + * The "used" size is the final value of a dimension property after all calculations have + * been performed. This method uses the computed style of `element` but returns undefined + * if the computed style is not expressed in pixels. That can happen in some cases where + * `element` has a size relative to its parent and this last one is not yet displayed, + * for example because of `display: none` on a parent node. + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value + * @returns {Number} Size in pixels or undefined if unknown. + */ + function readUsedSize(element, property) { + var value = helpers.getStyle(element, property); + var matches = value && value.match(/(\d+)px/); + return matches? Number(matches[1]) : undefined; + } + + /** + * Initializes the canvas style and render size without modifying the canvas display size, + * since responsiveness is handled by the controller.resize() method. The config is used + * to determine the aspect ratio to apply in case no explicit height has been specified. + */ + function initCanvas(canvas, config) { + var style = canvas.style; + + // NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it + // returns null or '' if no explicit value has been set to the canvas attribute. + var renderHeight = canvas.getAttribute('height'); + var renderWidth = canvas.getAttribute('width'); + + // Chart.js modifies some canvas values that we want to restore on destroy + canvas._chartjs = { + initial: { + height: renderHeight, + width: renderWidth, + style: { + display: style.display, + height: style.height, + width: style.width + } + } + }; + + // Force canvas to display as block to avoid extra space caused by inline + // elements, which would interfere with the responsive resize process. + // https://github.com/chartjs/Chart.js/issues/2538 + style.display = style.display || 'block'; + + if (renderWidth === null || renderWidth === '') { + var displayWidth = readUsedSize(canvas, 'width'); + if (displayWidth !== undefined) { + canvas.width = displayWidth; + } + } + + if (renderHeight === null || renderHeight === '') { + if (canvas.style.height === '') { + // If no explicit render height and style height, let's apply the aspect ratio, + // which one can be specified by the user but also by charts as default option + // (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2. + canvas.height = canvas.width / (config.options.aspectRatio || 2); + } else { + var displayHeight = readUsedSize(canvas, 'height'); + if (displayWidth !== undefined) { + canvas.height = displayHeight; + } + } + } + + return canvas; + } + + /** + * Creates an instance of the browser platform class + * @class BrowserPlatform + * @implements IPlatform + * @param chartController {Core.Controller} the main chart controller + */ + function BrowserPlatform(chartController) { + this.chartController = chartController; + } + + /** + * Creates a Chart.js event from a raw event + * @method BrowserPlatform#createEvent + * @implements IPlatform.createEvent + * @param e {Event} the raw event (such as a mouse event) + * @returns {Core.Event} the chart.js event for this event + */ + BrowserPlatform.prototype.createEvent = function(e) { + var chart = this.chartController.chart; + var relativePosition = helpers.getRelativePosition(e, chart); + var chartEvent = { + // allow access to the native event + native: e, + + // our interal event type + type: typeMap[e.type], + + // width and height of chart + width: chart.width, + height: chart.height, + + // Position relative to the canvas + x: relativePosition.x, + y: relativePosition.y + }; + return chartEvent; + }; + + /** + * @method BrowserPlatform#acquireContext + * @implements IPlatform#acquireContext + */ + BrowserPlatform.prototype.acquireContext = function(item, config) { + if (typeof item === 'string') { + item = document.getElementById(item); + } else if (item.length) { + // Support for array based queries (such as jQuery) + item = item[0]; + } + + if (item && item.canvas) { + // Support for any object associated to a canvas (including a context2d) + item = item.canvas; + } + + if (item instanceof HTMLCanvasElement) { + // To prevent canvas fingerprinting, some add-ons undefine the getContext + // method, for example: https://github.com/kkapsner/CanvasBlocker + // https://github.com/chartjs/Chart.js/issues/2807 + var context = item.getContext && item.getContext('2d'); + if (context instanceof CanvasRenderingContext2D) { + initCanvas(item, config); + return context; + } + } + + return null; + }; + + /** + * Restores the canvas initial state, such as render/display sizes and style. + * @method BrowserPlatform#releaseCanvas + * @implements IPlatform#releaseCanvas + */ + BrowserPlatform.prototype.releaseCanvas = function(canvas) { + if (!canvas._chartjs) { + return; + } + + var initial = canvas._chartjs.initial; + ['height', 'width'].forEach(function(prop) { + var value = initial[prop]; + if (value === undefined || value === null) { + canvas.removeAttribute(prop); + } else { + canvas.setAttribute(prop, value); + } + }); + + helpers.each(initial.style || {}, function(value, key) { + canvas.style[key] = value; + }); + + // The canvas render size might have been changed (and thus the state stack discarded), + // we can't use save() and restore() to restore the initial state. So make sure that at + // least the canvas context is reset to the default state by setting the canvas width. + // https://www.w3.org/TR/2011/WD-html5-20110525/the-canvas-element.html + canvas.width = canvas.width; + + delete canvas._chartjs; + }; + + return BrowserPlatform; +}; diff --git a/test/core.controller.tests.js b/test/core.controller.tests.js index 44e7d3d21ed..9de9afe774d 100644 --- a/test/core.controller.tests.js +++ b/test/core.controller.tests.js @@ -879,4 +879,59 @@ describe('Chart.Controller', function() { expect(chart.tooltip._options).toEqual(jasmine.objectContaining(newTooltipConfig)); }); }); + + describe('event handling', function() { + it('should notify plugins about events', function() { + var notifiedEvent; + var plugin = { + onEvent: function(chart, e) { + notifiedEvent = e; + } + }; + var chart = acquireChart({ + type: 'line', + data: { + labels: ['A', 'B', 'C', 'D'], + datasets: [{ + data: [10, 20, 30, 100] + }] + }, + options: { + responsive: true + }, + plugins: [plugin] + }); + + var node = chart.chart.canvas; + var rect = node.getBoundingClientRect(); + var clientX = (rect.left + rect.right) / 2; + var clientY = (rect.top + rect.bottom) / 2; + + var evt = new MouseEvent('click', { + view: window, + bubbles: true, + cancelable: true, + clientX: clientX, + clientY: clientY + }); + + // Manually trigger rather than having an async test + node.dispatchEvent(evt); + + // Check that notifiedEvent is correct + expect(notifiedEvent).not.toBe(undefined); + expect(notifiedEvent.native).toBe(evt); + + // Is type correctly translated + expect(notifiedEvent.type).toBe(evt.type); + + // Canvas width and height + expect(notifiedEvent.width).toBe(chart.chart.width); + expect(notifiedEvent.height).toBe(chart.chart.height); + + // Relative Position + expect(notifiedEvent.x).toBe(chart.chart.width / 2); + expect(notifiedEvent.y).toBe(chart.chart.height / 2); + }); + }); });