diff --git a/docs/configuration/interactions.md b/docs/configuration/interactions.md index abb111b54d4..2fe77f45caf 100644 --- a/docs/configuration/interactions.md +++ b/docs/configuration/interactions.md @@ -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; + } +} +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 96c2ddae4b7..d87bdabc33b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,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", @@ -5431,15 +5431,17 @@ } }, "node_modules/chartjs-test-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chartjs-test-utils/-/chartjs-test-utils-0.3.1.tgz", - "integrity": "sha512-QsRYLWOedYGsloDvJsByPNUK44TOiqnxQEO5FOrOm9SguEl5WmJDCOIdd/1ePLOX4gGRClXBDVxD7o1SJY+nWA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chartjs-test-utils/-/chartjs-test-utils-0.4.0.tgz", + "integrity": "sha512-hT7weEZeWDVduSflHMpoNYW4arxVNp3+u7iZW91P6+zTYLHqgtv1gB/K0wiMqForXvw7IsDWuMF2iEvh3WT1mg==", "dev": true, "dependencies": { + "pixelmatch": "^5.2.1" + }, + "peerDependencies": { "jasmine": "^3.6.4", "karma": "^6.1.1", - "karma-jasmine": "^4.0.1", - "pixelmatch": "^5.2.1" + "karma-jasmine": "^4.0.1" } }, "node_modules/chokidar": { @@ -23361,14 +23363,11 @@ "requires": {} }, "chartjs-test-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chartjs-test-utils/-/chartjs-test-utils-0.3.1.tgz", - "integrity": "sha512-QsRYLWOedYGsloDvJsByPNUK44TOiqnxQEO5FOrOm9SguEl5WmJDCOIdd/1ePLOX4gGRClXBDVxD7o1SJY+nWA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chartjs-test-utils/-/chartjs-test-utils-0.4.0.tgz", + "integrity": "sha512-hT7weEZeWDVduSflHMpoNYW4arxVNp3+u7iZW91P6+zTYLHqgtv1gB/K0wiMqForXvw7IsDWuMF2iEvh3WT1mg==", "dev": true, "requires": { - "jasmine": "^3.6.4", - "karma": "^6.1.1", - "karma-jasmine": "^4.0.1", "pixelmatch": "^5.2.1" } }, diff --git a/package.json b/package.json index a2175c4e79d..c5cb4507339 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/core.controller.js b/src/core/core.controller.js index 6b0bd2f71da..17923d90aba 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -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']; @@ -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') { @@ -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); diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index 662703d7f7b..2e18acc9d7e 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -1,6 +1,5 @@ -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'; /** @@ -8,45 +7,9 @@ import {_angleBetween, getAngleFromPoint} from '../helpers/helpers.math'; * @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 @@ -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) { @@ -121,7 +84,7 @@ 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 @@ -129,7 +92,7 @@ function getDistanceMetricForAxis(axis) { function getIntersectItems(chart, position, axis, useFinalPosition) { const items = []; - if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) { + if (!chart.isPointInArea(position)) { return items; } @@ -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) { @@ -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) { @@ -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; } @@ -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 []; } @@ -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; @@ -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: { /** @@ -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); }, /** @@ -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); } } }; diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index d6290f2ec0c..54ce2cd54f6 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -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 */ /** @@ -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. @@ -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} diff --git a/src/helpers/helpers.dom.js b/src/helpers/helpers.dom.js index 8fe1f3f9b2b..c4f54e00936 100644 --- a/src/helpers/helpers.dom.js +++ b/src/helpers/helpers.dom.js @@ -1,5 +1,13 @@ import {INFINITY} from './helpers.math'; +/** + * Note: typedefs are auto-exported, so use a made-up `dom` namespace where + * necessary to avoid duplicates with `export * from './helpers`; see + * https://github.com/microsoft/TypeScript/issues/46011 + * @typedef { import("../core/core.controller").default } dom.Chart + * @typedef { import('../../types/index.esm').ChartEvent } ChartEvent + */ + /** * @private */ @@ -59,8 +67,13 @@ function getPositionedStyle(styles, style, suffix) { const useOffsetPos = (x, y, target) => (x > 0 || y > 0) && (!target || !target.shadowRoot); -function getCanvasPosition(evt, canvas) { - const e = evt.native || evt; +/** + * @param {Event} e + * @param {HTMLCanvasElement} canvas + * @returns {{x: number, y: number, box: boolean}} + */ +function getCanvasPosition(e, canvas) { + // @ts-ignore const touches = e.touches; const source = touches && touches.length ? touches[0] : e; const {offsetX, offsetY} = source; @@ -78,7 +91,17 @@ function getCanvasPosition(evt, canvas) { return {x, y, box}; } +/** + * Gets an event's x, y coordinates, relative to the chart area + * @param {Event|ChartEvent} evt + * @param {dom.Chart} chart + * @returns {{x: number, y: number}} + */ export function getRelativePosition(evt, chart) { + if ('native' in evt) { + return evt; + } + const {canvas, currentDevicePixelRatio} = chart; const style = getComputedStyle(canvas); const borderBox = style.boxSizing === 'border-box'; diff --git a/test/specs/helpers.dom.tests.js b/test/specs/helpers.dom.tests.js index fd0d0de14c7..24dbe81b68a 100644 --- a/test/specs/helpers.dom.tests.js +++ b/test/specs/helpers.dom.tests.js @@ -427,5 +427,30 @@ describe('DOM helpers tests', function() { expect(dataX).not.toEqual(NaN); expect(dataY).not.toEqual(NaN); }); + + it('Should give consistent results for native and chart events', async function() { + let chartPosition = null; + const chart = window.acquireChart( + { + type: 'bar', + data: { + datasets: [{ + data: [{x: 'first', y: 10}, {x: 'second', y: 5}, {x: 'third', y: 15}] + }] + }, + options: { + onHover: (chartEvent) => { + chartPosition = Chart.helpers.getRelativePosition(chartEvent, chart); + } + } + }); + + const point = chart.getDatasetMeta(0).data[1]; + const nativeEvent = await jasmine.triggerMouseEvent(chart, 'mousemove', point); + const nativePosition = Chart.helpers.getRelativePosition(nativeEvent, chart); + + expect(chartPosition).not.toBeNull(); + expect(nativePosition).toEqual({x: chartPosition.x, y: chartPosition.y}); + }); }); }); diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 96ad30bf8cd..232ad9d03e9 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -509,6 +509,7 @@ export declare class Chart< render(): void; draw(): void; + isPointInArea(point: Point): boolean; getElementsAtEventForMode(e: Event, mode: string, options: InteractionOptions, useFinalPosition: boolean): InteractionItem[]; getSortedVisibleDatasetMetas(): ChartMeta[]; @@ -741,6 +742,17 @@ export type InteractionMode = keyof InteractionModeMap; export const Interaction: { modes: InteractionModeMap; + + /** + * Helper function to select candidate elements for interaction + */ + evaluateInteractionItems( + chart: Chart, + axis: InteractionAxis, + position: Point, + handler: (element: Element & VisualElement, datasetIndex: number, index: number) => void, + intersect?: boolean + ): InteractionItem[]; }; export const layouts: { @@ -1395,6 +1407,8 @@ export interface ChartComponent { afterUnregister?(): void; } +export type InteractionAxis = 'x' | 'y' | 'xy' | 'r'; + export interface CoreInteractionOptions { /** * Sets which elements appear in the tooltip. See Interaction Modes for details. @@ -1408,9 +1422,9 @@ export interface CoreInteractionOptions { intersect: boolean; /** - * Can be set to 'x', 'y', 'xy' or 'r' to define which directions are used in calculating distances. Defaults to 'x' for 'index' mode and 'xy' in dataset and 'nearest' modes. + * Defines which directions are used in calculating distances. Defaults to 'x' for 'index' mode and 'xy' in dataset and 'nearest' modes. */ - axis: 'x' | 'y' | 'xy' | 'r'; + axis: InteractionAxis; } export interface CoreChartOptions extends ParsingOptions, AnimationOptions {