Chart.js/src/core/core.controller.js
Toni Dietze c955ffad64
Round canvas.style dimensions to avoid blurring (#9015)
When canvas.height and canvas.width are set, the numbers are rounded to
integers. The same rounding must be applied to canvas.style.height and
canvas.style.width to avoid scaling of the canvas which would lead to
blurring.

Acknowledging that canvas.height and canvas.width are integers, the
framebuffer is now only redrawn if those integer values change.
2021-05-08 08:11:52 +03:00

1184 lines
32 KiB
JavaScript

import animator from './core.animator';
import defaults, {overrides} from './core.defaults';
import Interaction from './core.interaction';
import layouts from './core.layouts';
import {BasicPlatform, DomPlatform} from '../platform';
import PluginService from './core.plugins';
import registry from './core.registry';
import Config, {determineAxis, getIndexAxis} from './core.config';
import {retinaScale} from '../helpers/helpers.dom';
import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual} from '../helpers/helpers.core';
import {clearCanvas, clipArea, unclipArea, _isPointInArea} from '../helpers/helpers.canvas';
// @ts-ignore
import {version} from '../../package.json';
import {debounce} from '../helpers/helpers.extras';
/**
* @typedef { import("../platform/platform.base").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);
}
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 && 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();
};
class Chart {
// eslint-disable-next-line max-statements
constructor(item, config) {
const me = this;
this.config = config = new Config(config);
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(), me.getContext());
this.platform = me._initializePlatform(initialCanvas, config);
const context = me.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;
/** @type {{attach?: function, detach?: function, resize?: function}} */
this._listeners = {};
this._sortedMetasets = [];
this.scales = {};
this.scale = undefined;
this._plugins = new PluginService();
this.$proxies = {};
this._hiddenIndices = {};
this.attached = false;
this._animationsDisabled = undefined;
this.$context = undefined;
this._doResize = debounce(() => this.update('resize'), options.resizeDelay || 0);
// Add the chart instance to the global namespace
instances[me.id] = me;
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();
}
}
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() {
const me = this;
// Before init plugin notification
me.notifyPlugins('beforeInit');
if (me.options.responsive) {
me.resize();
} else {
retinaScale(me, me.options.devicePixelRatio);
}
me.bindEvents();
// After init plugin notification
me.notifyPlugins('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() {
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 me = this;
const options = me.options;
const canvas = me.canvas;
const aspectRatio = options.maintainAspectRatio && me.aspectRatio;
const newSize = me.platform.getMaximumSize(canvas, width, height, aspectRatio);
const newRatio = options.devicePixelRatio || me.platform.getDevicePixelRatio();
me.width = newSize.width;
me.height = newSize.height;
me._aspectRatio = me.aspectRatio;
if (!retinaScale(me, newRatio, true)) {
return;
}
me.notifyPlugins('resize', {size: newSize});
callCallback(options.onResize, [me, newSize], me);
if (me.attached) {
if (me._doResize()) {
// The resize update is delayed, only draw without updating.
me.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 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((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: me.ctx,
chart: me
});
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(me, scale, scale.options);
layouts.addBox(me, scale);
});
}
/**
* 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'));
}
/**
* @private
*/
_removeUnreferencedMetasets() {
const me = this;
const {_metasets: metasets, data: {datasets}} = me;
if (metasets.length > datasets.length) {
delete me._stacks;
}
metasets.forEach((meta, index) => {
if (datasets.filter(x => x === meta._dataset).length === 0) {
me._destroyDatasetMeta(index);
}
});
}
buildOrUpdateControllers() {
const me = this;
const newControllers = [];
const datasets = me.data.datasets;
let i, ilen;
me._removeUnreferencedMetasets();
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.indexAxis = dataset.indexAxis || getIndexAxis(type, me.options);
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 = 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(me, i);
newControllers.push(meta.controller);
}
}
me._updateMetasets();
return newControllers;
}
/**
* Reset the elements of all datasets
* @private
*/
_resetElements() {
const me = this;
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();
this.notifyPlugins('reset');
}
update(mode) {
const me = this;
const config = me.config;
config.update();
me._options = config.createResolver(config.chartOptionScopes(), me.getContext());
each(me.scales, (scale) => {
layouts.removeBox(me, scale);
});
const animsDisabled = me._animationsDisabled = !me.options.animation;
me.ensureScalesHaveIDs();
me.buildOrUpdateScales();
const existingEvents = new Set(Object.keys(me._listeners));
const newEvents = new Set(me.options.events);
if (!setsEqual(existingEvents, newEvents)) {
// The events array has changed. Rebind it
me.unbindEvents();
me.bindEvents();
}
// plugins options references might have change, let's invalidate the cache
// https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167
me._plugins.invalidate();
if (me.notifyPlugins('beforeUpdate', {mode, cancelable: true}) === false) {
return;
}
// Make sure dataset controllers are updated and new controllers are reset
const newControllers = me.buildOrUpdateControllers();
me.notifyPlugins('beforeElementsUpdate');
// Make sure all dataset controllers have correct meta data counts
let minPadding = 0;
for (let i = 0, ilen = me.data.datasets.length; i < ilen; i++) {
const {controller} = me.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);
}
me._minPadding = minPadding;
me._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();
});
}
me._updateDatasets(mode);
// Do this before render so that any plugins that need final scale updates can use it
me.notifyPlugins('afterUpdate', {mode});
me._layers.sort(compare2Level('z', '_idx'));
// Replay last event from before update
if (me._lastEvent) {
me._eventHandler(me._lastEvent, true);
}
me.render();
}
/**
* 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) {
const me = this;
if (me.notifyPlugins('beforeLayout', {cancelable: true}) === false) {
return;
}
layouts.update(me, me.width, me.height, minPadding);
const area = me.chartArea;
const noArea = area.width <= 0 || area.height <= 0;
me._layers = [];
each(me.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();
}
me._layers.push(...box._layers());
}, me);
me._layers.forEach((item, index) => {
item._idx = index;
});
me.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) {
const me = this;
const isFunction = typeof mode === 'function';
if (me.notifyPlugins('beforeDatasetsUpdate', {mode, cancelable: true}) === false) {
return;
}
for (let i = 0, ilen = me.data.datasets.length; i < ilen; ++i) {
me._updateDataset(i, isFunction ? mode({datasetIndex: i}) : mode);
}
me.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 me = this;
const meta = me.getDatasetMeta(index);
const args = {meta, index, mode, cancelable: true};
if (me.notifyPlugins('beforeDatasetUpdate', args) === false) {
return;
}
meta.controller._update(mode);
args.cancelable = false;
me.notifyPlugins('afterDatasetUpdate', args);
}
render() {
const me = this;
if (me.notifyPlugins('beforeRender', {cancelable: true}) === false) {
return;
}
if (animator.has(me)) {
if (me.attached && !animator.running(me)) {
animator.start(me);
}
} else {
me.draw();
onAnimationsComplete({chart: me});
}
}
draw() {
const me = this;
let i;
if (me._resizeBeforeDraw) {
const {width, height} = me._resizeBeforeDraw;
me._resize(width, height);
me._resizeBeforeDraw = null;
}
me.clear();
if (me.width <= 0 || me.height <= 0) {
return;
}
if (me.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 = 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);
}
me.notifyPlugins('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 (me.notifyPlugins('beforeDatasetsDraw', {cancelable: true}) === false) {
return;
}
const metasets = me.getSortedVisibleDatasetMetas();
for (let i = metasets.length - 1; i >= 0; --i) {
me._drawDataset(metasets[i]);
}
me.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 me = this;
const ctx = me.ctx;
const clip = meta._clip;
const area = me.chartArea;
const args = {
meta,
index: meta.index,
cancelable: true
};
if (me.notifyPlugins('beforeDatasetDraw', args) === false) {
return;
}
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();
unclipArea(ctx);
args.cancelable = false;
me.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 me = this;
const dataset = me.data.datasets[datasetIndex];
const metasets = me._metasets;
let meta = metasets.filter(x => 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 && dataset.order || 0,
index: datasetIndex,
_dataset: dataset,
_parsed: [],
_sorted: false
};
}
return meta;
}
getContext() {
return this.$context || (this.$context = {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
*/
_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) {
meta.controller._destroy();
delete me._metasets[datasetIndex];
}
}
destroy() {
const me = this;
const {canvas, ctx} = me;
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);
}
me.config.clearCache();
if (canvas) {
me.unbindEvents();
clearCanvas(canvas, ctx);
me.platform.releaseContext(ctx);
me.canvas = null;
me.ctx = null;
}
me.notifyPlugins('destroy');
delete 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, x, y) {
e.offsetX = x;
e.offsetY = y;
me._eventHandler(e);
};
each(me.options.events, (type) => _add(type, listener));
if (me.options.responsive) {
listener = (width, height) => {
if (me.canvas) {
me.resize(width, height);
}
};
let detached; // eslint-disable-line prefer-const
const attached = () => {
_remove('attach', attached);
me.attached = true;
me.resize();
_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;
}
me._listeners = {};
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];
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 me = this;
const lastActive = me._active || [];
const active = activeElements.map(({datasetIndex, index}) => {
const meta = me.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) {
me._active = active;
me._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 me = this;
const hoverOptions = me.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) {
me.updateHoverStyle(deactivated, hoverOptions.mode, false);
}
if (activated.length && hoverOptions.mode) {
me.updateHoverStyle(activated, hoverOptions.mode, true);
}
}
/**
* @private
*/
_eventHandler(e, replay) {
const me = this;
const args = {event: e, replay, cancelable: true};
const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.type);
if (me.notifyPlugins('beforeEvent', args, eventFilter) === false) {
return;
}
const changed = me._handleEvent(e, replay);
args.cancelable = false;
me.notifyPlugins('afterEvent', args, eventFilter);
if (changed || args.changed) {
me.render();
}
return me;
}
/**
* Handle an event
* @param {ChartEvent} 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 {_active: lastActive = [], options} = me;
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 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;
let active = [];
let changed = false;
let lastEvent = null;
// Find Active Elements for hover and tooltips
if (e.type !== 'mouseout') {
active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition);
lastEvent = e.type === 'click' ? me._lastEvent : e;
}
// Set _lastEvent to null while we are processing the event handlers.
// This prevents recursion if the handler calls chart.update()
me._lastEvent = null;
if (_isPointInArea(e, me.chartArea, me._minPadding)) {
// Invoke onHover hook
callCallback(options.onHover, [e, active, me], me);
if (e.type === 'mouseup' || e.type === 'click' || e.type === 'contextmenu') {
callCallback(options.onClick, [e, active, me], me);
}
}
changed = !_elementsEqual(active, lastActive);
if (changed || replay) {
me._active = active;
me._updateHoverStyles(active, lastActive, replay);
}
me._lastEvent = lastEvent;
return changed;
}
}
// @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;