From f63e219055474ef8408eb75ea5f506a2cbaa8bad Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 11:13:02 +0200 Subject: [PATCH 1/9] Add hitTolerance option to all annotations --- src/helpers/helpers.core.js | 10 ++-- src/types/box.js | 5 +- src/types/ellipse.js | 14 ++--- src/types/label.js | 4 +- src/types/line.js | 13 +++-- test/specs/line.spec.js | 105 ++++++++++++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index 41df86b33..3a9e944e2 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -41,13 +41,13 @@ export function inPointRange(point, center, radius, borderWidth) { * @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 / 2 + 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..be9b96d46 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -6,7 +6,8 @@ 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); + const {borderWidth, hitTolerance} = this.options; + return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, {borderWidth, hitTolerance}); } getCenterPoint(useFinalPosition) { @@ -43,6 +44,7 @@ BoxAnnotation.defaults = { borderWidth: 1, display: true, init: undefined, + hitTolerance: 0, label: { backgroundColor: 'transparent', borderWidth: 0, @@ -61,6 +63,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..96e5519ac 100644 --- a/src/types/ellipse.js +++ b/src/types/ellipse.js @@ -6,16 +6,16 @@ import BoxAnnotation from './box'; export default class EllipseAnnotation extends Element { inRange(mouseX, mouseY, axis, useFinalPosition) { - const rotation = this.options.rotation; - const borderWidth = this.options.borderWidth; + const {rotation, borderWidth, hitTolerance} = this.options; 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, borderWidth, hitTolerance}); } const {x, y, x2, y2} = this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition); const hBorderWidth = borderWidth / 2; + const tolerance = hitTolerance / 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 - hBorderWidth - tolerance - EPSILON && rotatedPoint[axis] <= limit.end + hBorderWidth + tolerance + EPSILON; } getCenterPoint(useFinalPosition) { @@ -59,6 +59,7 @@ EllipseAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + hitTolerance: 0, init: undefined, label: Object.assign({}, BoxAnnotation.defaults.label), rotation: 0, @@ -85,7 +86,7 @@ EllipseAnnotation.descriptors = { } }; -function pointInEllipse(p, ellipse, rotation, borderWidth) { +function pointInEllipse(p, ellipse, {rotation, borderWidth, hitTolerance}) { const {width, height, centerX, centerY} = ellipse; const xRadius = width / 2; const yRadius = height / 2; @@ -96,9 +97,10 @@ 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 tolerance = hitTolerance / 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 + hBorderWidth + tolerance, 2)) + (b / Math.pow(yRadius + hBorderWidth + tolerance, 2)) <= 1.0001; } diff --git a/src/types/label.js b/src/types/label.js index f1c03ce2a..2df5ea010 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -8,7 +8,8 @@ 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); + const {borderWidth, hitTolerance} = this.options; + return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, {borderWidth, hitTolerance}); } getCenterPoint(useFinalPosition) { @@ -87,6 +88,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..6ee71054a 100644 --- a/src/types/line.js +++ b/src/types/line.js @@ -18,10 +18,12 @@ export default class LineAnnotation extends Element { inRange(mouseX, mouseY, axis, useFinalPosition) { const hBorderWidth = this.options.borderWidth / 2; + const tolerance = this.options.hitTolerance / 2; if (axis !== 'x' && axis !== 'y') { const point = {mouseX, mouseY}; const {path, ctx} = this; if (path) { + // TODO hitTolerance to manage setBorderStyle(ctx, this.options); const {chart} = this.$context; const mx = mouseX * chart.currentDevicePixelRatio; @@ -30,10 +32,10 @@ export default class LineAnnotation extends Element { ctx.restore(); return result; } - const epsilon = sqr(hBorderWidth); + const epsilon = sqr(hBorderWidth + tolerance); return intersects(this, point, epsilon, useFinalPosition) || isOnLabel(this, point, useFinalPosition); } - return inAxisRange(this, {mouseX, mouseY}, axis, {hBorderWidth, useFinalPosition}); + return inAxisRange(this, {mouseX, mouseY}, axis, {hBorderWidth, tolerance, useFinalPosition}); } getCenterPoint(useFinalPosition) { @@ -142,6 +144,7 @@ LineAnnotation.defaults = { display: true, endValue: undefined, init: undefined, + hitTolerance: 0, label: { backgroundColor: 'rgba(0,0,0,0.8)', backgroundShadowColor: 'transparent', @@ -166,6 +169,7 @@ LineAnnotation.defaults = { weight: 'bold' }, height: undefined, + hitTolerance: undefined, opacity: undefined, padding: 6, position: 'center', @@ -211,9 +215,9 @@ LineAnnotation.defaultRoutes = { borderColor: 'color' }; -function inAxisRange(element, {mouseX, mouseY}, axis, {hBorderWidth, useFinalPosition}) { +function inAxisRange(element, {mouseX, mouseY}, axis, {hBorderWidth, tolerance, 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 (limit.value >= limit.start - hBorderWidth - tolerance && limit.value <= limit.end + hBorderWidth + tolerance) || isOnLabel(element, {mouseX, mouseY}, useFinalPosition, axis); } function isLineInArea({x, y, x2, y2}, {top, right, bottom, left}) { @@ -258,6 +262,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/test/specs/line.spec.js b/test/specs/line.spec.js index e66a3937a..62596221e 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; From 0f070e0e2bb585dd649ea56384483767e14add56 Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 14:22:27 +0200 Subject: [PATCH 2/9] add test cases for line, box and ellipse annotations --- src/types/line.js | 2 +- test/specs/box.spec.js | 57 ++++++++++++++++++++++++++++ test/specs/ellipse.spec.js | 38 ++++++++++++------- test/specs/label.spec.js | 54 ++++++++++++++++++++++++++ test/specs/line.spec.js | 78 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 15 deletions(-) diff --git a/src/types/line.js b/src/types/line.js index 6ee71054a..fd3df5a81 100644 --- a/src/types/line.js +++ b/src/types/line.js @@ -23,8 +23,8 @@ export default class LineAnnotation extends Element { const point = {mouseX, mouseY}; const {path, ctx} = this; if (path) { - // TODO hitTolerance to manage setBorderStyle(ctx, this.options); + ctx.lineWidth += this.options.hitTolerance; const {chart} = this.$context; const mx = mouseX * chart.currentDevicePixelRatio; const my = mouseY * chart.currentDevicePixelRatio; 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..70476d97f 100644 --- a/test/specs/ellipse.spec.js +++ b/test/specs/ellipse.spec.js @@ -27,15 +27,20 @@ describe('Ellipse annotation', function() { 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); + } } } }); @@ -45,15 +50,20 @@ describe('Ellipse annotation', function() { 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 62596221e..fdbfd292a 100644 --- a/test/specs/line.spec.js +++ b/test/specs/line.spec.js @@ -396,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); + } + } + }); + + }); + }); + }); From 3c334658736812dd45c124be211853b6c2e4a564 Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 14:56:54 +0200 Subject: [PATCH 3/9] add option to polygon annotation --- src/types/polygon.js | 9 +-- test/specs/ellipse.spec.js | 4 +- test/specs/polygon.spec.js | 123 +++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/types/polygon.js b/src/types/polygon.js index 2d622a7c5..427112116 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 / 2 + 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/specs/ellipse.spec.js b/test/specs/ellipse.spec.js index 70476d97f..63d91e034 100644 --- a/test/specs/ellipse.spec.js +++ b/test/specs/ellipse.spec.js @@ -22,7 +22,7 @@ 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; @@ -45,7 +45,7 @@ describe('Ellipse annotation', function() { } }); - 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; 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; From d9746f9b3794b0d20da32d883a56ca0f358f8476 Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 15:08:59 +0200 Subject: [PATCH 4/9] add option to point annotation --- src/helpers/helpers.core.js | 5 ++- src/types/point.js | 8 ++-- test/specs/point.spec.js | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index 3a9e944e2..9c3f465c8 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -29,12 +29,13 @@ export function clampAll(obj, from, to) { * @param {number} borderWidth * @returns {boolean} */ -export function inPointRange(point, center, radius, borderWidth) { +export function inPointRange(point, center, radius, {borderWidth, hitTolerance}) { 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); + const tolerance = hitTolerance / 2; + return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hBorderWidth + tolerance, 2); } /** diff --git a/src/types/point.js b/src/types/point.js index 8fd9549d6..af9bed075 100644 --- a/src/types/point.js +++ b/src/types/point.js @@ -5,13 +5,14 @@ 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 {hitTolerance, borderWidth} = this.options; 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, {borderWidth, hitTolerance}); } const hBorderWidth = borderWidth / 2; + const tolerance = hitTolerance / 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 limit.value >= limit.start - hBorderWidth - tolerance && limit.value <= limit.end + hBorderWidth + tolerance; } getCenterPoint(useFinalPosition) { @@ -54,6 +55,7 @@ PointAnnotation.defaults = { borderShadowColor: 'transparent', borderWidth: 1, display: true, + hitTolerance: 0, init: undefined, pointStyle: 'circle', radius: 10, 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', From 87467c094f94a59a58d08c0fb1272650ee355930 Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 15:16:55 +0200 Subject: [PATCH 5/9] add types --- types/label.d.ts | 2 ++ types/options.d.ts | 1 + 2 files changed, 3 insertions(+) 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, From 8311e40f630106ba924dd1182b2717c0475cf86f Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 15:33:24 +0200 Subject: [PATCH 6/9] add documentation --- docs/guide/types/_commonInnerLabel.md | 1 + docs/guide/types/_commonOptions.md | 1 + docs/guide/types/box.md | 1 + docs/guide/types/ellipse.md | 1 + docs/guide/types/label.md | 1 + docs/guide/types/line.md | 3 +++ docs/guide/types/point.md | 1 + docs/guide/types/polygon.md | 1 + 8 files changed, 10 insertions(+) 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. From 765e2e446e231f2e84ccf8b0877e4f3c4d47c54f Mon Sep 17 00:00:00 2001 From: stockiNail Date: Tue, 11 Jul 2023 17:20:08 +0200 Subject: [PATCH 7/9] code improving --- src/helpers/helpers.core.js | 15 ++++++++++----- src/types/box.js | 3 +-- src/types/ellipse.js | 15 ++++++--------- src/types/label.js | 3 +-- src/types/line.js | 13 ++++++------- src/types/point.js | 10 ++++------ src/types/polygon.js | 2 +- 7 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index 9c3f465c8..9b1bfbe81 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,16 +33,14 @@ 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, hitTolerance}) { +export function inPointRange(point, center, radius, hitSize) { if (!point || !center || radius <= 0) { return false; } - const hBorderWidth = borderWidth / 2; - const tolerance = hitTolerance / 2; - return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hBorderWidth + tolerance, 2); + return (Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2)) <= Math.pow(radius + hitSize, 2); } /** diff --git a/src/types/box.js b/src/types/box.js index be9b96d46..42ce4d549 100644 --- a/src/types/box.js +++ b/src/types/box.js @@ -6,8 +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)); - const {borderWidth, hitTolerance} = this.options; - return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, {borderWidth, hitTolerance}); + return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options); } getCenterPoint(useFinalPosition) { diff --git a/src/types/ellipse.js b/src/types/ellipse.js index 96e5519ac..211d0e512 100644 --- a/src/types/ellipse.js +++ b/src/types/ellipse.js @@ -6,16 +6,15 @@ import BoxAnnotation from './box'; export default class EllipseAnnotation extends Element { inRange(mouseX, mouseY, axis, useFinalPosition) { - const {rotation, borderWidth, hitTolerance} = this.options; + const rotation = this.options.rotation; + 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, hitTolerance}); + 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 tolerance = hitTolerance / 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 - tolerance - EPSILON && rotatedPoint[axis] <= limit.end + hBorderWidth + tolerance + EPSILON; + return rotatedPoint[axis] >= limit.start - hitSize - EPSILON && rotatedPoint[axis] <= limit.end + hitSize + EPSILON; } getCenterPoint(useFinalPosition) { @@ -86,7 +85,7 @@ EllipseAnnotation.descriptors = { } }; -function pointInEllipse(p, ellipse, {rotation, borderWidth, hitTolerance}) { +function pointInEllipse(p, ellipse, rotation, hitSize) { const {width, height, centerX, centerY} = ellipse; const xRadius = width / 2; const yRadius = height / 2; @@ -96,11 +95,9 @@ function pointInEllipse(p, ellipse, {rotation, borderWidth, hitTolerance}) { } // https://stackoverflow.com/questions/7946187/point-and-ellipse-rotated-position-test-algorithm const angle = toRadians(rotation || 0); - const hBorderWidth = borderWidth / 2 || 0; - const tolerance = hitTolerance / 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 + tolerance, 2)) + (b / Math.pow(yRadius + hBorderWidth + tolerance, 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 2df5ea010..881f1c4b1 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -8,8 +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)); - const {borderWidth, hitTolerance} = this.options; - return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, {borderWidth, hitTolerance}); + return inBoxRange({x, y}, this.getProps(['x', 'y', 'x2', 'y2'], useFinalPosition), axis, this.options); } getCenterPoint(useFinalPosition) { diff --git a/src/types/line.js b/src/types/line.js index fd3df5a81..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,8 +17,7 @@ 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 tolerance = this.options.hitTolerance / 2; + const hitSize = (this.options.borderWidth + this.options.hitTolerance) / 2; if (axis !== 'x' && axis !== 'y') { const point = {mouseX, mouseY}; const {path, ctx} = this; @@ -32,10 +31,10 @@ export default class LineAnnotation extends Element { ctx.restore(); return result; } - const epsilon = sqr(hBorderWidth + tolerance); + const epsilon = sqr(hitSize); return intersects(this, point, epsilon, useFinalPosition) || isOnLabel(this, point, useFinalPosition); } - return inAxisRange(this, {mouseX, mouseY}, axis, {hBorderWidth, tolerance, useFinalPosition}); + return inAxisRange(this, {mouseX, mouseY}, axis, {hitSize, useFinalPosition}); } getCenterPoint(useFinalPosition) { @@ -215,9 +214,9 @@ LineAnnotation.defaultRoutes = { borderColor: 'color' }; -function inAxisRange(element, {mouseX, mouseY}, axis, {hBorderWidth, tolerance, 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 - tolerance && limit.value <= limit.end + hBorderWidth + tolerance) || 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}) { diff --git a/src/types/point.js b/src/types/point.js index af9bed075..615051bec 100644 --- a/src/types/point.js +++ b/src/types/point.js @@ -1,18 +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 {hitTolerance, borderWidth} = this.options; + 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, hitTolerance}); + return inPointRange({x: mouseX, y: mouseY}, this.getCenterPoint(useFinalPosition), width / 2, hitSize); } - const hBorderWidth = borderWidth / 2; - const tolerance = hitTolerance / 2; const limit = axis === 'y' ? {start: y, end: y2, value: mouseY} : {start: x, end: x2, value: mouseX}; - return limit.value >= limit.start - hBorderWidth - tolerance && limit.value <= limit.end + hBorderWidth + tolerance; + return inLimit(limit, hitSize); } getCenterPoint(useFinalPosition) { diff --git a/src/types/polygon.js b/src/types/polygon.js index 427112116..577d22b1f 100644 --- a/src/types/polygon.js +++ b/src/types/polygon.js @@ -103,7 +103,7 @@ PolygonAnnotation.defaultRoutes = { }; function buildPointElement({centerX, centerY}, {radius, borderWidth, hitTolerance}, rad) { - const hitSize = borderWidth / 2 + hitTolerance / 2; + 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}; From 784c822fd41dca450f421dad1e0085bd8fb3cdcf Mon Sep 17 00:00:00 2001 From: stockiNail Date: Wed, 18 Sep 2024 13:26:04 +0200 Subject: [PATCH 8/9] Update src/helpers/helpers.core.js Co-authored-by: Jacco van den Berg --- src/helpers/helpers.core.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/helpers.core.js b/src/helpers/helpers.core.js index 9b1bfbe81..64076832a 100644 --- a/src/helpers/helpers.core.js +++ b/src/helpers/helpers.core.js @@ -51,7 +51,7 @@ export function inPointRange(point, center, radius, hitSize) { * @returns {boolean} */ export function inBoxRange(point, {x, y, x2, y2}, axis, {borderWidth, hitTolerance}) { - const hitSize = borderWidth / 2 + hitTolerance / 2; + 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') { From fa77191e03e0aeb2bc7eff57f14531e8b6ebb892 Mon Sep 17 00:00:00 2001 From: stockiNail Date: Wed, 18 Sep 2024 13:33:51 +0200 Subject: [PATCH 9/9] increase tolerance for a fixture --- test/fixtures/label/border.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: {