diff --git a/docs/guide/types/_commonInnerLabel.md b/docs/guide/types/_commonInnerLabel.md index b193ba977..e72e3f46b 100644 --- a/docs/guide/types/_commonInnerLabel.md +++ b/docs/guide/types/_commonInnerLabel.md @@ -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. diff --git a/docs/guide/types/_commonOptions.md b/docs/guide/types/_commonOptions.md index 4a9bbaf92..c0d69ab11 100644 --- a/docs/guide/types/_commonOptions.md +++ b/docs/guide/types/_commonOptions.md @@ -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` diff --git a/docs/guide/types/box.md b/docs/guide/types/box.md index 545c56edc..a9421a4ee 100644 --- a/docs/guide/types/box.md +++ b/docs/guide/types/box.md @@ -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. diff --git a/docs/guide/types/ellipse.md b/docs/guide/types/ellipse.md index 691433e95..45c0aa9c3 100644 --- a/docs/guide/types/ellipse.md +++ b/docs/guide/types/ellipse.md @@ -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. diff --git a/docs/guide/types/label.md b/docs/guide/types/label.md index ae21635a5..e4fbe7dd5 100644 --- a/docs/guide/types/label.md +++ b/docs/guide/types/label.md @@ -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. diff --git a/docs/guide/types/line.md b/docs/guide/types/line.md index 7bd13d26e..0bde5c35f 100644 --- a/docs/guide/types/line.md +++ b/docs/guide/types/line.md @@ -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. @@ -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. diff --git a/docs/guide/types/point.md b/docs/guide/types/point.md index c4038a740..1996b65a7 100644 --- a/docs/guide/types/point.md +++ b/docs/guide/types/point.md @@ -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. diff --git a/docs/guide/types/polygon.md b/docs/guide/types/polygon.md index 60cf5e79c..896aec062 100644 --- a/docs/guide/types/polygon.md +++ b/docs/guide/types/polygon.md @@ -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. diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index 41df86b33..64076832a 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -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 @@ -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') { diff --git a/src/types/box.js b/src/types/box.js index 66d02ae95..42ce4d549 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -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) { @@ -43,6 +43,7 @@ BoxAnnotation.defaults = { borderWidth: 1, display: true, init: undefined, + hitTolerance: 0, label: { backgroundColor: 'transparent', borderWidth: 0, @@ -61,6 +62,7 @@ BoxAnnotation.defaults = { weight: 'bold' }, height: undefined, + hitTolerance: undefined, opacity: undefined, padding: 6, position: 'center', diff --git a/src/types/ellipse.js b/src/types/ellipse.js index bddb9511f..211d0e512 100644 --- a/src/types/ellipse.js +++ b/src/types/ellipse.js @@ -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) { @@ -59,6 +58,7 @@ EllipseAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + hitTolerance: 0, init: undefined, label: Object.assign({}, BoxAnnotation.defaults.label), rotation: 0, @@ -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; @@ -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; } diff --git a/src/types/label.js b/src/types/label.js index f1c03ce2a..881f1c4b1 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -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) { @@ -87,6 +87,7 @@ LabelAnnotation.defaults = { weight: undefined }, height: undefined, + hitTolerance: 0, init: undefined, opacity: undefined, padding: 6, diff --git a/src/types/line.js b/src/types/line.js index ba0aaf874..dcf5715d3 100644 --- a/src/types/line.js +++ b/src/types/line.js @@ -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)}); @@ -17,12 +17,13 @@ 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; @@ -30,10 +31,10 @@ export default class LineAnnotation extends Element { 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) { @@ -142,6 +143,7 @@ LineAnnotation.defaults = { display: true, endValue: undefined, init: undefined, + hitTolerance: 0, label: { backgroundColor: 'rgba(0,0,0,0.8)', backgroundShadowColor: 'transparent', @@ -166,6 +168,7 @@ LineAnnotation.defaults = { weight: 'bold' }, height: undefined, + hitTolerance: undefined, opacity: undefined, padding: 6, position: 'center', @@ -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}) { @@ -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; diff --git a/src/types/point.js b/src/types/point.js index 8fd9549d6..615051bec 100644 --- a/src/types/point.js +++ b/src/types/point.js @@ -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) { @@ -54,6 +53,7 @@ PointAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + hitTolerance: 0, init: undefined, pointStyle: 'circle', radius: 10, diff --git a/src/types/polygon.js b/src/types/polygon.js index 2d622a7c5..577d22b1f 100644 --- a/src/types/polygon.js +++ b/src/types/polygon.js @@ -73,6 +73,7 @@ PolygonAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + hitTolerance: 0, init: undefined, point: { radius: 0 @@ -101,8 +102,8 @@ PolygonAnnotation.defaultRoutes = { backgroundColor: 'color' }; -function buildPointElement({centerX, centerY}, {radius, borderWidth}, rad) { - const halfBorder = borderWidth / 2; +function buildPointElement({centerX, centerY}, {radius, borderWidth, hitTolerance}, rad) { + const hitSize = (borderWidth + hitTolerance) / 2; const sin = Math.sin(rad); const cos = Math.cos(rad); const point = {x: centerX + sin * radius, y: centerY - cos * radius}; @@ -114,8 +115,8 @@ function buildPointElement({centerX, centerY}, {radius, borderWidth}, rad) { y: point.y, centerX: point.x, centerY: point.y, - bX: centerX + sin * (radius + halfBorder), - bY: centerY - cos * (radius + halfBorder) + bX: centerX + sin * (radius + hitSize), + bY: centerY - cos * (radius + hitSize) } }; } diff --git a/test/fixtures/label/border.js b/test/fixtures/label/border.js index b67d45eec..0d1ab348c 100644 --- a/test/fixtures/label/border.js +++ b/test/fixtures/label/border.js @@ -3,7 +3,7 @@ function content(ctx, opts) { } module.exports = { - tolerance: 0.0055, + tolerance: 0.0057, config: { type: 'scatter', options: { diff --git a/test/specs/box.spec.js b/test/specs/box.spec.js index 449f74b5d..8b698f7e3 100644 --- a/test/specs/box.spec.js +++ b/test/specs/box.spec.js @@ -23,6 +23,7 @@ describe('Box annotation', function() { for (const borderWidth of [0, 10]) { const halfBorder = borderWidth / 2; element.options.borderWidth = borderWidth; + for (const x of [element.x - halfBorder, element.x + element.width / 2, element.x2 + halfBorder]) { for (const y of [element.y - halfBorder, element.y + element.height / 2, element.y2 + halfBorder]) { const point = rotated({x, y}, center, rotation / 180 * Math.PI); @@ -54,6 +55,62 @@ describe('Box annotation', function() { } }); + describe('inRange with hit tolerance', function() { + const rotated = window.helpers.rotated; + + for (const rotation of [0, 45, 90, 135, 180, 225, 270, 315]) { + const annotation = { + type: 'box', + xMin: 2, + yMin: 4, + xMax: 8, + yMax: 6, + borderWidth: 0, + hitTolerance: 10, + rotation + }; + + const chart = window.scatterChart(10, 10, {test: annotation}); + const element = window.getAnnotationElements(chart)[0]; + const center = element.getCenterPoint(); + + it('should return true inside element', function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + for (const x of [element.x - halfBorder - halfTolerance, element.x2 + halfBorder + halfTolerance]) { + for (const y of [element.y - halfBorder - halfTolerance, element.y2 + halfBorder + halfTolerance]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(true); + } + } + } + }); + + it('should return false outside element', function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + + for (const x of [element.x - halfBorder - halfTolerance - 1, element.x2 + halfBorder + halfTolerance + 1]) { + for (const y of [element.y, element.y + element.height / 2, element.y2]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(false); + } + } + for (const x of [element.x, element.x + element.width / 2, element.x2]) { + for (const y of [element.y - halfBorder - halfTolerance - 1, element.y2 + halfBorder + halfTolerance + 1]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(false); + } + } + } + }); + } + }); + describe('scriptable options', function() { it('element should have dimensions when backgroundColor is resolved', function(done) { window.scatterChart(8, 8, { diff --git a/test/specs/ellipse.spec.js b/test/specs/ellipse.spec.js index b555c1b16..63d91e034 100644 --- a/test/specs/ellipse.spec.js +++ b/test/specs/ellipse.spec.js @@ -22,38 +22,48 @@ describe('Ellipse annotation', function() { const xRadius = element.width / 2; const yRadius = element.height / 2; - it(`should return true when point is inside element\n{x: ${center.x}, y: ${center.y}, xRadius: ${xRadius.toFixed(1)}, yRadius: ${yRadius.toFixed(1)}}`, function() { + it('should return true when point is inside element', function() { for (const borderWidth of [0, 10]) { const halfBorder = borderWidth / 2; element.options.borderWidth = borderWidth; - for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { - const rad = angle / 180 * Math.PI; + for (const hitTolerance of [0, 5, 10, 20]) { + const halfTolerance = hitTolerance / 2; + element.options.hitTolerance = hitTolerance; - const {x, y} = rotated({ - x: center.x + Math.cos(rad) * (xRadius + halfBorder), - y: center.y + Math.sin(rad) * (yRadius + halfBorder) - }, center, rotation / 180 * Math.PI); + for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { + const rad = angle / 180 * Math.PI; - expect(element.inRange(x, y)).withContext(`rotation: ${rotation}, angle: ${angle}, borderWidth: ${borderWidth}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + const {x, y} = rotated({ + x: center.x + Math.cos(rad) * (xRadius + halfBorder + halfTolerance), + y: center.y + Math.sin(rad) * (yRadius + halfBorder + halfTolerance) + }, center, rotation / 180 * Math.PI); + + expect(element.inRange(x, y)).withContext(`rotation: ${rotation}, angle: ${angle}, borderWidth: ${borderWidth}, hitTolerance: ${hitTolerance}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + } } } }); - it(`should return false when point is outside element\n{x: ${center.x}, y: ${center.y}, xRadius: ${xRadius.toFixed(1)}, yRadius: ${yRadius.toFixed(1)}}`, function() { + it('should return false when point is outside element', function() { for (const borderWidth of [0, 10]) { const halfBorder = borderWidth / 2; element.options.borderWidth = borderWidth; - for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { - const rad = angle / 180 * Math.PI; + for (const hitTolerance of [0, 5, 10, 20]) { + const halfTolerance = hitTolerance / 2; + element.options.hitTolerance = hitTolerance; + + for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { + const rad = angle / 180 * Math.PI; - const {x, y} = rotated({ - x: center.x + Math.cos(rad) * (xRadius + halfBorder + 1), - y: center.y + Math.sin(rad) * (yRadius + halfBorder + 1) - }, center, rotation / 180 * Math.PI); + const {x, y} = rotated({ + x: center.x + Math.cos(rad) * (xRadius + halfBorder + halfTolerance + 1), + y: center.y + Math.sin(rad) * (yRadius + halfBorder + halfTolerance + 1) + }, center, rotation / 180 * Math.PI); - expect(element.inRange(x, y)).withContext(`rotation: ${rotation}, angle: ${angle}, borderWidth: ${borderWidth}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + expect(element.inRange(x, y)).withContext(`rotation: ${rotation}, angle: ${angle}, borderWidth: ${borderWidth}, hitTolerance: ${hitTolerance}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + } } } }); diff --git a/test/specs/label.spec.js b/test/specs/label.spec.js index b972efc1d..d21fd7fc2 100644 --- a/test/specs/label.spec.js +++ b/test/specs/label.spec.js @@ -54,6 +54,60 @@ describe('Label annotation', function() { } }); + describe('inRange', function() { + for (const rotation of [0, 45, 90, 135, 180, 225, 270, 315]) { + const annotation = { + type: 'label', + id: 'test', + xValue: 5, + yValue: 5, + content: 'This is my text', + position: 'center', + hitTolerance: 10, + rotation + }; + + const chart = window.scatterChart(10, 10, {test: annotation}); + const element = window.getAnnotationElements(chart)[0]; + const center = element.getCenterPoint(); + + it('should return true inside element', function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + for (const x of [element.x - halfBorder - halfTolerance, element.x + element.width / 2, element.x2 + halfBorder + halfTolerance]) { + for (const y of [element.y - halfBorder - halfTolerance, element.y + element.height / 2, element.y2 + halfBorder + halfTolerance]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(true); + } + } + } + }); + + it('should return false outside element', function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + + for (const x of [element.x - halfBorder - halfTolerance - 1, element.x2 + halfBorder + halfTolerance + 1]) { + for (const y of [element.y, element.y + element.height / 2, element.y2]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(false); + } + } + for (const x of [element.x, element.x + element.width / 2, element.x2]) { + for (const y of [element.y - halfBorder - halfTolerance - 1, element.y2 + halfBorder + halfTolerance + 1]) { + const point = rotated({x, y}, center, rotation / 180 * Math.PI); + expect(element.inRange(point.x, point.y)).toEqual(false); + } + } + } + }); + } + }); + describe('interaction', function() { const outer = { type: 'label', diff --git a/test/specs/line.spec.js b/test/specs/line.spec.js index e66a3937a..fdbfd292a 100644 --- a/test/specs/line.spec.js +++ b/test/specs/line.spec.js @@ -52,6 +52,111 @@ describe('Line annotation', function() { }); }); + describe('inRange with hit tolerance', function() { + const annotation1 = { + type: 'line', + scaleID: 'y', + value: 2, + borderWidth: 10, + hitTolerance: 10 + }; + const annotation2 = { + type: 'line', + scaleID: 'x', + value: 8, + borderWidth: 5, + hitTolerance: 10 + }; + const annotation3 = { + type: 'line', + scaleID: 'x', + value: 8, + borderWidth: 1, + hitTolerance: 10 + }; + const annotation4 = { + type: 'line', + scaleID: 'x', + value: 8, + borderWidth: 0.5, + hitTolerance: 10 + }; + + const chart = window.scatterChart(10, 10, {annotation1, annotation2, annotation3, annotation4}); + const elems = window.getAnnotationElements(chart); + + elems.forEach(function(element) { + const center = element.getCenterPoint(); + it('should return true inside element', function() { + const halfBorder = element.options.borderWidth / 2; + const halfTolerance = element.options.hitTolerance / 2; + for (const x of [center.x - halfBorder - halfTolerance, center.x + halfBorder + halfTolerance]) { + for (const y of [center.y - halfBorder - halfTolerance, center.y + halfBorder + halfTolerance]) { + expect(element.inRange(x, y)).withContext(`scaleID: ${element.options.scaleID}, value: ${element.options.value}, center: {x: ${center.x.toFixed(1)}, y: ${center.y.toFixed(1)}}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + } + } + }); + + it('should return false outside element', function() { + const halfBorder = element.options.borderWidth / 2; + const halfTolerance = element.options.hitTolerance / 2; + for (const x of [center.x - halfBorder - halfTolerance - 1, center.x + halfBorder + halfTolerance + 1]) { + for (const y of [center.y - halfBorder - halfTolerance - 1, center.y + halfBorder + halfTolerance + 1]) { + expect(element.inRange(x, y)).withContext(`scaleID: ${element.options.scaleID}, value: ${element.options.value}, center: {x: ${center.x.toFixed(1)}, y: ${center.y.toFixed(1)}}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + } + } + }); + }); + }); + + describe('inRange with label and hit tolerance', function() { + const annotation1 = { + type: 'line', + scaleID: 'y', + value: 2, + hitTolerance: 10, + label: { + display: true, + content: 'Label of the element', + } + }; + const annotation2 = { + type: 'line', + scaleID: 'y', + value: 2, + label: { + display: true, + content: 'Label of the element', + hitTolerance: 10 + } + }; + + const chart = window.scatterChart(10, 10, {annotation1, annotation2}); + const elems = window.getAnnotationElements(chart); + + elems.forEach(function(element) { + it('should return true inside label', function() { + const halfBorder = element.label.options.borderWidth / 2; + const halfTolerance = element.label.options.hitTolerance / 2; + for (const x of [element.label.x - halfBorder - halfTolerance, element.label.x2 + halfBorder + halfTolerance]) { + for (const y of [element.label.y - halfBorder - halfTolerance, element.label.y2 + halfBorder + halfTolerance]) { + expect(element.inRange(x, y)).toEqual(true); + } + } + }); + + it('should return false outside label', function() { + const halfBorder = element.label.options.borderWidth / 2; + const halfTolerance = element.label.options.hitTolerance / 2; + for (const x of [element.label.x - halfBorder - halfTolerance - 1, element.label.x2 + halfBorder + halfTolerance + 1]) { + for (const y of [element.label.y - halfBorder - halfTolerance - 1, element.label.y2 + halfBorder + halfTolerance + 1]) { + expect(element.inRange(x, y)).toEqual(false); + } + } + }); + }); + }); + describe('inRange with rotation', function() { const rotated = window.helpers.rotated; @@ -291,4 +396,82 @@ describe('Line annotation', function() { }); }); + describe('curve inRange with hit tolerance', function() { + const rotated = window.helpers.rotated; + const getQuadraticXY = window.getQuadraticXY; + const getQuadraticAngle = window.getQuadraticAngle; + + const annotation1 = { + type: 'line', + scaleID: 'y', + value: 2, + curve: true, + borderWidth: 10, + hitTolerance: 10 + }; + const annotation2 = { + type: 'line', + scaleID: 'x', + value: 8, + curve: true, + borderWidth: 5, + hitTolerance: 10 + }; + const annotation3 = { + type: 'line', + scaleID: 'x', + value: 8, + curve: true, + borderWidth: 1, + hitTolerance: 10 + }; + const annotation4 = { + type: 'line', + scaleID: 'x', + value: 8, + curve: true, + borderWidth: 0.5, + hitTolerance: 10 + }; + + const chart = window.scatterChart(10, 10, {annotation1, annotation2, annotation3, annotation4}); + const elems = window.getAnnotationElements(chart); + const EPSILON = 0.2; + + elems.forEach(function(element) { + const cp = element.cp; + it('should return true inside element', function() { + const halfBorder = element.options.borderWidth / 2; + const halfTolerance = element.options.hitTolerance / 2; + for (let t = 0.1; t <= 0.9; t += 0.1) { + const point = getQuadraticXY(t, element.x, element.y, cp.x, cp.y, element.x2, element.y2); + const angle = getQuadraticAngle(t, element.x, element.y, cp.x, cp.y, element.x2, element.y2); + for (const bw of [-halfBorder - halfTolerance + EPSILON, halfBorder + halfTolerance - EPSILON]) { + const x = point.x + bw; + const y = point.y + bw; + const rotP = rotated({x, y}, point, angle); + element.debug = true; + expect(element.inRange(rotP.x, rotP.y)).withContext(`scaleID: ${element.options.scaleID}, value: ${element.options.value}, tension: ${t}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + } + } + }); + + it('should return false outside element', function() { + const halfBorder = element.options.borderWidth / 2 + 1; + const halfTolerance = element.options.hitTolerance / 2 + 1; + for (let t = 0.1; t <= 0.9; t += 0.1) { + const point = getQuadraticXY(t, element.x, element.y, cp.x, cp.y, element.x2, element.y2); + const angle = getQuadraticAngle(t, element.x, element.y, cp.x, cp.y, element.x2, element.y2); + for (const bw of [-halfBorder - halfTolerance + EPSILON, halfBorder + halfTolerance - EPSILON]) { + const x = point.x + bw; + const y = point.y + bw; + const rotP = rotated({x, y}, point, angle); + expect(element.inRange(rotP.x, rotP.y)).withContext(`scaleID: ${element.options.scaleID}, value: ${element.options.value}, halfBorderWidth: ${bw}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + } + } + }); + + }); + }); + }); diff --git a/test/specs/point.spec.js b/test/specs/point.spec.js index 00abb3516..9da097e65 100644 --- a/test/specs/point.spec.js +++ b/test/specs/point.spec.js @@ -75,6 +75,86 @@ describe('Point annotation', function() { }); }); + describe('inRange with hit tolerance', function() { + const annotation1 = { + type: 'point', + xValue: 7, + yValue: 7, + radius: 30, + hitTolerance: 5 + }; + const annotation2 = { + type: 'point', + xValue: 3, + yValue: 3, + radius: 5, + hitTolerance: 20 + }; + const annotation3 = { + type: 'point', + xValue: 5, + yValue: 5, + radius: 0, + hitTolerance: 10 + }; + + const chart = window.scatterChart(10, 10, {annotation1, annotation2, annotation3}); + const elems = window.getAnnotationElements(chart).filter(el => el.options.radius > 0); + const elemsNoRad = window.getAnnotationElements(chart).filter(el => el.options.radius === 0); + + elems.forEach(function(element) { + it(`should return true inside element '${element.options.id}'`, function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + const radius = element.height / 2; + for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { + const rad = angle * (Math.PI / 180); + const {x, y} = { + x: element.centerX + Math.cos(rad) * (radius + halfBorder + halfTolerance - 1), + y: element.centerY + Math.sin(rad) * (radius + halfBorder + halfTolerance - 1) + }; + expect(element.inRange(x, y)).withContext(`angle: ${angle}, radius: ${radius}, borderWidth: ${borderWidth}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + } + } + }); + + it(`should return false outside element '${element.options.id}'`, function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + const radius = element.height / 2; + for (const angle of [0, 45, 90, 135, 180, 225, 270, 315]) { + const rad = angle * (Math.PI / 180); + const {x, y} = { + x: element.centerX + Math.cos(rad) * (radius + halfBorder + halfTolerance + 1), + y: element.centerY + Math.sin(rad) * (radius + halfBorder + halfTolerance + 1) + }; + expect(element.inRange(x, y)).withContext(`angle: ${angle}, radius: ${radius}, borderWidth: ${borderWidth}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + } + } + }); + }); + + elemsNoRad.forEach(function(element) { + it(`should return false radius is 0 element '${element.options.id}'`, function() { + for (const borderWidth of [0, 10]) { + const halfBorder = borderWidth / 2; + element.options.borderWidth = borderWidth; + const halfTolerance = element.options.hitTolerance / 2; + for (const x of [element.centerX - halfBorder - halfTolerance, element.centerX + halfBorder + halfTolerance]) { + expect(element.inRange(x, element.centerY)).toEqual(false); + } + for (const y of [element.centerY - halfBorder - halfTolerance, element.centerY + halfBorder + halfTolerance]) { + expect(element.inRange(element.centerY, y)).toEqual(false); + } + } + }); + }); + }); + describe('interaction', function() { const outer = { type: 'point', diff --git a/test/specs/polygon.spec.js b/test/specs/polygon.spec.js index af2b4362d..52aae340e 100644 --- a/test/specs/polygon.spec.js +++ b/test/specs/polygon.spec.js @@ -113,6 +113,129 @@ describe('Polygon annotation', function() { }); }); + describe('inRange with hit tolerance', function() { + const annotation1 = { + type: 'polygon', + xValue: 1, + yValue: 1, + borderWidth: 0, + hitTolerance: 10, + sides: 3, + radius: 30 + }; + const annotation2 = { + type: 'polygon', + xValue: 2, + yValue: 2, + borderWidth: 10, + hitTolerance: 5, + sides: 4, + radius: 5 + }; + const annotation3 = { + type: 'polygon', + xValue: 3, + yValue: 3, + borderWidth: 0, + hitTolerance: 20, + sides: 5, + radius: 27 + }; + const annotation4 = { + type: 'polygon', + xValue: 4, + yValue: 4, + sides: 3, + borderWidth: 10, + hitTolerance: 2, + rotation: 21, + radius: 20 + }; + const annotation5 = { + type: 'polygon', + xValue: 5, + yValue: 5, + sides: 4, + borderWidth: 0, + hitTolerance: 10, + rotation: 131, + radius: 33 + }; + const annotation6 = { + type: 'polygon', + xValue: 6, + yValue: 6, + sides: 5, + borderWidth: 10, + hitTolerance: 5, + rotation: 241, + radius: 24 + }; + const annotation7 = { + type: 'polygon', + xValue: 7, + yValue: 7, + sides: 5, + hitTolerance: 10, + radius: 0 + }; + const annotation8 = { + type: 'polygon', + xValue: 8, + yValue: 8, + borderWidth: 10, + hitTolerance: 5, + sides: 5, + radius: 0 + }; + + const chart = window.scatterChart(10, 10, {annotation1, annotation2, annotation3, annotation4, annotation5, annotation6, annotation7, annotation8}); + const elems = window.getAnnotationElements(chart).filter(el => el.options.radius > 0); + const elemsNoRad = window.getAnnotationElements(chart).filter(el => el.options.radius === 0); + + elems.forEach(function(element) { + const center = element.getCenterPoint(); + const rotation = element.options.rotation; + const sides = element.options.sides; + const borderWidth = element.options.borderWidth; + const hitTolerance = element.options.hitTolerance; + const radius = element.height / 2; + const angle = (2 * Math.PI) / sides; + + it(`should return true inside element '${element.options.id}'`, function() { + const halfBorder = borderWidth / 2; + const halfTolerance = hitTolerance / 2; + let rad = rotation * (Math.PI / 180); + for (let i = 0; i < sides; i++, rad += angle) { + const sin = Math.sin(rad); + const cos = Math.cos(rad); + const x = center.x + sin * (radius + halfBorder + halfTolerance - 1); + const y = center.y - cos * (radius + halfBorder + halfTolerance - 1); + expect(element.inRange(x, y)).withContext(`sides: ${sides}, rotation: ${rotation}, radius: ${radius}, borderWidth: ${borderWidth}, hitTolerance: ${hitTolerance}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(true); + } + }); + + it(`should return false outside element '${element.options.id}'`, function() { + const halfBorder = borderWidth / 2; + const halfTolerance = hitTolerance / 2; + let rad = rotation * (Math.PI / 180); + for (let i = 0; i < sides; i++, rad += angle) { + const sin = Math.sin(rad); + const cos = Math.cos(rad); + const x = center.x + sin * (radius + halfBorder + halfTolerance + 1); + const y = center.y - cos * (radius + halfBorder + halfTolerance + 1); + expect(element.inRange(x, y)).withContext(`sides: ${sides}, rotation: ${rotation}, radius: ${radius}, borderWidth: ${borderWidth}, hitTolerance: ${hitTolerance}, {x: ${x.toFixed(1)}, y: ${y.toFixed(1)}}`).toEqual(false); + } + }); + }); + + elemsNoRad.forEach(function(element) { + it(`should return false radius is 0 element '${element.options.id}'`, function() { + expect(element.inRange(element.centerX, element.centerY)).toEqual(false); + }); + }); + }); + describe('interaction', function() { const rotated = window.helpers.rotated; diff --git a/types/label.d.ts b/types/label.d.ts index 4c66511f2..e08bd3ece 100644 --- a/types/label.d.ts +++ b/types/label.d.ts @@ -127,6 +127,7 @@ export interface LabelOptions extends ContainedLabelOptions, ShadowOptions { * @default true */ display?: Scriptable, + hitTolerance?: Scriptable, /** * Rotation of label, in degrees, or 'auto' to use the degrees of the line, default is 0 * @default 90 @@ -141,6 +142,7 @@ export interface BoxLabelOptions extends CoreLabelOptions { * @default true */ display?: Scriptable, + hitTolerance?: Scriptable, rotation?: Scriptable } diff --git a/types/options.d.ts b/types/options.d.ts index c36c55fd4..0d49d2380 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -42,6 +42,7 @@ export interface CoreAnnotationOptions extends AnnotationEvents, ShadowOptions, borderWidth?: Scriptable, display?: Scriptable, drawTime?: Scriptable, + hitTolerance?: Scriptable, init?: boolean | ((chart: Chart, properties: AnnotationBoxModel, options: AnnotationOptions) => void | boolean | AnnotationElement), id?: string, xMax?: Scriptable,