'use strict'; module.exports = function(Chart) { var helpers = Chart.helpers; // Create a dictionary of chart types, to allow for extension of existing types Chart.types = {}; // Store a reference to each instance - allowing us to globally resize chart instances on window resize. // Destroy method on the chart will remove the instance of the chart from this reference. Chart.instances = {}; // 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; }); delete canvas._chartjs; } /** * Initializes the given config with global and chart default values. */ function initConfig(config) { config = config || {}; // Do NOT use configMerge() for the data object because this method merges arrays // and so would change references to labels and datasets, preventing data updates. var data = config.data = config.data || {}; data.datasets = data.datasets || []; data.labels = data.labels || []; config.options = helpers.configMerge( Chart.defaults.global, Chart.defaults[config.type], config.options || {}); return config; } /** * @class Chart.Controller * The main controller of a chart. */ Chart.Controller = function(context, config, instance) { var me = this; var canvas; config = initConfig(config); canvas = initCanvas(context.canvas, config); instance.ctx = context; instance.canvas = canvas; instance.config = config; instance.width = canvas.width; instance.height = canvas.height; instance.aspectRatio = canvas.width / canvas.height; helpers.retinaScale(instance); me.id = helpers.uid(); me.chart = instance; me.config = config; me.options = config.options; // Add the chart instance to the global namespace Chart.instances[me.id] = me; Object.defineProperty(me, 'data', { get: function() { return me.config.data; } }); // Responsiveness is currently based on the use of an iframe, however this method causes // performance issues and could be troublesome when used with ad blockers. So make sure // that the user is still able to create a chart without iframe when responsive is false. // See https://github.com/chartjs/Chart.js/issues/2210 if (me.options.responsive) { helpers.addResizeListener(canvas.parentNode, function() { me.resize(); }); // Initial resize before chart draws (must be silent to preserve initial animations). me.resize(true); } me.initialize(); return me; }; helpers.extend(Chart.Controller.prototype, /** @lends Chart.Controller */ { initialize: function() { var me = this; // Before init plugin notification Chart.plugins.notify('beforeInit', [me]); me.bindEvents(); // Make sure controllers are built first so that each dataset is bound to an axis before the scales // are built me.ensureScalesHaveIDs(); me.buildOrUpdateControllers(); me.buildScales(); me.updateLayout(); me.resetElements(); me.initToolTip(); me.update(); // After init plugin notification Chart.plugins.notify('afterInit', [me]); return me; }, clear: function() { helpers.clear(this.chart); return this; }, stop: function() { // Stops any current animation loop occuring Chart.animationService.cancelAnimation(this); return this; }, resize: function(silent) { var me = this; var chart = me.chart; var options = me.options; var canvas = chart.canvas; var aspectRatio = (options.maintainAspectRatio && chart.aspectRatio) || null; // the canvas render width and height will be casted to integers so make sure that // the canvas display style uses the same integer values to avoid blurring effect. var newWidth = Math.floor(helpers.getMaximumWidth(canvas)); var newHeight = Math.floor(aspectRatio? newWidth / aspectRatio : helpers.getMaximumHeight(canvas)); if (chart.width === newWidth && chart.height === newHeight) { return; } canvas.width = chart.width = newWidth; canvas.height = chart.height = newHeight; helpers.retinaScale(chart); canvas.style.width = newWidth + 'px'; canvas.style.height = newHeight + 'px'; // Notify any plugins about the resize var newSize = {width: newWidth, height: newHeight}; Chart.plugins.notify('resize', [me, newSize]); // Notify of resize if (me.options.onResize) { me.options.onResize(me, newSize); } if (!silent) { me.stop(); me.update(me.options.responsiveAnimationDuration); } }, ensureScalesHaveIDs: function() { var options = this.options; var scalesOptions = options.scales || {}; var scaleOptions = options.scale; helpers.each(scalesOptions.xAxes, function(xAxisOptions, index) { xAxisOptions.id = xAxisOptions.id || ('x-axis-' + index); }); helpers.each(scalesOptions.yAxes, function(yAxisOptions, index) { yAxisOptions.id = yAxisOptions.id || ('y-axis-' + index); }); if (scaleOptions) { scaleOptions.id = scaleOptions.id || 'scale'; } }, /** * Builds a map of scale ID to scale object for future lookup. */ buildScales: function() { var me = this; var options = me.options; var scales = me.scales = {}; var items = []; if (options.scales) { items = items.concat( (options.scales.xAxes || []).map(function(xAxisOptions) { return {options: xAxisOptions, dtype: 'category'}; }), (options.scales.yAxes || []).map(function(yAxisOptions) { return {options: yAxisOptions, dtype: 'linear'}; }) ); } if (options.scale) { items.push({options: options.scale, dtype: 'radialLinear', isDefault: true}); } helpers.each(items, function(item) { var scaleOptions = item.options; var scaleType = helpers.getValueOrDefault(scaleOptions.type, item.dtype); var scaleClass = Chart.scaleService.getScaleConstructor(scaleType); if (!scaleClass) { return; } var scale = new scaleClass({ id: scaleOptions.id, options: scaleOptions, ctx: me.chart.ctx, chart: me }); scales[scale.id] = scale; // TODO(SB): I think we should be able to remove this custom case (options.scale) // and consider it as a regular scale part of the "scales"" map only! This would // make the logic easier and remove some useless? custom code. if (item.isDefault) { me.scale = scale; } }); Chart.scaleService.addScalesToLayout(this); }, updateLayout: function() { Chart.layoutService.update(this, this.chart.width, this.chart.height); }, buildOrUpdateControllers: function() { var me = this; var types = []; var newControllers = []; helpers.each(me.data.datasets, function(dataset, datasetIndex) { var meta = me.getDatasetMeta(datasetIndex); if (!meta.type) { meta.type = dataset.type || me.config.type; } types.push(meta.type); if (meta.controller) { meta.controller.updateIndex(datasetIndex); } else { meta.controller = new Chart.controllers[meta.type](me, datasetIndex); newControllers.push(meta.controller); } }, me); if (types.length > 1) { for (var i = 1; i < types.length; i++) { if (types[i] !== types[i - 1]) { me.isCombo = true; break; } } } return newControllers; }, resetElements: function() { var me = this; helpers.each(me.data.datasets, function(dataset, datasetIndex) { me.getDatasetMeta(datasetIndex).controller.reset(); }, me); }, update: function(animationDuration, lazy) { var me = this; Chart.plugins.notify('beforeUpdate', [me]); // In case the entire data object changed me.tooltip._data = me.data; // Make sure dataset controllers are updated and new controllers are reset var newControllers = me.buildOrUpdateControllers(); // Make sure all dataset controllers have correct meta data counts helpers.each(me.data.datasets, function(dataset, datasetIndex) { me.getDatasetMeta(datasetIndex).controller.buildOrUpdateElements(); }, me); Chart.layoutService.update(me, me.chart.width, me.chart.height); // Apply changes to the dataets that require the scales to have been calculated i.e BorderColor chages Chart.plugins.notify('afterScaleUpdate', [me]); // Can only reset the new controllers after the scales have been updated helpers.each(newControllers, function(controller) { controller.reset(); }); me.updateDatasets(); // Do this before render so that any plugins that need final scale updates can use it Chart.plugins.notify('afterUpdate', [me]); me.render(animationDuration, lazy); }, /** * @method beforeDatasetsUpdate * @description Called before all datasets are updated. If a plugin returns false, * the datasets update will be cancelled until another chart update is triggered. * @param {Object} instance the chart instance being updated. * @returns {Boolean} false to cancel the datasets update. * @memberof Chart.PluginBase * @since version 2.1.5 * @instance */ /** * @method afterDatasetsUpdate * @description Called after all datasets have been updated. Note that this * extension will not be called if the datasets update has been cancelled. * @param {Object} instance the chart instance being updated. * @memberof Chart.PluginBase * @since version 2.1.5 * @instance */ /** * Updates all datasets unless a plugin returns false to the beforeDatasetsUpdate * extension, in which case no datasets will be updated and the afterDatasetsUpdate * notification will be skipped. * @protected * @instance */ updateDatasets: function() { var me = this; var i, ilen; if (Chart.plugins.notify('beforeDatasetsUpdate', [me])) { for (i = 0, ilen = me.data.datasets.length; i < ilen; ++i) { me.getDatasetMeta(i).controller.update(); } Chart.plugins.notify('afterDatasetsUpdate', [me]); } }, render: function(duration, lazy) { var me = this; Chart.plugins.notify('beforeRender', [me]); var animationOptions = me.options.animation; if (animationOptions && ((typeof duration !== 'undefined' && duration !== 0) || (typeof duration === 'undefined' && animationOptions.duration !== 0))) { var animation = new Chart.Animation(); animation.numSteps = (duration || animationOptions.duration) / 16.66; // 60 fps animation.easing = animationOptions.easing; // render function animation.render = function(chartInstance, animationObject) { var easingFunction = helpers.easingEffects[animationObject.easing]; var stepDecimal = animationObject.currentStep / animationObject.numSteps; var easeDecimal = easingFunction(stepDecimal); chartInstance.draw(easeDecimal, stepDecimal, animationObject.currentStep); }; // user events animation.onAnimationProgress = animationOptions.onProgress; animation.onAnimationComplete = animationOptions.onComplete; Chart.animationService.addAnimation(me, animation, duration, lazy); } else { me.draw(); if (animationOptions && animationOptions.onComplete && animationOptions.onComplete.call) { animationOptions.onComplete.call(me); } } return me; }, draw: function(ease) { var me = this; var easingDecimal = ease || 1; me.clear(); Chart.plugins.notify('beforeDraw', [me, easingDecimal]); // Draw all the scales helpers.each(me.boxes, function(box) { box.draw(me.chartArea); }, me); if (me.scale) { me.scale.draw(); } Chart.plugins.notify('beforeDatasetsDraw', [me, easingDecimal]); // Draw each dataset via its respective controller (reversed to support proper line stacking) helpers.each(me.data.datasets, function(dataset, datasetIndex) { if (me.isDatasetVisible(datasetIndex)) { me.getDatasetMeta(datasetIndex).controller.draw(ease); } }, me, true); Chart.plugins.notify('afterDatasetsDraw', [me, easingDecimal]); // Finally draw the tooltip me.tooltip.transition(easingDecimal).draw(); Chart.plugins.notify('afterDraw', [me, easingDecimal]); }, // Get the single element that was clicked on // @return : An object containing the dataset index and element index of the matching element. Also contains the rectangle that was draw getElementAtEvent: function(e) { var me = this; var eventPosition = helpers.getRelativePosition(e, me.chart); var elementsArray = []; helpers.each(me.data.datasets, function(dataset, datasetIndex) { if (me.isDatasetVisible(datasetIndex)) { var meta = me.getDatasetMeta(datasetIndex); helpers.each(meta.data, function(element) { if (element.inRange(eventPosition.x, eventPosition.y)) { elementsArray.push(element); return elementsArray; } }); } }); return elementsArray.slice(0, 1); }, getElementsAtEvent: function(e) { var me = this; var eventPosition = helpers.getRelativePosition(e, me.chart); var elementsArray = []; var found = function() { if (me.data.datasets) { for (var i = 0; i < me.data.datasets.length; i++) { var meta = me.getDatasetMeta(i); if (me.isDatasetVisible(i)) { for (var j = 0; j < meta.data.length; j++) { if (meta.data[j].inRange(eventPosition.x, eventPosition.y)) { return meta.data[j]; } } } } } }.call(me); if (!found) { return elementsArray; } helpers.each(me.data.datasets, function(dataset, datasetIndex) { if (me.isDatasetVisible(datasetIndex)) { var meta = me.getDatasetMeta(datasetIndex), element = meta.data[found._index]; if (element && !element._view.skip) { elementsArray.push(element); } } }, me); return elementsArray; }, getElementsAtXAxis: function(e) { var me = this; var eventPosition = helpers.getRelativePosition(e, me.chart); var elementsArray = []; var found = function() { if (me.data.datasets) { for (var i = 0; i < me.data.datasets.length; i++) { var meta = me.getDatasetMeta(i); if (me.isDatasetVisible(i)) { for (var j = 0; j < meta.data.length; j++) { if (meta.data[j].inLabelRange(eventPosition.x, eventPosition.y)) { return meta.data[j]; } } } } } }.call(me); if (!found) { return elementsArray; } helpers.each(me.data.datasets, function(dataset, datasetIndex) { if (me.isDatasetVisible(datasetIndex)) { var meta = me.getDatasetMeta(datasetIndex); var index = helpers.findIndex(meta.data, function(it) { return found._model.x === it._model.x; }); if (index !== -1 && !meta.data[index]._view.skip) { elementsArray.push(meta.data[index]); } } }, me); return elementsArray; }, getElementsAtEventForMode: function(e, mode) { var me = this; switch (mode) { case 'single': return me.getElementAtEvent(e); case 'label': return me.getElementsAtEvent(e); case 'dataset': return me.getDatasetAtEvent(e); case 'x-axis': return me.getElementsAtXAxis(e); default: return e; } }, getDatasetAtEvent: function(e) { var elementsArray = this.getElementAtEvent(e); if (elementsArray.length > 0) { elementsArray = this.getDatasetMeta(elementsArray[0]._datasetIndex).data; } return elementsArray; }, getDatasetMeta: function(datasetIndex) { var me = this; var dataset = me.data.datasets[datasetIndex]; if (!dataset._meta) { dataset._meta = {}; } var meta = dataset._meta[me.id]; if (!meta) { meta = dataset._meta[me.id] = { type: null, data: [], dataset: null, controller: null, hidden: null, // See isDatasetVisible() comment xAxisID: null, yAxisID: null }; } return meta; }, getVisibleDatasetCount: function() { var count = 0; for (var i = 0, ilen = this.data.datasets.length; i