From: Luuk de Vlieger Date: Sun, 5 Dec 2021 13:57:07 +0000 (+0100) Subject: Support "r" axis for non-intersecting interaction (#9919) X-Git-Tag: v3.7.0~19 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7784fbfded3c01b61bb553b7fc769c12e6513884;p=thirdparty%2FChart.js.git Support "r" axis for non-intersecting interaction (#9919) * Support "r" axis for non-intersecting interaction * Extract some interaction functionality * Remove whitespace and semicolons * WIP: add interaction test * Update documentation * Fix test * Add another test * Update axis params * Add additional axis check to binary search * Update axis type --- diff --git a/docs/configuration/interactions.md b/docs/configuration/interactions.md index a2b924495..b19b397c3 100644 --- a/docs/configuration/interactions.md +++ b/docs/configuration/interactions.md @@ -6,7 +6,7 @@ Namespace: `options.interaction`, the global interaction configuration is at `Ch | ---- | ---- | ------- | ----------- | `mode` | `string` | `'nearest'` | Sets which elements appear in the interaction. See [Interaction Modes](#modes) for details. | `intersect` | `boolean` | `true` | if true, the interaction mode only applies when the mouse position intersects an item on the chart. -| `axis` | `string` | `'x'` | Can be set to `'x'`, `'y'`, or `'xy'` to define which directions are used in calculating distances. Defaults to `'x'` for `'index'` mode and `'xy'` in `dataset` and `'nearest'` modes. +| `axis` | `string` | `'x'` | 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. By default, these options apply to both the hover and tooltip interactions. The same options can be set in the `options.hover` namespace, in which case they will only affect the hover interaction. Similarly, the options can be set in the `options.plugins.tooltip` namespace to independently configure the tooltip interactions. diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index 0c37d1d8c..0a3502097 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -1,6 +1,7 @@ import {_isPointInArea} from '../helpers/helpers.canvas'; import {_lookupByKey, _rlookupByKey} from '../helpers/helpers.collection'; import {getRelativePosition as helpersGetRelativePosition} from '../helpers/helpers.dom'; +import {_angleBetween, getAngleFromPoint} from '../helpers/helpers.math'; /** * @typedef { import("./core.controller").default } Chart @@ -49,7 +50,7 @@ function evaluateAllVisibleItems(chart, handler) { /** * Helper function to do binary search when possible * @param {object} metaset - the dataset meta - * @param {string} axis - the axis mide. x|y|xy + * @param {string} axis - the axis mode. x|y|xy|r * @param {number} value - the value to find * @param {boolean} [intersect] - should the element intersect * @returns {{lo:number, hi:number}} indices to search data array between @@ -57,7 +58,7 @@ function evaluateAllVisibleItems(chart, handler) { function binarySearch(metaset, axis, value, intersect) { const {controller, data, _sorted} = metaset; const iScale = controller._cachedMeta.iScale; - if (iScale && axis === iScale.axis && _sorted && data.length) { + if (iScale && axis === iScale.axis && axis !== 'r' && _sorted && data.length) { const lookupMethod = iScale._reversePixels ? _rlookupByKey : _lookupByKey; if (!intersect) { return lookupMethod(data, axis, value); @@ -81,7 +82,7 @@ function binarySearch(metaset, axis, value, intersect) { /** * Helper function to get items using binary search, when the data is sorted. * @param {Chart} chart - the chart - * @param {string} axis - the axis mode. x|y|xy + * @param {string} axis - the axis mode. x|y|xy|r * @param {object} position - the point to be nearest to * @param {function} handler - the callback to execute for each visible item * @param {boolean} [intersect] - consider intersecting items @@ -104,7 +105,7 @@ function optimizedEvaluateItems(chart, axis, position, handler, intersect) { /** * Get a distance metric function for two points based on the * axis mode setting - * @param {string} axis - the axis mode. x|y|xy + * @param {string} axis - the axis mode. x|y|xy|r */ function getDistanceMetricForAxis(axis) { const useX = axis.indexOf('x') !== -1; @@ -121,7 +122,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 {string} axis - the axis mode. x|y|xy + * @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 */ @@ -143,32 +144,55 @@ function getIntersectItems(chart, position, axis, useFinalPosition) { } /** - * Helper function to get the items nearest to the event position considering all visible items in the chart + * 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 {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 * @return {InteractionItem[]} the nearest items */ -function getNearestItems(chart, position, axis, intersect, useFinalPosition) { - const distanceMetric = getDistanceMetricForAxis(axis); - let minDistance = Number.POSITIVE_INFINITY; +function getNearestRadialItems(chart, position, axis, useFinalPosition) { let items = []; - if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) { - return items; + function evaluationFunc(element, datasetIndex, index) { + const {startAngle, endAngle} = element.getProps(['startAngle', 'endAngle'], useFinalPosition); + const {angle} = getAngleFromPoint(element, {x: position.x, y: position.y}); + + if (_angleBetween(angle, startAngle, endAngle)) { + items.push({element, datasetIndex, index}); + } } - const evaluationFunc = function(element, datasetIndex, index) { - if (intersect && !element.inRange(position.x, position.y, useFinalPosition)) { + optimizedEvaluateItems(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 {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 + * @return {InteractionItem[]} the nearest items + */ +function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition) { + let items = []; + const distanceMetric = getDistanceMetricForAxis(axis); + let minDistance = Number.POSITIVE_INFINITY; + + function evaluationFunc(element, datasetIndex, index) { + const inRange = element.inRange(position.x, position.y, useFinalPosition); + if (intersect && !inRange) { return; } const center = element.getCenterPoint(useFinalPosition); - if (!_isPointInArea(center, chart.chartArea, chart._minPadding) && !element.inRange(position.x, position.y, useFinalPosition)) { + const pointInArea = _isPointInArea(center, chart.chartArea, chart._minPadding); + if (!pointInArea && !inRange) { return; } + const distance = distanceMetric(position, center); if (distance < minDistance) { items = [{element, datasetIndex, index}]; @@ -177,12 +201,31 @@ function getNearestItems(chart, position, axis, intersect, useFinalPosition) { // Can have multiple items at the same distance in which case we sort by size items.push({element, datasetIndex, index}); } - }; + } optimizedEvaluateItems(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 {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 + * @return {InteractionItem[]} the nearest items + */ +function getNearestItems(chart, position, axis, intersect, useFinalPosition) { + if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) { + return []; + } + + return axis === 'r' && !intersect + ? getNearestRadialItems(chart, position, axis, useFinalPosition) + : getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition); +} + function getAxisItems(chart, e, options, useFinalPosition) { const position = getRelativePosition(e, chart); const items = []; diff --git a/test/specs/core.interaction.tests.js b/test/specs/core.interaction.tests.js index f3e27aacb..79d9b1048 100644 --- a/test/specs/core.interaction.tests.js +++ b/test/specs/core.interaction.tests.js @@ -325,7 +325,7 @@ describe('Core.Interaction', function() { describe('nearest mode', function() { describe('intersect: false', function() { beforeEach(function() { - this.chart = window.acquireChart({ + this.lineChart = window.acquireChart({ type: 'line', data: { datasets: [{ @@ -344,11 +344,27 @@ describe('Core.Interaction', function() { labels: ['Point 1', 'Point 2', 'Point 3'] } }); + this.polarChart = window.acquireChart({ + type: 'polarArea', + data: { + datasets: [{ + data: [1, 9, 5] + }], + labels: ['Point 1', 'Point 2', 'Point 3'] + }, + options: { + plugins: { + legend: { + display: false + }, + }, + } + }); }); describe('axis: xy', function() { it ('should return the nearest item', function() { - var chart = this.chart; + var chart = this.lineChart; var evt = { type: 'click', chart: chart, @@ -364,7 +380,7 @@ describe('Core.Interaction', function() { }); it ('should return all items at the same nearest distance', function() { - var chart = this.chart; + var chart = this.lineChart; var meta0 = chart.getDatasetMeta(0); var meta1 = chart.getDatasetMeta(1); @@ -390,7 +406,7 @@ describe('Core.Interaction', function() { describe('axis: x', function() { it ('should return all items at current x', function() { - var chart = this.chart; + var chart = this.lineChart; var meta0 = chart.getDatasetMeta(0); var meta1 = chart.getDatasetMeta(1); @@ -414,7 +430,7 @@ describe('Core.Interaction', function() { }); it ('should return all items at nearest x-distance', function() { - var chart = this.chart; + var chart = this.lineChart; var meta0 = chart.getDatasetMeta(0); var meta1 = chart.getDatasetMeta(1); @@ -440,7 +456,7 @@ describe('Core.Interaction', function() { describe('axis: y', function() { it ('should return item with value 30', function() { - var chart = this.chart; + var chart = this.lineChart; var meta0 = chart.getDatasetMeta(0); // 'Point 1', y = 30 @@ -463,7 +479,7 @@ describe('Core.Interaction', function() { }); it ('should return all items at value 40', function() { - var chart = this.chart; + var chart = this.lineChart; var meta0 = chart.getDatasetMeta(0); var meta1 = chart.getDatasetMeta(1); @@ -486,6 +502,40 @@ describe('Core.Interaction', function() { expect(elements).toEqual([meta0.data[1], meta1.data[0], meta1.data[1], meta1.data[2]]); }); }); + + describe('axis: r', function() { + it ('should return item with value 9', function() { + var chart = this.polarChart; + var meta0 = chart.getDatasetMeta(0); + + var evt = { + type: 'click', + chart: chart, + native: true, // Needed, otherwise assumed to be a DOM event + x: chart.width / 2, + y: chart.height / 2 + 5, + }; + + var elements = Chart.Interaction.modes.nearest(chart, evt, {axis: 'r'}).map(item => item.element); + expect(elements).toEqual([meta0.data[1]]); + }); + + it ('should return item with value 1 when clicked outside of it', function() { + var chart = this.polarChart; + var meta0 = chart.getDatasetMeta(0); + + var evt = { + type: 'click', + chart: chart, + native: true, // Needed, otherwise assumed to be a DOM event + x: chart.width, + y: 0, + }; + + var elements = Chart.Interaction.modes.nearest(chart, evt, {axis: 'r', intersect: false}).map(item => item.element); + expect(elements).toEqual([meta0.data[0]]); + }); + }); }); describe('intersect: true', function() { diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 29512c4cf..22d6a53a0 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -1391,9 +1391,9 @@ export interface CoreInteractionOptions { intersect: boolean; /** - * Can be set to 'x', 'y', or 'xy' to define which directions are used in calculating distances. Defaults to 'x' for 'index' mode and 'xy' in dataset and 'nearest' modes. + * 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. */ - axis: 'x' | 'y' | 'xy'; + axis: 'x' | 'y' | 'xy' | 'r'; } export interface CoreChartOptions extends ParsingOptions, AnimationOptions {