feat: Add controls to combine and change annotations.
MODIFY
CHANGELOG.md
CHANGELOG.md
### Features
- Polygon operations ([#1198](../../pull/1198))
+- Use meta keys to modify annotations ([#1209](../../pull/1209))
## Version 1.8.10

MODIFY
src/annotation/annotation.js
src/annotation/annotation.js
@@ -74,6 +74,12 @@ var editHandleFeatureLevel = 3;
* @property {boolean|string[]} [showLabel=true] `true` to show the annotation
* label on annotations in done or edit states. Alternately, a list of
* states in which to show the label. Falsy to not show the label.
+ * @property {boolean} [allowBooleanOperations] This defaults to `true` for
+ * annotations that have area and `false` for those without area (e.g.,
+ * false for lines and points). If it is truthy, then, when the annotation
+ * is being created, it checks the metakeys on the first click that defines
+ * a coordinate to determine what boolean polygon operation should be
+ * performaned on the completion of the annotation.
*/
/**

MODIFY
src/annotation/polygonAnnotation.js
src/annotation/polygonAnnotation.js
@@ -79,7 +79,8 @@ var polygonAnnotation = function (args) {
},
stroke: false,
strokeColor: {r: 0, g: 0, b: 1}
- }
+ },
+ allowBooleanOperations: true
}, args || {});
args.vertices = args.vertices || args.coordinates || [];
delete args.coordinates;

MODIFY
src/annotation/rectangleAnnotation.js
src/annotation/rectangleAnnotation.js
@@ -69,7 +69,8 @@ var rectangleAnnotation = function (args, annotationName) {
fillColor: {r: 0.3, g: 0.3, b: 0.3},
fillOpacity: 0.25,
strokeColor: {r: 0, g: 0, b: 1}
- }
+ },
+ allowBooleanOperations: true
}, args || {});
args.corners = args.corners || args.coordinates || [];
delete args.coordinates;

MODIFY
src/annotationLayer.js
src/annotationLayer.js
@@ -181,6 +181,44 @@ var annotationLayer = function (arg) {
m_this._updateFromEvent(update);
};
+ /**
+ * Check if there is a current boolean operation.
+ *
+ * @returns {string?} Either undefined for no current boolean operation or
+ * the name of the operation.
+ */
+ this.currentBooleanOperation = function () {
+ let op;
+ if (m_this._currentBooleanClass) {
+ op = m_this._currentBooleanClass.split('-')[1];
+ }
+ return op;
+ };
+
+ /**
+ * Check if the map is currently in a mode where we are adding an annotation
+ * with a boolean operation. If so, remove the current annotation from the
+ * layer, then apply it via the boolean operation.
+ */
+ this._handleBooleanOperation = function () {
+ const op = m_this.currentBooleanOperation();
+ if (!op || !m_this.currentAnnotation || !m_this.currentAnnotation.toPolygonList) {
+ return;
+ }
+ const newAnnot = m_this.currentAnnotation;
+ m_this.removeAnnotation(m_this.currentAnnotation, false);
+ if (m_this.annotations().length || (op !== 'difference' && op !== 'intersect')) {
+ const evt = {
+ annotation: newAnnot,
+ operation: op
+ };
+ m_this.geoTrigger(geo_event.annotation.boolean, evt);
+ if (evt.cancel !== false) {
+ util.polyops[op](m_this, newAnnot.toPolygonList(), {correspond: {}, keepAnnotations: 'exact', style: m_this});
+ }
+ }
+ };
+
/**
* Handle updating the current annotation based on an update state.
*
@@ -196,6 +234,7 @@ var annotationLayer = function (arg) {
m_this.mode(null);
break;
case 'done':
+ m_this._handleBooleanOperation();
m_this.mode(null);
break;
}
@@ -205,6 +244,27 @@ var annotationLayer = function (arg) {
}
};
+ /**
+ * Check the state of the modifier keys and apply them if appropriate.
+ *
+ * @param {geo.event} evt The mouse move or click event.
+ */
+ this._handleMouseMoveModifiers = function (evt) {
+ if (m_this.mode() !== m_this.modes.edit && m_this.currentAnnotation.options('allowBooleanOperations') && m_this.currentAnnotation._coordinates().length < 2) {
+ if (evt.modifiers) {
+ const mod = (evt.modifiers.shift ? 's' : '') + (evt.modifiers.ctrl ? 'c' : '') + (evt.modifiers.meta || evt.modifiers.alt ? 'a' : '');
+ if (m_this._currentBooleanClass === m_this._booleanClasses[mod]) {
+ return;
+ }
+ m_this._currentBooleanClass = m_this._booleanClasses[mod];
+ const mapNode = m_this.map().node();
+ Object.values(m_this._booleanClasses).forEach((c) => {
+ mapNode.toggleClass(c, m_this._booleanClasses[mod] === c);
+ });
+ }
+ }
+ };
+
/**
* Handle mouse movement. If there is a current annotation, the movement
* event is sent to it.
@@ -213,6 +273,7 @@ var annotationLayer = function (arg) {
*/
this._handleMouseMove = function (evt) {
if (m_this.mode() && m_this.currentAnnotation) {
+ m_this._handleMouseMoveModifiers(evt);
var update = m_this.currentAnnotation.mouseMove(evt);
if (update) {
m_this.modified();
@@ -316,6 +377,7 @@ var annotationLayer = function (arg) {
});
retrigger = true;
} else if (m_this.mode() && m_this.currentAnnotation) {
+ m_this._handleMouseMoveModifiers(evt);
update = m_this.currentAnnotation.mouseClick(evt);
m_this._updateFromEvent(update);
retrigger = !m_this.mode();
@@ -518,6 +580,15 @@ var annotationLayer = function (arg) {
edit: 'edit'
};
+ /* Keys are short-hand for preferred event modifiers. Values are classes to
+ * apply to the map node. */
+ this._booleanClasses = {
+ s: 'annotation-union',
+ sc: 'annotation-intersect',
+ c: 'annotation-difference',
+ sa: 'annotation-xor'
+ };
+
/**
* Get or set the current mode.
*
@@ -537,11 +608,15 @@ var annotationLayer = function (arg) {
if (arg === undefined) {
return m_mode;
}
- if (arg !== m_mode || (arg === m_this.modes.edit && editAnnotation !== m_this.editAnnotation)) {
+ if (arg !== m_mode || (arg === m_this.modes.edit && editAnnotation !== m_this.editAnnotation) || (arg !== m_this.modes.edit && arg && !m_this.currentAnnotation)) {
var createAnnotation, actions,
mapNode = m_this.map().node(), oldMode = m_mode;
m_mode = arg;
mapNode.toggleClass('annotation-input', !!(m_mode && m_mode !== m_this.modes.edit));
+ if (!m_mode || m_mode === m_this.modes.edit) {
+ Object.values(m_this._booleanClasses).forEach((c) => mapNode.toggleClass(c, false));
+ m_this._currentBooleanClass = undefined;
+ }
if (!m_keyHandler) {
m_keyHandler = Mousetrap(mapNode[0]);
}
@@ -1199,6 +1274,13 @@ var annotationLayer = function (arg) {
exact = opts.correspond.exact1;
annot = m_this.annotations();
}
+ if (keep !== 'all' && keep !== 'none') {
+ annot.forEach((oldAnnot, idx) => {
+ if (opts.annotationIndices.indexOf(idx) < 0) {
+ keepIds[oldAnnot.id()] = true;
+ }
+ });
+ }
const polyAnnot = [];
poly.forEach((p, idx) => {
p = p.map((h) => h.map((pt) => ({x: pt[0], y: pt[1]})));
@@ -1218,7 +1300,9 @@ var annotationLayer = function (arg) {
reusedIds[orig.id()] = true;
}
['name', 'description', 'label'].forEach((k) => {
- result[k] = orig[k]();
+ if (orig[k](undefined, true)) {
+ result[k] = orig[k](undefined, true);
+ }
});
Object.entries(orig.options()).forEach(([key, value]) => {
if (['showLabel', 'style'].indexOf(key) >= 0 || key.endsWith('Style')) {

ADD
src/css/cursor-crosshair-difference.svg
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
+<path d="M0,11l11,0l0,-11l2,0l0,11l11,0l0,2l-11,0l0,11l-2,0l0,-11l-11,0z" fill="black" stroke-width="0"/>
+<path d="M24,27l8,0l0,2l-8,0z" fill="black" stroke-width="0"/>
+</svg>

ADD
src/css/cursor-crosshair-intersect.svg
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
+<path d="M0,11l11,0l0,-11l2,0l0,11l11,0l0,2l-11,0l0,11l-2,0l0,-11l-11,0z" fill="black" stroke-width="0"/>
+<path d="M24,32l0,-4a4,4,0,0,1,8,0l0,4l-2,0l0,-4a2,2,0,0,0,-4,0l0,4z" fill="black" stroke-width="0"/>
+</svg>

ADD
src/css/cursor-crosshair-union.svg
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
+<path d="M0,11l11,0l0,-11l2,0l0,11l11,0l0,2l-11,0l0,11l-2,0l0,-11l-11,0z" fill="black" stroke-width="0"/>
+<path d="M24,27l3,0l0,-3l2,0l0,3l3,0l0,2l-3,0l0,3l-2,0l0,-3l-3,0z" fill="black" stroke-width="0"/>
+</svg>

ADD
src/css/cursor-crosshair-xor.svg
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
+<path d="M0,11l11,0l0,-11l2,0l0,11l11,0l0,2l-11,0l0,11l-2,0l0,-11l-11,0z" fill="black" stroke-width="0"/>
+<path d="M32,30.59l-1.41,1.41l-2.12,-2.12l-2.12,2.12l-1.41,-1.41l2.12,-2.12l-2.12,-2.12l1.41,-1.41l2.12,2.12l2.12,-2.12l1.41,1.41l-2.12,2.12z" fill="black" stroke-width="0"/>
+</svg>

MODIFY
src/event.js
src/event.js
@@ -692,4 +692,18 @@ geo_event.annotation.state = 'geo_annotation_state';
*/
geo_event.annotation.mode = 'geo_annotation_mode';
+/**
+ * Triggered when an annotation can be combined via a boolean operation (union,
+ * intersect, difference, xor).
+ *
+ * @event geo.event.annotation.boolean
+ * @type {geo.event.base}
+ * @property {geo.annotation} annotation The annotation that is being operated
+ * on.
+ * @property {string} operation The operation being performed.
+ * @property {boolean} [cancel] If the handle sets this to false, don't apply
+ * the operation to the annotation layer.
+ */
+geo_event.annotation.boolean = 'geo_annotation_boolean';
+
module.exports = geo_event;

MODIFY
src/main.styl
src/main.styl
&.annotation-input
cursor crosshair
+ &.annotation-intersect
+ cursor embedurl("./css/cursor-crosshair-intersect.svg") 12 12,crosshair
+ &.annotation-difference
+ cursor embedurl("./css/cursor-crosshair-difference.svg") 12 12,crosshair
+ &.annotation-union
+ cursor embedurl("./css/cursor-crosshair-union.svg") 12 12,crosshair
+ &.annotation-xor
+ cursor embedurl("./css/cursor-crosshair-xor.svg") 12 12,crosshair
&.highlight-focus
&:after

MODIFY
src/util/polyops.js
src/util/polyops.js
@@ -39,7 +39,7 @@ var geo_map = require('../map');
* applied. If not specified , this is taken from the map second if
* available.
* @property {geo.map} [map] Used for ``ingcs`` and ``gcs`` if needed.
- * @property {string} [innerOperation="xor"] one of union, intersect, xor.
+ * @property {string} [innerOperation="union"] one of union, intersect, xor.
* Used to combine individual polygons in each of ``poly1`` and ``poly2``
* before the main operation is carried out.
* @property {object} [correspond] If present, information about the
@@ -181,6 +181,11 @@ function toPolygonList(poly, mode, opts) {
if (poly.toPolygonList) {
mode.style = poly;
poly = poly.toPolygonList(opts);
+ if (!poly.length) {
+ mode.min = mode.max = [0, 0];
+ mode.epsilon = 1e-10;
+ return poly;
+ }
} else {
mode.style = '';
if (poly.outer) {
@@ -401,8 +406,8 @@ function generalOperationProcess(op, poly1, poly2, opts) {
}
let seglist1 = poly1.map(p => PolyBool.segments({regions: p}));
let seglist2 = poly2.map(p => PolyBool.segments({regions: p}));
- seglist1 = polygonOperationSeglist(opts.innerOperation || 'xor', mode1.epsilon, seglist1);
- seglist2 = polygonOperationSeglist(opts.innerOperation || 'xor', mode2.epsilon, seglist2);
+ seglist1 = polygonOperationSeglist(opts.innerOperation || 'union', mode1.epsilon, seglist1);
+ seglist2 = polygonOperationSeglist(opts.innerOperation || 'union', mode2.epsilon, seglist2);
let seglist = seglist1;
if (seglist1[0] && seglist2[0]) {
/* We need to do the main operation with the same inversion flags */

MODIFY
tests/cases/annotationLayer.js
tests/cases/annotationLayer.js
@@ -514,6 +514,47 @@ describe('geo.annotationLayer', function () {
expect(layer.mode()).toBe(null);
layer.options('clickToEdit', false);
});
+ it('_handleMouseClick with modifier', function () {
+ layer.removeAllAnnotations();
+ layer.mode('polygon');
+ expect(layer.annotations().length).toBe(1);
+ expect(layer.annotations()[0].options('vertices').length).toBe(0);
+ var time = Date.now();
+ layer._handleMouseClick({
+ buttonsDown: {left: true},
+ modifiers: {shift: true},
+ time: time,
+ map: {x: 10, y: 20},
+ mapgcs: map.displayToGcs({x: 10, y: 20}, null)
+ });
+ layer._handleMouseClick({
+ buttonsDown: {left: true},
+ time: time,
+ map: {x: 30, y: 20},
+ mapgcs: map.displayToGcs({x: 30, y: 20}, null)
+ });
+ layer._handleMouseClick({
+ buttonsDown: {left: true},
+ time: time + 1000,
+ map: {x: 30, y: 20},
+ mapgcs: map.displayToGcs({x: 30, y: 20}, null)
+ });
+ layer._handleMouseClick({
+ buttonsDown: {left: true},
+ time: time + 1000,
+ map: {x: 20, y: 50},
+ mapgcs: map.displayToGcs({x: 20, y: 50}, null)
+ });
+ layer._handleMouseClick({
+ buttonsDown: {left: true},
+ time: time + 1000,
+ map: {x: 20, y: 50},
+ mapgcs: map.displayToGcs({x: 20, y: 50}, null)
+ });
+ expect(layer.annotations()[0].options('vertices').length).toBe(3);
+ expect(layer.annotations()[0].state()).toBe(geo.annotation.state.done);
+ layer.removeAllAnnotations();
+ });
it('_handleMouseOn', function () {
var rect = geo.annotation.rectangleAnnotation({
layer: layer,

MODIFY
tests/cases/polyops.js
tests/cases/polyops.js
@@ -68,6 +68,12 @@ describe('geo.util.polyops', function () {
});
});
+ describe('toPolygonList', function () {
+ it('empty list', function () {
+ expect(geo.util.polyops.toPolygonList([])).toEqual([]);
+ });
+ });
+
var opTests = [{
a: [[0, 0], [10, 0], [10, 10], [0, 10]],
b: [[5, 0], [15, 0], [15, 5], [5, 5]],

feat: Add controls to combine and change annotations.