Skip to content

Commit

Permalink
Add hitTolerance option to all annotations (#902)
Browse files Browse the repository at this point in the history
* Add hitTolerance option to all annotations

* add test cases for line, box and ellipse annotations

* add option to polygon annotation

* add option to point annotation

* add types

* add documentation

* code improving

* Update src/helpers/helpers.core.js

Co-authored-by: Jacco van den Berg <[email protected]>

* increase tolerance for a fixture

---------

Co-authored-by: Jacco van den Berg <[email protected]>
  • Loading branch information
stockiNail and LeeLenaleee authored Sep 19, 2024
1 parent e94475c commit 8d79a33
Show file tree
Hide file tree
Showing 24 changed files with 583 additions and 50 deletions.
1 change: 1 addition & 0 deletions docs/guide/types/_commonInnerLabel.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All of these options can be [Scriptable](../options.md#scriptable-options)
| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options#draw-time). Defaults to the annotation draw time if unset
| [`font`](#fonts-and-colors) | [`Font`\|`Font[]`](../options#font) | `{ weight: 'bold' }` | Label font
| `height` | `number`\|`string` | `undefined` | Overrides the height of the image or canvas element. Could be set in pixel by a number, or in percentage of current height of image or canvas element by a string. If undefined, uses the height of the image or canvas element. It is used only when the content is an image or canvas element.
| `hitTolerance` | `number` | `undefined` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `opacity` | `number` | `undefined` | Overrides the opacity of the image or canvas element. Could be set a number in the range 0.0 to 1.0, inclusive. If undefined, uses the opacity of the image or canvas element. It is used only when the content is an image or canvas element.
| `padding` | [`Padding`](../options.md#padding) | `6` | The padding to add around the text label.
| [`position`](#position) | `string`\|`{x: string, y: string}` | `'center'` | Anchor position of label in the annotation.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/_commonOptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The following options are available for all annotations.
| [`borderShadowColor`](#styling) | [`Color`](../options.md#color) | Yes | `'transparent'`
| [`display`](#general) | `boolean` | Yes | `true`
| [`drawTime`](#general) | `string` | Yes | `'afterDatasetsDraw'`
| [`hitTolerance`](#general) | `number` | Yes | `0`
| [`init`](../configuration.html#common) | `boolean` | [See initial animation](../configuration.html#initial-animation) | `undefined`
| [`id`](#general) | `string` | No | `undefined`
| [`shadowBlur`](#styling) | `number` | Yes | `0`
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/box.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ If one of the axes does not match an axis in the chart, the box will take the en
| `adjustScaleRange` | Should the scale range be adjusted if this annotation is out of range.
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `rotation` | Rotation of the box in degrees.
| `xMax` | Right edge of the box in units along the x axis.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/ellipse.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ If one of the axes does not match an axis in the chart, the ellipse will take th
| `adjustScaleRange` | Should the scale range be adjusted if this annotation is out of range.
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `rotation` | Rotation of the ellipse in degrees, default is 0.
| `xMax` | Right edge of the ellipse in units along the x axis.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/label.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The 4 coordinates, xMin, xMax, yMin, yMax are optional. If not specified, the bo
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `height` | Overrides the height of the image or canvas element. Could be set in pixel by a number, or in percentage of current height of image or canvas element by a string. If undefined, uses the height of the image or canvas element. It is used only when the content is an image or canvas element.
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `padding` | The padding to add around the text label.
| `rotation` | Rotation of the label in degrees.
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/types/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ If `scaleID` is unset, then `xScaleID` and `yScaleID` are used to draw a line fr
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `endValue` | End two of the line when a single scale is specified.
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `scaleID` | ID of the scale in single scale mode. If unset, `xScaleID` and `yScaleID` are used.
| `value` | End one of the line when a single scale is specified.
| `xMax` | X coordinate of end two of the line in units along the x axis.
Expand Down Expand Up @@ -137,6 +139,7 @@ All of these options can be [Scriptable](../options.md#scriptable-options)
| `drawTime` | `string` | `options.drawTime` | See [drawTime](../options#draw-time). Defaults to the line annotation draw time if unset.
| [`font`](#fonts-and-colors) | [`Font`\|`Font[]`](../options#font) | `{ weight: 'bold' }` | Label font.
| `height` | `number`\|`string` | `undefined` | Overrides the height of the image or canvas element. Could be set in pixel by a number, or in percentage of current height of image or canvas element by a string. If undefined, uses the height of the image or canvas element. It is used only when the content is an image or canvas element.
| `hitTolerance` | `number` | `undefined` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `opacity` | `number` | `undefined` | Overrides the opacity of the image or canvas element. Could be set a number in the range 0.0 to 1.0, inclusive. If undefined, uses the opacity of the image or canvas element. It is used only when the content is an image or canvas element.
| `padding` | [`Padding`](../options.md#padding) | `6` | The padding to add around the text label.
| `position` | `string` | `'center'` | Anchor position of label on line. Possible options are: `'start'`, `'center'`, `'end'`. It can be set by a string in percentage format `'number%'` which are representing the percentage on the width of the line where the label will be located.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/point.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The 4 coordinates, xMin, xMax, yMin, yMax are optional. If not specified, the bo
| `adjustScaleRange` | Should the scale range be adjusted if this annotation is out of range.
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `radius` | Size of the point in pixels.
| `rotation` | Rotation of point, in degrees.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/types/polygon.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ The 4 coordinates, xMin, xMax, yMin, yMax are optional. If not specified, the bo
| `adjustScaleRange` | Should the scale range be adjusted if this annotation is out of range.
| `display` | Whether or not this annotation is visible.
| `drawTime` | See [drawTime](../options.md#draw-time).
| `hitTolerance` | Amount of pixels to interact with annotations within some distance of the mouse point.
| `id` | Identifies a unique id for the annotation and it will be stored in the element context. When the annotations are defined by an object, the id is automatically set using the key used to store the annotations in the object. When the annotations are configured by an array, the id, passed by this option in the annotation, will be used.
| `radius` | Size of the polygon in pixels.
| `rotation` | Rotation of polygon, in degrees.
Expand Down
24 changes: 15 additions & 9 deletions src/helpers/helpers.core.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ const isOlderPart = (act, req) => req > act || (act.length > req.length && act.s
export const EPSILON = 0.001;
export const clamp = (x, from, to) => Math.min(to, Math.max(from, x));

/**
* @param {{value: number, start: number, end: number}} limit
* @param {number} hitSize
* @returns {boolean}
*/
export const inLimit = (limit, hitSize) => limit.value >= limit.start - hitSize && limit.value <= limit.end + hitSize;

/**
* @param {Object} obj
* @param {number} from
Expand All @@ -26,28 +33,27 @@ export function clampAll(obj, from, to) {
* @param {Point} point
* @param {Point} center
* @param {number} radius
* @param {number} borderWidth
* @param {number} hitSize
* @returns {boolean}
*/
export function inPointRange(point, center, radius, borderWidth) {
export function inPointRange(point, center, radius, hitSize) {
if (!point || !center || radius <= 0) {
return false;
}
const hBorderWidth = borderWidth / 2;
return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hBorderWidth, 2);
return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hitSize, 2);
}

/**
* @param {Point} point
* @param {{x: number, y: number, x2: number, y2: number}} rect
* @param {InteractionAxis} axis
* @param {number} borderWidth
* @param {{borderWidth: number, hitTolerance: number}} hitsize
* @returns {boolean}
*/
export function inBoxRange(point, {x, y, x2, y2}, axis, borderWidth) {
const hBorderWidth = borderWidth / 2;
const inRangeX = point.x >= x - hBorderWidth - EPSILON && point.x <= x2 + hBorderWidth + EPSILON;
const inRangeY = point.y >= y - hBorderWidth - EPSILON && point.y <= y2 + hBorderWidth + EPSILON;
export function inBoxRange(point, {x, y, x2, y2}, axis, {borderWidth, hitTolerance}) {
const hitSize = (borderWidth + hitTolerance) / 2;
const inRangeX = point.x >= x - hitSize - EPSILON && point.x <= x2 + hitSize + EPSILON;
const inRangeY = point.y >= y - hitSize - EPSILON && point.y <= y2 + hitSize + EPSILON;
if (axis === 'x') {
return inRangeX;
} else if (axis === 'y') {
Expand Down
4 changes: 3 additions & 1 deletion src/types/box.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default class BoxAnnotation extends Element {

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', 'x2', 'y2'], useFinalPosition), axis, this.options.borderWidth);
return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options);
}

getCenterPoint(useFinalPosition) {
Expand Down Expand Up @@ -43,6 +43,7 @@ BoxAnnotation.defaults = {
borderWidth: 1,
display: true,
init: undefined,
hitTolerance: 0,
label: {
backgroundColor: 'transparent',
borderWidth: 0,
Expand All @@ -61,6 +62,7 @@ BoxAnnotation.defaults = {
weight: 'bold'
},
height: undefined,
hitTolerance: undefined,
opacity: undefined,
padding: 6,
position: 'center',
Expand Down
13 changes: 6 additions & 7 deletions src/types/ellipse.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ export default class EllipseAnnotation extends Element {

inRange(mouseX, mouseY, axis, useFinalPosition) {
const rotation = this.options.rotation;
const borderWidth = this.options.borderWidth;
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
if (axis !== 'x' && axis !== 'y') {
return pointInEllipse({x: mouseX, y: mouseY}, this.getProps(['width', 'height', 'centerX', 'centerY'], useFinalPosition), rotation, borderWidth);
return pointInEllipse({x: mouseX, y: mouseY}, this.getProps(['width', 'height', 'centerX', 'centerY'], useFinalPosition), rotation, hitSize);
}
const {x, y, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition);
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;
return rotatedPoint[axis] >= limit.start - hitSize - EPSILON && rotatedPoint[axis] <= limit.end + hitSize + EPSILON;
}

getCenterPoint(useFinalPosition) {
Expand Down Expand Up @@ -59,6 +58,7 @@ EllipseAnnotation.defaults = {
borderShadowColor: 'transparent',
borderWidth: 1,
display: true,
hitTolerance: 0,
init: undefined,
label: Object.assign({}, BoxAnnotation.defaults.label),
rotation: 0,
Expand All @@ -85,7 +85,7 @@ EllipseAnnotation.descriptors = {
}
};

function pointInEllipse(p, ellipse, rotation, borderWidth) {
function pointInEllipse(p, ellipse, rotation, hitSize) {
const {width, height, centerX, centerY} = ellipse;
const xRadius = width / 2;
const yRadius = height / 2;
Expand All @@ -95,10 +95,9 @@ function pointInEllipse(p, ellipse, rotation, borderWidth) {
}
// https://stackoverflow.com/questions/7946187/point-and-ellipse-rotated-position-test-algorithm
const angle = toRadians(rotation || 0);
const hBorderWidth = borderWidth / 2 || 0;
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
const a = Math.pow(cosAngle * (p.x - centerX) + sinAngle * (p.y - centerY), 2);
const b = Math.pow(sinAngle * (p.x - centerX) - cosAngle * (p.y - centerY), 2);
return (a / Math.pow(xRadius + hBorderWidth, 2)) + (b / Math.pow(yRadius + hBorderWidth, 2)) <= 1.0001;
return (a / Math.pow(xRadius + hitSize, 2)) + (b / Math.pow(yRadius + hitSize, 2)) <= 1.0001;
}
3 changes: 2 additions & 1 deletion src/types/label.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default class LabelAnnotation extends Element {

inRange(mouseX, mouseY, axis, useFinalPosition) {
const {x, y} = rotated({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), toRadians(-this.rotation));
return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options.borderWidth);
return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options);
}

getCenterPoint(useFinalPosition) {
Expand Down Expand Up @@ -87,6 +87,7 @@ LabelAnnotation.defaults = {
weight: undefined
},
height: undefined,
hitTolerance: 0,
init: undefined,
opacity: undefined,
padding: 6,
Expand Down
16 changes: 10 additions & 6 deletions src/types/line.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Element} from 'chart.js';
import {PI, toRadians, toDegrees, toPadding, distanceBetweenPoints} from 'chart.js/helpers';
import {EPSILON, clamp, rotated, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, getElementCenterPoint, toPosition, getSize, resolveLineProperties, initAnimationProperties} from '../helpers';
import {EPSILON, clamp, rotated, measureLabelSize, getRelativePosition, setBorderStyle, setShadowStyle, getElementCenterPoint, toPosition, getSize, resolveLineProperties, initAnimationProperties, inLimit} from '../helpers';
import LabelAnnotation from './label';

const pointInLine = (p1, p2, t) => ({x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y)});
Expand All @@ -17,23 +17,24 @@ const angleInCurve = (start, cp, end, t) => -Math.atan2(coordAngleInCurve(start.
export default class LineAnnotation extends Element {

inRange(mouseX, mouseY, axis, useFinalPosition) {
const hBorderWidth = this.options.borderWidth / 2;
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
if (axis !== 'x' && axis !== 'y') {
const point = {mouseX, mouseY};
const {path, ctx} = this;
if (path) {
setBorderStyle(ctx, this.options);
ctx.lineWidth += this.options.hitTolerance;
const {chart} = this.$context;
const mx = mouseX * chart.currentDevicePixelRatio;
const my = mouseY * chart.currentDevicePixelRatio;
const result = ctx.isPointInStroke(path, mx, my) || isOnLabel(this, point, useFinalPosition);
ctx.restore();
return result;
}
const epsilon = sqr(hBorderWidth);
const epsilon = sqr(hitSize);
return intersects(this, point, epsilon, useFinalPosition) || isOnLabel(this, point, useFinalPosition);
}
return inAxisRange(this, {mouseX, mouseY}, axis, {hBorderWidth, useFinalPosition});
return inAxisRange(this, {mouseX, mouseY}, axis, {hitSize, useFinalPosition});
}

getCenterPoint(useFinalPosition) {
Expand Down Expand Up @@ -142,6 +143,7 @@ LineAnnotation.defaults = {
display: true,
endValue: undefined,
init: undefined,
hitTolerance: 0,
label: {
backgroundColor: 'rgba(0,0,0,0.8)',
backgroundShadowColor: 'transparent',
Expand All @@ -166,6 +168,7 @@ LineAnnotation.defaults = {
weight: 'bold'
},
height: undefined,
hitTolerance: undefined,
opacity: undefined,
padding: 6,
position: 'center',
Expand Down Expand Up @@ -211,9 +214,9 @@ LineAnnotation.defaultRoutes = {
borderColor: 'color'
};

function inAxisRange(element, {mouseX, mouseY}, axis, {hBorderWidth, useFinalPosition}) {
function inAxisRange(element, {mouseX, mouseY}, axis, {hitSize, useFinalPosition}) {
const limit = rangeLimit(mouseX, mouseY, element.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis);
return (limit.value >= limit.start - hBorderWidth && limit.value <= limit.end + hBorderWidth) || isOnLabel(element, {mouseX, mouseY}, useFinalPosition, axis);
return inLimit(limit, hitSize) || isOnLabel(element, {mouseX, mouseY}, useFinalPosition, axis);
}

function isLineInArea({x, y, x2, y2}, {top, right, bottom, left}) {
Expand Down Expand Up @@ -258,6 +261,7 @@ function intersects(element, {mouseX, mouseY}, epsilon = EPSILON, useFinalPositi
const dy = y2 - y1;
const lenSq = sqr(dx) + sqr(dy);
const t = lenSq === 0 ? -1 : ((mouseX - x1) * dx + (mouseY - y1) * dy) / lenSq;

let xx, yy;
if (t < 0) {
xx = x1;
Expand Down
10 changes: 5 additions & 5 deletions src/types/point.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import {Element} from 'chart.js';
import {inPointRange, getElementCenterPoint, resolvePointProperties, setBorderStyle, setShadowStyle, isImageOrCanvas, initAnimationProperties, drawPoint} from '../helpers';
import {inPointRange, getElementCenterPoint, resolvePointProperties, setBorderStyle, setShadowStyle, isImageOrCanvas, initAnimationProperties, drawPoint, inLimit} from '../helpers';

export default class PointAnnotation extends Element {

inRange(mouseX, mouseY, axis, useFinalPosition) {
const {x, y, x2, y2, width} = this.getProps(['x', 'y', 'x2', 'y2', 'width'], useFinalPosition);
const borderWidth = this.options.borderWidth;
const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2;
if (axis !== 'x' && axis !== 'y') {
return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, borderWidth);
return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, hitSize);
}
const hBorderWidth = borderWidth / 2;
const limit = axis === 'y' ? {start: y, end: y2, value: mouseY} : {start: x, end: x2, value: mouseX};
return limit.value >= limit.start - hBorderWidth && limit.value <= limit.end + hBorderWidth;
return inLimit(limit, hitSize);
}

getCenterPoint(useFinalPosition) {
Expand Down Expand Up @@ -54,6 +53,7 @@ PointAnnotation.defaults = {
borderShadowColor: 'transparent',
borderWidth: 1,
display: true,
hitTolerance: 0,
init: undefined,
pointStyle: 'circle',
radius: 10,
Expand Down
Loading

0 comments on commit 8d79a33

Please sign in to comment.