diff --git a/examples/modify-features.html b/examples/modify-features.html new file mode 100644 index 0000000000..1f8b02941a --- /dev/null +++ b/examples/modify-features.html @@ -0,0 +1,56 @@ + + + + + + + + + + + Modify features example + + + + + +
+ +
+
+
+
+
+ +
+ +
+

Modify features example

+

Example of using the Modify interaction. Select a feature and drag the circle that appears when the cursor gets close to the selected geometry.

+
+

See the modify-features.js source to see how this is done.

+
+
modify, edit, vector
+
+ +
+ +
+ + + + + + diff --git a/examples/modify-features.js b/examples/modify-features.js new file mode 100644 index 0000000000..7858f53f00 --- /dev/null +++ b/examples/modify-features.js @@ -0,0 +1,88 @@ +goog.require('ol.Map'); +goog.require('ol.RendererHint'); +goog.require('ol.View2D'); +goog.require('ol.interaction'); +goog.require('ol.interaction.Modify'); +goog.require('ol.interaction.Select'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.parser.ogc.GML_v3'); +goog.require('ol.source.MapQuestOpenAerial'); +goog.require('ol.source.Vector'); +goog.require('ol.style.Fill'); +goog.require('ol.style.Rule'); +goog.require('ol.style.Shape'); +goog.require('ol.style.Stroke'); +goog.require('ol.style.Style'); + +var raster = new ol.layer.Tile({ + source: new ol.source.MapQuestOpenAerial() +}); + +var vector = new ol.layer.Vector({ + id: 'vector', + source: new ol.source.Vector({ + parser: new ol.parser.ogc.GML_v3(), + url: 'data/gml/topp-states-wfs.xml' + }), + style: new ol.style.Style({ + rules: [ + new ol.style.Rule({ + filter: 'renderIntent("selected")', + symbolizers: [ + new ol.style.Fill({ + color: '#ffffff', + opacity: 0.5 + }), + new ol.style.Stroke({ + color: '#6666ff', + width: 0.5 + }) + ] + }), + new ol.style.Rule({ + filter: 'renderIntent("temporary")', + symbolizers: [ + new ol.style.Shape({ + fill: new ol.style.Fill({color: '#bada55'}), + size: 16 + }) + ] + }), + new ol.style.Rule({ + filter: 'renderIntent("future")', + symbolizers: [ + new ol.style.Shape({ + fill: new ol.style.Fill({color: '#013'}), + size: 16 + }) + ] + }) + ], + symbolizers: [ + new ol.style.Fill({ + color: '#ffffff', + opacity: 0.25 + }), + new ol.style.Stroke({ + color: '#6666ff' + }) + ] + }) +}); + +var selectInteraction = new ol.interaction.Select({ + layerFilter: function(layer) { return layer.get('id') == 'vector'; } +}); + +var map = new ol.Map({ + interactions: ol.interaction.defaults().extend( + [selectInteraction, new ol.interaction.Modify()]), + layers: [raster, vector], + renderer: ol.RendererHint.CANVAS, + target: 'map', + view: new ol.View2D({ + center: [-11000000, 4600000], + zoom: 4 + }) +}); diff --git a/src/objectliterals.jsdoc b/src/objectliterals.jsdoc index e39f3b5156..76713ddf18 100644 --- a/src/objectliterals.jsdoc +++ b/src/objectliterals.jsdoc @@ -377,6 +377,14 @@ * @todo stability experimental */ +/** + * @typedef {Object} ol.interaction.ModifyOptions + * @property {undefined|function(ol.layer.Layer):boolean} layerFilter Filter + * function to restrict modification to a subset of layers. + * @property {number|undefined} pixelTolerance Pixel tolerance for considering + * the pointer close enough to a vertex for editing. Default is 20 pixels. + */ + /** * @typedef {Object} ol.interaction.SelectOptions * @property {ol.events.ConditionType|undefined} addCondition A conditional diff --git a/src/ol/interaction/draginteraction.js b/src/ol/interaction/draginteraction.js index 4132e8eaae..f3af8a62ef 100644 --- a/src/ol/interaction/draginteraction.js +++ b/src/ol/interaction/draginteraction.js @@ -26,6 +26,12 @@ ol.interaction.Drag = function() { */ this.dragging_ = false; + /** + * @type {number} Delta for INTERACTING view hint. Subclasses that do not want + * the INTERACTING hint to be set should override this to 0. + */ + this.interactingHint = 1; + /** * @type {number} */ @@ -60,6 +66,14 @@ ol.interaction.Drag = function() { goog.inherits(ol.interaction.Drag, ol.interaction.Interaction); +/** + * @return {boolean} Whether we're dragging. + */ +ol.interaction.Drag.prototype.getDragging = function() { + return this.dragging_; +}; + + /** * @param {ol.MapBrowserEvent} mapBrowserEvent Event. * @protected @@ -115,7 +129,7 @@ ol.interaction.Drag.prototype.handleMapBrowserEvent = goog.asserts.assertInstanceof(browserEvent, goog.events.BrowserEvent); this.deltaX = browserEvent.clientX - this.startX; this.deltaY = browserEvent.clientY - this.startY; - view.setHint(ol.ViewHint.INTERACTING, -1); + view.setHint(ol.ViewHint.INTERACTING, -this.interactingHint); this.dragging_ = false; this.handleDragEnd(mapBrowserEvent); } @@ -131,7 +145,7 @@ ol.interaction.Drag.prototype.handleMapBrowserEvent = (mapBrowserEvent.getCoordinate()); var handled = this.handleDragStart(mapBrowserEvent); if (handled) { - view.setHint(ol.ViewHint.INTERACTING, 1); + view.setHint(ol.ViewHint.INTERACTING, this.interactingHint); this.dragging_ = true; mapBrowserEvent.preventDefault(); stopEvent = true; diff --git a/src/ol/interaction/modifyinteraction.js b/src/ol/interaction/modifyinteraction.js new file mode 100644 index 0000000000..0888551fb9 --- /dev/null +++ b/src/ol/interaction/modifyinteraction.js @@ -0,0 +1,391 @@ +goog.provide('ol.interaction.Modify'); + +goog.require('goog.array'); +goog.require('goog.events'); +goog.require('goog.object'); +goog.require('ol.Feature'); +goog.require('ol.MapBrowserEvent.EventType'); +goog.require('ol.ViewHint'); +goog.require('ol.coordinate'); +goog.require('ol.extent'); +goog.require('ol.geom.AbstractCollection'); +goog.require('ol.geom.LineString'); +goog.require('ol.geom.LinearRing'); +goog.require('ol.geom.Point'); +goog.require('ol.geom.Polygon'); +goog.require('ol.interaction.Drag'); +goog.require('ol.layer.Vector'); +goog.require('ol.layer.VectorLayerEventType'); +goog.require('ol.layer.VectorLayerRenderIntent'); +goog.require('ol.structs.RTree'); + + + +/** + * @constructor + * @extends {ol.interaction.Drag} + * @param {ol.interaction.ModifyOptions=} opt_options Options. + */ +ol.interaction.Modify = function(opt_options) { + goog.base(this); + + var options = goog.isDef(opt_options) ? opt_options : {}; + + /** + * @type {null|function(ol.layer.Layer):boolean} + * @private + */ + this.layerFilter_ = goog.isDef(options.layerFilter) ? + options.layerFilter : null; + + /** + * @type {Array.} + * @private + */ + this.layers_ = null; + + /** + * @type {boolean} + * @private + */ + this.modifiable_ = false; + + /** + * @type {number} + * @private + */ + this.pixelTolerance_ = goog.isDef(options.pixelTolerance) ? + options.pixelTolerance : 20; + + /** + * @type {Array} + * @private + */ + this.dragVertices_ = null; + + this.interactingHint = 0; +}; +goog.inherits(ol.interaction.Modify, ol.interaction.Drag); + + +/** + * @param {ol.layer.VectorLayerEventObject} evt Event object. + */ +ol.interaction.Modify.prototype.addIndex = function(evt) { + var layer = evt.target; + var features = evt.features; + for (var i = 0, ii = features.length; i < ii; ++i) { + var feature = features[i]; + var geometry = feature.getGeometry(); + if (geometry instanceof ol.geom.AbstractCollection) { + for (var j = 0, jj = geometry.components.length; j < jj; ++j) { + this.addSegments_(layer, feature, geometry.components[j], + [[geometry.components, j]]); + } + } else { + this.addSegments_(layer, feature, geometry); + } + } +}; + + +/** + * @param {ol.layer.Layer} layer Layer. + */ +ol.interaction.Modify.prototype.addLayer = function(layer) { + var selectionData = layer.getSelectionData(); + var selectionLayer = selectionData.layer; + var editData = selectionLayer.getEditData(); + if (goog.isNull(editData.rTree)) { + editData.rTree = new ol.structs.RTree(); + var vertexFeature = new ol.Feature(); + vertexFeature.renderIntent = ol.layer.VectorLayerRenderIntent.HIDDEN; + vertexFeature.setGeometry(new ol.geom.Point([NaN, NaN])); + selectionLayer.addFeatures([vertexFeature]); + editData.vertexFeature = vertexFeature; + } + this.addIndex(/** @type {ol.layer.VectorLayerEventObject} */ + ({target: selectionLayer, features: goog.object.getValues( + selectionData.selectedFeaturesByFeatureUid)})); + goog.events.listen(selectionLayer, ol.layer.VectorLayerEventType.ADD, + this.addIndex, false, this); + goog.events.listen(selectionLayer, ol.layer.VectorLayerEventType.REMOVE, + this.removeIndex, false, this); +}; + + +/** + * @param {ol.layer.Vector} selectionLayer Selection layer. + * @param {ol.Feature} feature Feature to add segments for. + * @param {ol.geom.Geometry} geometry Geometry to add segments for. + * @param {Array=} opt_parent Parent structure on the feature that the geometry + * belongs to. This array has two values: + * [0] the parent array; + * [1] the index of the geometry in the parent array. + * @private + */ +ol.interaction.Modify.prototype.addSegments_ = + function(selectionLayer, feature, geometry, opt_parent) { + var uid = goog.getUid(feature); + var rTree = selectionLayer.getEditData().rTree; + var vertex, segment, segmentData, coordinates; + if (geometry instanceof ol.geom.Point) { + vertex = geometry.getCoordinates(); + segmentData = [[vertex, vertex], feature, geometry, NaN]; + if (goog.isDef(opt_parent)) { + segmentData.push(opt_parent); + } + rTree.insert(geometry.getBounds(), segmentData, uid); + } else if (geometry instanceof ol.geom.LineString || + geometry instanceof ol.geom.LinearRing) { + coordinates = geometry.getCoordinates(); + for (var i = 0, ii = coordinates.length - 1; i < ii; ++i) { + segment = coordinates.slice(i, i + 2); + segmentData = [segment, feature, geometry, i]; + if (opt_parent) { + segmentData.push(opt_parent); + } + rTree.insert(ol.extent.boundingExtent(segment), segmentData, uid); + } + } else if (geometry instanceof ol.geom.Polygon) { + for (var j = 0, jj = geometry.rings.length; j < jj; ++j) { + this.addSegments_(selectionLayer, feature, geometry.rings[j], + [geometry.rings, j]); + } + } +}; + + +/** + * @inheritDoc + */ +ol.interaction.Modify.prototype.handleDragStart = function(evt) { + this.dragVertices_ = []; + for (var i = 0, ii = this.layers_.length; i < ii; ++i) { + var selectionData = this.layers_[i].getSelectionData(); + var selectionLayer = selectionData.layer; + if (!goog.isNull(selectionLayer)) { + var editData = selectionLayer.getEditData(); + var vertexFeature = editData.vertexFeature; + if (!goog.isNull(vertexFeature) && vertexFeature.renderIntent != + ol.layer.VectorLayerRenderIntent.HIDDEN) { + var vertex = vertexFeature.getGeometry().getCoordinates(); + var vertexExtent = ol.extent.boundingExtent([vertex]); + var segments = editData.rTree.search(vertexExtent); + for (var j = 0, jj = segments.length; j < jj; ++j) { + var segmentData = segments[j]; + var segment = segmentData[0]; + if (vertexFeature.renderIntent == + ol.layer.VectorLayerRenderIntent.TEMPORARY) { + if (ol.coordinate.equals(segment[0], vertex)) { + this.dragVertices_.push([selectionLayer, segmentData, 0]); + } else { + this.dragVertices_.push([selectionLayer, segmentData, 1]); + } + } else { + this.insertVertex_(selectionLayer, segmentData, vertex); + } + } + } + } + } + return this.modifiable_; +}; + + +/** + * @inheritDoc + */ +ol.interaction.Modify.prototype.handleDrag = function(evt) { + var vertex = evt.getCoordinate(); + for (var i = 0, ii = this.dragVertices_.length; i < ii; ++i) { + var dragVertex = this.dragVertices_[i]; + var selectionLayer = dragVertex[0]; + var segmentData = dragVertex[1]; + var feature = segmentData[1]; + var geometry = segmentData[2]; + var index = dragVertex[2]; + geometry.set(segmentData[3] + index, 0, vertex[0]); + geometry.set(segmentData[3] + index, 1, vertex[1]); + feature.getGeometry().invalidateBounds(); + + var editData = selectionLayer.getEditData(); + var vertexFeature = editData.vertexFeature; + var vertexGeometry = vertexFeature.getGeometry(); + var segment = segmentData[0]; + editData.rTree.remove(ol.extent.boundingExtent(segment), segmentData); + segment[index] = vertex; + vertexGeometry.set(0, vertex[0]); + vertexGeometry.set(1, vertex[1]); + editData.rTree.insert(ol.extent.boundingExtent(segment), segmentData, + goog.getUid(feature)); + + selectionLayer.updateFeatures([feature, vertexFeature]); + } +}; + + +/** + * @inheritDoc + */ +ol.interaction.Modify.prototype.handleMapBrowserEvent = + function(mapBrowserEvent) { + if (!mapBrowserEvent.map.getView().getHints()[ol.ViewHint.INTERACTING] && + !this.getDragging() && + mapBrowserEvent.type == ol.MapBrowserEvent.EventType.MOUSEMOVE) { + this.handleMouseMove_(mapBrowserEvent); + } + goog.base(this, 'handleMapBrowserEvent', mapBrowserEvent); + return !this.modifiable_; +}; + + +/** + * @param {ol.MapBrowserEvent} evt Event. + * @private + */ +ol.interaction.Modify.prototype.handleMouseMove_ = function(evt) { + var map = evt.map; + var layers = goog.array.filter(map.getLayerGroup().getLayers().getArray(), + this.ignoreTemporaryLayersFilter_); + if (!goog.isNull(this.layerFilter_)) { + layers = goog.array.filter(layers, this.layerFilter_); + } + this.layers_ = layers; + var pixel = evt.getPixel(); + var pixelCoordinate = map.getCoordinateFromPixel(pixel); + var sortByDistance = function(a, b) { + return ol.coordinate.closestOnSegment(pixelCoordinate, a[0])[2] - + ol.coordinate.closestOnSegment(pixelCoordinate, b[0])[2]; + }; + + 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 vertexFeature; + this.modifiable_ = false; + for (var i = layers.length - 1; i >= 0; --i) { + var layer = layers[i]; + var selectionLayer = layer.getSelectionData().layer; + if (!goog.isNull(selectionLayer)) { + if (goog.isNull(goog.events.getListener(selectionLayer, + ol.layer.VectorLayerEventType.ADD, this.addIndex, false, this))) { + this.addLayer(layer); + } + var editData = selectionLayer.getEditData(); + vertexFeature = editData.vertexFeature; + var segments = editData.rTree.search(box); + var renderIntent = ol.layer.VectorLayerRenderIntent.HIDDEN; + if (segments.length > 0) { + segments.sort(sortByDistance); + var segment = segments[0][0]; // the closest segment + var geometry = vertexFeature.getGeometry(); + var vertex = /** @type {ol.Coordinate} */ + (ol.coordinate.closestOnSegment(pixelCoordinate, segment)); + var coordPixel = map.getPixelFromCoordinate(vertex); + var pixel1 = map.getPixelFromCoordinate(segment[0]); + var pixel2 = map.getPixelFromCoordinate(segment[1]); + var squaredDist1 = ol.coordinate.squaredDistance(coordPixel, pixel1); + var squaredDist2 = ol.coordinate.squaredDistance(coordPixel, pixel2); + var dist = Math.sqrt(Math.min(squaredDist1, squaredDist2)); + renderIntent = ol.layer.VectorLayerRenderIntent.FUTURE; + if (dist <= 10) { + vertex = squaredDist1 > squaredDist2 ? segment[1] : segment[0]; + renderIntent = ol.layer.VectorLayerRenderIntent.TEMPORARY; + } + geometry.set(0, vertex[0]); + geometry.set(1, vertex[1]); + selectionLayer.updateFeatures([vertexFeature]); + this.modifiable_ = true; + } + if (vertexFeature.renderIntent != renderIntent) { + selectionLayer.setRenderIntent(renderIntent, [vertexFeature]); + } + } + } +}; + + +/** + * @param {ol.layer.Layer} layer Layer. + * @return {boolean} Whether the layer is no temporary vector layer. + * @private + */ +ol.interaction.Modify.prototype.ignoreTemporaryLayersFilter_ = function(layer) { + return !(layer instanceof ol.layer.Vector && layer.getTemporary()); +}; + + +/** + * @param {ol.layer.Vector} selectionLayer Selection layer. + * @param {Array} segmentData Segment data. + * @param {ol.Coordinate} vertex Vertex. + * @private + */ +ol.interaction.Modify.prototype.insertVertex_ = + function(selectionLayer, segmentData, vertex) { + var segment = segmentData[0]; + var feature = segmentData[1]; + var geometry = segmentData[2]; + var index = segmentData[3]; + var coordinates = geometry.getCoordinates(); + coordinates.splice(index + 1, 0, vertex); + var oldGeometry = geometry; + geometry = new geometry.constructor(coordinates); + var parent; + if (segmentData.length > 4) { + parent = segmentData[4]; + parent[0][parent[1]] = geometry; + feature.getGeometry().invalidateBounds(); + } else { + feature.setGeometry(geometry); + } + var rTree = selectionLayer.getEditData().rTree; + rTree.remove(ol.extent.boundingExtent(segment), segmentData); + var uid = goog.getUid(feature); + var allSegments = rTree.search(geometry.getBounds(), uid); + for (var i = 0, ii = allSegments.length; i < ii; ++i) { + var allSegmentsData = allSegments[i]; + if (allSegmentsData[2] === oldGeometry) { + allSegmentsData[2] = geometry; + if (allSegmentsData[3] > index) { + ++allSegmentsData[3]; + } + } + } + var newSegment = [segment[0], vertex]; + var newSegmentData = [newSegment, feature, geometry, index]; + if (goog.isDef(parent)) { + newSegmentData.push(parent); + } + rTree.insert(ol.extent.boundingExtent(newSegment), newSegmentData, uid); + this.dragVertices_.push([selectionLayer, newSegmentData, 1]); + newSegment = [vertex, segment[1]]; + newSegmentData = [newSegment, feature, geometry, index + 1]; + if (goog.isDef(parent)) { + newSegmentData.push(parent); + } + rTree.insert(ol.extent.boundingExtent(newSegment), newSegmentData, uid); + this.dragVertices_.push([selectionLayer, newSegmentData, 0]); +}; + + +/** + * @param {ol.layer.VectorLayerEventObject} evt Event object. + */ +ol.interaction.Modify.prototype.removeIndex = function(evt) { + var layer = evt.target; + var rTree = layer.getEditData().rTree; + var features = evt.features; + for (var i = 0, ii = features.length; i < ii; ++i) { + var feature = features[i]; + var segments = rTree.search(feature.getGeometry().getBounds(), + goog.getUid(feature)); + for (var j = segments.length - 1; j >= 0; --j) { + var segment = segments[j]; + rTree.remove(ol.extent.boundingExtent(segment[0]), segment); + } + } +}; diff --git a/src/ol/layer/vectorlayer.js b/src/ol/layer/vectorlayer.js index 48c90a2817..4810a77915 100644 --- a/src/ol/layer/vectorlayer.js +++ b/src/ol/layer/vectorlayer.js @@ -158,7 +158,7 @@ ol.layer.Vector = function(options) { * @type {boolean} * @private */ - this.temp_ = false; + this.temporary_ = false; }; goog.inherits(ol.layer.Vector, ol.layer.Layer); @@ -241,7 +241,7 @@ ol.layer.Vector.prototype.clear = function() { * @return {boolean} Whether this layer is temporary. */ ol.layer.Vector.prototype.getTemporary = function() { - return this.temp_; + return this.temporary_; }; @@ -463,10 +463,30 @@ ol.layer.Vector.prototype.removeFeatures = function(features) { /** - * @param {boolean} temp Whether this layer is temporary. + * @param {boolean} temporary Whether this layer is temporary. */ -ol.layer.Vector.prototype.setTemporary = function(temp) { - this.temp_ = temp; +ol.layer.Vector.prototype.setTemporary = function(temporary) { + this.temporary_ = temporary; +}; + + +/** + * TODO: This should go away - features should either fire events when changed, + * or feature changes should be made through the layer. + * + * @param {Array.} features Features. + */ +ol.layer.Vector.prototype.updateFeatures = function(features) { + var extent = ol.extent.createEmpty(); + for (var i = features.length - 1; i >= 0; --i) { + var feature = features[i]; + var geometry = feature.getGeometry(); + this.featureCache_.remove(feature); + this.featureCache_.add(feature); + ol.extent.extend(extent, geometry.getBounds()); + } + this.dispatchEvent(new ol.layer.VectorEvent( + ol.layer.VectorLayerEventType.CHANGE, features, [extent])); }; diff --git a/src/ol/layer/vectorlayerrenderintent.js b/src/ol/layer/vectorlayerrenderintent.js index f877dc1732..6b84c28700 100644 --- a/src/ol/layer/vectorlayerrenderintent.js +++ b/src/ol/layer/vectorlayerrenderintent.js @@ -6,6 +6,7 @@ goog.provide('ol.layer.VectorLayerRenderIntent'); */ ol.layer.VectorLayerRenderIntent = { DEFAULT: 'default', + FUTURE: 'future', HIDDEN: 'hidden', SELECTED: 'selected', TEMPORARY: 'temporary' diff --git a/src/ol/structs/rtree.js b/src/ol/structs/rtree.js index 5416d4844d..6057998bc6 100644 --- a/src/ol/structs/rtree.js +++ b/src/ol/structs/rtree.js @@ -36,7 +36,7 @@ goog.require('ol.extent'); * leaf: (Object|undefined), * nodes: (Array.|undefined), * target: (Object|undefined), - * type: (string|undefined)}} + * type: (string|number|undefined)}} */ ol.structs.RTreeNode; @@ -186,7 +186,8 @@ ol.structs.RTree.prototype.chooseLeafSubtree_ = function(rect, root) { * * @param {ol.Extent} extent Extent. * @param {Object} obj Object to insert. - * @param {string=} opt_type Optional type to store along with the object. + * @param {string|number=} opt_type Optional type to store along with the + * object. */ ol.structs.RTree.prototype.insert = function(extent, obj, opt_type) { var node = /** @type {ol.structs.RTreeNode} */ @@ -554,7 +555,8 @@ ol.structs.RTree.prototype.removeSubtree_ = function(rect, obj, root) { * Non-recursive search function * * @param {ol.Extent} extent Extent. - * @param {string=} opt_type Optional type of the objects we want to find. + * @param {string|number=} opt_type Optional type of the objects we want to + * find. * @return {Array} Result. * @this {ol.structs.RTree} */ @@ -569,7 +571,8 @@ ol.structs.RTree.prototype.search = function(extent, opt_type) { * Non-recursive search function * * @param {ol.Extent} extent Extent. - * @param {string=} opt_type Optional type of the objects we want to find. + * @param {string|number=} opt_type Optional type of the objects we want to + * find. * @return {Object} Result. Keys are UIDs of the values. * @this {ol.structs.RTree} */ @@ -587,7 +590,7 @@ ol.structs.RTree.prototype.searchReturningObject = function(extent, opt_type) { * @param {boolean} returnNode Do we return nodes? * @param {Array|Object} result Result. * @param {ol.structs.RTreeNode} root Root. - * @param {string=} opt_type Optional type to search for. + * @param {string|number=} opt_type Optional type to search for. * @param {boolean=} opt_resultAsObject If set, result will be an object keyed * by UID. * @private