diff --git a/examples/snap.html b/examples/snap.html new file mode 100644 index 0000000000..b30424fabf --- /dev/null +++ b/examples/snap.html @@ -0,0 +1,77 @@ + + + + + + + + + + + Snap example + + + + + +
+ +
+
+
+
+
+ +
+ +
+

Snap interaction example

+

Example of using the snap interaction together with + draw and modify interactions. The snap interaction must be added + last, as it needs to be the first to handle the + pointermove event.

+
+
+ +
+
+ +
+
+ + +
+
+ +
+

See the snap.js source to see how this is done.

+
+
draw, edit, modify, vector, featureoverlay, snap
+
+ +
+ +
+ + + + + + + diff --git a/examples/snap.js b/examples/snap.js new file mode 100644 index 0000000000..a644f61fe3 --- /dev/null +++ b/examples/snap.js @@ -0,0 +1,162 @@ +goog.require('ol.FeatureOverlay'); +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.interaction'); +goog.require('ol.interaction.Draw'); +goog.require('ol.interaction.Modify'); +goog.require('ol.interaction.Select'); +goog.require('ol.interaction.Snap'); +goog.require('ol.layer.Tile'); +goog.require('ol.source.MapQuest'); +goog.require('ol.style.Circle'); +goog.require('ol.style.Fill'); +goog.require('ol.style.Stroke'); +goog.require('ol.style.Style'); + +var raster = new ol.layer.Tile({ + source: new ol.source.MapQuest({layer: 'sat'}) +}); + +var map = new ol.Map({ + layers: [raster], + target: 'map', + view: new ol.View({ + center: [-11000000, 4600000], + zoom: 4 + }) +}); + +// The features are not added to a regular vector layer/source, +// but to a feature overlay which holds a collection of features. +// This collection is passed to the modify and also the draw +// interaction, so that both can add or modify features. +var featureOverlay = new ol.FeatureOverlay({ + style: new ol.style.Style({ + fill: new ol.style.Fill({ + color: 'rgba(255, 255, 255, 0.2)' + }), + stroke: new ol.style.Stroke({ + color: '#ffcc33', + width: 2 + }), + image: new ol.style.Circle({ + radius: 7, + fill: new ol.style.Fill({ + color: '#ffcc33' + }) + }) + }), + map: map +}); + +var Modify = { + init: function() { + this.select = new ol.interaction.Select(); + map.addInteraction(this.select); + + this.modify = new ol.interaction.Modify({ + features: this.select.getFeatures() + }); + map.addInteraction(this.modify); + + this.setEvents(); + }, + setEvents: function() { + var selectedFeatures = this.select.getFeatures(); + + this.select.on('change:active', function() { + selectedFeatures.forEach(selectedFeatures.remove, selectedFeatures); + }); + + // since snap control duplicates the geometry of features + // when creating the index and it doesn't rebuild the index + // on 'change:geometry', modified geometries should be handled manualy + selectedFeatures.on('add', function(evt) { + // removes the feature geometry from the snap index + featureOverlay.getFeatures().remove(evt.element); + }); + selectedFeatures.on('remove', function(evt) { + // adds the feature geometry to the snap index + featureOverlay.getFeatures().push(evt.element); + }); + }, + setActive: function(active) { + this.select.setActive(active); + this.modify.setActive(active); + } +}; +Modify.init(); + + +var Draw = { + init: function() { + map.addInteraction(this.Point); + this.Point.setActive(false); + map.addInteraction(this.LineString); + this.LineString.setActive(false); + map.addInteraction(this.Polygon); + this.Polygon.setActive(false); + }, + Point: new ol.interaction.Draw({ + features: featureOverlay.getFeatures(), + type: /** @type {ol.geom.GeometryType} */ ('Point') + }), + LineString: new ol.interaction.Draw({ + features: featureOverlay.getFeatures(), + type: /** @type {ol.geom.GeometryType} */ ('LineString') + }), + Polygon: new ol.interaction.Draw({ + features: featureOverlay.getFeatures(), + type: /** @type {ol.geom.GeometryType} */ ('Polygon') + }), + getActive: function() { + return this.activeType ? this[this.activeType].getActive() : false; + }, + setActive: function(active) { + var type = optionsForm.elements['draw-type'].value; + if (active) { + this.activeType && this[this.activeType].setActive(false); + this[type].setActive(true); + this.activeType = type; + } else { + this.activeType && this[this.activeType].setActive(false); + this.activeType = null; + } + } +}; +Draw.init(); + +var optionsForm = document.getElementById('options-form'); + + +/** + * Let user change the geometry type. + * @param {Event} e Change event. + */ +optionsForm.onchange = function(e) { + var type = e.target.getAttribute('name'); + var value = e.target.value; + if (type == 'draw-type') { + Draw.getActive() && Draw.setActive(true); + } else if (type == 'interaction') { + if (value == 'modify') { + Draw.setActive(false); + Modify.setActive(true); + } else if (value == 'draw') { + Draw.setActive(true); + Modify.setActive(false); + } + } +}; + +Draw.setActive(true); +Modify.setActive(false); + + +// snap duplicates the geometries so it can create a index. +// when a gemetry is modified, index needs to be rebuild for that +// geometry by removeing the old and adding the new +var snap = new ol.interaction.Snap({ + features: featureOverlay.getFeatures() +}); +map.addInteraction(snap); diff --git a/externs/olx.js b/externs/olx.js index bb863598be..9b42facd4b 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -2450,7 +2450,7 @@ olx.interaction.ModifyOptions.prototype.deleteCondition; /** * Pixel tolerance for considering the pointer close enough to a segment or - * vertex for editing. Default is `10` pixels. + * vertex for editing. * @type {number|undefined} * @api */ @@ -2671,6 +2671,42 @@ olx.interaction.SelectOptions.prototype.toggleCondition; olx.interaction.SelectOptions.prototype.multi; +/** + * Options for snap + * @typedef {{ + * features: (Array.|ol.Collection.|undefined), + * pixelTolerance: (number|undefined), + * source: (ol.source.Vector|undefined) + * }} + * @api + */ +olx.interaction.SnapOptions; + + +/** + * Snap to this features + * @type {Array.|ol.Collection.|undefined} + * @api + */ +olx.interaction.SnapOptions.prototype.features; + + +/** + * Pixel tolerance for considering the pointer close enough to a segment or + * vertex for editing. Default is `10` pixels. + * @type {number|undefined} + * @api + */ +olx.interaction.SnapOptions.prototype.pixelTolerance; + + +/** + * Snap to features from this source + * @type {ol.source.Vector|undefined} + */ +olx.interaction.SnapOptions.prototype.source; + + /** * Namespace. * @type {Object} diff --git a/src/ol/interaction/snapinteraction.js b/src/ol/interaction/snapinteraction.js new file mode 100644 index 0000000000..42e95a7afb --- /dev/null +++ b/src/ol/interaction/snapinteraction.js @@ -0,0 +1,490 @@ +goog.provide('ol.interaction.Snap'); +goog.provide('ol.interaction.SnapProperty'); + +goog.require('goog.asserts'); +goog.require('goog.events'); +goog.require('ol.Collection'); +goog.require('ol.CollectionEvent'); +goog.require('ol.CollectionEventType'); +goog.require('ol.Feature'); +goog.require('ol.MapBrowserEvent.EventType'); +goog.require('ol.Observable'); +goog.require('ol.coordinate'); +goog.require('ol.extent'); +goog.require('ol.interaction.Pointer'); +goog.require('ol.source.Vector'); +goog.require('ol.source.VectorEvent'); +goog.require('ol.source.VectorEventType'); +goog.require('ol.structs.RBush'); + + + +/** + * @classdesc + * Helper class for providing snap in ol.interaction.Pointer. + * + * Example: + * + * var snap = new ol.interaction.Snap({ + * source: source + * }); + * + * @constructor + * @extends {ol.interaction.Pointer} + * @param {olx.interaction.SnapOptions=} opt_options Options. + * @api + */ +ol.interaction.Snap = function(opt_options) { + + goog.base(this, { + handleDownEvent: ol.interaction.Snap.handleDownAndUpEvent, + handleEvent: ol.interaction.Snap.handleEvent, + handleUpEvent: ol.interaction.Snap.handleDownAndUpEvent + }); + + var options = goog.isDef(opt_options) ? opt_options : {}; + + /** + * @type {?ol.source.Vector} + * @private + */ + this.source_ = goog.isDef(options.source) ? options.source : null; + + /** + * @type {?ol.Collection.} + * @private + */ + this.features_ = goog.isDef(options.features) ? + goog.isArray(options.features) ? + new ol.Collection(options.features) : + options.features : + null; + + var features; + if (!goog.isNull(this.features_)) { + features = this.features_; + } else if (!goog.isNull(this.source_)) { + features = this.source_.getFeatures(); + } + goog.asserts.assert(goog.isDef(features)); + + /** + * @type {ol.Collection.} + * @private + */ + this.featuresListenerKeys_ = new ol.Collection(); + + /** + * @type {number} + * @private + */ + this.pixelTolerance_ = goog.isDef(options.pixelTolerance) ? + options.pixelTolerance : 10; + + + /** + * Segment RTree for each layer + * @type {Object.<*, ol.structs.RBush>} + * @private + */ + this.rBush_ = new ol.structs.RBush(); + + + /** + * @const + * @private + * @type {Object. } + */ + this.SEGMENT_WRITERS_ = { + 'Point': this.writePointGeometry_, + 'LineString': this.writeLineStringGeometry_, + 'LinearRing': this.writeLineStringGeometry_, + 'Polygon': this.writePolygonGeometry_, + 'MultiPoint': this.writeMultiPointGeometry_, + 'MultiLineString': this.writeMultiLineStringGeometry_, + 'MultiPolygon': this.writeMultiPolygonGeometry_, + 'GeometryCollection': this.writeGeometryCollectionGeometry_ + }; + + + features.forEach(this.addFeature, this); +}; +goog.inherits(ol.interaction.Snap, ol.interaction.Pointer); + + +/** + * @param {ol.Feature} feature Feature. + * @api + */ +ol.interaction.Snap.prototype.addFeature = function(feature) { + var geometry = feature.getGeometry(); + if (goog.isDef(this.SEGMENT_WRITERS_[geometry.getType()])) { + this.SEGMENT_WRITERS_[geometry.getType()].call(this, feature, geometry); + } +}; +goog.exportProperty( + ol.interaction.Snap.prototype, + 'addFeature', + ol.interaction.Snap.prototype.addFeature); + + +/** + * @inheritDoc + */ +ol.interaction.Snap.prototype.setMap = function(map) { + var currentMap = this.getMap(); + var keys = this.featuresListenerKeys_; + var features = this.features_; + var source = this.source_; + + if (currentMap) { + keys.forEach(ol.Observable.unByKey, this); + keys.clear(); + } + + goog.base(this, 'setMap', map); + + if (map) { + if (!goog.isNull(features)) { + keys.push(features.on(ol.CollectionEventType.ADD, + this.handleFeatureAdd_, this)); + keys.push(features.on(ol.CollectionEventType.REMOVE, + this.handleFeatureRemove_, this)); + } else if (!goog.isNull(source)) { + keys.push(source.on(ol.source.VectorEventType.ADDFEATURE, + this.handleFeatureAdd_, this)); + keys.push(source.on(ol.source.VectorEventType.REMOVEFEATURE, + this.handleFeatureRemove_, this)); + } + } +}; + + +/** + * @param {ol.MapBrowserPointerEvent} evt Event. + * @return {boolean} Stop drag sequence? + * @this {ol.interaction.Snap} + * @api + */ +ol.interaction.Snap.handleDownAndUpEvent = function(evt) { + return this.handleEvent_(evt); +}; + + +/** + * @param {ol.MapBrowserEvent} mapBrowserEvent Map browser event. + * @return {boolean} `false` to stop event propagation. + * @this {ol.interaction.Snap} + * @api + */ +ol.interaction.Snap.handleEvent = function(mapBrowserEvent) { + var pass = true; + if (mapBrowserEvent.type === ol.MapBrowserEvent.EventType.POINTERMOVE) { + pass = this.handleEvent_(mapBrowserEvent); + } + return ol.interaction.Pointer.handleEvent.call(this, mapBrowserEvent) && pass; +}; + + +/** + * Handle 'pointerdown', 'pointermove' and 'pointerup' events. + * @param {ol.MapBrowserEvent} evt A move event. + * @return {boolean} Pass the event to other interactions. + * @private + */ +ol.interaction.Snap.prototype.handleEvent_ = function(evt) { + var result = this.snapTo(evt.pixel, evt.coordinate, evt.map); + if (result.snapped) { + evt.coordinate = result.vertex; + evt.pixel = result.vertexPixel; + } + return true; +}; + + +/** + * @param {ol.source.VectorEvent|ol.CollectionEvent} evt Event. + * @private + */ +ol.interaction.Snap.prototype.handleFeatureAdd_ = function(evt) { + var feature; + if (evt instanceof ol.source.VectorEvent) { + feature = evt.feature; + } else if (evt instanceof ol.CollectionEvent) { + feature = evt.element; + } + goog.asserts.assertInstanceof(feature, ol.Feature); + this.addFeature(feature); +}; + + +/** + * @param {ol.source.VectorEvent|ol.CollectionEvent} evt Event. + * @private + */ +ol.interaction.Snap.prototype.handleFeatureRemove_ = function(evt) { + var feature; + if (evt instanceof ol.source.VectorEvent) { + feature = evt.feature; + } else if (evt instanceof ol.CollectionEvent) { + feature = evt.element; + } + goog.asserts.assertInstanceof(feature, ol.Feature); + this.removeFeature(feature, + feature.getGeometry().getExtent()); +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.Point} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writePointGeometry_ = + function(feature, geometry) { + var coordinates = geometry.getCoordinates(); + var segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: [coordinates, coordinates] + }); + this.rBush_.insert(geometry.getExtent(), segmentData); +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.MultiPoint} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writeMultiPointGeometry_ = + function(feature, geometry) { + var points = geometry.getCoordinates(); + var coordinates, i, ii, segmentData; + for (i = 0, ii = points.length; i < ii; ++i) { + coordinates = points[i]; + segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: [coordinates, coordinates] + }); + this.rBush_.insert(geometry.getExtent(), segmentData); + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.LineString} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writeLineStringGeometry_ = + function(feature, geometry) { + var coordinates = geometry.getCoordinates(); + var i, ii, segment, segmentData; + for (i = 0, ii = coordinates.length - 1; i < ii; ++i) { + segment = coordinates.slice(i, i + 2); + segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: segment + }); + this.rBush_.insert(ol.extent.boundingExtent(segment), segmentData); + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.MultiLineString} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writeMultiLineStringGeometry_ = + function(feature, geometry) { + var lines = geometry.getCoordinates(); + var coordinates, i, ii, j, jj, segment, segmentData; + for (j = 0, jj = lines.length; j < jj; ++j) { + coordinates = lines[j]; + for (i = 0, ii = coordinates.length - 1; i < ii; ++i) { + segment = coordinates.slice(i, i + 2); + segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: segment + }); + this.rBush_.insert(ol.extent.boundingExtent(segment), segmentData); + } + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.Polygon} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writePolygonGeometry_ = + function(feature, geometry) { + var rings = geometry.getCoordinates(); + var coordinates, i, ii, j, jj, segment, segmentData; + for (j = 0, jj = rings.length; j < jj; ++j) { + coordinates = rings[j]; + for (i = 0, ii = coordinates.length - 1; i < ii; ++i) { + segment = coordinates.slice(i, i + 2); + segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: segment + }); + this.rBush_.insert(ol.extent.boundingExtent(segment), segmentData); + } + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.MultiPolygon} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writeMultiPolygonGeometry_ = + function(feature, geometry) { + var polygons = geometry.getCoordinates(); + var coordinates, i, ii, j, jj, k, kk, rings, segment, segmentData; + for (k = 0, kk = polygons.length; k < kk; ++k) { + rings = polygons[k]; + for (j = 0, jj = rings.length; j < jj; ++j) { + coordinates = rings[j]; + for (i = 0, ii = coordinates.length - 1; i < ii; ++i) { + segment = coordinates.slice(i, i + 2); + segmentData = /** @type {ol.interaction.Snap.SegmentDataType} */ ({ + feature: feature, + segment: segment + }); + this.rBush_.insert(ol.extent.boundingExtent(segment), segmentData); + } + } + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.geom.GeometryCollection} geometry Geometry. + * @private + */ +ol.interaction.Snap.prototype.writeGeometryCollectionGeometry_ = + function(feature, geometry) { + var i, geometries = geometry.getGeometriesArray(); + for (i = 0; i < geometries.length; ++i) { + this.SEGMENT_WRITERS_[geometries[i].getType()].call( + this, feature, geometries[i]); + } +}; + + +/** + * @param {ol.Feature} feature Feature + * @param {ol.Extent} extent Extent. + * @api + */ +ol.interaction.Snap.prototype.removeFeature = function(feature, extent) { + var rBush = this.rBush_; + var i, nodesToRemove = []; + rBush.forEachInExtent(extent, function(node) { + if (feature === node.feature) { + nodesToRemove.push(node); + } + }); + for (i = nodesToRemove.length - 1; i >= 0; --i) { + rBush.remove(nodesToRemove[i]); + } +}; +goog.exportProperty( + ol.interaction.Snap.prototype, + 'removeFeature', + ol.interaction.Snap.prototype.removeFeature); + + +/** + * @param {ol.Pixel} pixel Pixel + * @param {ol.Coordinate} pixelCoordinate Coordinate + * @param {ol.Map} map Map. + * @return {ol.interaction.Snap.ResultType} Snap result + */ +ol.interaction.Snap.prototype.snapTo = function(pixel, pixelCoordinate, map) { + + var lowerLeft = map.getCoordinateFromPixel( + [pixel[0] - this.pixelTolerance_, pixel[1] + this.pixelTolerance_]); + var upperRight = map.getCoordinateFromPixel( + [pixel[0] + this.pixelTolerance_, pixel[1] - this.pixelTolerance_]); + var box = ol.extent.boundingExtent([lowerLeft, upperRight]); + + var segments = this.rBush_.getInExtent(box); + var snappedToVertex = false; + var snapped = false; + var vertex = null; + var vertexPixel = null; + if (segments.length > 0) { + segments.sort(goog.partial( + ol.interaction.Snap.sortByDistance, pixelCoordinate)); + var closestSegment = segments[0].segment; + vertex = (ol.coordinate.closestOnSegment(pixelCoordinate, + closestSegment)); + vertexPixel = map.getPixelFromCoordinate(vertex); + if (Math.sqrt(ol.coordinate.squaredDistance(pixel, vertexPixel)) <= + this.pixelTolerance_) { + snapped = true; + var pixel1 = map.getPixelFromCoordinate(closestSegment[0]); + var pixel2 = map.getPixelFromCoordinate(closestSegment[1]); + var squaredDist1 = ol.coordinate.squaredDistance(vertexPixel, pixel1); + var squaredDist2 = ol.coordinate.squaredDistance(vertexPixel, pixel2); + var dist = Math.sqrt(Math.min(squaredDist1, squaredDist2)); + snappedToVertex = dist <= this.pixelTolerance_; + if (snappedToVertex) { + vertex = squaredDist1 > squaredDist2 ? + closestSegment[1] : closestSegment[0]; + vertexPixel = map.getPixelFromCoordinate(vertex); + vertexPixel = [Math.round(vertexPixel[0]), Math.round(vertexPixel[1])]; + } + } + } + return /** @type {ol.interaction.Snap.ResultType} */ ({ + snapped: snapped, + vertex: vertex, + vertexPixel: vertexPixel + }); +}; + + +/** + * Sort segments by distance, helper function + * @param {ol.Coordinate} pixelCoordinate Coordinate to determine distance + * @param {ol.interaction.Snap.SegmentDataType} a + * @param {ol.interaction.Snap.SegmentDataType} b + * @return {number} + */ +ol.interaction.Snap.sortByDistance = function(pixelCoordinate, a, b) { + return ol.coordinate.squaredDistanceToSegment(pixelCoordinate, a.segment) - + ol.coordinate.squaredDistanceToSegment(pixelCoordinate, b.segment); +}; + + +/** + * @inheritDoc + */ +ol.interaction.Snap.prototype.shouldStopEvent = goog.functions.FALSE; + + +/** + * @typedef {{ + * snapped: {boolean}, + * vertex: (ol.Coordinate|null), + * vertexPixel: (ol.Pixel|null) + * }} + */ +ol.interaction.Snap.ResultType; + + +/** + * @typedef {{ + * feature: ol.Feature, + * segment: Array. + * }} + */ +ol.interaction.Snap.SegmentDataType;