From 4f8234e0c61a501b28158bf815de015c9fa0a8ff Mon Sep 17 00:00:00 2001 From: Luuk Date: Mon, 29 Nov 2021 12:13:36 +0100 Subject: [PATCH 01/10] Support "r" axis for non-intersecting interaction --- src/core/core.interaction.js | 56 ++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index 5365f6c9108..02abdcc9199 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 @@ -152,34 +153,51 @@ function getIntersectItems(chart, position, axis, useFinalPosition) { * @return {InteractionItem[]} the nearest items */ function getNearestItems(chart, position, axis, intersect, useFinalPosition) { - const distanceMetric = getDistanceMetricForAxis(axis); - let minDistance = Number.POSITIVE_INFINITY; let items = []; if (!_isPointInArea(position, chart.chartArea, chart._minPadding)) { return items; } - const evaluationFunc = function(element, datasetIndex, index) { - if (intersect && !element.inRange(position.x, position.y, useFinalPosition)) { - return; - } + function getEvaluationFunc() { + if (axis === 'r' && !intersect) { + return function(element, datasetIndex, index) { + const {startAngle, endAngle} = element.getProps(['startAngle', 'endAngle'], useFinalPosition); + const {angle} = getAngleFromPoint(element, {x: position.x, y: position.y}); - const center = element.getCenterPoint(useFinalPosition); - if (!_isPointInArea(center, chart.chartArea, chart._minPadding) && !element.inRange(position.x, position.y, useFinalPosition)) { - return; - } - const distance = distanceMetric(position, center); - if (distance < minDistance) { - items = [{element, datasetIndex, index}]; - minDistance = distance; - } else if (distance === minDistance) { - // Can have multiple items at the same distance in which case we sort by size - items.push({element, datasetIndex, index}); + if (_angleBetween(angle, startAngle, endAngle)) { + items = [{element, datasetIndex, index}]; + } + }; } - }; - optimizedEvaluateItems(chart, axis, position, evaluationFunc); + const distanceMetric = getDistanceMetricForAxis(axis); + let minDistance = Number.POSITIVE_INFINITY; + + return function(element, datasetIndex, index) { + const inRange = element.inRange(position.x, position.y, useFinalPosition); + if (intersect && !inRange) { + return; + } + + const center = element.getCenterPoint(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}]; + minDistance = distance; + } else if (distance === minDistance) { + // Can have multiple items at the same distance in which case we sort by size + items.push({element, datasetIndex, index}); + } + }; + } + + optimizedEvaluateItems(chart, axis, position, getEvaluationFunc()); return items; } From 7629c6e62e07b468787ef380f4075591e311d06f Mon Sep 17 00:00:00 2001 From: Luuk Date: Mon, 29 Nov 2021 12:32:07 +0100 Subject: [PATCH 02/10] Extract some interaction functionality --- src/core/core.interaction.js | 101 ++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index 02abdcc9199..f2250988537 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -144,61 +144,86 @@ 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) { +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}); + } + }; - function getEvaluationFunc() { - if (axis === 'r' && !intersect) { - return function(element, datasetIndex, index) { - const {startAngle, endAngle} = element.getProps(['startAngle', 'endAngle'], useFinalPosition); - const {angle} = getAngleFromPoint(element, {x: position.x, y: position.y}); + optimizedEvaluateItems(chart, axis, position, evaluationFunc); + return items; +} - if (_angleBetween(angle, startAngle, endAngle)) { - items = [{element, datasetIndex, index}]; - } - }; +/** + * 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 distanceMetric = getDistanceMetricForAxis(axis); - let minDistance = Number.POSITIVE_INFINITY; + const center = element.getCenterPoint(useFinalPosition); + const pointInArea = _isPointInArea(center, chart.chartArea, chart._minPadding); + if (!pointInArea && !inRange) { + return; + } - return function(element, datasetIndex, index) { - const inRange = element.inRange(position.x, position.y, useFinalPosition); - if (intersect && !inRange) { - return; - } + const distance = distanceMetric(position, center); + if (distance < minDistance) { + items = [{element, datasetIndex, index}]; + minDistance = distance; + } else if (distance === minDistance) { + // Can have multiple items at the same distance in which case we sort by size + items.push({element, datasetIndex, index}); + } + }; - const center = element.getCenterPoint(useFinalPosition); - const pointInArea = _isPointInArea(center, chart.chartArea, chart._minPadding); - if (!pointInArea && !inRange) { - return; - } + optimizedEvaluateItems(chart, axis, position, evaluationFunc); + return items; +} - const distance = distanceMetric(position, center); - if (distance < minDistance) { - items = [{element, datasetIndex, index}]; - minDistance = distance; - } else if (distance === minDistance) { - // Can have multiple items at the same distance in which case we sort by size - items.push({element, datasetIndex, index}); - } - }; +/** + * 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 []; } - optimizedEvaluateItems(chart, axis, position, getEvaluationFunc()); - return items; + return axis === 'r' && !intersect + ? getNearestRadialItems(chart, position, axis, useFinalPosition) + : getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition); } function getAxisItems(chart, e, options, useFinalPosition) { From 8ea1b88b4494b1f6147a6937a42e74d329f412aa Mon Sep 17 00:00:00 2001 From: Luuk Date: Mon, 29 Nov 2021 12:56:42 +0100 Subject: [PATCH 03/10] Remove whitespace and semicolons --- src/core/core.interaction.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index f2250988537..695348521ae 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -157,11 +157,11 @@ function getNearestRadialItems(chart, position, axis, useFinalPosition) { 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}); } - }; + } optimizedEvaluateItems(chart, axis, position, evaluationFunc); return items; @@ -201,7 +201,7 @@ function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosi // 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; From c4acb36b67da4876974cea3c8b733af813a8ee46 Mon Sep 17 00:00:00 2001 From: Luuk Date: Mon, 29 Nov 2021 15:16:13 +0100 Subject: [PATCH 04/10] WIP: add interaction test --- test/specs/core.interaction.tests.js | 40 +++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/test/specs/core.interaction.tests.js b/test/specs/core.interaction.tests.js index f3e27aacb73..86c2b6848af 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,19 @@ describe('Core.Interaction', function() { labels: ['Point 1', 'Point 2', 'Point 3'] } }); + this.polarChart = window.acquireChart({ + type: 'polarArea', + data: { + datasets: [{ + data: [0.1, 0.4, 0.3], + }], + } + }); }); 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 +372,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 +398,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 +422,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 +448,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 +471,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 +494,24 @@ 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 0.3', 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: 256, + y: 265, + }; + + var elements = Chart.Interaction.modes.nearest(chart, evt, {axis: 'r', intersect: false}).map(item => item.element); + expect(elements).toEqual([meta0.data[2]]); + }); + }); }); describe('intersect: true', function() { From c8c4ce70714e558f5f096d8f967d15f9592e9ba5 Mon Sep 17 00:00:00 2001 From: Luuk Date: Mon, 29 Nov 2021 15:19:45 +0100 Subject: [PATCH 05/10] Update documentation --- docs/configuration/interactions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/interactions.md b/docs/configuration/interactions.md index a2b92449583..b19b397c376 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. From 147fb463d97d53c80785524ba22bfddec82f46a5 Mon Sep 17 00:00:00 2001 From: Luuk Date: Tue, 30 Nov 2021 09:24:27 +0100 Subject: [PATCH 06/10] Fix test --- test/specs/core.interaction.tests.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/specs/core.interaction.tests.js b/test/specs/core.interaction.tests.js index 86c2b6848af..90bd5d9d665 100644 --- a/test/specs/core.interaction.tests.js +++ b/test/specs/core.interaction.tests.js @@ -348,8 +348,16 @@ describe('Core.Interaction', function() { type: 'polarArea', data: { datasets: [{ - data: [0.1, 0.4, 0.3], + data: [1, 9, 5] }], + labels: ['Point 1', 'Point 2', 'Point 3'] + }, + options: { + plugins: { + legend: { + display: false + }, + }, } }); }); @@ -496,7 +504,7 @@ describe('Core.Interaction', function() { }); describe('axis: r', function() { - it ('should return item with value 0.3', function() { + it ('should return item with value 9', function() { var chart = this.polarChart; var meta0 = chart.getDatasetMeta(0); @@ -504,12 +512,12 @@ describe('Core.Interaction', function() { type: 'click', chart: chart, native: true, // Needed, otherwise assumed to be a DOM event - x: 256, - y: 265, + x: chart.width / 2, + y: chart.height / 2 + 5, }; var elements = Chart.Interaction.modes.nearest(chart, evt, {axis: 'r', intersect: false}).map(item => item.element); - expect(elements).toEqual([meta0.data[2]]); + expect(elements).toEqual([meta0.data[1]]); }); }); }); From cf4e8d228f42ef29f7b365a86b0be4c771a75f91 Mon Sep 17 00:00:00 2001 From: Luuk Date: Tue, 30 Nov 2021 10:07:55 +0100 Subject: [PATCH 07/10] Add another test --- test/specs/core.interaction.tests.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/specs/core.interaction.tests.js b/test/specs/core.interaction.tests.js index 90bd5d9d665..79d9b104894 100644 --- a/test/specs/core.interaction.tests.js +++ b/test/specs/core.interaction.tests.js @@ -516,9 +516,25 @@ describe('Core.Interaction', function() { y: chart.height / 2 + 5, }; - var elements = Chart.Interaction.modes.nearest(chart, evt, {axis: 'r', intersect: false}).map(item => item.element); + 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]]); + }); }); }); From 3863d24aa028bdca99047683c263b6e30005a9be Mon Sep 17 00:00:00 2001 From: Luuk Date: Tue, 30 Nov 2021 11:23:38 +0100 Subject: [PATCH 08/10] Update axis params --- src/core/core.interaction.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index 695348521ae..a896f8e4370 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -50,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 @@ -82,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 @@ -105,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; @@ -122,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 */ From 5ea3930ea68de1a9e45345dc1705a5ab95decf23 Mon Sep 17 00:00:00 2001 From: Luuk Date: Tue, 30 Nov 2021 11:25:33 +0100 Subject: [PATCH 09/10] Add additional axis check to binary search --- src/core/core.interaction.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/core.interaction.js b/src/core/core.interaction.js index a896f8e4370..6389761ccf6 100644 --- a/src/core/core.interaction.js +++ b/src/core/core.interaction.js @@ -58,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); From bb45479d6ec61588dfdafb8431bfce08a6cf9ae1 Mon Sep 17 00:00:00 2001 From: Luuk Date: Tue, 30 Nov 2021 12:00:52 +0100 Subject: [PATCH 10/10] Update axis type --- types/index.esm.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index cf1570a3105..bfe6edf5ba3 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 {