diff --git a/examples/modify-icon.html b/examples/modify-icon.html
new file mode 100644
index 0000000000..37d936c61c
--- /dev/null
+++ b/examples/modify-icon.html
@@ -0,0 +1,10 @@
+---
+layout: example.html
+title: Icon modification
+shortdesc: Example using a Modify interaction to edit an icon.
+docs: >
+ The icon on this map can be dragged to modify its location.
+
The Modify interaction can be configured with a `layer` option. With this option, hit detection will be used to determine the modification candidate.
+tags: "vector, modify, icon, marker"
+---
+
diff --git a/examples/modify-icon.js b/examples/modify-icon.js
new file mode 100644
index 0000000000..b89971e5fa
--- /dev/null
+++ b/examples/modify-icon.js
@@ -0,0 +1,66 @@
+import Feature from '../src/ol/Feature.js';
+import Map from '../src/ol/Map.js';
+import Point from '../src/ol/geom/Point.js';
+import TileJSON from '../src/ol/source/TileJSON.js';
+import VectorSource from '../src/ol/source/Vector.js';
+import View from '../src/ol/View.js';
+import {Icon, Style} from '../src/ol/style.js';
+import {Modify} from '../src/ol/interaction.js';
+import {Tile as TileLayer, Vector as VectorLayer} from '../src/ol/layer.js';
+
+const iconFeature = new Feature({
+ geometry: new Point([0, 0]),
+ name: 'Null Island',
+ population: 4000,
+ rainfall: 500,
+});
+
+const iconStyle = new Style({
+ image: new Icon({
+ anchor: [0.5, 46],
+ anchorXUnits: 'fraction',
+ anchorYUnits: 'pixels',
+ src: 'data/icon.png',
+ }),
+});
+
+iconFeature.setStyle(iconStyle);
+
+const vectorSource = new VectorSource({
+ features: [iconFeature],
+});
+
+const vectorLayer = new VectorLayer({
+ source: vectorSource,
+});
+
+const rasterLayer = new TileLayer({
+ source: new TileJSON({
+ url: 'https://a.tiles.mapbox.com/v3/aj.1x1-degrees.json?secure=1',
+ crossOrigin: '',
+ }),
+});
+
+const target = document.getElementById('map');
+const map = new Map({
+ layers: [rasterLayer, vectorLayer],
+ target: target,
+ view: new View({
+ center: [0, 0],
+ zoom: 3,
+ }),
+});
+
+const modify = new Modify({
+ hitDetection: vectorLayer,
+ source: vectorSource,
+});
+modify.on(['modifystart', 'modifyend'], function (evt) {
+ target.style.cursor = evt.type === 'modifystart' ? 'grabbing' : 'pointer';
+});
+const overlaySource = modify.getOverlay().getSource();
+overlaySource.on(['addfeature', 'removefeature'], function (evt) {
+ target.style.cursor = evt.type === 'addfeature' ? 'pointer' : '';
+});
+
+map.addInteraction(modify);
diff --git a/src/ol/interaction/Modify.js b/src/ol/interaction/Modify.js
index 3c946bffc4..58b6178553 100644
--- a/src/ol/interaction/Modify.js
+++ b/src/ol/interaction/Modify.js
@@ -108,14 +108,24 @@ const ModifyEventType = {
* @property {number} [pixelTolerance=10] Pixel tolerance for considering the
* pointer close enough to a segment or vertex for editing.
* @property {import("../style/Style.js").StyleLike} [style]
- * Style used for the features being modified. By default the default edit
- * style is used (see {@link module:ol/style}).
+ * Style used for the modification point or vertex. For linestrings and polygons, this will
+ * be the affected vertex, for circles a point along the circle, and for points the actual
+ * point. If not configured, the default edit style is used (see {@link module:ol/style}).
+ * When using a style function, the point feature passed to the function will have a `features`
+ * property - an array whose entries are the features that are being modified, and a `geometries`
+ * property - an array whose entries are the geometries that are being modified. Both arrays are
+ * in the same order. The `geometries` are only useful when modifying geometry collections, where
+ * the geometry will be the particular geometry from the collection that is being modified.
* @property {VectorSource} [source] The vector source with
* features to modify. If a vector source is not provided, a feature collection
- * must be provided with the features option.
+ * must be provided with the `features` option.
+ * @property {boolean|import("../layer/BaseVector").default} [hitDetection] When configured, point
+ * features will be considered for modification based on their visual appearance, instead of being within
+ * the `pixelTolerance` from the pointer location. When a {@link module:ol/layer/BaseVector} is
+ * provided, only the rendered representation of the features on that layer will be considered.
* @property {Collection} [features]
* The features the interaction works on. If a feature collection is not
- * provided, a vector source must be provided with the source option.
+ * provided, a vector source must be provided with the `source` option.
* @property {boolean} [wrapX=false] Wrap the world horizontally on the sketch
* overlay.
*/
@@ -161,6 +171,12 @@ export class ModifyEvent extends Event {
* the `features` option. The interaction must be constructed with either a
* `source` or `features` option.
*
+ * Cartesian distance from the pointer is used to determine the features that
+ * will be modified. This means that geometries will only be considered for
+ * modification when they are within the configured `pixelTolerane`. For point
+ * geometries, the `hitDetection` option can be used to match their visual
+ * appearance.
+ *
* By default, the interaction will allow deletion of vertices when the `alt`
* key is pressed. To configure the interaction with a different condition
* for deletion, use the `deleteCondition` option.
@@ -237,10 +253,10 @@ class Modify extends PointerInteraction {
this.ignoreNextSingleClick_ = false;
/**
- * @type {boolean}
+ * @type {Collection}
* @private
*/
- this.modified_ = false;
+ this.featuresBeingModified_ = null;
/**
* Segment RTree for each layer
@@ -314,8 +330,15 @@ class Modify extends PointerInteraction {
*/
this.source_ = null;
+ /**
+ * @type {boolean|import("../layer/BaseVector").default}
+ */
+ this.hitDetection_ = null;
+
let features;
- if (options.source) {
+ if (options.features) {
+ features = options.features;
+ } else if (options.source) {
this.source_ = options.source;
features = new Collection(this.source_.getFeatures());
this.source_.addEventListener(
@@ -326,15 +349,18 @@ class Modify extends PointerInteraction {
VectorEventType.REMOVEFEATURE,
this.handleSourceRemove_.bind(this)
);
- } else {
- features = options.features;
}
if (!features) {
- throw new Error('The modify interaction requires features or a source');
+ throw new Error(
+ 'The modify interaction requires features, a source or a layer'
+ );
+ }
+ if (options.hitDetection) {
+ this.hitDetection_ = options.hitDetection;
}
/**
- * @type {Collection}
+ * @type {Collection}
* @private
*/
this.features_ = features;
@@ -354,6 +380,12 @@ class Modify extends PointerInteraction {
* @private
*/
this.lastPointerEvent_ = null;
+
+ /**
+ * Delta (x, y in map units) between matched rtree vertex and pointer vertex.
+ * @type {Array}
+ */
+ this.delta_ = [0, 0];
}
/**
@@ -376,14 +408,27 @@ class Modify extends PointerInteraction {
}
/**
- * @param {import("../MapBrowserEvent.js").default} evt Map browser event
+ * @param {import("../MapBrowserEvent.js").default} evt Map browser event.
+ * @param {Array>} segments The segments subject to modification.
* @private
*/
- willModifyFeatures_(evt) {
- if (!this.modified_) {
- this.modified_ = true;
+ willModifyFeatures_(evt, segments) {
+ if (!this.featuresBeingModified_) {
+ this.featuresBeingModified_ = new Collection();
+ const features = this.featuresBeingModified_.getArray();
+ for (let i = 0, ii = segments.length; i < ii; ++i) {
+ const feature = segments[i][0].feature;
+ if (features.indexOf(feature) === -1) {
+ this.featuresBeingModified_.push(feature);
+ }
+ }
+
this.dispatchEvent(
- new ModifyEvent(ModifyEventType.MODIFYSTART, this.features_, evt)
+ new ModifyEvent(
+ ModifyEventType.MODIFYSTART,
+ this.featuresBeingModified_,
+ evt
+ )
);
}
}
@@ -460,7 +505,7 @@ class Modify extends PointerInteraction {
}
/**
- * Get the overlay layer that this interaction renders sketch features to.
+ * Get the overlay layer that this interaction renders the modification point or vertex to.
* @return {VectorLayer} Overlay layer.
* @api
*/
@@ -725,10 +770,12 @@ class Modify extends PointerInteraction {
/**
* @param {import("../coordinate.js").Coordinate} coordinates Coordinates.
+ * @param {Array} features The features being modified.
+ * @param {Array} geometries The geometries being modified.
* @return {Feature} Vertex feature.
* @private
*/
- createOrUpdateVertexFeature_(coordinates) {
+ createOrUpdateVertexFeature_(coordinates, features, geometries) {
let vertexFeature = this.vertexFeature_;
if (!vertexFeature) {
vertexFeature = new Feature(new Point(coordinates));
@@ -738,6 +785,8 @@ class Modify extends PointerInteraction {
const geometry = vertexFeature.getGeometry();
geometry.setCoordinates(coordinates);
}
+ vertexFeature.set('features', features);
+ vertexFeature.set('geometries', geometries);
return vertexFeature;
}
@@ -784,14 +833,26 @@ class Modify extends PointerInteraction {
*/
handleDragEvent(evt) {
this.ignoreNextSingleClick_ = false;
- this.willModifyFeatures_(evt);
+ this.willModifyFeatures_(evt, this.dragSegments_);
- const vertex = evt.coordinate;
+ const vertex = [
+ evt.coordinate[0] + this.delta_[0],
+ evt.coordinate[1] + this.delta_[1],
+ ];
+ const features = [];
+ const geometries = [];
for (let i = 0, ii = this.dragSegments_.length; i < ii; ++i) {
const dragSegment = this.dragSegments_[i];
const segmentData = dragSegment[0];
- const depth = segmentData.depth;
+ const feature = segmentData.feature;
+ if (features.indexOf(feature) === -1) {
+ features.push(feature);
+ }
const geometry = segmentData.geometry;
+ if (geometries.indexOf(geometry) === -1) {
+ geometries.push(geometry);
+ }
+ const depth = segmentData.depth;
let coordinates;
const segment = segmentData.segment;
const index = dragSegment[1];
@@ -869,7 +930,7 @@ class Modify extends PointerInteraction {
this.setGeometryCoordinates_(geometry, coordinates);
}
}
- this.createOrUpdateVertexFeature_(vertex);
+ this.createOrUpdateVertexFeature_(vertex, features, geometries);
}
/**
@@ -884,7 +945,7 @@ class Modify extends PointerInteraction {
const pixelCoordinate = evt.coordinate;
this.handlePointerAtPixel_(evt.pixel, evt.map, pixelCoordinate);
this.dragSegments_.length = 0;
- this.modified_ = false;
+ this.featuresBeingModified_ = null;
const vertexFeature = this.vertexFeature_;
if (vertexFeature) {
const projection = evt.map.getView().getProjection();
@@ -965,7 +1026,7 @@ class Modify extends PointerInteraction {
}
if (insertVertices.length) {
- this.willModifyFeatures_(evt);
+ this.willModifyFeatures_(evt, [insertVertices]);
}
for (let j = insertVertices.length - 1; j >= 0; --j) {
@@ -1014,11 +1075,15 @@ class Modify extends PointerInteraction {
this.rBush_.update(boundingExtent(segmentData.segment), segmentData);
}
}
- if (this.modified_) {
+ if (this.featuresBeingModified_) {
this.dispatchEvent(
- new ModifyEvent(ModifyEventType.MODIFYEND, this.features_, evt)
+ new ModifyEvent(
+ ModifyEventType.MODIFYEND,
+ this.featuresBeingModified_,
+ evt
+ )
);
- this.modified_ = false;
+ this.featuresBeingModified_ = null;
}
return false;
}
@@ -1048,36 +1113,68 @@ class Modify extends PointerInteraction {
);
};
- const viewExtent = fromUserExtent(
- createExtent(pixelCoordinate, tempExtent),
- projection
- );
- const buffer = map.getView().getResolution() * this.pixelTolerance_;
- const box = toUserExtent(
- bufferExtent(viewExtent, buffer, tempExtent),
- projection
- );
+ let nodes, hitPointGeometry;
+ if (this.hitDetection_) {
+ const layerFilter =
+ typeof this.hitDetection_ === 'object'
+ ? (layer) => layer === this.hitDetection_
+ : undefined;
+ map.forEachFeatureAtPixel(
+ pixel,
+ (feature, layer, geometry) => {
+ geometry = geometry || feature.getGeometry();
+ if (geometry.getType() === GeometryType.POINT) {
+ hitPointGeometry = geometry;
+ const coordinate = geometry.getCoordinates();
+ nodes = [
+ {
+ feature,
+ geometry,
+ segment: [coordinate, coordinate],
+ },
+ ];
+ }
+ return true;
+ },
+ {layerFilter}
+ );
+ }
+ if (!nodes) {
+ const viewExtent = fromUserExtent(
+ createExtent(pixelCoordinate, tempExtent),
+ projection
+ );
+ const buffer = map.getView().getResolution() * this.pixelTolerance_;
+ const box = toUserExtent(
+ bufferExtent(viewExtent, buffer, tempExtent),
+ projection
+ );
+ nodes = this.rBush_.getInExtent(box);
+ }
- const rBush = this.rBush_;
- const nodes = rBush.getInExtent(box);
- if (nodes.length > 0) {
- nodes.sort(sortByDistance);
- const node = nodes[0];
+ if (nodes && nodes.length > 0) {
+ const node = nodes.sort(sortByDistance)[0];
const closestSegment = node.segment;
let vertex = closestOnSegmentData(pixelCoordinate, node, projection);
const vertexPixel = map.getPixelFromCoordinate(vertex);
let dist = coordinateDistance(pixel, vertexPixel);
- if (dist <= this.pixelTolerance_) {
+ if (hitPointGeometry || dist <= this.pixelTolerance_) {
/** @type {Object} */
const vertexSegments = {};
vertexSegments[getUid(closestSegment)] = true;
+ this.delta_[0] = vertex[0] - pixelCoordinate[0];
+ this.delta_[1] = vertex[1] - pixelCoordinate[1];
if (
node.geometry.getType() === GeometryType.CIRCLE &&
node.index === CIRCLE_CIRCUMFERENCE_INDEX
) {
this.snappedToVertex_ = true;
- this.createOrUpdateVertexFeature_(vertex);
+ this.createOrUpdateVertexFeature_(
+ vertex,
+ [node.feature],
+ [node.geometry]
+ );
} else {
const pixel1 = map.getPixelFromCoordinate(closestSegment[0]);
const pixel2 = map.getPixelFromCoordinate(closestSegment[1]);
@@ -1091,7 +1188,11 @@ class Modify extends PointerInteraction {
? closestSegment[1]
: closestSegment[0];
}
- this.createOrUpdateVertexFeature_(vertex);
+ this.createOrUpdateVertexFeature_(
+ vertex,
+ [node.feature],
+ [node.geometry]
+ );
const geometries = {};
geometries[getUid(node.geometry)] = true;
for (let i = 1, ii = nodes.length; i < ii; ++i) {
@@ -1203,12 +1304,16 @@ class Modify extends PointerInteraction {
this.lastPointerEvent_.type != MapBrowserEventType.POINTERDRAG
) {
const evt = this.lastPointerEvent_;
- this.willModifyFeatures_(evt);
+ this.willModifyFeatures_(evt, this.dragSegments_);
const removed = this.removeVertex_();
this.dispatchEvent(
- new ModifyEvent(ModifyEventType.MODIFYEND, this.features_, evt)
+ new ModifyEvent(
+ ModifyEventType.MODIFYEND,
+ this.featuresBeingModified_,
+ evt
+ )
);
- this.modified_ = false;
+ this.featuresBeingModified_ = null;
return removed;
}
return false;
diff --git a/test/spec/ol/interaction/modify.test.js b/test/spec/ol/interaction/modify.test.js
index 367033ce8c..f6387f5b57 100644
--- a/test/spec/ol/interaction/modify.test.js
+++ b/test/spec/ol/interaction/modify.test.js
@@ -1,4 +1,5 @@
import Circle from '../../../../src/ol/geom/Circle.js';
+import CircleStyle from '../../../../src/ol/style/Circle.js';
import Collection from '../../../../src/ol/Collection.js';
import Event from '../../../../src/ol/events/Event.js';
import Feature from '../../../../src/ol/Feature.js';
@@ -8,11 +9,13 @@ import Map from '../../../../src/ol/Map.js';
import MapBrowserEvent from '../../../../src/ol/MapBrowserEvent.js';
import Modify, {ModifyEvent} from '../../../../src/ol/interaction/Modify.js';
import Point from '../../../../src/ol/geom/Point.js';
-import Polygon from '../../../../src/ol/geom/Polygon.js';
+import Polygon, {fromExtent} from '../../../../src/ol/geom/Polygon.js';
import Snap from '../../../../src/ol/interaction/Snap.js';
import VectorLayer from '../../../../src/ol/layer/Vector.js';
import VectorSource from '../../../../src/ol/source/Vector.js';
import View from '../../../../src/ol/View.js';
+import {Fill, Style} from '../../../../src/ol/style.js';
+import {MultiPoint} from '../../../../src/ol/geom.js';
import {
clearUserProjection,
setUserProjection,
@@ -21,7 +24,7 @@ import {doubleClick} from '../../../../src/ol/events/condition.js';
import {getValues} from '../../../../src/ol/obj.js';
describe('ol.interaction.Modify', function () {
- let target, map, source, features;
+ let target, map, layer, source, features;
const width = 360;
const height = 180;
@@ -55,7 +58,7 @@ describe('ol.interaction.Modify', function () {
features: features,
});
- const layer = new VectorLayer({source: source});
+ layer = new VectorLayer({source: source});
map = new Map({
target: target,
@@ -194,6 +197,17 @@ describe('ol.interaction.Modify', function () {
expect(rbushEntries.length).to.be(1);
expect(rbushEntries[0].feature).to.be(feature);
});
+
+ it('accepts a hitDetection option', function () {
+ const feature = new Feature(new Point([0, 0]));
+ const source = new VectorSource({features: [feature]});
+ const layer = new VectorLayer({source: source});
+ const modify = new Modify({hitDetection: layer, source: source});
+ const rbushEntries = modify.rBush_.getAll();
+ expect(rbushEntries.length).to.be(1);
+ expect(rbushEntries[0].feature).to.be(feature);
+ expect(modify.hitDetection_).to.be(layer);
+ });
});
describe('vertex deletion', function () {
@@ -484,7 +498,7 @@ describe('ol.interaction.Modify', function () {
simulateEvent('pointerdrag', 30, -5, null, 0);
simulateEvent('pointerup', 30, -5, null, 0);
- expect(circleFeature.getGeometry().getRadius()).to.equal(25);
+ expect(circleFeature.getGeometry().getRadius()).to.roughlyEqual(25, 0.1);
expect(circleFeature.getGeometry().getCenter()).to.eql([5, 5]);
// Increase radius along y axis
@@ -494,7 +508,7 @@ describe('ol.interaction.Modify', function () {
simulateEvent('pointerdrag', 5, -35, null, 0);
simulateEvent('pointerup', 5, -35, null, 0);
- expect(circleFeature.getGeometry().getRadius()).to.equal(30);
+ expect(circleFeature.getGeometry().getRadius()).to.roughlyEqual(30, 0.1);
expect(circleFeature.getGeometry().getCenter()).to.eql([5, 5]);
});
@@ -539,7 +553,7 @@ describe('ol.interaction.Modify', function () {
.getGeometry()
.clone()
.transform(userProjection, viewProjection);
- expect(geometry2.getRadius()).to.roughlyEqual(25, 1e-9);
+ expect(geometry2.getRadius()).to.roughlyEqual(25, 0.1);
expect(geometry2.getCenter()).to.eql([5, 5]);
// Increase radius along y axis
@@ -553,7 +567,7 @@ describe('ol.interaction.Modify', function () {
.getGeometry()
.clone()
.transform(userProjection, viewProjection);
- expect(geometry3.getRadius()).to.roughlyEqual(30, 1e-9);
+ expect(geometry3.getRadius()).to.roughlyEqual(30, 0.1);
expect(geometry3.getCenter()).to.eql([5, 5]);
});
});
@@ -562,6 +576,7 @@ describe('ol.interaction.Modify', function () {
let modify, feature, events;
beforeEach(function () {
+ features.push(new Feature(new Point([12, 34])));
modify = new Modify({
features: new Collection(features),
});
@@ -941,19 +956,88 @@ describe('ol.interaction.Modify', function () {
});
});
- describe('#setActive', function () {
- it('removes the vertexFeature of deactivation', function () {
+ describe('Vertex feature', function () {
+ it('tracks features and geometries and removes the vertexFeature on deactivation', function () {
+ const collection = new Collection(features);
const modify = new Modify({
- features: new Collection(features),
+ features: collection,
});
map.addInteraction(modify);
expect(modify.vertexFeature_).to.be(null);
simulateEvent('pointermove', 10, -20, null, 0);
expect(modify.vertexFeature_).to.not.be(null);
+ expect(modify.vertexFeature_.get('features').length).to.be(1);
+ expect(modify.vertexFeature_.get('geometries').length).to.be(1);
modify.setActive(false);
expect(modify.vertexFeature_).to.be(null);
+ map.removeInteraction(modify);
+ });
+
+ it('tracks features and geometries - multi geometry', function () {
+ const collection = new Collection();
+ const modify = new Modify({
+ features: collection,
+ });
+ map.addInteraction(modify);
+ const feature = new Feature(
+ new MultiPoint([
+ [10, 10],
+ [10, 20],
+ ])
+ );
+ collection.push(feature);
+ simulateEvent('pointermove', 10, -20, null, 0);
+ expect(modify.vertexFeature_.get('features')[0]).to.eql(feature);
+ expect(modify.vertexFeature_.get('geometries')[0]).to.eql(
+ feature.getGeometry()
+ );
+ map.removeInteraction(modify);
+ });
+
+ it('tracks features and geometries - geometry collection', function () {
+ const collection = new Collection();
+ const modify = new Modify({
+ features: collection,
+ });
+ map.addInteraction(modify);
+ const feature = new Feature(
+ new GeometryCollection([fromExtent([0, 0, 10, 10]), new Point([5, 5])])
+ );
+ collection.push(feature);
+ simulateEvent('pointermove', 5, -5, null, 0);
+ expect(modify.vertexFeature_.get('features')[0]).to.eql(feature);
+ expect(modify.vertexFeature_.get('geometries')[0]).to.eql(
+ feature.getGeometry().getGeometriesArray()[1]
+ );
+ });
+
+ it('works with hit detection of point features', function () {
+ const modify = new Modify({
+ hitDetection: layer,
+ source: source,
+ });
+ map.addInteraction(modify);
+ source.clear();
+ const pointFeature = new Feature(new Point([0, 0]));
+ source.addFeature(pointFeature);
+ layer.setStyle(
+ new Style({
+ image: new CircleStyle({
+ radius: 30,
+ fill: new Fill({
+ color: 'fuchsia',
+ }),
+ }),
+ })
+ );
+ map.renderSync();
+ simulateEvent('pointermove', 10, -10, null, 0);
+ expect(modify.vertexFeature_.get('features')[0]).to.eql(pointFeature);
+ expect(modify.vertexFeature_.get('geometries')[0]).to.eql(
+ pointFeature.getGeometry()
+ );
});
});
@@ -1000,7 +1084,7 @@ describe('ol.interaction.Modify', function () {
simulateEvent('pointerdrag', 30, -5, null, 0);
simulateEvent('pointerup', 30, -5, null, 0);
- expect(circleFeature.getGeometry().getRadius()).to.equal(25);
+ expect(circleFeature.getGeometry().getRadius()).to.roughlyEqual(25, 1e-9);
expect(circleFeature.getGeometry().getCenter()).to.eql([5, 5]);
// Increase radius along y axis