Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support "r" axis for non-intersecting interaction #9919

Merged
merged 10 commits into from Dec 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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