Skip to content

Commit

Permalink
Support "r" axis for non-intersecting interaction (#9919)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
luukdv committed Dec 5, 2021
1 parent 6d3a1c5 commit 7784fbf
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 26 deletions.
2 changes: 1 addition & 1 deletion docs/configuration/interactions.md
Expand Up @@ -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.

Expand Down
75 changes: 59 additions & 16 deletions 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
Expand Down Expand Up @@ -49,15 +50,15 @@ 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
*/
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);
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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
*/
Expand All @@ -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}];
Expand All @@ -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 = [];
Expand Down
64 changes: 57 additions & 7 deletions test/specs/core.interaction.tests.js
Expand Up @@ -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: [{
Expand All @@ -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,
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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
Expand All @@ -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);

Expand All @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions types/index.esm.d.ts
Expand Up @@ -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<TType extends ChartType> extends ParsingOptions, AnimationOptions<TType> {
Expand Down

0 comments on commit 7784fbf

Please sign in to comment.