diff --git a/src/annotation/annotation.js b/src/annotation/annotation.js index ef7e71c316..b162d5860b 100644 --- a/src/annotation/annotation.js +++ b/src/annotation/annotation.js @@ -1358,10 +1358,20 @@ function continuousVerticesProcessAction(m_this, evt, name) { * wide or half as wide as it is tall. Sizes (e.g., {width: 400, height: * 500}) snap to that size. * @returns {Function} A function that can be passed to the mapIterator - * selectionConstraint or to an annotation constraint function. + * selectionConstraint or to an annotation constraint function. If any sizes + * are given, the returned function is stateful: it remembers where the + * fixed-size box was left so that the box only moves once the mouse reaches + * its edge. That state is reset whenever the origin changes, so build a + * separate function per shape being drawn rather than sharing one. */ function constrainAspectRatio(ratio) { const ratios = Array.isArray(ratio) ? ratio : [ratio]; + /* A fixed-size box lags the mouse: it holds still while the mouse is inside it and is only + * pushed along once the mouse reaches an edge. That is path-dependent, so the center of the + * last box is remembered here. It is reset whenever a new action begins, which is detected by + * a change of origin. Each annotation builds its own constraint function, so this state is + * scoped to a single shape being drawn. */ + let lastOrigin, lastCenter; /** * Constrain a mouse action or annotation action to a list of aspect ratios. @@ -1459,6 +1469,13 @@ function constrainAspectRatio(ratio) { } else { /* Not in edit vertex or edge mode */ const area = Math.abs((pos.x - origin.x) * (pos.y - origin.y)); + let anchor = origin; + if (!lastOrigin || lastOrigin.x !== origin.x || lastOrigin.y !== origin.y) { + lastOrigin = {x: origin.x, y: origin.y}; + lastCenter = {x: origin.x, y: origin.y}; + } + /* Only the winning ratio's center may be committed, so hold it aside until the loop ends. */ + let bestCenter; ratios.forEach((ratio) => { let width, height; if (ratio.width) { @@ -1468,18 +1485,48 @@ function constrainAspectRatio(ratio) { width = (area * ratio) ** 0.5; height = width / ratio; } - const adjusted = { + const originAnchored = { x: origin.x + Math.sign(pos.x - origin.x) * width, y: origin.y + Math.sign(pos.y - origin.y) * height }; - const score = (adjusted.x - pos.x) ** 2 + (adjusted.y - pos.y) ** 2; + const score = (originAnchored.x - pos.x) ** 2 + (originAnchored.y - pos.y) ** 2; if (best === undefined || score < best) { best = score; - newpos = adjusted; + if (ratio.width) { + /* Fixed-size shapes have no remaining degree of freedom to resize, so dragging moves + * the box rather than resizing it. It starts centered on the origin and only gives + * way once the mouse reaches an edge: clamping the previous center to within half a + * box of the mouse leaves it untouched while the mouse is inside, and otherwise + * pushes it just far enough to keep the mouse on the edge. Each axis is clamped + * independently, so moving the mouse off the right edge slides the box sideways + * without disturbing it vertically. */ + const center = { + x: Math.min(Math.max(lastCenter.x, pos.x - width / 2), pos.x + width / 2), + y: Math.min(Math.max(lastCenter.y, pos.y - height / 2), pos.y + height / 2) + }; + bestCenter = center; + /* `anchor` is the upper-left corner and `newpos` the lower-right; map gcs has y + * increasing upwards, so the upper corner is the larger y. */ + anchor = { + x: center.x - width / 2, + y: center.y + height / 2 + }; + newpos = { + x: center.x + width / 2, + y: center.y - height / 2 + }; + } else { + bestCenter = undefined; + anchor = origin; + newpos = originAnchored; + } } }); - corners[0].y = corners[1].y = origin.y; - corners[0].x = corners[3].x = origin.x; + if (bestCenter) { + lastCenter = bestCenter; + } + corners[0].y = corners[1].y = anchor.y; + corners[0].x = corners[3].x = anchor.x; corners[1].x = corners[2].x = newpos.x; corners[2].y = corners[3].y = newpos.y; } diff --git a/src/annotation/rectangleAnnotation.js b/src/annotation/rectangleAnnotation.js index ee3e9ee17d..264a7d9b2c 100644 --- a/src/annotation/rectangleAnnotation.js +++ b/src/annotation/rectangleAnnotation.js @@ -22,10 +22,15 @@ const annotationActionOwner = require('./annotation').annotationActionOwner; * finished rectangle. This uses styles for {@link geo.polygonFeature}. * @property {geo.polygonFeature.styleSpec} [editStyle] The style to apply to a * rectangle in edit mode. - * @property {number|number[]|Function} [constraint] If specified, an aspect - * ratio or list of aspect ratios to constraint the rectangle to. If a - * function, a selection constraint function to call to adjust the - * rectangle. + * @property {number|geo.geoSize|Array.|Function} [constraint] + * If specified, an aspect ratio, a fixed size, or a list of allowed aspect ratios and sizes to + * constrain the rectangle to. A number (or a list of numbers) is an aspect ratio - the + * rectangle is resized as it is drawn, with the initial click point fixed as one corner. A size + * (e.g., `{width: 20, height: 10}`) instead fixes the rectangle's dimensions - since there is + * no remaining degree of freedom to resize, dragging translates the whole rectangle rather + * than resizing it: it starts centered on the initial click and then stays put while the + * mouse moves inside it, only being pushed along once the mouse reaches one of its edges. If + * a function, a selection constraint function to call to adjust the rectangle. */ /** @@ -55,7 +60,9 @@ var rectangleAnnotation = function (args, annotationName) { var m_this = this, s_actions = this.actions, - s_processEditAction = this.processEditAction; + s_processEditAction = this.processEditAction, + // The original click location that started the current draw. + m_origin = null; /** * Return actions needed for the specified state of this annotation. @@ -234,7 +241,8 @@ var rectangleAnnotation = function (args, annotationName) { corners[1] = map.displayToGcs(c1, null); corners[3] = map.displayToGcs(c3, null); if (this._selectionConstraint) { - this._selectionConstraint(evt.mapgcs, corners[0], corners); + // Use the origin recorded at the start of the draw. + this._selectionConstraint(evt.mapgcs, m_origin || corners[0], corners); } }; @@ -291,10 +299,13 @@ var rectangleAnnotation = function (args, annotationName) { return 'done'; } if (evt.buttonsDown.left) { + m_origin = Object.assign({}, evt.mapgcs); corners.push(Object.assign({}, evt.mapgcs)); corners.push(Object.assign({}, evt.mapgcs)); corners.push(Object.assign({}, evt.mapgcs)); corners.push(Object.assign({}, evt.mapgcs)); + // Apply the constraint immediately so fixed-size shapes show right away. + m_this._setCornersFromMouse(corners, evt); return true; } return undefined; diff --git a/src/mapInteractor.js b/src/mapInteractor.js index 5ce5ddce1c..9d0f07918d 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -3,6 +3,10 @@ var object = require('./object'); var util = require('./util'); var Mousetrap = require('mousetrap'); +/* A rectangle's corners array must have at least this many points before its + * first corner can be trusted as a valid anchor point. */ +var MIN_ANCHOR_CORNERS = 3; + /** * Map Interactor specification. * @@ -989,9 +993,15 @@ var mapInteractor = function (args) { display = {}, gcs = {}; let mousexy = mouse.map; + let anchorxy = origin.map; if (m_state.actionRecord && util.isFunction(m_state.actionRecord.selectionConstraint)) { const constraint = m_state.actionRecord.selectionConstraint(mouse.mapgcs, origin.mapgcs); - mousexy = constraint ? map.gcsToDisplay(constraint.pos, null) : mousexy; + if (constraint) { + mousexy = map.gcsToDisplay(constraint.pos, null); + if (constraint.corners && constraint.corners.length >= MIN_ANCHOR_CORNERS) { + anchorxy = map.gcsToDisplay(constraint.corners[0], null); + } + } } else if (mouse.modifiers.shift) { const width = Math.abs((mousexy.x - origin.map.x) * (mousexy.y - origin.map.y)) ** 0.5; mousexy = { @@ -1001,13 +1011,13 @@ var mapInteractor = function (args) { } // Get the display coordinates display.upperLeft = { - x: Math.min(origin.map.x, mousexy.x), - y: Math.min(origin.map.y, mousexy.y) + x: Math.min(anchorxy.x, mousexy.x), + y: Math.min(anchorxy.y, mousexy.y) }; display.lowerRight = { - x: Math.max(origin.map.x, mousexy.x), - y: Math.max(origin.map.y, mousexy.y) + x: Math.max(anchorxy.x, mousexy.x), + y: Math.max(anchorxy.y, mousexy.y) }; display.upperRight = { diff --git a/tests/cases/annotation.js b/tests/cases/annotation.js index 6dae308e4d..4327bdcc50 100644 --- a/tests/cases/annotation.js +++ b/tests/cases/annotation.js @@ -928,6 +928,32 @@ describe('geo.annotation', function () { }); }); + describe('geo.annotation.rectangleAnnotation with mixed aspect ratio and fixed size', function () { + it('does not let a fixed-size selection drift the origin used for later aspect-ratio checks', function () { + var map = createMap(); + var layer = map.createLayer('annotation', {annotations: ['rectangle']}); + var ann = geo.annotation.rectangleAnnotation({ + layer: layer, + constraint: [2, {width: 10, height: 10}] + }); + ann.state(geo.annotation.state.create); + // First click establishes the true origin at (0, 0). + ann.mouseClick({ + buttonsDown: {left: true}, + time: Date.now(), + map: {x: 0, y: 0}, + mapgcs: {x: 0, y: 0} + }); + ann.mouseMove({mapgcs: {x: 11, y: 9}}); + ann.mouseMove({mapgcs: {x: 40, y: 20}}); + var corners = ann.options('corners'); + expect([corners[0].x, corners[0].y]).toEqual([0, 0]); + expect([corners[1].x, corners[1].y]).toEqual([40, 0]); + expect([corners[2].x, corners[2].y]).toEqual([40, 20]); + expect([corners[3].x, corners[3].y]).toEqual([0, 20]); + }); + }); + describe('geo.annotation.polygonAnnotation', function () { var vertices = [{x: 30, y: 0}, {x: 50, y: 0}, {x: 40, y: 20}, {x: 30, y: 10}]; var vertices2 = [{x: 30, y: 10}, {x: 50, y: 10}, {x: 40, y: 30}]; @@ -1682,20 +1708,29 @@ describe('geo.annotation', function () { const func = geo.annotation.constrainAspectRatio({width: 20, height: 10}); let result; + // A fixed-size shape anchors at the current mouse position (not origin) and always extends + // in the same direction, so it translates with the cursor instead of flipping across + // quadrants. result = func( {x: 40, y: 5}, {x: 0, y: 0}); - expect(result.pos).toEqual({x: 20, y: 10}); + expect(result.pos).toEqual({x: 60, y: 15}); + expect(result.corners).toEqual([{x: 40, y: 5}, {x: 60, y: 5}, {x: 60, y: 15}, {x: 40, y: 15}]); result = func( {x: 40, y: 5}, {x: 0, y: 0}, [{x: 0, y: 0}, {x: 10, y: 0}, {x: 10, y: 10}, {x: 0, y: 10}]); - expect(result.pos).toEqual({x: 20, y: 10}); + expect(result.pos).toEqual({x: 60, y: 15}); result = func( {x: 5, y: 40}, {x: 0, y: 0}, [{x: 0, y: 0}, {x: 10, y: 0}, {x: 10, y: 10}, {x: 0, y: 10}]); - expect(result.pos).toEqual({x: 20, y: 10}); + expect(result.pos).toEqual({x: 25, y: 50}); + // Moving the mouse to the opposite side of origin must not flip the extension direction. + result = func( + {x: -40, y: -5}, + {x: 0, y: 0}); + expect(result.pos).toEqual({x: -20, y: 5}); result = func( {x: 0, y: 0}, {x: 0, y: 0}, @@ -1709,6 +1744,18 @@ describe('geo.annotation', function () { 'vertex', [0, -Math.PI / 2, Math.PI, Math.PI / 2], 0); expect(result.corners).toEqual([{x: 10, y: 20}, {x: 30, y: 20}, {x: 30, y: 10}, {x: 10, y: 10}]); }); + it('multiple fixed sizes picks the closest match', function () { + // With multiple fixed sizes in the list, the one closest to the actual drag distance + // must be chosen, not simply the first fixed-size entry. + const func = geo.annotation.constrainAspectRatio([ + {width: 100, height: 100}, {width: 10, height: 10}]); + + let result; + result = func({x: 11, y: 11}, {x: 0, y: 0}); + expect(result.pos).toEqual({x: 21, y: 21}); + result = func({x: 95, y: 95}, {x: 0, y: 0}); + expect(result.pos).toEqual({x: 195, y: 195}); + }); }); describe('annotation registry', function () { diff --git a/tests/cases/mapInteractor.js b/tests/cases/mapInteractor.js index 316dc4c215..cc26d2bb91 100644 --- a/tests/cases/mapInteractor.js +++ b/tests/cases/mapInteractor.js @@ -652,6 +652,52 @@ describe('mapInteractor', function () { expect(clickTriggered).toBe(1); }); + it('Test _getSelection uses the selectionConstraint anchor corner', function () { + var map = mockedMap('#mapNode1'), + selection; + + // A selectionConstraint that mimics a fixed-size annotation: the anchor corner always tracks + // the current mouse position, and the opposite corner is a constant offset away. + var interactor = geo.mapInteractor({ + map: map, + actions: [{ + action: geo.geo_action.select, + input: 'left', + selectionRectangle: geo.event.select, + selectionConstraint: function (pos) { + return { + pos: {x: pos.x + 10, y: pos.y + 5}, + corners: [ + {x: pos.x, y: pos.y}, + {x: pos.x + 10, y: pos.y}, + {x: pos.x + 10, y: pos.y + 5}, + {x: pos.x, y: pos.y + 5} + ] + }; + } + }], + throttle: false + }); + map.geoOn(geo.event.select, function (evt) { + selection = evt; + }); + + interactor.simulateEvent( + 'mousedown', {map: {x: 20, y: 20}, button: 'left'} + ); + interactor.simulateEvent( + 'mousemove.geojs', {map: {x: 50, y: 50}, button: 'left'} + ); + interactor.simulateEvent( + 'mouseup.geojs', {map: {x: 50, y: 50}, button: 'left'} + ); + + // The selection box should be anchored at the current mouse position, not at the original + // mousedown point. + expect(selection.display.upperLeft).toEqual({x: 50, y: 50}); + expect(selection.display.lowerRight).toEqual({x: 60, y: 55}); + }); + describe('pause state', function () { it('defaults', function () { expect(geo.mapInteractor().pause()).toBe(false); diff --git a/tutorials/annotation/index.pug b/tutorials/annotation/index.pug index 7b544a5e11..42fd4d0746 100644 --- a/tutorials/annotation/index.pug +++ b/tutorials/annotation/index.pug @@ -3,7 +3,7 @@ extends ../common/index.pug block mainTutorial :markdown-it # Tutorial - Annotations - Draw different annotations, optionally setting the aspect ratio for rectangles and ellipses. + Draw different annotations, optionally setting the aspect ratio or a fixed size for rectangles and ellipses. Define some HTML with the divs for the map and controls. @@ -26,6 +26,7 @@ block mainTutorial Aspect Ratio + Fixed Size (w,h) @@ -106,6 +107,47 @@ block mainTutorial } }); + :markdown-it + Rectangles and ellipses can also be constrained to a *fixed size* rather than an aspect ratio, using the "Fixed Size" field. `square` and `circle` always use a fixed aspect ratio of 1 and cannot be given a fixed size, so the field only applies to `rectangle` and `ellipse`. + + Unlike an aspect-ratio constraint, a fixed-size shape has no remaining degree of freedom to resize as you drag - it is always exactly the size you specify. Because of this, dragging *moves* the shape instead of resizing it: the current mouse position becomes the rectangle's corner, and the rest of the rectangle extends from there in a fixed direction, so it translates smoothly with the cursor. + + +codeblock('javascript', 5, 3). + $('#draw').on('click', () => { + if ($('#draw').hasClass('drawing')) { + // turn off drawing an annotation + annotLayer.mode(null); + $('#draw').removeClass('drawing'); + } else { + // mode is the type of anntoation to draw + var mode = $('#mode option:selected').attr('id'); + var opts = {}; + if (mode === 'rectangle' || mode === 'ellipse') { + // if specified, a fixed width and height (comma-separated) take + // precedence over the aspect ratio + var fixedsize = $('#fixedsize').val().trim(); + if (fixedsize) { + var size = fixedsize.split(',').map((v) => parseFloat(v)); + opts.constraint = {width: size[0], height: size.length > 1 ? size[1] : size[0]}; + } else { + // otherwise, if specified, set the aspect ratio constraint to a + // list of values + var aspect = $('#aspect').val().trim(); + if (aspect) { + opts.constraint = aspect.split(',').map((v) => parseFloat(v)); + } + } + } + // switch to drawing mode + annotLayer.mode(mode, undefined, opts); + $('#draw').addClass('drawing'); + // when finished, turn off the button style so it is obvious + annotLayer.geoOnce(geo.event.annotation.mode, () => { + $('#draw').removeClass('drawing'); + }); + } + }); + +codeblock_test('map has an annotation layer', [ 'map.layers().length === 2', 'map.layers()[1] instanceof geo.annotationLayer',