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 16 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
11 changes: 10 additions & 1 deletion src/annotation.js
Expand Up @@ -46,7 +46,8 @@ export default {
visibleElements: [],
listeners: {},
listened: false,
moveListened: false
moveListened: false,
hovered: []
});
},

Expand Down Expand Up @@ -120,6 +121,11 @@ export default {
clip: true,
dblClickSpeed: 350, // ms
drawTime: 'afterDatasetsDraw',
interaction: {
mode: undefined,
axis: undefined,
intersect: true
},
label: {
drawTime: null
}
Expand All @@ -132,6 +138,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;

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
}
2 changes: 2 additions & 0 deletions src/helpers/helpers.core.js
@@ -1,3 +1,5 @@
export const EPSILON = 0.001;

export const clamp = (x, from, to) => Math.min(to, Math.max(from, x));

export function clampAll(obj, from, to) {
Expand Down
84 changes: 84 additions & 0 deletions src/interaction.js
@@ -0,0 +1,84 @@
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 getIntersectItems(state, event);
},

/**
* 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 {options} options - interaction options to use
* @return {Element[]} - elements that are found (only 1 element)
*/
nearest(state, event, options) {
return getNearestItem(state, event, options);
},
}
};

/**
* 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 {options} 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 getIntersectItems(state, event) {
return state.visibleElements.filter((element) => element.inRange(event.x, event.y, true));
}

function inRangeByAxis(element, event, axis) {
if (axis === 'x') {
return element.inXRange(event.x, event.y, true);
} else if (axis === 'y') {
return element.inYRange(event.x, event.y, true);
}
return element.inXRange(event.x, event.y, true) || element.inYRange(event.x, event.y, true);
}
stockiNail marked this conversation as resolved.
Show resolved Hide resolved

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 getNearestItem(state, event, options) {
const axis = options.axis || 'xy';
let minDistance = Number.POSITIVE_INFINITY;

return state.visibleElements
.filter((element) => options.intersect ? element.inRange(event.x, event.y, true) : inRangeByAxis(element, event, axis))
.reduce((nearestItems, element) => {
const center = element.getCenterPoint();
const evenPoint = getPointByAxis(event, center, 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;
}
15 changes: 15 additions & 0 deletions src/types/box.js
Expand Up @@ -7,6 +7,16 @@ export default class BoxAnnotation extends Element {
return inBoxRange(mouseX, mouseY, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), this.options.borderWidth);
}

inXRange(mouseX, mouseY, useFinalPosition) {
const {x: start, width: size} = this.getProps(['x', 'width'], useFinalPosition);
return inRangeByCoord(mouseX, start, size, this.options.borderWidth);
}

inYRange(mouseX, mouseY, useFinalPosition) {
const {y: start, height: size} = this.getProps(['y', 'height'], useFinalPosition);
return inRangeByCoord(mouseY, start, size, this.options.borderWidth);
}

getCenterPoint(useFinalPosition) {
return getRectCenterPoint(this.getProps(['x', 'y', 'width', 'height'], useFinalPosition));
}
Expand Down Expand Up @@ -102,6 +112,11 @@ BoxAnnotation.descriptors = {
}
};

function inRangeByCoord(value, start, size, borderWidth) {
const hBorderWidth = borderWidth / 2;
return value >= start - hBorderWidth && value <= start + size + hBorderWidth;
}

function calculateX(box, labelSize, position, padding) {
const {x: start, x2: end, width: size, options} = box;
const {xAdjust: adjust, borderWidth} = options.label;
Expand Down
16 changes: 15 additions & 1 deletion src/types/ellipse.js
@@ -1,13 +1,27 @@
import {Element} from 'chart.js';
import {PI, toRadians} from 'chart.js/helpers';
import {getRectCenterPoint, getChartRect, setBorderStyle, setShadowStyle} from '../helpers';
import {EPSILON, getRectCenterPoint, getChartRect, setBorderStyle, setShadowStyle, rotated} 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);
}

inXRange(mouseX, mouseY, useFinalPosition) {
const {x, x2} = this.getProps(['x', 'x2'], useFinalPosition);
const rotValue = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation));
const hBorderWidth = this.options.borderWidth / 2;
return rotValue.x >= x - hBorderWidth - EPSILON && rotValue.x <= x2 + hBorderWidth + EPSILON;
}

inYRange(mouseX, mouseY, useFinalPosition) {
const {y, y2} = this.getProps(['y', 'y2'], useFinalPosition);
const rotValue = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation));
const hBorderWidth = this.options.borderWidth / 2;
return rotValue.y >= y - hBorderWidth && rotValue.y <= y2 + hBorderWidth;
}

getCenterPoint(useFinalPosition) {
return getRectCenterPoint(this.getProps(['x', 'y', 'width', 'height'], useFinalPosition));
}
Expand Down
12 changes: 12 additions & 0 deletions src/types/label.js
Expand Up @@ -8,6 +8,18 @@ export default class LabelAnnotation extends Element {
return inBoxRange(mouseX, mouseY, this.getProps(['x', 'y', 'width', 'height'], useFinalPosition), this.options.borderWidth);
}

inXRange(mouseX, mouseY, useFinalPosition) {
const {x, width} = this.getProps(['x', 'width'], useFinalPosition);
const hBorderWidth = this.options.borderWidth / 2;
return mouseX >= x - hBorderWidth && mouseX <= x + width + hBorderWidth;
}

inYRange(mouseX, mouseY, useFinalPosition) {
const {y, height} = this.getProps(['y', 'height'], useFinalPosition);
const hBorderWidth = this.options.borderWidth / 2;
return mouseY >= y - hBorderWidth && mouseY <= y + height + hBorderWidth;
}

getCenterPoint(useFinalPosition) {
return getRectCenterPoint(this.getProps(['x', 'y', 'width', 'height'], useFinalPosition));
}
Expand Down
9 changes: 4 additions & 5 deletions src/types/line.js
@@ -1,12 +1,11 @@
import {Element} from 'chart.js';
import {PI, toRadians, toPadding} from 'chart.js/helpers';
import {clamp, scaleValue, rotated, drawBox, drawLabel, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle} from '../helpers';
import {EPSILON, clamp, scaleValue, rotated, drawBox, drawLabel, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle} 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 !(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -91,8 +90,8 @@ 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;
return x >= labelX - w2 - EPSILON && x <= labelX + w2 + EPSILON &&
y >= labelY - h2 - EPSILON && y <= labelY + h2 + EPSILON;
}

inRange(mouseX, mouseY, useFinalPosition) {
Expand Down
12 changes: 12 additions & 0 deletions src/types/point.js
Expand Up @@ -9,6 +9,18 @@ export default class PointAnnotation extends Element {
return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, this.options.borderWidth);
}

inXRange(mouseX, mouseY, useFinalPosition) {
const {x, width} = this.getProps(['x', 'width'], useFinalPosition);
const hBorderWidth = this.options.borderWidth / 2;
return mouseX >= x - width / 2 - hBorderWidth && mouseX <= x + width / 2 + hBorderWidth;
}

inYRange(mouseX, mouseY, useFinalPosition) {
const {y, height} = this.getProps(['y', 'height'], useFinalPosition);
const hBorderWidth = this.options.borderWidth / 2;
return mouseY >= y - height / 2 - hBorderWidth && mouseY <= y + height / 2 + hBorderWidth;
}

getCenterPoint(useFinalPosition) {
return getElementCenterPoint(this, useFinalPosition);
}
Expand Down
21 changes: 18 additions & 3 deletions src/types/polygon.js
@@ -1,12 +1,28 @@
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);
}

inXRange(mouseX, mouseY, useFinalPosition) {
const rotValue = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation));
const allX = this.elements.map((point) => point.bX);
const x = Math.min(...allX);
const x2 = Math.max(...allX);
return rotValue.x >= x && rotValue.x <= x2;
}

inYRange(mouseX, mouseY, useFinalPosition) {
const rotValue = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.options.rotation));
const allY = this.elements.map((point) => point.bY);
const y = Math.min(...allY);
const y2 = Math.max(...allY);
return rotValue.y >= y && rotValue.y <= y2;
}

getCenterPoint(useFinalPosition) {
return getElementCenterPoint(this, useFinalPosition);
}
Expand Down Expand Up @@ -100,7 +116,6 @@ PolygonAnnotation.defaultRoutes = {
backgroundColor: 'color'
};


function pointIsInPolygon(points, x, y, useFinalPosition) {
let isInside = false;
let A = points[points.length - 1].getProps(['bX', 'bY'], useFinalPosition);
Expand Down