mirror of
https://github.com/chartjs/Chart.js.git
synced 2025-12-08 20:36:08 +00:00
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
import {_lookupByKey, _rlookupByKey} from '../helpers/helpers.collection.js';
|
|
import {getRelativePosition} from '../helpers/helpers.dom.js';
|
|
import {_angleBetween, getAngleFromPoint} from '../helpers/helpers.math.js';
|
|
import {_isPointInArea} from '../helpers/index.js';
|
|
|
|
/**
|
|
* @typedef { import('./core.controller.js').default } Chart
|
|
* @typedef { import('../types/index.js').ChartEvent } ChartEvent
|
|
* @typedef {{axis?: string, intersect?: boolean, includeInvisible?: boolean}} InteractionOptions
|
|
* @typedef {{datasetIndex: number, index: number, element: import('./core.element.js').default}} InteractionItem
|
|
* @typedef { import('../types/index.js').Point } Point
|
|
*/
|
|
|
|
/**
|
|
* Helper function to do binary search when possible
|
|
* @param {object} metaset - the dataset meta
|
|
* @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 && axis !== 'r' && _sorted && data.length) {
|
|
const lookupMethod = iScale._reversePixels ? _rlookupByKey : _lookupByKey;
|
|
if (!intersect) {
|
|
return lookupMethod(data, axis, value);
|
|
} else if (controller._sharedOptions) {
|
|
// _sharedOptions indicates that each element has equal options -> equal proportions
|
|
// So we can do a ranged binary search based on the range of first element and
|
|
// be confident to get the full range of indices that can intersect with the value.
|
|
const el = data[0];
|
|
const range = typeof el.getRange === 'function' && el.getRange(axis);
|
|
if (range) {
|
|
const start = lookupMethod(data, axis, value - range);
|
|
const end = lookupMethod(data, axis, value + range);
|
|
return {lo: start.lo, hi: end.hi};
|
|
}
|
|
}
|
|
}
|
|
// Default to all elements, when binary search can not be used.
|
|
return {lo: 0, hi: data.length - 1};
|
|
}
|
|
|
|
/**
|
|
* Helper function to select candidate elements for interaction
|
|
* @param {Chart} chart - the chart
|
|
* @param {string} axis - the axis mode. x|y|xy|r
|
|
* @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 evaluateInteractionItems(chart, axis, position, handler, intersect) {
|
|
const metasets = chart.getSortedVisibleDatasetMetas();
|
|
const value = position[axis];
|
|
for (let i = 0, ilen = metasets.length; i < ilen; ++i) {
|
|
const {index, data} = metasets[i];
|
|
const {lo, hi} = binarySearch(metasets[i], axis, value, intersect);
|
|
for (let j = lo; j <= hi; ++j) {
|
|
const element = data[j];
|
|
if (!element.skip) {
|
|
handler(element, index, j);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a distance metric function for two points based on the
|
|
* axis mode setting
|
|
* @param {string} axis - the axis mode. x|y|xy|r
|
|
*/
|
|
function getDistanceMetricForAxis(axis) {
|
|
const useX = axis.indexOf('x') !== -1;
|
|
const useY = axis.indexOf('y') !== -1;
|
|
|
|
return function(pt1, pt2) {
|
|
const deltaX = useX ? Math.abs(pt1.x - pt2.x) : 0;
|
|
const deltaY = useY ? Math.abs(pt1.y - pt2.y) : 0;
|
|
return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper function to get the items that intersect the event position
|
|
* @param {Chart} chart - the chart
|
|
* @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
|
|
* @param {boolean} [includeInvisible] - include invisible points that are outside of the chart area
|
|
* @return {InteractionItem[]} the nearest items
|
|
*/
|
|
function getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible) {
|
|
const items = [];
|
|
|
|
if (!includeInvisible && !chart.isPointInArea(position)) {
|
|
return items;
|
|
}
|
|
|
|
const evaluationFunc = function(element, datasetIndex, index) {
|
|
if (!includeInvisible && !_isPointInArea(element, chart.chartArea, 0)) {
|
|
return;
|
|
}
|
|
if (element.inRange(position.x, position.y, useFinalPosition)) {
|
|
items.push({element, datasetIndex, index});
|
|
}
|
|
};
|
|
|
|
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 {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 element's animation target instead of current position
|
|
* @return {InteractionItem[]} the nearest items
|
|
*/
|
|
function getNearestRadialItems(chart, position, axis, useFinalPosition) {
|
|
let 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});
|
|
}
|
|
}
|
|
|
|
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 {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 element's animation target instead of current position
|
|
* @param {boolean} [includeInvisible] - include invisible points that are outside of the chart area
|
|
* @return {InteractionItem[]} the nearest items
|
|
*/
|
|
function getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition, includeInvisible) {
|
|
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);
|
|
const pointInArea = !!includeInvisible || chart.isPointInArea(center);
|
|
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});
|
|
}
|
|
}
|
|
|
|
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 {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 element's animation target instead of current position
|
|
* @param {boolean} [includeInvisible] - include invisible points that are outside of the chart area
|
|
* @return {InteractionItem[]} the nearest items
|
|
*/
|
|
function getNearestItems(chart, position, axis, intersect, useFinalPosition, includeInvisible) {
|
|
if (!includeInvisible && !chart.isPointInArea(position)) {
|
|
return [];
|
|
}
|
|
|
|
return axis === 'r' && !intersect
|
|
? getNearestRadialItems(chart, position, axis, useFinalPosition)
|
|
: getNearestCartesianItems(chart, position, axis, intersect, useFinalPosition, includeInvisible);
|
|
}
|
|
|
|
/**
|
|
* 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 rangeMethod = axis === 'x' ? 'inXRange' : 'inYRange';
|
|
let intersectsItem = false;
|
|
|
|
evaluateInteractionItems(chart, axis, position, (element, datasetIndex, index) => {
|
|
if (element[rangeMethod] && element[rangeMethod](position[axis], useFinalPosition)) {
|
|
items.push({element, datasetIndex, index});
|
|
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 (intersect && !intersectsItem) {
|
|
return [];
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Contains interaction related functions
|
|
* @namespace Chart.Interaction
|
|
*/
|
|
export default {
|
|
// Part of the public API to facilitate developers creating their own modes
|
|
evaluateInteractionItems,
|
|
|
|
// Helper function for different modes
|
|
modes: {
|
|
/**
|
|
* Returns items at the same index. If the options.intersect parameter is true, we only return items if we intersect something
|
|
* If the options.intersect mode is false, we find the nearest item and return the items at the same index as that item
|
|
* @function Chart.Interaction.modes.index
|
|
* @since v2.4.0
|
|
* @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
|
|
*/
|
|
index(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
// Default axis for index mode is 'x' to match old behaviour
|
|
const axis = options.axis || 'x';
|
|
const includeInvisible = options.includeInvisible || false;
|
|
const items = options.intersect
|
|
? getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible)
|
|
: getNearestItems(chart, position, axis, false, useFinalPosition, includeInvisible);
|
|
const elements = [];
|
|
|
|
if (!items.length) {
|
|
return [];
|
|
}
|
|
|
|
chart.getSortedVisibleDatasetMetas().forEach((meta) => {
|
|
const index = items[0].index;
|
|
const element = meta.data[index];
|
|
|
|
// don't count items that are skipped (null data)
|
|
if (element && !element.skip) {
|
|
elements.push({element, datasetIndex: meta.index, index});
|
|
}
|
|
});
|
|
|
|
return elements;
|
|
},
|
|
|
|
/**
|
|
* Returns items in the same dataset. If the options.intersect parameter is true, we only return items if we intersect something
|
|
* If the options.intersect is false, we find the nearest item and return the items in that dataset
|
|
* @function Chart.Interaction.modes.dataset
|
|
* @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
|
|
*/
|
|
dataset(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
const axis = options.axis || 'xy';
|
|
const includeInvisible = options.includeInvisible || false;
|
|
let items = options.intersect
|
|
? getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible) :
|
|
getNearestItems(chart, position, axis, false, useFinalPosition, includeInvisible);
|
|
|
|
if (items.length > 0) {
|
|
const datasetIndex = items[0].datasetIndex;
|
|
const data = chart.getDatasetMeta(datasetIndex).data;
|
|
items = [];
|
|
for (let i = 0; i < data.length; ++i) {
|
|
items.push({element: data[i], datasetIndex, index: i});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
},
|
|
|
|
/**
|
|
* Point mode returns all elements that hit test based on the event position
|
|
* of the event
|
|
* @function Chart.Interaction.modes.intersect
|
|
* @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
|
|
*/
|
|
point(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
const axis = options.axis || 'xy';
|
|
const includeInvisible = options.includeInvisible || false;
|
|
return getIntersectItems(chart, position, axis, useFinalPosition, includeInvisible);
|
|
},
|
|
|
|
/**
|
|
* nearest mode returns the element closest to the point
|
|
* @function Chart.Interaction.modes.intersect
|
|
* @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
|
|
*/
|
|
nearest(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
const axis = options.axis || 'xy';
|
|
const includeInvisible = options.includeInvisible || false;
|
|
return getNearestItems(chart, position, axis, options.intersect, useFinalPosition, includeInvisible);
|
|
},
|
|
|
|
/**
|
|
* x mode returns the elements that hit-test at the current x coordinate
|
|
* @function Chart.Interaction.modes.x
|
|
* @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
|
|
*/
|
|
x(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
return getAxisItems(chart, position, 'x', options.intersect, useFinalPosition);
|
|
},
|
|
|
|
/**
|
|
* y mode returns the elements that hit-test at the current y coordinate
|
|
* @function Chart.Interaction.modes.y
|
|
* @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
|
|
*/
|
|
y(chart, e, options, useFinalPosition) {
|
|
const position = getRelativePosition(e, chart);
|
|
return getAxisItems(chart, position, 'y', options.intersect, useFinalPosition);
|
|
}
|
|
}
|
|
};
|