import Animator from './core.animator'; import controllers from '../controllers/index'; import defaults from './core.defaults'; import helpers from '../helpers/index'; import Interaction from './core.interaction'; import layouts from './core.layouts'; import {BasicPlatform, DomPlatform} from '../platform/platforms'; import plugins from './core.plugins'; import scaleService from './core.scaleService'; import {getMaximumWidth, getMaximumHeight} from '../helpers/helpers.dom'; // @ts-ignore import {version} from '../../package.json'; /** * @typedef { import("../platform/platform.base").IEvent } IEvent */ const valueOrDefault = helpers.valueOrDefault; function mergeScaleConfig(config, options) { options = options || {}; const chartDefaults = defaults[config.type] || {scales: {}}; const configScales = options.scales || {}; const firstIDs = {}; const scales = {}; // First figure out first scale id's per axis. // Note: for now, axis is determined from first letter of scale id! Object.keys(configScales).forEach(id => { const axis = id[0]; firstIDs[axis] = firstIDs[axis] || id; scales[id] = helpers.mergeIf({}, [configScales[id], chartDefaults.scales[axis]]); }); // Backward compatibility if (options.scale) { scales[options.scale.id || 'r'] = helpers.mergeIf({}, [options.scale, chartDefaults.scales.r]); firstIDs.r = firstIDs.r || options.scale.id || 'r'; } // Then merge dataset defaults to scale configs config.data.datasets.forEach(dataset => { const datasetDefaults = defaults[dataset.type || config.type] || {scales: {}}; const defaultScaleOptions = datasetDefaults.scales || {}; Object.keys(defaultScaleOptions).forEach(defaultID => { const id = dataset[defaultID + 'AxisID'] || firstIDs[defaultID] || defaultID; scales[id] = scales[id] || {}; helpers.mergeIf(scales[id], [ configScales[id], defaultScaleOptions[defaultID] ]); }); }); // apply scale defaults, if not overridden by dataset defaults Object.keys(scales).forEach(key => { const scale = scales[key]; helpers.mergeIf(scale, scaleService.getScaleDefaults(scale.type)); }); return scales; } /** * Recursively merge the given config objects as the root options by handling * default scale options for the `scales` and `scale` properties, then returns * a deep copy of the result, thus doesn't alter inputs. */ function mergeConfig(...args/* config objects ... */) { return helpers.merge({}, args, { merger(key, target, source, options) { if (key !== 'scales' && key !== 'scale') { helpers._merger(key, target, source, options); } } }); } function initConfig(config) { config = config || {}; // Do NOT use mergeConfig for the data object because this method merges arrays // and so would change references to labels and datasets, preventing data updates. const data = config.data = config.data || {datasets: [], labels: []}; data.datasets = data.datasets || []; data.labels = data.labels || []; const scaleConfig = mergeScaleConfig(config, config.options); config.options = mergeConfig( defaults, defaults[config.type], config.options || {}); config.options.scales = scaleConfig; return config; } function isAnimationDisabled(config) { return !config.animation; } function updateConfig(chart) { let newOptions = chart.options; helpers.each(chart.scales, (scale) => { layouts.removeBox(chart, scale); }); const scaleConfig = mergeScaleConfig(chart.config, newOptions); newOptions = mergeConfig( defaults, defaults[chart.config.type], newOptions); chart.options = chart.config.options = newOptions; chart.options.scales = scaleConfig; chart._animationsDisabled = isAnimationDisabled(newOptions); } const KNOWN_POSITIONS = new Set(['top', 'bottom', 'left', 'right', 'chartArea']); function positionIsHorizontal(position, axis) { return position === 'top' || position === 'bottom' || (!KNOWN_POSITIONS.has(position) && 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(ctx) { const chart = ctx.chart; const animationOptions = chart.options.animation; plugins.notify(chart, 'afterRender'); helpers.callback(animationOptions && animationOptions.onComplete, [ctx], chart); } function onAnimationProgress(ctx) { const chart = ctx.chart; const animationOptions = chart.options.animation; helpers.callback(animationOptions && animationOptions.onProgress, [ctx], chart); } function isDomSupported() { return typeof window !== 'undefined' && typeof document !== 'undefined'; } /** * 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.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; } export default class Chart { static version = version; /** * NOTE(SB) We actually don't use this container anymore but we need to keep it * for backward compatibility. Though, it can still be useful for plugins that * would need to work on multiple charts?! */ static instances = {}; constructor(item, config) { const me = this; config = initConfig(config); const initialCanvas = getCanvas(item); this.platform = me._initializePlatform(initialCanvas, config); const context = me.platform.acquireContext(initialCanvas, config); const canvas = context && context.canvas; const height = canvas && canvas.height; const width = canvas && canvas.width; this.id = helpers.uid(); this.ctx = context; this.canvas = canvas; this.config = config; this.width = width; this.height = height; this.aspectRatio = height ? width / height : null; this.options = config.options; this._bufferedRender = false; this._layers = []; this._metasets = []; this.boxes = []; this.currentDevicePixelRatio = undefined; this.chartArea = undefined; this.data = undefined; this.active = undefined; this.lastActive = []; this._lastEvent = undefined; /** @type {{attach?: function, detach?: function, resize?: function}} */ this._listeners = {}; this._sortedMetasets = []; this._updating = false; this.scales = {}; this.scale = undefined; this.$plugins = undefined; this.$proxies = {}; this._hiddenIndices = {}; this.attached = true; // Add the chart instance to the global namespace Chart.instances[me.id] = me; // Define alias to the config data: `chart.data === chart.config.data` Object.defineProperty(me, 'data', { get() { return me.config.data; }, set(value) { me.config.data = value; } }); 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(me, 'complete', onAnimationsComplete); Animator.listen(me, 'progress', onAnimationProgress); me._initialize(); if (me.attached) { me.update(); } } /** * @private */ _initialize() { const me = this; // Before init plugin notification plugins.notify(me, 'beforeInit'); if (me.options.responsive) { // Initial resize before chart draws (must be silent to preserve initial animations). me.resize(true); } else { helpers.dom.retinaScale(me, me.options.devicePixelRatio); } me.bindEvents(); // After init plugin notification plugins.notify(me, 'afterInit'); return me; } /** * @private */ _initializePlatform(canvas, config) { if (config.platform) { return new config.platform(); } else if (!isDomSupported() || (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas)) { return new BasicPlatform(); } return new DomPlatform(); } clear() { helpers.canvas.clear(this); return this; } stop() { Animator.stop(this); return this; } resize(silent, width, height) { const me = this; const options = me.options; const canvas = me.canvas; const aspectRatio = options.maintainAspectRatio && me.aspectRatio; if (width === undefined || height === undefined) { width = getMaximumWidth(canvas); height = getMaximumHeight(canvas); } // 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. // Set to 0 instead of canvas.size because the size defaults to 300x150 if the element is collapsed const newWidth = Math.max(0, Math.floor(width)); const newHeight = Math.max(0, Math.floor(aspectRatio ? newWidth / aspectRatio : height)); // detect devicePixelRation changes const oldRatio = me.currentDevicePixelRatio; const newRatio = options.devicePixelRatio || me.platform.getDevicePixelRatio(); if (me.width === newWidth && me.height === newHeight && oldRatio === newRatio) { return; } canvas.width = me.width = newWidth; canvas.height = me.height = newHeight; if (canvas.style) { canvas.style.width = newWidth + 'px'; canvas.style.height = newHeight + 'px'; } helpers.dom.retinaScale(me, newRatio); if (!silent) { // Notify any plugins about the resize const newSize = {width: newWidth, height: newHeight}; plugins.notify(me, 'resize', [newSize]); // Notify of resize if (options.onResize) { options.onResize(me, newSize); } // Only apply 'resize' mode if we are attached, else do a regular update. me.update(me.attached && 'resize'); } } ensureScalesHaveIDs() { const options = this.options; const scalesOptions = options.scales || {}; const scaleOptions = options.scale; helpers.each(scalesOptions, (axisOptions, axisID) => { axisOptions.id = axisID; }); if (scaleOptions) { scaleOptions.id = scaleOptions.id || 'scale'; } } /** * Builds a map of scale ID to scale object for future lookup. */ buildOrUpdateScales() { const me = this; const options = me.options; const scaleOpts = options.scales; const scales = me.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((axisID) => { const axisOptions = scaleOpts[axisID]; const isRadial = axisID.charAt(0).toLowerCase() === 'r'; const isHorizontal = axisID.charAt(0).toLowerCase() === 'x'; return { options: axisOptions, dposition: isRadial ? 'chartArea' : isHorizontal ? 'bottom' : 'left', dtype: isRadial ? 'radialLinear' : isHorizontal ? 'category' : 'linear' }; }) ); } helpers.each(items, (item) => { const scaleOptions = item.options; const id = scaleOptions.id; const scaleType = valueOrDefault(scaleOptions.type, item.dtype); if (scaleOptions.position === undefined || positionIsHorizontal(scaleOptions.position, scaleOptions.axis || id[0]) !== 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 = scaleService.getScaleConstructor(scaleType); if (!scaleClass) { return; } scale = new scaleClass({ id, type: scaleType, ctx: me.ctx, chart: me }); scales[scale.id] = scale; } scale.init(scaleOptions); // 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; } }); // clear up discarded scales helpers.each(updated, (hasUpdated, id) => { if (!hasUpdated) { delete scales[id]; } }); me.scales = scales; scaleService.addScalesToLayout(this); } /** * Updates the given metaset with the given dataset index. Ensures it's stored at that index * in the _metasets array by swapping with the metaset at that index if necessary. * @param {Object} meta - the dataset metadata * @param {number} index - the dataset index * @private */ _updateMetasetIndex(meta, index) { const metasets = this._metasets; const oldIndex = meta.index; if (oldIndex !== index) { metasets[oldIndex] = metasets[index]; metasets[index] = meta; meta.index = index; } } /** * @private */ _updateMetasets() { const me = this; const metasets = me._metasets; const numData = me.data.datasets.length; const numMeta = metasets.length; if (numMeta > numData) { for (let i = numData; i < numMeta; ++i) { me._destroyDatasetMeta(i); } metasets.splice(numData, numMeta - numData); } me._sortedMetasets = metasets.slice(0).sort(compare2Level('order', 'index')); } buildOrUpdateControllers() { const me = this; const newControllers = []; const datasets = me.data.datasets; let i, ilen; for (i = 0, ilen = datasets.length; i < ilen; i++) { const dataset = datasets[i]; let meta = me.getDatasetMeta(i); const type = dataset.type || me.config.type; if (meta.type && meta.type !== type) { me._destroyDatasetMeta(i); meta = me.getDatasetMeta(i); } meta.type = type; meta.order = dataset.order || 0; me._updateMetasetIndex(meta, i); meta.label = '' + dataset.label; meta.visible = me.isDatasetVisible(i); if (meta.controller) { meta.controller.updateIndex(i); meta.controller.linkScales(); } else { const ControllerClass = controllers[meta.type]; if (ControllerClass === undefined) { throw new Error('"' + meta.type + '" is not a chart type.'); } meta.controller = new ControllerClass(me, i); newControllers.push(meta.controller); } } me._updateMetasets(); return newControllers; } /** * Reset the elements of all datasets * @private */ _resetElements() { const me = this; helpers.each(me.data.datasets, (dataset, datasetIndex) => { me.getDatasetMeta(datasetIndex).controller.reset(); }, me); } /** * Resets the chart back to its state before the initial animation */ reset() { this._resetElements(); plugins.notify(this, 'reset'); } update(mode) { const me = this; let i, ilen; me._updating = true; updateConfig(me); me.ensureScalesHaveIDs(); me.buildOrUpdateScales(); // plugins options references might have change, let's invalidate the cache // https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167 plugins.invalidate(me); if (plugins.notify(me, 'beforeUpdate') === false) { return; } // Make sure dataset controllers are updated and new controllers are reset const newControllers = me.buildOrUpdateControllers(); // Make sure all dataset controllers have correct meta data counts for (i = 0, ilen = me.data.datasets.length; i < ilen; i++) { me.getDatasetMeta(i).controller.buildOrUpdateElements(); } me._updateLayout(); // Can only reset the new controllers after the scales have been updated if (me.options.animation) { helpers.each(newControllers, (controller) => { controller.reset(); }); } me._updateDatasets(mode); // Do this before render so that any plugins that need final scale updates can use it plugins.notify(me, 'afterUpdate'); me._layers.sort(compare2Level('z', '_idx')); // Replay last event from before update if (me._lastEvent) { me._eventHandler(me._lastEvent, true); } me.render(); me._updating = false; } /** * 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() { const me = this; if (plugins.notify(me, 'beforeLayout') === false) { return; } layouts.update(me, me.width, me.height); me._layers = []; helpers.each(me.boxes, (box) => { // 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(); } me._layers.push(...box._layers()); }, me); me._layers.forEach((item, index) => { item._idx = index; }); plugins.notify(me, '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) { const me = this; const isFunction = typeof mode === 'function'; if (plugins.notify(me, 'beforeDatasetsUpdate') === false) { return; } for (let i = 0, ilen = me.data.datasets.length; i < ilen; ++i) { me._updateDataset(i, isFunction ? mode({datasetIndex: i}) : mode); } plugins.notify(me, 'afterDatasetsUpdate'); } /** * 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 me = this; const meta = me.getDatasetMeta(index); const args = {meta, index, mode}; if (plugins.notify(me, 'beforeDatasetUpdate', [args]) === false) { return; } meta.controller._update(mode); plugins.notify(me, 'afterDatasetUpdate', [args]); } render() { const me = this; const animationOptions = me.options.animation; if (plugins.notify(me, 'beforeRender') === false) { return; } const onComplete = function() { plugins.notify(me, 'afterRender'); helpers.callback(animationOptions && animationOptions.onComplete, [], me); }; if (Animator.has(me)) { if (me.attached && !Animator.running(me)) { Animator.start(me); } } else { me.draw(); onComplete(); } } draw() { const me = this; let i; me.clear(); if (me.width <= 0 || me.height <= 0) { return; } if (plugins.notify(me, 'beforeDraw') === 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 = me._layers; for (i = 0; i < layers.length && layers[i].z <= 0; ++i) { layers[i].draw(me.chartArea); } me._drawDatasets(); // Rest of layers for (; i < layers.length; ++i) { layers[i].draw(me.chartArea); } plugins.notify(me, 'afterDraw'); } /** * @private */ _getSortedDatasetMetas(filterVisible) { const me = this; const metasets = me._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() { const me = this; if (plugins.notify(me, 'beforeDatasetsDraw') === false) { return; } const metasets = me.getSortedVisibleDatasetMetas(); for (let i = metasets.length - 1; i >= 0; --i) { me._drawDataset(metasets[i]); } plugins.notify(me, '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 me = this; const ctx = me.ctx; const clip = meta._clip; const area = me.chartArea; const args = { meta, index: meta.index, }; if (plugins.notify(me, 'beforeDatasetDraw', [args]) === false) { return; } helpers.canvas.clipArea(ctx, { left: clip.left === false ? 0 : area.left - clip.left, right: clip.right === false ? me.width : area.right + clip.right, top: clip.top === false ? 0 : area.top - clip.top, bottom: clip.bottom === false ? me.height : area.bottom + clip.bottom }); meta.controller.draw(); helpers.canvas.unclipArea(ctx); plugins.notify(me, 'afterDatasetDraw', [args]); } /** * 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(e) { return Interaction.modes.nearest(this, e, {intersect: true}); } getElementsAtEvent(e) { return Interaction.modes.index(this, e, {intersect: true}); } getElementsAtXAxis(e) { return Interaction.modes.index(this, e, {intersect: false}); } getElementsAtEventForMode(e, mode, options, useFinalPosition) { const method = Interaction.modes[mode]; if (typeof method === 'function') { return method(this, e, options, useFinalPosition); } return []; } getDatasetAtEvent(e) { return Interaction.modes.dataset(this, e, {intersect: true}); } getDatasetMeta(datasetIndex) { const me = this; const dataset = me.data.datasets[datasetIndex]; const metasets = me._metasets; let meta = metasets.filter(x => x._dataset === dataset).pop(); if (!meta) { meta = metasets[datasetIndex] = { type: null, data: [], dataset: null, controller: null, hidden: null, // See isDatasetVisible() comment xAxisID: null, yAxisID: null, order: dataset.order || 0, index: datasetIndex, _dataset: dataset, _parsed: [], _sorted: false }; } return meta; } getVisibleDatasetCount() { return this.getSortedVisibleDatasetMetas().length; } isDatasetVisible(datasetIndex) { 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 : !this.data.datasets[datasetIndex].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 */ _updateDatasetVisibility(datasetIndex, visible) { const me = this; const mode = visible ? 'show' : 'hide'; const meta = me.getDatasetMeta(datasetIndex); const anims = meta.controller._resolveAnimations(undefined, mode); me.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}); me.update((ctx) => ctx.datasetIndex === datasetIndex ? mode : undefined); } hide(datasetIndex) { this._updateDatasetVisibility(datasetIndex, false); } show(datasetIndex) { this._updateDatasetVisibility(datasetIndex, true); } /** * @private */ _destroyDatasetMeta(datasetIndex) { const me = this; const meta = me._metasets && me._metasets[datasetIndex]; if (meta) { meta.controller._destroy(); delete me._metasets[datasetIndex]; } } destroy() { const me = this; const canvas = me.canvas; let i, ilen; me.stop(); Animator.remove(me); // dataset controllers need to cleanup associated data for (i = 0, ilen = me.data.datasets.length; i < ilen; ++i) { me._destroyDatasetMeta(i); } if (canvas) { me.unbindEvents(); helpers.canvas.clear(me); me.platform.releaseContext(me.ctx); me.canvas = null; me.ctx = null; } plugins.notify(me, 'destroy'); delete Chart.instances[me.id]; } toBase64Image(...args) { return this.canvas.toDataURL(...args); } /** * @private */ bindEvents() { const me = this; const listeners = me._listeners; const platform = me.platform; const _add = (type, listener) => { platform.addEventListener(me, type, listener); listeners[type] = listener; }; const _remove = (type, listener) => { if (listeners[type]) { platform.removeEventListener(me, type, listener); delete listeners[type]; } }; let listener = function(e) { me._eventHandler(e); }; helpers.each(me.options.events, (type) => _add(type, listener)); if (me.options.responsive) { listener = (width, height) => { if (me.canvas) { me.resize(false, width, height); } }; let detached; // eslint-disable-line prefer-const const attached = () => { _remove('attach', attached); me.resize(); me.attached = true; _add('resize', listener); _add('detach', detached); }; detached = () => { me.attached = false; _remove('resize', listener); _add('attach', attached); }; if (platform.isAttached(me.canvas)) { attached(); } else { detached(); } } else { me.attached = true; } } /** * @private */ unbindEvents() { const me = this; const listeners = me._listeners; if (!listeners) { return; } delete me._listeners; helpers.each(listeners, (listener, type) => { me.platform.removeEventListener(me, type, listener); }); } 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]; if (item) { this.getDatasetMeta(item.datasetIndex).controller[prefix + 'HoverStyle'](item.element, item.datasetIndex, item.index); } } } /** * @private */ _updateHoverStyles() { const me = this; const options = me.options || {}; const hoverOptions = options.hover; // Remove styling for last active (even if it may still be active) if (me.lastActive.length) { me.updateHoverStyle(me.lastActive, hoverOptions.mode, false); } // Built-in hover styling if (me.active.length && hoverOptions.mode) { me.updateHoverStyle(me.active, hoverOptions.mode, true); } } /** * @private */ _eventHandler(e, replay) { const me = this; if (plugins.notify(me, 'beforeEvent', [e, replay]) === false) { return; } me._handleEvent(e, replay); plugins.notify(me, 'afterEvent', [e, replay]); me.render(); return me; } /** * Handle an event * @param {IEvent} e the event to handle * @param {boolean} [replay] - true if the event was replayed by `update` * @return {boolean} true if the chart needs to re-render * @private */ _handleEvent(e, replay) { const me = this; const options = me.options; const hoverOptions = options.hover; // 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 occured 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; let changed = false; // Find Active Elements for hover and tooltips if (e.type === 'mouseout') { me.active = []; me._lastEvent = null; } else { me.active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition); me._lastEvent = e.type === 'click' ? me._lastEvent : e; } // Invoke onHover hook // Need to call with native event here to not break backwards compatibility helpers.callback(options.onHover || options.hover.onHover, [e.native, me.active], me); if (e.type === 'mouseup' || e.type === 'click') { if (options.onClick && helpers.canvas._isPointInArea(e, me.chartArea)) { // Use e.native here for backwards compatibility options.onClick.call(me, e.native, me.active); } } changed = !helpers._elementsEqual(me.active, me.lastActive); if (changed || replay) { me._updateHoverStyles(); } // Remember Last Actives me.lastActive = me.active; return changed; } }