Skip to content

Commit

Permalink
Interaction functions (#10046)
Browse files Browse the repository at this point in the history
* Refactor get...Items functions to take events rather than positions

To work toward exposing something like the get...Items functions.

* Switch getAxisItems to use optimizedEvaluateItems

optimizedEvaluateItems falls back to evaluating all items for unsorted items, and sorting / optimizing ought to be okay, so this ought to be equivalent.

* Performance

* Consolidate getRelativePosition

helpers.dom.js's getRelativePosition already had logic to handle ChartEvent vs. Event (as demonstrated by the `native` check within `getCanvasPosition`), so it's redundant for core.interaction.js to have its own `native` check.

Update `getRelativePosition` to use the same `native` check as core.interaction.js's version.  As best as I can tell, the ChartEvent's x and y are populated from `getRelativePosition`, so the previous `getCanvasPosition` was effectively just duplicating `getRelativePosition'`s work.  I added a test to verify this; it depends on a local, not-yet-submitted change in chartjs-test-utils' `triggerMouseEvent` to return the mouse event that it triggers.

* Add an API to refactor duplicate isPointInArea

* Rename and update JSDoc to prepare for making this public

* Give functions a consistent, generic interface

* Export functions for discussion

* Code review feedback

Add a non-null assertion, as requested in code review.

Add JSDoc to clarify that `getCanvasPosition` now expects a native `Event`, not a `ChartEvent`.  Add `@ts-ignore`; `getCanvasPosition` relied on some loose conversions between `Event`, `TouchEvent`, and `Touch` that would require several changes to make TypeScript happy.

* Code review feedback

Return the event directly, to speed up the code a bit.  Add JSDoc to help communicate its intent.  Update various comments.

* Finalize exports; add docs and TypeScript

* Update src/helpers/helpers.dom.js

* Update src/helpers/helpers.dom.js

Only thing needed actually is the update of chartjs-test-utils to 0.4.0

* Bump chartjs-test-utils dependency

To get supporting work from chartjs/chartjs-test-utils#19

Co-authored-by: Jukka Kurkela <jukka.kurkela@gmail.com>
  • Loading branch information
joshkel and kurkle committed Mar 24, 2022
1 parent ec65900 commit c057c96
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 87 deletions.
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;
}
}
```
23 changes: 11 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -62,7 +62,7 @@
"@vuepress/plugin-html-redirect": "^0.1.2",
"chartjs-adapter-luxon": "^1.0.0",
"chartjs-adapter-moment": "^1.0.0",
"chartjs-test-utils": "^0.3.1",
"chartjs-test-utils": "^0.4.0",
"concurrently": "^6.0.1",
"coveralls": "^3.1.0",
"cross-env": "^7.0.3",
Expand Down
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);
}
}
};

0 comments on commit c057c96

Please sign in to comment.