Skip to content

Commit

Permalink
Enable interaction mode support for events (#659)
Browse files Browse the repository at this point in the history
* Enable events triggering on all affected annotations

* removes commented code

* removes useless if statements

* first interaction mode implementation

* removes useless callback

* applies interaction mode to click events

* applies review

* removes array creation if not needed

* interaction for box annotation

* adds test cases to box.spec

* adds ellipse to interaction management

* adds label to interaction management

* adds point to interaction management

* adds polygon to interaction management

* should fix CC on box

* removes inX/YRange going to a unique method inRange

* adds line to interaction management

* adds mode x and y

* removes tabs

* fixes similar code and adds point to the context of interaction tests

* adds types

* orders the options in types for annotation node

* adds documentation

* fixes typo on doc

* fixes CC for function with 5 arguments (exceeds 4 allowed)

* adds note about the breaking change using interaction

* Update docs/guide/migrationV2.md

Co-authored-by: Jukka Kurkela <jukka.kurkela@gmail.com>

Co-authored-by: Jukka Kurkela <jukka.kurkela@gmail.com>
  • Loading branch information
stockiNail and kurkle committed Apr 4, 2022
1 parent 17352cd commit e8bd9c0
Show file tree
Hide file tree
Showing 23 changed files with 731 additions and 74 deletions.
1 change: 1 addition & 0 deletions docs/guide/configuration.md
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/guide/migrationV2.md
Expand Up @@ -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`.
12 changes: 12 additions & 0 deletions docs/guide/options.md
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/annotation.js
Expand Up @@ -47,7 +47,8 @@ export default {
visibleElements: [],
listeners: {},
listened: false,
moveListened: false
moveListened: false,
hovered: []
});
},

Expand Down Expand Up @@ -121,6 +122,11 @@ export default {
clip: true,
dblClickSpeed: 350, // ms
drawTime: 'afterDatasetsDraw',
interaction: {
mode: undefined,
axis: undefined,
intersect: undefined
},
label: {
drawTime: null
}
Expand All @@ -133,6 +139,9 @@ export default {
_allKeys: false,
_fallback: (prop, opts) => `elements.${annotationTypes[resolveType(opts.type)].id}`,
},
interaction: {
_fallback: true,
}
},

additionalOptionScopes: ['']
Expand Down
59 changes: 20 additions & 39 deletions 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'];
Expand All @@ -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') {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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
}
16 changes: 10 additions & 6 deletions src/helpers/helpers.core.js
Expand Up @@ -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) {
Expand Down
101 changes: 101 additions & 0 deletions 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;
}
5 changes: 3 additions & 2 deletions src/types/box.js
Expand Up @@ -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) {
Expand Down
15 changes: 12 additions & 3 deletions 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) {
Expand Down
4 changes: 2 additions & 2 deletions src/types/label.js
Expand Up @@ -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) {
Expand Down

0 comments on commit e8bd9c0

Please sign in to comment.