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

Enable interaction mode support for events #659

Merged
merged 31 commits into from Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
631fced
Enable events triggering on all affected annotations
stockiNail Jan 26, 2022
fa34f48
removes commented code
stockiNail Jan 26, 2022
46a6ee7
removes useless if statements
stockiNail Jan 26, 2022
f0f6697
first interaction mode implementation
stockiNail Jan 27, 2022
a3765c6
removes useless callback
stockiNail Jan 27, 2022
86136ef
applies interaction mode to click events
stockiNail Jan 27, 2022
ebddfed
applies review
stockiNail Jan 27, 2022
9ece84c
removes array creation if not needed
stockiNail Jan 27, 2022
fb61612
interaction for box annotation
stockiNail Jan 31, 2022
68c76f0
Merge remote-tracking branch 'origin/master' into multipleElementsFor…
stockiNail Jan 31, 2022
f64c524
adds test cases to box.spec
stockiNail Jan 31, 2022
8078441
adds ellipse to interaction management
stockiNail Feb 1, 2022
0c47496
adds label to interaction management
stockiNail Feb 1, 2022
3f314c9
adds point to interaction management
stockiNail Feb 1, 2022
d4f2af5
adds polygon to interaction management
stockiNail Feb 1, 2022
308bd90
should fix CC on box
stockiNail Feb 2, 2022
159f155
removes inX/YRange going to a unique method inRange
stockiNail Feb 2, 2022
dd90732
adds line to interaction management
stockiNail Feb 2, 2022
5227c1f
adds mode x and y
stockiNail Feb 3, 2022
8b6c5c7
removes tabs
stockiNail Feb 3, 2022
56a555c
fixes similar code and adds point to the context of interaction tests
stockiNail Feb 3, 2022
83a8e59
adds types
stockiNail Feb 3, 2022
c2fdd06
orders the options in types for annotation node
stockiNail Feb 3, 2022
ebe1035
adds documentation
stockiNail Feb 3, 2022
03536c2
fixes typo on doc
stockiNail Feb 3, 2022
0c620d2
Merge remote-tracking branch 'origin/master' into
stockiNail Feb 4, 2022
82c7d1e
Merge remote-tracking branch 'origin/master' into
stockiNail Feb 10, 2022
049a5ba
fixes CC for function with 5 arguments (exceeds 4 allowed)
stockiNail Feb 10, 2022
6be30ed
Merge remote-tracking branch 'origin/master' into
stockiNail Apr 4, 2022
a307dbb
adds note about the breaking change using interaction
stockiNail Apr 4, 2022
a4026de
Update docs/guide/migrationV2.md
stockiNail Apr 4, 2022
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
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
stockiNail marked this conversation as resolved.
Show resolved Hide resolved

`chartjs-plugin-annotation` plugin version 2 introduces the `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).
stockiNail marked this conversation as resolved.
Show resolved Hide resolved

* 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