diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c9ac4ae1c..7007dfa86 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -12,6 +12,7 @@ The following options are available at the top level. They apply to all annotati | `clip` | `boolean` | No | `true` | Are the annotations clipped to the chartArea. | `dblClickSpeed` | `number` | Yes | `350` | Time to detect a double click in ms. | `drawTime` | `string` | Yes | `'afterDatasetsDraw'` | See [drawTime](options#draw-time). +| [`interaction`](options#interaction) | `Object` | No | `options.interaction` | To configure which events trigger plugin interactions :::warning diff --git a/docs/guide/migrationV2.md b/docs/guide/migrationV2.md index 47e0ef6d8..eeaae0fba 100644 --- a/docs/guide/migrationV2.md +++ b/docs/guide/migrationV2.md @@ -9,3 +9,9 @@ A number of changes were made to the configuration options passed to the plugin * `xScaleID` option default has been changed, now set to `undefined`. If the option is missing, the plugin will try to use the first scale of the chart, configured as `'x'` axis. If more than one scale has been defined in the chart as `'x'` axis, the option is mandatory to select the right scale. * `yScaleID` option default has been changed, now set to `undefined`. If the option is missing, the plugin will try to use the first scale of the chart, configured as `'y'` axis. If more than one scale has been defined in the chart as `'y'` axis, the option is mandatory to select the right scale. * When [stacked scales](https://www.chartjs.org/docs/latest/axes/cartesian/#common-options-to-all-cartesian-axes) are used, instead of the whole chart area, the designated scale area is used as fallback for `xMin`, `xMax`, `yMin`, `yMax`, `xValue` or `yValue` options. + +## Events + +`chartjs-plugin-annotation` plugin version 2 introduces the [`interaction`](options#interaction) options, to configure which events trigger annotation interactions. By default, the plugin uses the [chart interaction configuration](https://www.chartjs.org/docs/latest/configuration/interactions.html#interactions). + + * When [scatter charts](https://www.chartjs.org/docs/latest/charts/scatter.html) are used, the interaction default `mode` in Chart.js is `point`, while, in the previous plugin version, the default was `nearest`. diff --git a/docs/guide/options.md b/docs/guide/options.md index 3b97291df..13c85f875 100644 --- a/docs/guide/options.md +++ b/docs/guide/options.md @@ -16,6 +16,18 @@ Paddings use the same format as [chart.js](https://www.chartjs.org/docs/master/g Point styles use the same format as [chart.js](https://www.chartjs.org/docs/master/configuration/elements.html#point-styles). +## Interaction + +Interaction uses the same format as [chart.js](https://www.chartjs.org/docs/latest/configuration/interactions.html#interactions). + +:::warning + +Interaction `index` and `dataset` modes are not supported by the plugin. If set, the plugin will use `nearest` mode. + +Interaction `r` axis is not supported by the plugin. If set, the plugin will use `xy` mode. + +::: + ## Scriptable Options As with most options in chart.js, the annotation plugin options are scriptable. This means that a function can be passed which returns the value as needed. In the example below, the annotation is hidden when the screen is less than 1000px wide. diff --git a/src/annotation.js b/src/annotation.js index 08d585f38..e935eba76 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -47,7 +47,8 @@ export default { visibleElements: [], listeners: {}, listened: false, - moveListened: false + moveListened: false, + hovered: [] }); }, @@ -121,6 +122,11 @@ export default { clip: true, dblClickSpeed: 350, // ms drawTime: 'afterDatasetsDraw', + interaction: { + mode: undefined, + axis: undefined, + intersect: undefined + }, label: { drawTime: null } @@ -133,6 +139,9 @@ export default { _allKeys: false, _fallback: (prop, opts) => `elements.${annotationTypes[resolveType(opts.type)].id}`, }, + interaction: { + _fallback: true, + } }, additionalOptionScopes: [''] diff --git a/src/events.js b/src/events.js index 17c984140..f59b9083f 100644 --- a/src/events.js +++ b/src/events.js @@ -1,4 +1,5 @@ -import {distanceBetweenPoints, defined, callback} from 'chart.js/helpers'; +import {defined, callback} from 'chart.js/helpers'; +import {getElements} from './interaction'; const clickHooks = ['click', 'dblclick']; const moveHooks = ['enter', 'leave']; @@ -7,6 +8,7 @@ export const hooks = clickHooks.concat(moveHooks); export function updateListeners(chart, state, options) { state.listened = false; state.moveListened = false; + state._getElements = getElements; // for testing hooks.forEach(hook => { if (typeof options[hook] === 'function') { @@ -48,7 +50,7 @@ export function handleEvent(state, event, options) { switch (event.type) { case 'mousemove': case 'mouseout': - handleMoveEvents(state, event); + handleMoveEvents(state, event, options); break; case 'click': handleClickEvents(state, event, options); @@ -58,37 +60,39 @@ export function handleEvent(state, event, options) { } } -function handleMoveEvents(state, event) { +function handleMoveEvents(state, event, options) { if (!state.moveListened) { return; } - let element; + let elements; if (event.type === 'mousemove') { - element = getNearestItem(state.elements, event); + elements = getElements(state, event, options.interaction); + } else { + elements = []; } const previous = state.hovered; - state.hovered = element; + state.hovered = elements; - dispatchMoveEvents(state, {previous, element}, event); + const context = {state, event}; + dispatchMoveEvents(context, 'leave', previous, elements); + dispatchMoveEvents(context, 'enter', elements, previous); } -function dispatchMoveEvents(state, elements, event) { - const {previous, element} = elements; - if (previous && previous !== element) { - dispatchEvent(previous.options.leave || state.listeners.leave, previous, event); - } - if (element && element !== previous) { - dispatchEvent(element.options.enter || state.listeners.enter, element, event); +function dispatchMoveEvents({state, event}, hook, elements, checkElements) { + for (const element of elements) { + if (checkElements.indexOf(element) < 0) { + dispatchEvent(element.options[hook] || state.listeners[hook], element, event); + } } } function handleClickEvents(state, event, options) { const listeners = state.listeners; - const element = getNearestItem(state.elements, event); - if (element) { + const elements = getElements(state, event, options.interaction); + for (const element of elements) { const elOpts = element.options; const dblclick = elOpts.dblclick || listeners.dblclick; const click = elOpts.click || listeners.click; @@ -113,26 +117,3 @@ function handleClickEvents(state, event, options) { function dispatchEvent(handler, element, event) { callback(handler, [element.$context, event]); } - -function getNearestItem(elements, position) { - let minDistance = Number.POSITIVE_INFINITY; - - return elements - .filter((element) => element.options.display && element.inRange(position.x, position.y)) - .reduce((nearestItems, element) => { - const center = element.getCenterPoint(); - const distance = distanceBetweenPoints(position, center); - - if (distance < minDistance) { - nearestItems = [element]; - minDistance = distance; - } else if (distance === minDistance) { - // Can have multiple items at the same distance in which case we sort by size - nearestItems.push(element); - } - - return nearestItems; - }, []) - .sort((a, b) => a._index - b._index) - .slice(0, 1)[0]; // return only the top item -} diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index e169e228c..c2c16acc0 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -13,16 +13,20 @@ export function inPointRange(point, center, radius, borderWidth) { if (!point || !center || radius <= 0) { return false; } - const hBorderWidth = borderWidth / 2 || 0; + const hBorderWidth = borderWidth / 2; return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hBorderWidth, 2); } -export function inBoxRange(mouseX, mouseY, {x, y, width, height}, borderWidth) { +export function inBoxRange(point, {x, y, width, height}, axis, borderWidth) { const hBorderWidth = borderWidth / 2; - return mouseX >= x - hBorderWidth - EPSILON && - mouseX <= x + width + hBorderWidth + EPSILON && - mouseY >= y - hBorderWidth - EPSILON && - mouseY <= y + height + hBorderWidth + EPSILON; + const inRangeX = point.x >= x - hBorderWidth - EPSILON && point.x <= x + width + hBorderWidth + EPSILON; + const inRangeY = point.y >= y - hBorderWidth - EPSILON && point.y <= y + height + hBorderWidth + EPSILON; + if (axis === 'x') { + return inRangeX; + } else if (axis === 'y') { + return inRangeY; + } + return inRangeX && inRangeY; } export function getElementCenterPoint(element, useFinalPosition) { diff --git a/src/interaction.js b/src/interaction.js new file mode 100644 index 000000000..51a87b4ea --- /dev/null +++ b/src/interaction.js @@ -0,0 +1,101 @@ +import {distanceBetweenPoints} from 'chart.js/helpers'; + +const interaction = { + modes: { + /** + * Point mode returns all elements that hit test based on the event position + * @param {Object} state - the state of the plugin + * @param {ChartEvent} event - the event we are find things at + * @return {Element[]} - elements that are found + */ + point(state, event) { + return filterElements(state, event, {intersect: true}); + }, + + /** + * Nearest mode returns the element closest to the event position + * @param {Object} state - the state of the plugin + * @param {ChartEvent} event - the event we are find things at + * @param {Object} options - interaction options to use + * @return {Element[]} - elements that are found (only 1 element) + */ + nearest(state, event, options) { + return getNearestItem(state, event, options); + }, + /** + * x mode returns the elements that hit-test at the current x coordinate + * @param {Object} state - the state of the plugin + * @param {ChartEvent} event - the event we are find things at + * @param {Object} options - interaction options to use + * @return {Element[]} - elements that are found + */ + x(state, event, options) { + return filterElements(state, event, {intersect: options.intersect, axis: 'x'}); + }, + + /** + * y mode returns the elements that hit-test at the current y coordinate + * @param {Object} state - the state of the plugin + * @param {ChartEvent} event - the event we are find things at + * @param {Object} options - interaction options to use + * @return {Element[]} - elements that are found + */ + y(state, event, options) { + return filterElements(state, event, {intersect: options.intersect, axis: 'y'}); + } + } +}; + +/** + * Returns all elements that hit test based on the event position + * @param {Object} state - the state of the plugin + * @param {ChartEvent} event - the event we are find things at + * @param {Object} options - interaction options to use + * @return {Element[]} - elements that are found + */ +export function getElements(state, event, options) { + const mode = interaction.modes[options.mode] || interaction.modes.nearest; + return mode(state, event, options); +} + +function inRangeByAxis(element, event, axis) { + if (axis !== 'x' && axis !== 'y') { + return element.inRange(event.x, event.y, 'x', true) || element.inRange(event.x, event.y, 'y', true); + } + return element.inRange(event.x, event.y, axis, true); +} + +function getPointByAxis(event, center, axis) { + if (axis === 'x') { + return {x: event.x, y: center.y}; + } else if (axis === 'y') { + return {x: center.x, y: event.y}; + } + return center; +} + +function filterElements(state, event, options) { + return state.visibleElements.filter((element) => options.intersect ? element.inRange(event.x, event.y) : inRangeByAxis(element, event, options.axis)); +} + +function getNearestItem(state, event, options) { + let minDistance = Number.POSITIVE_INFINITY; + + return filterElements(state, event, options) + .reduce((nearestItems, element) => { + const center = element.getCenterPoint(); + const evenPoint = getPointByAxis(event, center, options.axis); + const distance = distanceBetweenPoints(event, evenPoint); + if (distance < minDistance) { + nearestItems = [element]; + minDistance = distance; + } else if (distance === minDistance) { + // Can have multiple items at the same distance in which case we sort by size + nearestItems.push(element); + } + + return nearestItems; + }, []) + .sort((a, b) => a._index - b._index) + .slice(0, 1); // return only the top item; +} diff --git a/src/types/box.js b/src/types/box.js index 7daa704c1..1b26b6f4f 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -3,9 +3,10 @@ import {toPadding, toRadians} from 'chart.js/helpers'; import {drawBox, drawLabel, getRelativePosition, measureLabelSize, getRectCenterPoint, getChartRect, toPosition, inBoxRange, rotated, translate} from '../helpers'; export default class BoxAnnotation extends Element { - inRange(mouseX, mouseY, useFinalPosition) { + + inRange(mouseX, mouseY, axis, useFinalPosition) { const {x, y} = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation)); - return inBoxRange(x, y, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), this.options.borderWidth); + return inBoxRange({x, y}, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), axis, this.options.borderWidth); } getCenterPoint(useFinalPosition) { diff --git a/src/types/ellipse.js b/src/types/ellipse.js index 15e8f1245..62ca4c722 100644 --- a/src/types/ellipse.js +++ b/src/types/ellipse.js @@ -1,11 +1,20 @@ import {Element} from 'chart.js'; import {PI, toRadians} from 'chart.js/helpers'; -import {getRectCenterPoint, getChartRect, setBorderStyle, setShadowStyle, translate} from '../helpers'; +import {EPSILON, getRectCenterPoint, getChartRect, setBorderStyle, setShadowStyle, rotated, translate} from '../helpers'; export default class EllipseAnnotation extends Element { - inRange(mouseX, mouseY, useFinalPosition) { - return pointInEllipse({x: mouseX, y: mouseY}, this.getProps(['width', 'height'], useFinalPosition), this.options.rotation, this.options.borderWidth); + inRange(mouseX, mouseY, axis, useFinalPosition) { + const {x, y, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition); + const rotation = this.options.rotation; + const borderWidth = this.options.borderWidth; + if (axis !== 'x' && axis !== 'y') { + return pointInEllipse({x: mouseX, y: mouseY}, this, rotation, borderWidth); + } + const hBorderWidth = borderWidth / 2; + const limit = axis === 'y' ? {start: y, end: y2} : {start: x, end: x2}; + const rotatedPoint = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-rotation)); + return rotatedPoint[axis] >= limit.start - hBorderWidth - EPSILON && rotatedPoint[axis] <= limit.end + hBorderWidth + EPSILON; } getCenterPoint(useFinalPosition) { diff --git a/src/types/label.js b/src/types/label.js index 826f608e3..112e26465 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -4,9 +4,9 @@ import {Element} from 'chart.js'; export default class LabelAnnotation extends Element { - inRange(mouseX, mouseY, useFinalPosition) { + inRange(mouseX, mouseY, axis, useFinalPosition) { const {x, y} = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation)); - return inBoxRange(x, y, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), this.options.borderWidth); + return inBoxRange({x, y}, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), axis, this.options.borderWidth); } getCenterPoint(useFinalPosition) { diff --git a/src/types/line.js b/src/types/line.js index 66ddf34b3..0f176a259 100644 --- a/src/types/line.js +++ b/src/types/line.js @@ -1,12 +1,11 @@ import {Element} from 'chart.js'; import {PI, toRadians, toPadding, valueOrDefault} from 'chart.js/helpers'; -import {clamp, scaleValue, rotated, drawBox, drawLabel, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, retrieveScaleID, getDimensionByScale} from '../helpers'; +import {EPSILON, clamp, scaleValue, rotated, drawBox, drawLabel, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, retrieveScaleID, getDimensionByScale} from '../helpers'; const pointInLine = (p1, p2, t) => ({x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y)}); const interpolateX = (y, p1, p2) => pointInLine(p1, p2, Math.abs((y - p1.y) / (p2.y - p1.y))).x; const interpolateY = (x, p1, p2) => pointInLine(p1, p2, Math.abs((x - p1.x) / (p2.x - p1.x))).y; const sqr = v => v * v; -const defaultEpsilon = 0.001; function isLineInArea({x, y, x2, y2}, {top, right, bottom, left}) { return !( @@ -46,7 +45,7 @@ function limitLineToArea(p1, p2, area) { export default class LineAnnotation extends Element { // TODO: make private in v2 - intersects(x, y, epsilon = defaultEpsilon, useFinalPosition) { + intersects(x, y, epsilon = EPSILON, useFinalPosition) { // Adapted from https://stackoverflow.com/a/6853926/25507 const {x: x1, y: y1, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition); const dx = x2 - x1; @@ -82,7 +81,7 @@ export default class LineAnnotation extends Element { } // TODO: make private in v2 - isOnLabel(mouseX, mouseY, useFinalPosition) { + isOnLabel(mouseX, mouseY, useFinalPosition, axis) { if (!this.labelIsVisible(useFinalPosition)) { return false; } @@ -91,13 +90,25 @@ export default class LineAnnotation extends Element { const hBorderWidth = this.options.label.borderWidth / 2 || 0; const w2 = labelWidth / 2 + hBorderWidth; const h2 = labelHeight / 2 + hBorderWidth; - return x >= labelX - w2 - defaultEpsilon && x <= labelX + w2 + defaultEpsilon && - y >= labelY - h2 - defaultEpsilon && y <= labelY + h2 + defaultEpsilon; + const inRangeX = x >= labelX - w2 - EPSILON && x <= labelX + w2 + EPSILON; + const inRangeY = y >= labelY - h2 - EPSILON && y <= labelY + h2 + EPSILON; + if (axis === 'x') { + return inRangeX; + } else if (axis === 'y') { + return inRangeY; + } + return inRangeX && inRangeY; } - inRange(mouseX, mouseY, useFinalPosition) { - const epsilon = sqr(this.options.borderWidth / 2); - return this.intersects(mouseX, mouseY, epsilon, useFinalPosition) || this.isOnLabel(mouseX, mouseY, useFinalPosition); + inRange(mouseX, mouseY, axis, useFinalPosition) { + const hBorderWidth = this.options.borderWidth / 2; + if (axis !== 'x' && axis !== 'y') { + const epsilon = sqr(hBorderWidth); + return this.intersects(mouseX, mouseY, epsilon, useFinalPosition) || this.isOnLabel(mouseX, mouseY, useFinalPosition); + } + const {x, y, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition); + const limit = axis === 'y' ? {start: Math.min(y, y2), end: Math.max(y, y2), value: mouseY} : {start: Math.min(x, x2), end: Math.max(x, x2), value: mouseX}; + return (limit.value >= limit.start - hBorderWidth && limit.value <= limit.end + hBorderWidth) || this.isOnLabel(mouseX, mouseY, useFinalPosition, axis); } getCenterPoint() { diff --git a/src/types/point.js b/src/types/point.js index fff75aafa..50a11502d 100644 --- a/src/types/point.js +++ b/src/types/point.js @@ -4,9 +4,15 @@ import {inPointRange, getElementCenterPoint, resolvePointPosition, setBorderStyl export default class PointAnnotation extends Element { - inRange(mouseX, mouseY, useFinalPosition) { - const {width} = this.getProps(['width'], useFinalPosition); - return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, this.options.borderWidth); + inRange(mouseX, mouseY, axis, useFinalPosition) { + const {x, y, width, height} = this.getProps(['x', 'y', 'width', 'height'], useFinalPosition); + const borderWidth = this.options.borderWidth; + if (axis !== 'x' && axis !== 'y') { + return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, borderWidth); + } + const hBorderWidth = borderWidth / 2; + const limit = axis === 'y' ? {start: y, size: height, value: mouseY} : {start: x, size: width, value: mouseX}; + return limit.value >= limit.start - limit.size / 2 - hBorderWidth && limit.value <= limit.start + limit.size / 2 + hBorderWidth; } getCenterPoint(useFinalPosition) { diff --git a/src/types/polygon.js b/src/types/polygon.js index 38ed3c5a0..30b6f8bed 100644 --- a/src/types/polygon.js +++ b/src/types/polygon.js @@ -1,10 +1,18 @@ import {Element} from 'chart.js'; -import {PI, RAD_PER_DEG} from 'chart.js/helpers'; -import {setBorderStyle, resolvePointPosition, getElementCenterPoint, setShadowStyle} from '../helpers'; +import {PI, RAD_PER_DEG, toRadians} from 'chart.js/helpers'; +import {setBorderStyle, resolvePointPosition, getElementCenterPoint, setShadowStyle, rotated} from '../helpers'; export default class PolygonAnnotation extends Element { - inRange(mouseX, mouseY, useFinalPosition) { - return this.options.radius >= 0.1 && this.elements.length > 1 && pointIsInPolygon(this.elements, mouseX, mouseY, useFinalPosition); + + inRange(mouseX, mouseY, axis, useFinalPosition) { + if (axis !== 'x' && axis !== 'y') { + return this.options.radius >= 0.1 && this.elements.length > 1 && pointIsInPolygon(this.elements, mouseX, mouseY, useFinalPosition); + } + const rotatedPoint = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation)); + const axisPoints = this.elements.map((point) => axis === 'y' ? point.bY : point.bX); + const start = Math.min(...axisPoints); + const end = Math.max(...axisPoints); + return rotatedPoint[axis] >= start && rotatedPoint[axis] <= end; } getCenterPoint(useFinalPosition) { @@ -100,7 +108,6 @@ PolygonAnnotation.defaultRoutes = { backgroundColor: 'color' }; - function pointIsInPolygon(points, x, y, useFinalPosition) { let isInside = false; let A = points[points.length - 1].getProps(['bX', 'bY'], useFinalPosition); diff --git a/test/index.js b/test/index.js index 12e734e7d..47a0dcd4c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,6 @@ import {acquireChart, addMatchers, releaseCharts, specsFromFixtures, triggerMouseEvent, afterEvent} from 'chartjs-test-utils'; import {testEvents, eventPoint0, getCenterPoint} from './events'; -import {createCanvas, getAnnotationElements, scatterChart, stringifyObject} from './utils'; +import {createCanvas, getAnnotationElements, scatterChart, stringifyObject, interactionData} from './utils'; import * as helpers from '../src/helpers'; window.helpers = helpers; @@ -15,6 +15,7 @@ window.createCanvas = createCanvas; window.getAnnotationElements = getAnnotationElements; window.scatterChart = scatterChart; window.stringifyObject = stringifyObject; +window.interactionData = interactionData; jasmine.fixtures = specsFromFixtures; diff --git a/test/specs/box.spec.js b/test/specs/box.spec.js index 0afe68a21..55068cb48 100644 --- a/test/specs/box.spec.js +++ b/test/specs/box.spec.js @@ -76,4 +76,56 @@ describe('Box annotation', function() { }); }); }); + + describe('interaction', function() { + const outer = { + type: 'box', + xMin: 2, + xMax: 8, + yMin: 2, + yMax: 8, + borderWidth: 0 + }; + const inner = { + type: 'box', + xMin: 4.5, + xMax: 6, + yMin: 4.5, + yMax: 6, + borderWidth: 0 + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.x, y: outerEl.y + outerEl.height / 2}, + {x: innerEl.x, y: innerEl.y + innerEl.height / 2}, + {x: innerEl.x + innerEl.width / 2, y: innerEl.y + innerEl.height / 2}, + {x: innerEl.x2 + 1, y: innerEl.y2 - innerEl.height / 2}, + {x: outerEl.x2 + 1, y: outerEl.y2 - outerEl.height / 2}, + {x: outerEl.x + 1, y: outerEl.y - 1}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const elements = state._getElements(state, point, interactionOpts); + expect(elements.length).withContext(`with interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}}`).toEqual(elementsCount); + } + }); + } + } + }); + }); }); diff --git a/test/specs/ellipse.spec.js b/test/specs/ellipse.spec.js index 83a51d3b7..81bee2a27 100644 --- a/test/specs/ellipse.spec.js +++ b/test/specs/ellipse.spec.js @@ -1,8 +1,9 @@ describe('Ellipse annotation', function() { describe('auto', jasmine.fixtures('ellipse')); + const rotated = window.helpers.rotated; + describe('inRange', function() { - const rotated = window.helpers.rotated; for (const rotation of [0, 45, 90, 135, 180, 225, 270, 315]) { const annotation = { @@ -71,4 +72,62 @@ describe('Ellipse annotation', function() { } }); }); + + describe('interaction', function() { + + for (const rotation of [0, 45, 90, 135, 180, 225, 270, 315]) { + const outer = { + type: 'ellipse', + xMin: 3, + xMax: 7, + yMin: 3, + yMax: 7, + borderWidth: 0, + rotation + }; + const inner = { + type: 'ellipse', + xMin: 4.5, + xMax: 6, + yMin: 4.5, + yMax: 6, + borderWidth: 0, + rotation + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.x, y: outerEl.y + outerEl.height / 2, el: outerEl}, + {x: innerEl.x, y: innerEl.y + innerEl.height / 2, el: innerEl}, + {x: innerEl.x + innerEl.width / 2, y: innerEl.y + innerEl.height / 2, el: innerEl}, + {x: innerEl.x2 + 1, y: innerEl.y2 - innerEl.height / 2, el: innerEl}, + {x: outerEl.x2 + 1, y: outerEl.y2 - outerEl.height / 2, el: outerEl}, + {x: outerEl.x + 1, y: outerEl.y - 1, el: outerEl}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const {x, y} = rotated(point, point.el.getCenterPoint(), rotation / 180 * Math.PI); + const elements = state._getElements(state, {x, y}, interactionOpts); + expect(elements.length).withContext(`with rotation ${rotation}, interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + } + }); }); diff --git a/test/specs/label.spec.js b/test/specs/label.spec.js index 2adfa8e1a..148759c32 100644 --- a/test/specs/label.spec.js +++ b/test/specs/label.spec.js @@ -53,4 +53,60 @@ describe('Label annotation', function() { }); } }); + + describe('interaction', function() { + const outer = { + type: 'label', + xMin: 2, + xMax: 8, + yMin: 2, + yMax: 8, + content: ['outer label row 1', 'outer label row 2', 'outer label row 3'], + backgroundColor: 'transparent', + borderWidth: 0 + }; + const inner = { + type: 'label', + xMin: 4, + xMax: 6, + yMin: 4, + yMax: 6, + content: ['inner label 1', 'inner label 2'], + backgroundColor: 'transparent', + borderWidth: 0 + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.x, y: outerEl.y + outerEl.height / 2}, + {x: innerEl.x, y: innerEl.y + innerEl.height / 2}, + {x: innerEl.x + innerEl.width / 2, y: innerEl.y + innerEl.height / 2}, + {x: innerEl.x + innerEl.width + 1, y: innerEl.y + innerEl.height / 2}, + {x: outerEl.x + outerEl.width + 1, y: outerEl.y + outerEl.height / 2}, + {x: outerEl.x + 1, y: outerEl.y - 1}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const elements = state._getElements(state, point, interactionOpts); + expect(elements.length).withContext(`with interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + }); }); diff --git a/test/specs/line.spec.js b/test/specs/line.spec.js index 841f4f04c..de4286304 100644 --- a/test/specs/line.spec.js +++ b/test/specs/line.spec.js @@ -99,4 +99,124 @@ describe('Line annotation', function() { }); } }); + + describe('interaction', function() { + const outer = { + type: 'line', + xMin: 5, + xMax: 5, + yMin: 2, + yMax: 8, + borderWidth: 20 + }; + const inner = { + type: 'line', + xMin: 5, + xMax: 5, + yMin: 2, + yMax: 8, + borderWidth: 8 + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const outCenter = outerEl.getCenterPoint(); + const outHBordeWidth = outerEl.options.borderWidth / 2; + const innerEl = window.getAnnotationElements(chart)[1]; + const inCenter = outerEl.getCenterPoint(); + const inHBordeWidth = innerEl.options.borderWidth / 2; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outCenter.x - outHBordeWidth, y: outCenter.y}, + {x: inCenter.x - inHBordeWidth, y: inCenter.y}, + {x: inCenter.x, y: inCenter.y}, + {x: inCenter.x + inHBordeWidth + 1, y: inCenter.y}, + {x: outCenter.x + outHBordeWidth + 1, y: outCenter.y}, + {x: outCenter.x - outHBordeWidth + 1, y: outCenter.y - outerEl.height / 2 - outHBordeWidth - 1}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const elements = state._getElements(state, point, interactionOpts); + expect(elements.length).withContext(`with interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + }); + + describe('with label interaction', function() { + const outer = { + type: 'line', + xMin: 5, + xMax: 5, + yMin: 2, + yMax: 8, + borderWidth: 0, + label: { + enabled: true, + content: ['outer label row 1', 'outer label row 2', 'outer label row 3'], + borderWidth: 0 + } + }; + const inner = { + type: 'line', + xMin: 5, + xMax: 5, + yMin: 2, + yMax: 8, + borderWidth: 0, + label: { + enabled: true, + content: ['inner label 1', 'inner label 2'], + borderWidth: 0 + } + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const outCenter = outerEl.getCenterPoint(); + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.labelX - outerEl.labelWidth / 2, y: outerEl.labelY}, + {x: innerEl.labelX - innerEl.labelWidth / 2, y: innerEl.labelY}, + {x: innerEl.labelX, y: innerEl.labelY}, + {x: innerEl.labelX + innerEl.labelWidth / 2 + 1, y: innerEl.labelY}, + {x: outerEl.labelX + outerEl.labelWidth / 2 + 1, y: outerEl.labelY}, + {x: outerEl.labelX - outerEl.labelWidth / 2 + 1, y: outCenter.y - outerEl.height / 2 - 1}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const elements = state._getElements(state, point, interactionOpts); + expect(elements.length).withContext(`with interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + }); + }); diff --git a/test/specs/point.spec.js b/test/specs/point.spec.js index 1d612cc50..c88253085 100644 --- a/test/specs/point.spec.js +++ b/test/specs/point.spec.js @@ -74,4 +74,54 @@ describe('Point annotation', function() { }); }); }); + + describe('interaction', function() { + const outer = { + type: 'point', + xValue: 5, + yValue: 5, + radius: 40, + borderWidth: 0 + }; + const inner = { + type: 'point', + xValue: 5, + yValue: 5, + radius: 20, + borderWidth: 0 + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.x - outerEl.width / 2, y: outerEl.y}, + {x: innerEl.x - innerEl.width / 2, y: innerEl.y}, + {x: innerEl.x, y: innerEl.y}, + {x: innerEl.x + innerEl.width / 2 + 1, y: innerEl.y}, + {x: outerEl.x + outerEl.width / 2 + 1, y: outerEl.y}, + {x: outerEl.x - outerEl.width / 2 + 1, y: outerEl.y - outerEl.height / 2 - 1}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const elements = state._getElements(state, point, interactionOpts); + expect(elements.length).withContext(`with interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + }); }); diff --git a/test/specs/polygon.spec.js b/test/specs/polygon.spec.js index 1612bc621..cbc5e3d75 100644 --- a/test/specs/polygon.spec.js +++ b/test/specs/polygon.spec.js @@ -112,4 +112,63 @@ describe('Polygon annotation', function() { }); }); }); + + describe('interaction', function() { + const rotated = window.helpers.rotated; + + for (const rotation of [0, 90, 180, 270]) { + const outer = { + type: 'polygon', + xValue: 5, + yValue: 5, + radius: 50, + sides: 4, + borderWidth: 1, + rotation + }; + const inner = { + type: 'polygon', + xValue: 5, + yValue: 5, + radius: 25, + sides: 4, + borderWidth: 1, + rotation + }; + + const chart = window.scatterChart(10, 10, {outer, inner}); + const state = window['chartjs-plugin-annotation']._getState(chart); + const interactionOpts = {}; + const outerEl = window.getAnnotationElements(chart)[0]; + const innerEl = window.getAnnotationElements(chart)[1]; + + it('should return the right amount of annotation elements', function() { + for (const interaction of window.interactionData) { + const mode = interaction.mode; + interactionOpts.mode = mode; + for (const axis of Object.keys(interaction.axes)) { + interactionOpts.axis = axis; + [true, false].forEach(function(intersect) { + interactionOpts.intersect = intersect; + const elementsCounts = interaction.axes[axis].intersect[intersect]; + const points = [{x: outerEl.x - outerEl.width / 2, y: outerEl.y, el: outerEl}, + {x: innerEl.x - innerEl.width / 2, y: innerEl.y, el: innerEl}, + {x: innerEl.x, y: innerEl.y, el: innerEl}, + {x: innerEl.x + innerEl.width / 2 + 1, y: innerEl.y, el: innerEl}, + {x: outerEl.x + outerEl.width / 2 + 1, y: outerEl.y, el: outerEl}, + {x: outerEl.x - outerEl.width / 2 + 1, y: outerEl.y - outerEl.height / 2 - 1, el: outerEl}]; + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const elementsCount = elementsCounts[i]; + const {x, y} = rotated(point, point.el.getCenterPoint(), rotation / 180 * Math.PI); + const elements = state._getElements(state, {x, y}, interactionOpts); + expect(elements.length).withContext(`with rotation ${rotation}, interaction mode ${mode}, axis ${axis}, intersect ${intersect}, {x: ${point.x.toFixed(1)}, y: ${point.y.toFixed(1)}`).toEqual(elementsCount); + } + }); + } + } + }); + } + }); }); diff --git a/test/utils.js b/test/utils.js index 2008dc220..bc1ac5e99 100644 --- a/test/utils.js +++ b/test/utils.js @@ -53,3 +53,109 @@ function keepInf(key, value) { export function stringifyObject(obj) { return JSON.stringify(obj, keepInf).replaceAll('"', '').replaceAll(':', ': ').replaceAll(',', ', '); } + +export const interactionData = [{ + mode: 'point', + axes: { + xy: { + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [1, 2, 2, 1, 0, 0] + } + }, + x: { + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [1, 2, 2, 1, 0, 0] + } + }, + y: { + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [1, 2, 2, 1, 0, 0] + } + }, + r: { // not supported, use xy + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [1, 2, 2, 1, 0, 0] + } + } + }, +}, { + mode: 'nearest', + axes: { + xy: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 1] + } + }, + x: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 0, 1] + } + }, + y: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 0] + } + }, + r: { // not supported, use xy + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 1] + } + } + } +}, { + mode: 'x', + axes: { + x: { + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [1, 2, 2, 1, 0, 1] + } + } + } +}, { + mode: 'y', + axes: { + y: { + intersect: { + true: [1, 2, 2, 1, 0, 0], + false: [2, 2, 2, 2, 2, 0] + } + } + } +}, { + mode: 'dataset', // not supported, use nearest + axes: { + xy: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 1] + } + }, + x: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 0, 1] + } + }, + y: { + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 0] + } + }, + r: { // not supported, use xy + intersect: { + true: [1, 1, 1, 1, 0, 0], + false: [1, 1, 1, 1, 1, 1] + } + } + } +}]; diff --git a/types/options.d.ts b/types/options.d.ts index b0d8d49e2..68d8a373d 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -1,4 +1,4 @@ -import { Color, PointStyle, BorderRadius } from 'chart.js'; +import { Color, PointStyle, BorderRadius, CoreInteractionOptions } from 'chart.js'; import { AnnotationEvents, PartialEventContext } from './events'; import { LabelOptions, BoxLabelOptions, LabelTypeOptions } from './label'; @@ -158,9 +158,10 @@ interface PolygonAnnotationOptions extends CoreAnnotationOptions, AnnotationPoin } export interface AnnotationPluginOptions extends AnnotationEvents { + animations?: Record, annotations: AnnotationOptions[] | Record, clip?: boolean, dblClickSpeed?: Scriptable, drawTime?: Scriptable, - animations?: Record, + interaction?: CoreInteractionOptions } diff --git a/types/tests/exports.ts b/types/tests/exports.ts index 726ef6257..324117691 100644 --- a/types/tests/exports.ts +++ b/types/tests/exports.ts @@ -16,6 +16,11 @@ const chart = new Chart('id', { plugins: { annotation: { clip: false, + interaction: { + mode: 'nearest', + axis: 'xy', + intersect: true + }, annotations: [{ type: 'line', label: {