Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interaction functions #10046

Merged
merged 14 commits into from Mar 24, 2022
53 changes: 53 additions & 0 deletions docs/configuration/interactions.md
Expand Up @@ -220,3 +220,56 @@ const chart = new Chart(ctx, {
}
});
```

## Custom Interaction Modes

New modes can be defined by adding functions to the `Chart.Interaction.modes` map. You can use the `Chart.Interaction.evaluateInteractionItems` function to help implement these.

Example:

```javascript
import { Interaction } from 'chart.js';
import { getRelativePosition } from 'chart.js/helpers';

/**
* Custom interaction mode
* @function Interaction.modes.myCustomMode
* @param {Chart} chart - the chart we are returning items from
* @param {Event} e - the event we are find things at
* @param {InteractionOptions} options - options to use
* @param {boolean} [useFinalPosition] - use final element position (animation target)
* @return {InteractionItem[]} - items that are found
*/
Interaction.modes.myCustomMode = function(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);

const items = [];
Interaction.evaluateInteractionItems(chart, 'x', position, (element, datasetIndex, index) => {
if (element.inXRange(position.x, useFinalPosition) && myCustomLogic(element)) {
items.push({element, datasetIndex, index});
}
});
return items;
};

// Then, to use it...
new Chart.js(ctx, {
type: 'line',
data: data,
options: {
interaction: {
mode: 'myCustomMode'
}
}
})
```

If you're using TypeScript, you'll also need to register the new mode:

```typescript
declare module 'chart.js' {
interface InteractionModeMap {
myCustomMode: InteractionModeFunction;
}
}
```
12 changes: 11 additions & 1 deletion src/core/core.controller.js
Expand Up @@ -15,6 +15,7 @@ import {debounce} from '../helpers/helpers.extras';

/**
* @typedef { import('../../types/index.esm').ChartEvent } ChartEvent
* @typedef { import("../../types/index.esm").Point } Point
*/

const KNOWN_POSITIONS = ['top', 'bottom', 'left', 'right', 'chartArea'];
Expand Down Expand Up @@ -792,6 +793,15 @@ class Chart {
this.notifyPlugins('afterDatasetDraw', args);
}

/**
* Checks whether the given point is in the chart area.
* @param {Point} point - in relative coordinates (see, e.g., getRelativePosition)
* @returns {boolean}
*/
isPointInArea(point) {
return _isPointInArea(point, this.chartArea, this._minPadding);
}

getElementsAtEventForMode(e, mode, options, useFinalPosition) {
const method = Interaction.modes[mode];
if (typeof method === 'function') {
Expand Down Expand Up @@ -1134,7 +1144,7 @@ class Chart {
event: e,
replay,
cancelable: true,
inChartArea: _isPointInArea(e, this.chartArea, this._minPadding)
inChartArea: this.isPointInArea(e)
};
const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.native.type);

Expand Down
104 changes: 38 additions & 66 deletions src/core/core.interaction.js
@@ -1,52 +1,15 @@
import {_isPointInArea} from '../helpers/helpers.canvas';
import {_lookupByKey, _rlookupByKey} from '../helpers/helpers.collection';
import {getRelativePosition as helpersGetRelativePosition} from '../helpers/helpers.dom';
import {getRelativePosition} from '../helpers/helpers.dom';
import {_angleBetween, getAngleFromPoint} from '../helpers/helpers.math';

/**
* @typedef { import("./core.controller").default } Chart
* @typedef { import("../../types/index.esm").ChartEvent } ChartEvent
* @typedef {{axis?: string, intersect?: boolean}} InteractionOptions
* @typedef {{datasetIndex: number, index: number, element: import("./core.element").default}} InteractionItem
* @typedef { import("../../types/index.esm").Point } Point
*/

/**
* Helper function to get relative position for an event
* @param {Event|ChartEvent} e - The event to get the position for
* @param {Chart} chart - The chart
* @returns {object} the event position
*/
function getRelativePosition(e, chart) {
if ('native' in e) {
return {
x: e.x,
y: e.y
};
}

return helpersGetRelativePosition(e, chart);
}

/**
* Helper function to traverse all of the visible elements in the chart
* @param {Chart} chart - the chart
* @param {function} handler - the callback to execute for each visible item
*/
function evaluateAllVisibleItems(chart, handler) {
const metasets = chart.getSortedVisibleDatasetMetas();
let index, data, element;

for (let i = 0, ilen = metasets.length; i < ilen; ++i) {
({index, data} = metasets[i]);
for (let j = 0, jlen = data.length; j < jlen; ++j) {
element = data[j];
if (!element.skip) {
handler(element, index, j);
}
}
}
}

/**
* Helper function to do binary search when possible
* @param {object} metaset - the dataset meta
Expand Down Expand Up @@ -80,14 +43,14 @@ function binarySearch(metaset, axis, value, intersect) {
}

/**
* Helper function to get items using binary search, when the data is sorted.
* Helper function to select candidate elements for interaction
* @param {Chart} chart - the chart
* @param {string} axis - the axis mode. x|y|xy|r
* @param {object} position - the point to be nearest to
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {function} handler - the callback to execute for each visible item
* @param {boolean} [intersect] - consider intersecting items
*/
function optimizedEvaluateItems(chart, axis, position, handler, intersect) {
function evaluateInteractionItems(chart, axis, position, handler, intersect) {
const metasets = chart.getSortedVisibleDatasetMetas();
const value = position[axis];
for (let i = 0, ilen = metasets.length; i < ilen; ++i) {
Expand Down Expand Up @@ -121,15 +84,15 @@ function getDistanceMetricForAxis(axis) {
/**
* Helper function to get the items that intersect the event position
* @param {Chart} chart - the chart
* @param {object} position - the point to be nearest to
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {string} axis - the axis mode. x|y|xy|r
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getIntersectItems(chart, position, axis, useFinalPosition) {
const items = [];

if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) {
if (!chart.isPointInArea(position)) {
return items;
}

Expand All @@ -139,16 +102,16 @@ function getIntersectItems(chart, position, axis, useFinalPosition) {
}
};

optimizedEvaluateItems(chart, axis, position, evaluationFunc, true);
evaluateInteractionItems(chart, axis, position, evaluationFunc, true);
return items;
}

/**
* Helper function to get the items nearest to the event position for a radial chart
* @param {Chart} chart - the chart to look at elements from
* @param {object} position - the point to be nearest to
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {string} axis - the axes along which to measure distance
* @param {boolean} [useFinalPosition] - use the elements animation target instead of current position
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getNearestRadialItems(chart, position, axis, useFinalPosition) {
Expand All @@ -163,17 +126,17 @@ function getNearestRadialItems(chart, position, axis, useFinalPosition) {
}
}

optimizedEvaluateItems(chart, axis, position, evaluationFunc);
evaluateInteractionItems(chart, axis, position, evaluationFunc);
return items;
}

/**
* Helper function to get the items nearest to the event position for a cartesian chart
* @param {Chart} chart - the chart to look at elements from
* @param {object} position - the point to be nearest to
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {string} axis - the axes along which to measure distance
* @param {boolean} [intersect] - if true, only consider items that intersect the position
* @param {boolean} [useFinalPosition] - use the elements animation target instead of current position
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition) {
Expand All @@ -188,7 +151,7 @@ function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosi
}

const center = element.getCenterPoint(useFinalPosition);
const pointInArea = _isPointInArea(center, chart.chartArea, chart._minPadding);
const pointInArea = chart.isPointInArea(center);
if (!pointInArea && !inRange) {
return;
}
Expand All @@ -203,21 +166,21 @@ function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosi
}
}

optimizedEvaluateItems(chart, axis, position, evaluationFunc);
evaluateInteractionItems(chart, axis, position, evaluationFunc);
return items;
}

/**
* Helper function to get the items nearest to the event position considering all visible items in the chart
* @param {Chart} chart - the chart to look at elements from
* @param {object} position - the point to be nearest to
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {string} axis - the axes along which to measure distance
* @param {boolean} [intersect] - if true, only consider items that intersect the position
* @param {boolean} [useFinalPosition] - use the elements animation target instead of current position
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getNearestItems(chart, position, axis, intersect, useFinalPosition) {
if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) {
if (!chart.isPointInArea(position)) {
return [];
}

Expand All @@ -226,26 +189,30 @@ function getNearestItems(chart, position, axis, intersect, useFinalPosition) {
: getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition);
}

function getAxisItems(chart, e, options, useFinalPosition) {
const position = getRelativePosition(e, chart);
/**
* Helper function to get the items matching along the given X or Y axis
* @param {Chart} chart - the chart to look at elements from
* @param {Point} position - the point to be nearest to, in relative coordinates
* @param {string} axis - the axis to match
* @param {boolean} [intersect] - if true, only consider items that intersect the position
* @param {boolean} [useFinalPosition] - use the element's animation target instead of current position
* @return {InteractionItem[]} the nearest items
*/
function getAxisItems(chart, position, axis, intersect, useFinalPosition) {
const items = [];
const axis = options.axis;
const rangeMethod = axis === 'x' ? 'inXRange' : 'inYRange';
let intersectsItem = false;

evaluateAllVisibleItems(chart, (element, datasetIndex, index) => {
evaluateInteractionItems(chart, axis, position, (element, datasetIndex, index) => {
if (element[rangeMethod](position[axis], useFinalPosition)) {
items.push({element, datasetIndex, index});
}

if (element.inRange(position.x, position.y, useFinalPosition)) {
intersectsItem = true;
intersectsItem = intersectsItem || element.inRange(position.x, position.y, useFinalPosition);
}
});

// If we want to trigger on an intersect and we don't have any items
// that intersect the position, return nothing
if (options.intersect && !intersectsItem) {
if (intersect && !intersectsItem) {
return [];
}
return items;
Expand All @@ -256,6 +223,9 @@ function getAxisItems(chart, e, options, useFinalPosition) {
* @namespace Chart.Interaction
*/
export default {
// Part of the public API to facilitate developers creating their own modes
evaluateInteractionItems,

// Helper function for different modes
modes: {
/**
Expand Down Expand Up @@ -365,7 +335,8 @@ export default {
* @return {InteractionItem[]} - items that are found
*/
x(chart, e, options, useFinalPosition) {
return getAxisItems(chart, e, {axis: 'x', intersect: options.intersect}, useFinalPosition);
const position = getRelativePosition(e, chart);
return getAxisItems(chart, position, 'x', options.intersect, useFinalPosition);
},

/**
Expand All @@ -378,7 +349,8 @@ export default {
* @return {InteractionItem[]} - items that are found
*/
y(chart, e, options, useFinalPosition) {
return getAxisItems(chart, e, {axis: 'y', intersect: options.intersect}, useFinalPosition);
const position = getRelativePosition(e, chart);
return getAxisItems(chart, position, 'y', options.intersect, useFinalPosition);
}
}
};
10 changes: 7 additions & 3 deletions src/helpers/helpers.canvas.js
Expand Up @@ -2,7 +2,11 @@ import {isArray, isNullOrUndef} from './helpers.core';
import {PI, TAU, HALF_PI, QUARTER_PI, TWO_THIRDS_PI, RAD_PER_DEG} from './helpers.math';

/**
* @typedef { import("../core/core.controller").default } Chart
* Note: typedefs are auto-exported, so use a made-up `canvas` namespace where
* necessary to avoid duplicates with `export * from './helpers`; see
* https://github.com/microsoft/TypeScript/issues/46011
* @typedef { import("../core/core.controller").default } canvas.Chart
* @typedef { import("../../types/index.esm").Point } Point
*/

/**
Expand Down Expand Up @@ -94,7 +98,7 @@ export function _longestText(ctx, font, arrayOfThings, cache) {

/**
* Returns the aligned pixel value to avoid anti-aliasing blur
* @param {Chart} chart - The chart instance.
* @param {canvas.Chart} chart - The chart instance.
* @param {number} pixel - A pixel value.
* @param {number} width - The width of the element.
* @returns {number} The aligned pixel value.
Expand Down Expand Up @@ -242,7 +246,7 @@ export function drawPoint(ctx, options, x, y) {

/**
* Returns true if the point is inside the rectangle
* @param {object} point - The point to test
* @param {Point} point - The point to test
* @param {object} area - The rectangle
* @param {number} [margin] - allowed margin
* @returns {boolean}
Expand Down