import animator from './core.animator'; import defaults, {overrides} from './core.defaults'; import Interaction from './core.interaction'; import layouts from './core.layouts'; import {_detectPlatform} from '../platform'; import PluginService from './core.plugins'; import registry from './core.registry'; import Config, {determineAxis, getIndexAxis} from './core.config'; import {retinaScale, _isDomSupported} from '../helpers/helpers.dom'; import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual, defined, isFunction, _isClickEvent} from '../helpers/helpers.core'; import {clearCanvas, clipArea, createContext, unclipArea, _isPointInArea} from '../helpers'; // @ts-ignore import {version} from '../../package.json'; import {debounce} from '../helpers/helpers.extras'; /** * @typedef { import('../../types/index.esm').ChartEvent } ChartEvent */ const KNOWN_POSITIONS = ['top', 'bottom', 'left', 'right', 'chartArea']; function positionIsHorizontal(position, axis) { return position === 'top' || position === 'bottom' || (KNOWN_POSITIONS.indexOf(position) === -1 && axis === 'x'); } function compare2Level(l1, l2) { return function(a, b) { return a[l1] === b[l1] ? a[l2] - b[l2] : a[l1] - b[l1]; }; } function onAnimationsComplete(context) { const chart = context.chart; const animationOptions = chart.options.animation; chart.notifyPlugins('afterRender'); callCallback(animationOptions && animationOptions.onComplete, [context], chart); } function onAnimationProgress(context) { const chart = context.chart; const animationOptions = chart.options.animation; callCallback(animationOptions && animationOptions.onProgress, [context], chart); } /** * Chart.js can take a string id of a canvas element, a 2d context, or a canvas element itself. * Attempt to unwrap the item passed into the chart constructor so that it is a canvas element (if possible). */ function getCanvas(item) { if (_isDomSupported() && typeof item === 'string') { item = document.getElementById(item); } else if (item && 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; } return item; } const instances = {}; const getChart = (key) => { const canvas = getCanvas(key); return Object.values(instances).filter((c) => c.canvas === canvas).pop(); }; function moveNumericKeys(obj, start, move) { const keys = Object.keys(obj); for (const key of keys) { const intKey = +key; if (intKey >= start) { const value = obj[key]; delete obj[key]; if (move > 0 || intKey > start) { obj[intKey + move] = value; } } } } /** * @param {ChartEvent} e * @param {ChartEvent|null} lastEvent * @param {boolean} inChartArea * @param {boolean} isClick * @returns {ChartEvent|null} */ function determineLastEvent(e, lastEvent, inChartArea, isClick) { if (!inChartArea || e.type === 'mouseout') { return null; } if (isClick) { return lastEvent; } return e; } class Chart { // eslint-disable-next-line max-statements constructor(item, userConfig) { const config = this.config = new Config(userConfig); const initialCanvas = getCanvas(item); const existingChart = getChart(initialCanvas); if (existingChart) { throw new Error( 'Canvas is already in use. Chart with ID \'' + existingChart.id + '\'' + ' must be destroyed before the canvas can be reused.' ); } const options = config.createResolver(config.chartOptionScopes(), this.getContext()); this.platform = new (config.platform || _detectPlatform(initialCanvas))(); this.platform.updateConfig(config); const context = this.platform.acquireContext(initialCanvas, options.aspectRatio); const canvas = context && context.canvas; const height = canvas && canvas.height; const width = canvas && canvas.width; this.id = uid(); this.ctx = context; this.canvas = canvas; this.width = width; this.height = height; this._options = options; // Store the previously used aspect ratio to determine if a resize // is needed during updates. Do this after _options is set since // aspectRatio uses a getter this._aspectRatio = this.aspectRatio; this._layers = []; this._metasets = []; this._stacks = undefined; this.boxes = []; this.currentDevicePixelRatio = undefined; this.chartArea = undefined; this._active = []; this._lastEvent = undefined; this._listeners = {}; /** @type {?{attach?: function, detach?: function, resize?: function}} */ this._responsiveListeners = undefined; this._sortedMetasets = []; this.scales = {}; this._plugins = new PluginService(); this.$proxies = {}; this._hiddenIndices = {}; this.attached = false; this._animationsDisabled = undefined; this.$context = undefined; this._doResize = debounce(mode => this.update(mode), options.resizeDelay || 0); this._dataChanges = []; // Add the chart instance to the global namespace instances[this.id] = this; if (!context || !canvas) { // The given item is not a compatible context2d element, let's return before finalizing // the chart initialization but after setting basic chart / controller properties that // can help to figure out that the chart is not valid (e.g chart.canvas !== null); // https://github.com/chartjs/Chart.js/issues/2807 console.error("Failed to create chart: can't acquire context from the given item"); return; } animator.listen(this, 'complete', onAnimationsComplete); animator.listen(this, 'progress', onAnimationProgress); this._initialize(); if (this.attached) { this.update(); } } get aspectRatio() { const {options: {aspectRatio, maintainAspectRatio}, width, height, _aspectRatio} = this; if (!isNullOrUndef(aspectRatio)) { // If aspectRatio is defined in options, use that. return aspectRatio; } if (maintainAspectRatio && _aspectRatio) { // If maintainAspectRatio is truthly and we had previously determined _aspectRatio, use that return _aspectRatio; } // Calculate return height ? width / height : null; } get data() { return this.config.data; } set data(data) { this.config.data = data; } get options() { return this._options; } set options(options) { this.config.options = options; } /** * @private */ _initialize() { // Before init plugin notification this.notifyPlugins('beforeInit'); if (this.options.responsive) { this.resize(); } else { retinaScale(this, this.options.devicePixelRatio); } this.bindEvents(); // After init plugin notification this.notifyPlugins('afterInit'); return this; } clear() { clearCanvas(this.canvas, this.ctx); return this; } stop() { animator.stop(this); return this; } /** * Resize the chart to its container or to explicit dimensions. * @param {number} [width] * @param {number} [height] */ resize(width, height) { if (!animator.running(this)) { this._resize(width, height); } else { this._resizeBeforeDraw = {width, height}; } } _resize(width, height) { const options = this.options; const canvas = this.canvas; const aspectRatio = options.maintainAspectRatio && this.aspectRatio; const newSize = this.platform.getMaximumSize(canvas, width, height, aspectRatio); const newRatio = options.devicePixelRatio || this.platform.getDevicePixelRatio(); const mode = this.width ? 'resize' : 'attach'; this.width = newSize.width; this.height = newSize.height; this._aspectRatio = this.aspectRatio; if (!retinaScale(this, newRatio, true)) { return; } this.notifyPlugins('resize', {size: newSize}); callCallback(options.onResize, [this, newSize], this); if (this.attached) { if (this._doResize(mode)) { // The resize update is delayed, only draw without updating. this.render(); } } } ensureScalesHaveIDs() { const options = this.options; const scalesOptions = options.scales || {}; each(scalesOptions, (axisOptions, axisID) => { axisOptions.id = axisID; }); } /** * Builds a map of scale ID to scale object for future lookup. */ buildOrUpdateScales() { const options = this.options; const scaleOpts = options.scales; const scales = this.scales; const updated = Object.keys(scales).reduce((obj, id) => { obj[id] = false; return obj; }, {}); let items = []; if (scaleOpts) { items = items.concat( Object.keys(scaleOpts).map((id) => { const scaleOptions = scaleOpts[id]; const axis = determineAxis(id, scaleOptions); const isRadial = axis === 'r'; const isHorizontal = axis === 'x'; return { options: scaleOptions, dposition: isRadial ? 'chartArea' : isHorizontal ? 'bottom' : 'left', dtype: isRadial ? 'radialLinear' : isHorizontal ? 'category' : 'linear' }; }) ); } each(items, (item) => { const scaleOptions = item.options; const id = scaleOptions.id; const axis = determineAxis(id, scaleOptions); const scaleType = valueOrDefault(scaleOptions.type, item.dtype); if (scaleOptions.position === undefined || positionIsHorizontal(scaleOptions.position, axis) !== positionIsHorizontal(item.dposition)) { scaleOptions.position = item.dposition; } updated[id] = true; let scale = null; if (id in scales && scales[id].type === scaleType) { scale = scales[id]; } else { const scaleClass = registry.getScale(scaleType); scale = new scaleClass({ id, type: scaleType, ctx: this.ctx, chart: this }); scales[scale.id] = scale; } scale.init(scaleOptions, options); }); // clear up discarded scales each(updated, (hasUpdated, id) => { if (!hasUpdated) { delete scales[id]; } }); each(scales, (scale) => { layouts.configure(this, scale, scale.options); layouts.addBox(this, scale); }); } /** * @private */ _updateMetasets() { const metasets = this._metasets; const numData = this.data.datasets.length; const numMeta = metasets.length; metasets.sort((a, b) => a.index - b.index); if (numMeta > numData) { for (let i = numData; i < numMeta; ++i) { this._destroyDatasetMeta(i); } metasets.splice(numData, numMeta - numData); } this._sortedMetasets = metasets.slice(0).sort(compare2Level('order', 'index')); } /** * @private */ _removeUnreferencedMetasets() { const {_metasets: metasets, data: {datasets}} = this; if (metasets.length > datasets.length) { delete this._stacks; } metasets.forEach((meta, index) => { if (datasets.filter(x => x === meta._dataset).length === 0) { this._destroyDatasetMeta(index); } }); } buildOrUpdateControllers() { const newControllers = []; const datasets = this.data.datasets; let i, ilen; this._removeUnreferencedMetasets(); for (i = 0, ilen = datasets.length; i < ilen; i++) { const dataset = datasets[i]; let meta = this.getDatasetMeta(i); const type = dataset.type || this.config.type; if (meta.type && meta.type !== type) { this._destroyDatasetMeta(i); meta = this.getDatasetMeta(i); } meta.type = type; meta.indexAxis = dataset.indexAxis || getIndexAxis(type, this.options); meta.order = dataset.order || 0; meta.index = i; meta.label = '' + dataset.label; meta.visible = this.isDatasetVisible(i); if (meta.controller) { meta.controller.updateIndex(i); meta.controller.linkScales(); } else { const ControllerClass = registry.getController(type); const {datasetElementType, dataElementType} = defaults.datasets[type]; Object.assign(ControllerClass.prototype, { dataElementType: registry.getElement(dataElementType), datasetElementType: datasetElementType && registry.getElement(datasetElementType) }); meta.controller = new ControllerClass(this, i); newControllers.push(meta.controller); } } this._updateMetasets(); return newControllers; } /** * Reset the elements of all datasets * @private */ _resetElements() { each(this.data.datasets, (dataset, datasetIndex) => { this.getDatasetMeta(datasetIndex).controller.reset(); }, this); } /** * Resets the chart back to its state before the initial animation */ reset() { this._resetElements(); this.notifyPlugins('reset'); } update(mode) { const config = this.config; config.update(); const options = this._options = config.createResolver(config.chartOptionScopes(), this.getContext()); const animsDisabled = this._animationsDisabled = !options.animation; this._updateScales(); this._checkEventBindings(); this._updateHiddenIndices(); // plugins options references might have change, let's invalidate the cache // https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167 this._plugins.invalidate(); if (this.notifyPlugins('beforeUpdate', {mode, cancelable: true}) === false) { return; } // Make sure dataset controllers are updated and new controllers are reset const newControllers = this.buildOrUpdateControllers(); this.notifyPlugins('beforeElementsUpdate'); // Make sure all dataset controllers have correct meta data counts let minPadding = 0; for (let i = 0, ilen = this.data.datasets.length; i < ilen; i++) { const {controller} = this.getDatasetMeta(i); const reset = !animsDisabled && newControllers.indexOf(controller) === -1; // New controllers will be reset after the layout pass, so we only want to modify // elements added to new datasets controller.buildOrUpdateElements(reset); minPadding = Math.max(+controller.getMaxOverflow(), minPadding); } minPadding = this._minPadding = options.layout.autoPadding ? minPadding : 0; this._updateLayout(minPadding); // Only reset the controllers if we have animations if (!animsDisabled) { // Can only reset the new controllers after the scales have been updated // Reset is done to get the starting point for the initial animation each(newControllers, (controller) => { controller.reset(); }); } this._updateDatasets(mode); // Do this before render so that any plugins that need final scale updates can use it this.notifyPlugins('afterUpdate', {mode}); this._layers.sort(compare2Level('z', '_idx')); // Replay last event from before update, or set hover styles on active elements const {_active, _lastEvent} = this; if (_lastEvent) { this._eventHandler(_lastEvent, true); } else if (_active.length) { this._updateHoverStyles(_active, _active, true); } this.render(); } /** * @private */ _updateScales() { each(this.scales, (scale) => { layouts.removeBox(this, scale); }); this.ensureScalesHaveIDs(); this.buildOrUpdateScales(); } /** * @private */ _checkEventBindings() { const options = this.options; const existingEvents = new Set(Object.keys(this._listeners)); const newEvents = new Set(options.events); if (!setsEqual(existingEvents, newEvents) || !!this._responsiveListeners !== options.responsive) { // The configured events have changed. Rebind. this.unbindEvents(); this.bindEvents(); } } /** * @private */ _updateHiddenIndices() { const {_hiddenIndices} = this; const changes = this._getUniformDataChanges() || []; for (const {method, start, count} of changes) { const move = method === '_removeElements' ? -count : count; moveNumericKeys(_hiddenIndices, start, move); } } /** * @private */ _getUniformDataChanges() { const _dataChanges = this._dataChanges; if (!_dataChanges || !_dataChanges.length) { return; } this._dataChanges = []; const datasetCount = this.data.datasets.length; const makeSet = (idx) => new Set( _dataChanges .filter(c => c[0] === idx) .map((c, i) => i + ',' + c.splice(1).join(',')) ); const changeSet = makeSet(0); for (let i = 1; i < datasetCount; i++) { if (!setsEqual(changeSet, makeSet(i))) { return; } } return Array.from(changeSet) .map(c => c.split(',')) .map(a => ({method: a[1], start: +a[2], count: +a[3]})); } /** * Updates the chart layout unless a plugin returns `false` to the `beforeLayout` * hook, in which case, plugins will not be called on `afterLayout`. * @private */ _updateLayout(minPadding) { if (this.notifyPlugins('beforeLayout', {cancelable: true}) === false) { return; } layouts.update(this, this.width, this.height, minPadding); const area = this.chartArea; const noArea = area.width <= 0 || area.height <= 0; this._layers = []; each(this.boxes, (box) => { if (noArea && box.position === 'chartArea') { // Skip drawing and configuring chartArea boxes when chartArea is zero or negative return; } // configure is called twice, once in core.scale.update and once here. // Here the boxes are fully updated and at their final positions. if (box.configure) { box.configure(); } this._layers.push(...box._layers()); }, this); this._layers.forEach((item, index) => { item._idx = index; }); this.notifyPlugins('afterLayout'); } /** * Updates all datasets unless a plugin returns `false` to the `beforeDatasetsUpdate` * hook, in which case, plugins will not be called on `afterDatasetsUpdate`. * @private */ _updateDatasets(mode) { if (this.notifyPlugins('beforeDatasetsUpdate', {mode, cancelable: true}) === false) { return; } for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this.getDatasetMeta(i).controller.configure(); } for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this._updateDataset(i, isFunction(mode) ? mode({datasetIndex: i}) : mode); } this.notifyPlugins('afterDatasetsUpdate', {mode}); } /** * Updates dataset at index unless a plugin returns `false` to the `beforeDatasetUpdate` * hook, in which case, plugins will not be called on `afterDatasetUpdate`. * @private */ _updateDataset(index, mode) { const meta = this.getDatasetMeta(index); const args = {meta, index, mode, cancelable: true}; if (this.notifyPlugins('beforeDatasetUpdate', args) === false) { return; } meta.controller._update(mode); args.cancelable = false; this.notifyPlugins('afterDatasetUpdate', args); } render() { if (this.notifyPlugins('beforeRender', {cancelable: true}) === false) { return; } if (animator.has(this)) { if (this.attached && !animator.running(this)) { animator.start(this); } } else { this.draw(); onAnimationsComplete({chart: this}); } } draw() { let i; if (this._resizeBeforeDraw) { const {width, height} = this._resizeBeforeDraw; this._resize(width, height); this._resizeBeforeDraw = null; } this.clear(); if (this.width <= 0 || this.height <= 0) { return; } if (this.notifyPlugins('beforeDraw', {cancelable: true}) === false) { return; } // Because of plugin hooks (before/afterDatasetsDraw), datasets can't // currently be part of layers. Instead, we draw // layers <= 0 before(default, backward compat), and the rest after const layers = this._layers; for (i = 0; i < layers.length && layers[i].z <= 0; ++i) { layers[i].draw(this.chartArea); } this._drawDatasets(); // Rest of layers for (; i < layers.length; ++i) { layers[i].draw(this.chartArea); } this.notifyPlugins('afterDraw'); } /** * @private */ _getSortedDatasetMetas(filterVisible) { const metasets = this._sortedMetasets; const result = []; let i, ilen; for (i = 0, ilen = metasets.length; i < ilen; ++i) { const meta = metasets[i]; if (!filterVisible || meta.visible) { result.push(meta); } } return result; } /** * Gets the visible dataset metas in drawing order * @return {object[]} */ getSortedVisibleDatasetMetas() { return this._getSortedDatasetMetas(true); } /** * Draws all datasets unless a plugin returns `false` to the `beforeDatasetsDraw` * hook, in which case, plugins will not be called on `afterDatasetsDraw`. * @private */ _drawDatasets() { if (this.notifyPlugins('beforeDatasetsDraw', {cancelable: true}) === false) { return; } const metasets = this.getSortedVisibleDatasetMetas(); for (let i = metasets.length - 1; i >= 0; --i) { this._drawDataset(metasets[i]); } this.notifyPlugins('afterDatasetsDraw'); } /** * Draws dataset at index unless a plugin returns `false` to the `beforeDatasetDraw` * hook, in which case, plugins will not be called on `afterDatasetDraw`. * @private */ _drawDataset(meta) { const ctx = this.ctx; const clip = meta._clip; const useClip = !clip.disabled; const area = this.chartArea; const args = { meta, index: meta.index, cancelable: true }; if (this.notifyPlugins('beforeDatasetDraw', args) === false) { return; } if (useClip) { clipArea(ctx, { left: clip.left === false ? 0 : area.left - clip.left, right: clip.right === false ? this.width : area.right + clip.right, top: clip.top === false ? 0 : area.top - clip.top, bottom: clip.bottom === false ? this.height : area.bottom + clip.bottom }); } meta.controller.draw(); if (useClip) { unclipArea(ctx); } args.cancelable = false; this.notifyPlugins('afterDatasetDraw', args); } getElementsAtEventForMode(e, mode, options, useFinalPosition) { const method = Interaction.modes[mode]; if (typeof method === 'function') { return method(this, e, options, useFinalPosition); } return []; } getDatasetMeta(datasetIndex) { const dataset = this.data.datasets[datasetIndex]; const metasets = this._metasets; let meta = metasets.filter(x => x && x._dataset === dataset).pop(); if (!meta) { meta = { type: null, data: [], dataset: null, controller: null, hidden: null, // See isDatasetVisible() comment xAxisID: null, yAxisID: null, order: dataset && dataset.order || 0, index: datasetIndex, _dataset: dataset, _parsed: [], _sorted: false }; metasets.push(meta); } return meta; } getContext() { return this.$context || (this.$context = createContext(null, {chart: this, type: 'chart'})); } getVisibleDatasetCount() { return this.getSortedVisibleDatasetMetas().length; } isDatasetVisible(datasetIndex) { const dataset = this.data.datasets[datasetIndex]; if (!dataset) { return false; } const meta = this.getDatasetMeta(datasetIndex); // meta.hidden is a per chart dataset hidden flag override with 3 states: if true or false, // the dataset.hidden value is ignored, else if null, the dataset hidden state is returned. return typeof meta.hidden === 'boolean' ? !meta.hidden : !dataset.hidden; } setDatasetVisibility(datasetIndex, visible) { const meta = this.getDatasetMeta(datasetIndex); meta.hidden = !visible; } toggleDataVisibility(index) { this._hiddenIndices[index] = !this._hiddenIndices[index]; } getDataVisibility(index) { return !this._hiddenIndices[index]; } /** * @private */ _updateVisibility(datasetIndex, dataIndex, visible) { const mode = visible ? 'show' : 'hide'; const meta = this.getDatasetMeta(datasetIndex); const anims = meta.controller._resolveAnimations(undefined, mode); if (defined(dataIndex)) { meta.data[dataIndex].hidden = !visible; this.update(); } else { this.setDatasetVisibility(datasetIndex, visible); // Animate visible state, so hide animation can be seen. This could be handled better if update / updateDataset returned a Promise. anims.update(meta, {visible}); this.update((ctx) => ctx.datasetIndex === datasetIndex ? mode : undefined); } } hide(datasetIndex, dataIndex) { this._updateVisibility(datasetIndex, dataIndex, false); } show(datasetIndex, dataIndex) { this._updateVisibility(datasetIndex, dataIndex, true); } /** * @private */ _destroyDatasetMeta(datasetIndex) { const meta = this._metasets[datasetIndex]; if (meta && meta.controller) { meta.controller._destroy(); } delete this._metasets[datasetIndex]; } _stop() { let i, ilen; this.stop(); animator.remove(this); for (i = 0, ilen = this.data.datasets.length; i < ilen; ++i) { this._destroyDatasetMeta(i); } } destroy() { this.notifyPlugins('beforeDestroy'); const {canvas, ctx} = this; this._stop(); this.config.clearCache(); if (canvas) { this.unbindEvents(); clearCanvas(canvas, ctx); this.platform.releaseContext(ctx); this.canvas = null; this.ctx = null; } // TODO V4: delete destroy hook and reference to it in plugin flowchart this.notifyPlugins('destroy'); delete instances[this.id]; this.notifyPlugins('afterDestroy'); } toBase64Image(...args) { return this.canvas.toDataURL(...args); } /** * @private */ bindEvents() { this.bindUserEvents(); if (this.options.responsive) { this.bindResponsiveEvents(); } else { this.attached = true; } } /** * @private */ bindUserEvents() { const listeners = this._listeners; const platform = this.platform; const _add = (type, listener) => { platform.addEventListener(this, type, listener); listeners[type] = listener; }; const listener = (e, x, y) => { e.offsetX = x; e.offsetY = y; this._eventHandler(e); }; each(this.options.events, (type) => _add(type, listener)); } /** * @private */ bindResponsiveEvents() { if (!this._responsiveListeners) { this._responsiveListeners = {}; } const listeners = this._responsiveListeners; const platform = this.platform; const _add = (type, listener) => { platform.addEventListener(this, type, listener); listeners[type] = listener; }; const _remove = (type, listener) => { if (listeners[type]) { platform.removeEventListener(this, type, listener); delete listeners[type]; } }; const listener = (width, height) => { if (this.canvas) { this.resize(width, height); } }; let detached; // eslint-disable-line prefer-const const attached = () => { _remove('attach', attached); this.attached = true; this.resize(); _add('resize', listener); _add('detach', detached); }; detached = () => { this.attached = false; _remove('resize', listener); // Stop animating and remove metasets, so when re-attached, the animations start from beginning. this._stop(); this._resize(0, 0); _add('attach', attached); }; if (platform.isAttached(this.canvas)) { attached(); } else { detached(); } } /** * @private */ unbindEvents() { each(this._listeners, (listener, type) => { this.platform.removeEventListener(this, type, listener); }); this._listeners = {}; each(this._responsiveListeners, (listener, type) => { this.platform.removeEventListener(this, type, listener); }); this._responsiveListeners = undefined; } updateHoverStyle(items, mode, enabled) { const prefix = enabled ? 'set' : 'remove'; let meta, item, i, ilen; if (mode === 'dataset') { meta = this.getDatasetMeta(items[0].datasetIndex); meta.controller['_' + prefix + 'DatasetHoverStyle'](); } for (i = 0, ilen = items.length; i < ilen; ++i) { item = items[i]; const controller = item && this.getDatasetMeta(item.datasetIndex).controller; if (controller) { controller[prefix + 'HoverStyle'](item.element, item.datasetIndex, item.index); } } } /** * Get active (hovered) elements * @returns array */ getActiveElements() { return this._active || []; } /** * Set active (hovered) elements * @param {array} activeElements New active data points */ setActiveElements(activeElements) { const lastActive = this._active || []; const active = activeElements.map(({datasetIndex, index}) => { const meta = this.getDatasetMeta(datasetIndex); if (!meta) { throw new Error('No dataset found at index ' + datasetIndex); } return { datasetIndex, element: meta.data[index], index, }; }); const changed = !_elementsEqual(active, lastActive); if (changed) { this._active = active; // Make sure we don't use the previous mouse event to override the active elements in update. this._lastEvent = null; this._updateHoverStyles(active, lastActive); } } /** * Calls enabled plugins on the specified hook and with the given args. * This method immediately returns as soon as a plugin explicitly returns false. The * returned value can be used, for instance, to interrupt the current action. * @param {string} hook - The name of the plugin method to call (e.g. 'beforeUpdate'). * @param {Object} [args] - Extra arguments to apply to the hook call. * @param {import('./core.plugins').filterCallback} [filter] - Filtering function for limiting which plugins are notified * @returns {boolean} false if any of the plugins return false, else returns true. */ notifyPlugins(hook, args, filter) { return this._plugins.notify(this, hook, args, filter); } /** * @private */ _updateHoverStyles(active, lastActive, replay) { const hoverOptions = this.options.hover; const diff = (a, b) => a.filter(x => !b.some(y => x.datasetIndex === y.datasetIndex && x.index === y.index)); const deactivated = diff(lastActive, active); const activated = replay ? active : diff(active, lastActive); if (deactivated.length) { this.updateHoverStyle(deactivated, hoverOptions.mode, false); } if (activated.length && hoverOptions.mode) { this.updateHoverStyle(activated, hoverOptions.mode, true); } } /** * @private */ _eventHandler(e, replay) { const args = { event: e, replay, cancelable: true, inChartArea: _isPointInArea(e, this.chartArea, this._minPadding) }; const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.native.type); if (this.notifyPlugins('beforeEvent', args, eventFilter) === false) { return; } const changed = this._handleEvent(e, replay, args.inChartArea); args.cancelable = false; this.notifyPlugins('afterEvent', args, eventFilter); if (changed || args.changed) { this.render(); } return this; } /** * Handle an event * @param {ChartEvent} e the event to handle * @param {boolean} [replay] - true if the event was replayed by `update` * @param {boolean} [inChartArea] - true if the event is inside chartArea * @return {boolean} true if the chart needs to re-render * @private */ _handleEvent(e, replay, inChartArea) { const {_active: lastActive = [], options} = this; // If the event is replayed from `update`, we should evaluate with the final positions. // // The `replay`: // It's the last event (excluding click) that has occurred before `update`. // So mouse has not moved. It's also over the chart, because there is a `replay`. // // The why: // If animations are active, the elements haven't moved yet compared to state before update. // But if they will, we are activating the elements that would be active, if this check // was done after the animations have completed. => "final positions". // If there is no animations, the "final" and "current" positions are equal. // This is done so we do not have to evaluate the active elements each animation frame // - it would be expensive. const useFinalPosition = replay; const active = this._getActiveElements(e, lastActive, inChartArea, useFinalPosition); const isClick = _isClickEvent(e); const lastEvent = determineLastEvent(e, this._lastEvent, inChartArea, isClick); if (inChartArea) { // Set _lastEvent to null while we are processing the event handlers. // This prevents recursion if the handler calls chart.update() this._lastEvent = null; // Invoke onHover hook callCallback(options.onHover, [e, active, this], this); if (isClick) { callCallback(options.onClick, [e, active, this], this); } } const changed = !_elementsEqual(active, lastActive); if (changed || replay) { this._active = active; this._updateHoverStyles(active, lastActive, replay); } this._lastEvent = lastEvent; return changed; } /** * @param {ChartEvent} e - The event * @param {import('../../types/index.esm').ActiveElement[]} lastActive - Previously active elements * @param {boolean} inChartArea - Is the envent inside chartArea * @param {boolean} useFinalPosition - Should the evaluation be done with current or final (after animation) element positions * @returns {import('../../types/index.esm').ActiveElement[]} - The active elements * @pravate */ _getActiveElements(e, lastActive, inChartArea, useFinalPosition) { if (e.type === 'mouseout') { return []; } if (!inChartArea) { // Let user control the active elements outside chartArea. Eg. using Legend. return lastActive; } const hoverOptions = this.options.hover; return this.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition); } } // @ts-ignore const invalidatePlugins = () => each(Chart.instances, (chart) => chart._plugins.invalidate()); const enumerable = true; // These are available to both, UMD and ESM packages. Read Only! Object.defineProperties(Chart, { defaults: { enumerable, value: defaults }, instances: { enumerable, value: instances }, overrides: { enumerable, value: overrides }, registry: { enumerable, value: registry }, version: { enumerable, value: version }, getChart: { enumerable, value: getChart }, register: { enumerable, value: (...items) => { registry.add(...items); invalidatePlugins(); } }, unregister: { enumerable, value: (...items) => { registry.remove(...items); invalidatePlugins(); } } }); export default Chart;