Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions src/annotation/annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
23 changes: 17 additions & 6 deletions src/annotation/rectangleAnnotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<number|geo.geoSize>|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.
*/

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
};

Expand Down Expand Up @@ -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;
Expand Down
20 changes: 15 additions & 5 deletions src/mapInteractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down
53 changes: 50 additions & 3 deletions tests/cases/annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}];
Expand Down Expand Up @@ -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},
Expand All @@ -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 () {
Expand Down
46 changes: 46 additions & 0 deletions tests/cases/mapInteractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
44 changes: 43 additions & 1 deletion tutorials/annotation/index.pug
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -26,6 +26,7 @@ block mainTutorial
<option id="point">Point</option>
</select>
Aspect Ratio <input id="aspect" size="3"></input>
Fixed Size (w,h) <input id="fixedsize" size="7"></input>
<button id="draw">Draw</button>
</div>
</body>
Expand Down Expand Up @@ -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',
Expand Down
Loading