diff --git a/examples/extent-interaction.html b/examples/extent-interaction.html new file mode 100644 index 0000000000..cce19e3890 --- /dev/null +++ b/examples/extent-interaction.html @@ -0,0 +1,12 @@ +--- +layout: example.html +title: Extent Interaction +shortdesc: Using an Extent interaction to draw an extent. +docs: > +

This example shows how to use an Extent interaction to draw a modifiable extent.

+

Use Shift+Drag to draw an extent. + Shift+Drag on the corners or edges of the extent to resize it. Shift+Click off the extent to remove it. +

+tags: "Extent, interaction, box" +--- +
diff --git a/examples/extent-interaction.js b/examples/extent-interaction.js new file mode 100644 index 0000000000..11d5918275 --- /dev/null +++ b/examples/extent-interaction.js @@ -0,0 +1,49 @@ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.events.condition'); +goog.require('ol.format.GeoJSON'); +goog.require('ol.interaction.Extent'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.OSM'); +goog.require('ol.source.Vector'); + +var vectorSource = new ol.source.Vector({ + url: 'data/geojson/countries.geojson', + format: new ol.format.GeoJSON() +}); + +var map = new ol.Map({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM() + }), + new ol.layer.Vector({ + source: vectorSource + }) + ], + renderer: 'canvas', + target: 'map', + view: new ol.View({ + center: [0, 0], + zoom: 2 + }) +}); + +var extent = new ol.interaction.Extent({ + condition: ol.events.condition.platformModifierKeyOnly +}); +map.addInteraction(extent); +extent.setActive(false); + +//Enable interaction by holding shift +this.addEventListener('keydown', function(event) { + if (event.keyCode == 16) { + extent.setActive(true); + } +}); +this.addEventListener('keyup', function(event) { + if (event.keyCode == 16) { + extent.setActive(false); + } +}); diff --git a/externs/olx.js b/externs/olx.js index ea0dc65fa3..abc6dbea76 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -2705,6 +2705,47 @@ olx.interaction.DrawOptions.prototype.freehandCondition; olx.interaction.DrawOptions.prototype.wrapX; +/** + * @typedef {{extent: (ol.Extent|undefined), + * boxStyle: (ol.style.Style|Array.|ol.StyleFunction|undefined), + * pointerStyle: (ol.style.Style|Array.|ol.StyleFunction|undefined), + * wrapX: (boolean|undefined)}} + * @api + */ +olx.interaction.ExtentOptions; + +/** + * Initial extent. Defaults to no inital extent + * @type {ol.Extent|undefined} + * @api + */ +olx.interaction.ExtentOptions.prototype.extent; + +/** + * Style for the drawn extent box. + * Defaults to ol.style.Style.createDefaultEditing()[ol.geom.GeometryType.POLYGON] + * @type {ol.style.Style|Array.|ol.StyleFunction|undefined} + * @api + */ +olx.interaction.ExtentOptions.prototype.boxStyle; + +/** + * Style for the cursor used to draw the extent. + * Defaults to ol.style.Style.createDefaultEditing()[ol.geom.GeometryType.POINT] + * @type {ol.style.Style|Array.|ol.StyleFunction|undefined} + * @api + */ +olx.interaction.ExtentOptions.prototype.pointerStyle; + +/** + * Wrap the drawn extent across multiple maps in the X direction? + * Only affects visuals, not functionality. Defaults to false. + * @type {boolean|undefined} + * @api + */ +olx.interaction.ExtentOptions.prototype.wrapX; + + /** * @typedef {{ * features: (ol.Collection.|undefined), diff --git a/src/ol/interaction/extent.js b/src/ol/interaction/extent.js new file mode 100644 index 0000000000..fb6b1a6e6d --- /dev/null +++ b/src/ol/interaction/extent.js @@ -0,0 +1,506 @@ +goog.provide('ol.interaction.Extent'); +goog.provide('ol.interaction.ExtentEvent'); +goog.provide('ol.interaction.ExtentEventType'); + +goog.require('ol'); +goog.require('ol.Feature'); +goog.require('ol.MapBrowserEvent.EventType'); +goog.require('ol.MapBrowserPointerEvent'); +goog.require('ol.coordinate'); +goog.require('ol.events.Event'); +goog.require('ol.extent'); +goog.require('ol.geom.GeometryType'); +goog.require('ol.geom.Point'); +goog.require('ol.geom.Polygon'); +goog.require('ol.interaction.Pointer'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.Vector'); +goog.require('ol.style.Style'); + + +/** + * @enum {string} + */ +ol.interaction.ExtentEventType = { + /** + * Triggered after the extent is changed + * @event ol.interaction.ExtentEvent + * @api + */ + EXTENTCHANGED: 'extentchanged' +}; + +/** + * @classdesc + * Events emitted by {@link ol.interaction.Extent} instances are instances of + * this type. + * + * @constructor + * @param {ol.Extent} extent the new extent + * @extends {ol.events.Event} + */ +ol.interaction.ExtentEvent = function(extent) { + ol.events.Event.call(this, ol.interaction.ExtentEventType.EXTENTCHANGED); + + /** + * The current extent. + * @type {ol.Extent} + * @api + */ + this.extent_ = extent; +}; + +ol.inherits(ol.interaction.ExtentEvent, ol.events.Event); + +/** + * @classdesc + * Allows the user to draw a vector box by clicking and dragging on the map. + * Once drawn, the vector box can be modified by dragging its vertices or edges. + * This interaction is only supported for mouse devices. + * + * @constructor + * @extends {ol.interaction.Pointer} + * @fires ol.interaction.ExtentEvent + * @param {olx.interaction.ExtentOptions} opt_options Options. + * @api + */ +ol.interaction.Extent = function(opt_options) { + + /** + * Extent of the drawn box + * @type {ol.Extent} + * @private + */ + this.extent_ = null; + + /** + * Handler for pointer move events + * @type {function (ol.Coordinate): ol.Extent|null} + * @private + */ + this.pointerHandler_ = null; + + /** + * Pixel threshold to snap to extent + * @type {number} + * @private + */ + this.pixelTolerance_ = 10; + + /** + * Last known pixel coordinate of the pointer + * @type {ol.Pixel} + * @private + */ + this.lastPixel_ = null; + + /** + * Is the pointer snapped to an extent vertex + * @type {boolean} + * @private + */ + this.snappedToVertex_ = false; + + /** + * Feature for displaying the visible extent + * @type {ol.Feature} + * @private + */ + this.extentFeature_ = null; + + /** + * Feature for displaying the visible pointer + * @type {ol.Feature} + * @private + */ + this.vertexFeature_ = null; + + if (!opt_options) { + opt_options = {}; + } + + if (opt_options.extent) { + this.setExtent(opt_options.extent); + } + + /* Inherit ol.interaction.Pointer */ + ol.interaction.Pointer.call(this, { + handleDownEvent: ol.interaction.Extent.handleDownEvent_, + handleDragEvent: ol.interaction.Extent.handleDragEvent_, + handleEvent: ol.interaction.Extent.handleEvent_, + handleUpEvent: ol.interaction.Extent.handleUpEvent_ + }); + + /** + * Layer for the extentFeature + * @type {ol.layer.Vector} + * @private + */ + this.extentOverlay_ = new ol.layer.Vector({ + source: new ol.source.Vector({ + useSpatialIndex: false, + wrapX: !!opt_options.wrapX + }), + style: opt_options.boxStyle ? opt_options.boxStyle : ol.interaction.Extent.getDefaultExtentStyleFunction_(), + updateWhileAnimating: true, + updateWhileInteracting: true + }); + + /** + * Layer for the vertexFeature + * @type {ol.layer.Vector} + * @private + */ + this.vertexOverlay_ = new ol.layer.Vector({ + source: new ol.source.Vector({ + useSpatialIndex: false, + wrapX: !!opt_options.wrapX + }), + style: opt_options.pointerStyle ? opt_options.pointerStyle : ol.interaction.Extent.getDefaultPointerStyleFunction_(), + updateWhileAnimating: true, + updateWhileInteracting: true + }); +}; + +ol.inherits(ol.interaction.Extent, ol.interaction.Pointer); + +/** + * @param {ol.MapBrowserEvent} mapBrowserEvent Event. + * @return {boolean} Propagate event? + * @this {ol.interaction.Extent} + * @private + */ +ol.interaction.Extent.handleEvent_ = function(mapBrowserEvent) { + if (!(mapBrowserEvent instanceof ol.MapBrowserPointerEvent)) { + return true; + } + //display pointer (if not dragging) + if (mapBrowserEvent.type == ol.MapBrowserEvent.EventType.POINTERMOVE && !this.handlingDownUpSequence) { + this.handlePointerMove_(mapBrowserEvent); + } + //call pointer to determine up/down/drag + ol.interaction.Pointer.handleEvent.call(this, mapBrowserEvent); + //return false to stop propagation + return false; +}; + +/** + * @param {ol.MapBrowserPointerEvent} mapBrowserEvent Event. + * @return {boolean} Event handled? + * @this {ol.interaction.Extent} + * @private + */ +ol.interaction.Extent.handleDownEvent_ = function(mapBrowserEvent) { + var pixel = mapBrowserEvent.pixel; + var map = mapBrowserEvent.map; + + var extent = this.getExtent(); + var vertex = this.snapToVertex_(pixel, map); + + //find the extent corner opposite the passed corner + var getOpposingPoint = function(point) { + var x_ = null; + var y_ = null; + if (point[0] == extent[0]) { + x_ = extent[2]; + } else if (point[0] == extent[2]) { + x_ = extent[0]; + } + if (point[1] == extent[1]) { + y_ = extent[3]; + } else if (point[1] == extent[3]) { + y_ = extent[1]; + } + if (x_ !== null && y_ !== null) { + return [x_, y_]; + } + return null; + }; + if (vertex && extent) { + var x = (vertex[0] == extent[0] || vertex[0] == extent[2]) ? vertex[0] : null; + var y = (vertex[1] == extent[1] || vertex[1] == extent[3]) ? vertex[1] : null; + + //snap to point + if (x !== null && y !== null) { + this.pointerHandler_ = ol.interaction.Extent.getPointHandler_(getOpposingPoint(vertex)); + //snap to edge + } else if (x !== null) { + this.pointerHandler_ = ol.interaction.Extent.getEdgeHandler_( + getOpposingPoint([x, extent[1]]), + getOpposingPoint([x, extent[3]]) + ); + } else if (y !== null) { + this.pointerHandler_ = ol.interaction.Extent.getEdgeHandler_( + getOpposingPoint([extent[0], y]), + getOpposingPoint([extent[2], y]) + ); + } + //no snap - new bbox + } else { + vertex = map.getCoordinateFromPixel(pixel); + this.setExtent([vertex[0], vertex[1], vertex[0], vertex[1]]); + this.pointerHandler_ = ol.interaction.Extent.getPointHandler_(vertex); + } + return true; //event handled; start downup sequence +}; + +/** + * @param {ol.MapBrowserPointerEvent} mapBrowserEvent Event. + * @return {boolean} Event handled? + * @this {ol.interaction.Extent} + * @private + */ +ol.interaction.Extent.handleDragEvent_ = function(mapBrowserEvent) { + this.lastPixel_ = mapBrowserEvent.pixel; + if (this.pointerHandler_) { + var pixelCoordinate = mapBrowserEvent.coordinate; + this.setExtent(this.pointerHandler_(pixelCoordinate)); + this.createOrUpdatePointerFeature_(pixelCoordinate); + } + return true; +}; + +/** + * @param {ol.MapBrowserPointerEvent} mapBrowserEvent Event. + * @return {boolean} Stop drag sequence? + * @this {ol.interaction.Extent} + * @private + */ +ol.interaction.Extent.handleUpEvent_ = function(mapBrowserEvent) { + this.pointerHandler_ = null; + //If bbox is zero area, set to null; + var extent = this.getExtent(); + if (!extent || ol.extent.getArea(extent) === 0) { + this.setExtent(null); + } + return false; //Stop handling downup sequence +}; + +/** + * Returns the default style for the drawn bbox + * + * @return {ol.StyleFunction} Default Extent style + * @private + */ +ol.interaction.Extent.getDefaultExtentStyleFunction_ = function() { + var style = ol.style.Style.createDefaultEditing(); + return function(feature, resolution) { + return style[ol.geom.GeometryType.POLYGON]; + }; +}; + +/** + * Returns the default style for the pointer + * + * @return {ol.StyleFunction} Default pointer style + * @private + */ +ol.interaction.Extent.getDefaultPointerStyleFunction_ = function() { + var style = ol.style.Style.createDefaultEditing(); + return function(feature, resolution) { + return style[ol.geom.GeometryType.POINT]; + }; +}; + +/** + * @param {ol.Coordinate} fixedPoint corner that will be unchanged in the new extent + * @returns {function (ol.Coordinate): ol.Extent} event handler + * @private + */ +ol.interaction.Extent.getPointHandler_ = function(fixedPoint) { + return function(point) { + return ol.extent.boundingExtent([fixedPoint, point]); + }; +}; + +/** + * @param {ol.Coordinate} fixedP1 first corner that will be unchanged in the new extent + * @param {ol.Coordinate} fixedP2 second corner that will be unchanged in the new extent + * @returns {function (ol.Coordinate): ol.Extent|null} event handler + * @private + */ +ol.interaction.Extent.getEdgeHandler_ = function(fixedP1, fixedP2) { + if (fixedP1[0] == fixedP2[0]) { + return function(point) { + return ol.extent.boundingExtent([fixedP1, [point[0], fixedP2[1]]]); + }; + } else if (fixedP1[1] == fixedP2[1]) { + return function(point) { + return ol.extent.boundingExtent([fixedP1, [fixedP2[0], point[1]]]); + }; + } else { + return null; + } +}; + +/** + * @param {ol.Extent} extent extent + * @returns {Array>} extent line segments + * @private + */ +ol.interaction.Extent.getSegments_ = function(extent) { + return [ + [[extent[0], extent[1]], [extent[0], extent[3]]], + [[extent[0], extent[3]], [extent[2], extent[3]]], + [[extent[2], extent[3]], [extent[2], extent[1]]], + [[extent[2], extent[1]], [extent[0], extent[1]]] + ]; +}; + +/** + * @param {ol.Pixel} pixel cursor location + * @param {ol.Map} map map + * @returns {ol.Coordinate|null} snapped vertex on extent + * @private + */ +ol.interaction.Extent.prototype.snapToVertex_ = function(pixel, map) { + var pixelCoordinate = map.getCoordinateFromPixel(pixel); + var sortByDistance = function(a, b) { + return ol.coordinate.squaredDistanceToSegment(pixelCoordinate, a) - + ol.coordinate.squaredDistanceToSegment(pixelCoordinate, b); + }; + var extent = this.getExtent(); + if (extent) { + //convert extents to line segments and find the segment closest to pixelCoordinate + var segments = ol.interaction.Extent.getSegments_(extent); + segments.sort(sortByDistance); + var closestSegment = segments[0]; + + var vertex = (ol.coordinate.closestOnSegment(pixelCoordinate, + closestSegment)); + var vertexPixel = map.getPixelFromCoordinate(vertex); + + //if the distance is within tolerance, snap to the segment + if (Math.sqrt(ol.coordinate.squaredDistance(pixel, vertexPixel)) <= + this.pixelTolerance_) { + + //test if we should further snap to a vertex + 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)); + this.snappedToVertex_ = dist <= this.pixelTolerance_; + if (this.snappedToVertex_) { + vertex = squaredDist1 > squaredDist2 ? + closestSegment[1] : closestSegment[0]; + } + return vertex; + } + } + return null; +}; + +/** + * @param {ol.MapBrowserEvent} mapBrowserEvent pointer move event + * @private + */ +ol.interaction.Extent.prototype.handlePointerMove_ = function(mapBrowserEvent) { + var pixel = mapBrowserEvent.pixel; + var map = mapBrowserEvent.map; + + var vertex = this.snapToVertex_(pixel, map); + if (!vertex) { + vertex = map.getCoordinateFromPixel(pixel); + } + this.createOrUpdatePointerFeature_(vertex); +}; + +/** + * @param {ol.Extent} extent extent + * @returns {ol.Feature} extent as featrue + * @private + */ +ol.interaction.Extent.prototype.createOrUpdateExtentFeature_ = function(extent) { + var extentFeature = this.extentFeature_; + + if (!extentFeature) { + if (!extent) { + extentFeature = new ol.Feature({}); + } else { + extentFeature = new ol.Feature(ol.geom.Polygon.fromExtent(extent)); + } + this.extentFeature_ = extentFeature; + this.extentOverlay_.getSource().addFeature(extentFeature); + } else { + if (!extent) { + extentFeature.setGeometry(undefined); + } else { + extentFeature.setGeometry(ol.geom.Polygon.fromExtent(extent)); + } + } + return extentFeature; +}; + +/** + * @this {ol.interaction.Extent} + * @private + */ +ol.interaction.Extent.prototype.removeExtentFeature_ = function() { + var extentFeature = this.extentFeature_; + if (extentFeature) { + this.extentOverlay_.getSource().removeFeature(extentFeature); + this.extentFeature_ = null; + } +}; + +/** + * @param {ol.Coordinate} vertex location of feature + * @returns {ol.Feature} vertex as feature + * @private + */ +ol.interaction.Extent.prototype.createOrUpdatePointerFeature_ = function(vertex) { + var vertexFeature = this.vertexFeature_; + if (!vertexFeature) { + vertexFeature = new ol.Feature(new ol.geom.Point(vertex)); + this.vertexFeature_ = vertexFeature; + this.vertexOverlay_.getSource().addFeature(vertexFeature); + } else { + var geometry = /** @type {ol.geom.Point} */ (vertexFeature.getGeometry()); + geometry.setCoordinates(vertex); + } + return vertexFeature; +}; + +/** + * @private + */ +ol.interaction.Extent.prototype.removePointerFeature_ = function() { + var vertexFeature = this.vertexFeature_; + if (vertexFeature) { + this.vertexOverlay_.getSource().removeFeature(vertexFeature); + this.vertexFeature_ = null; + } +}; + +/** + * @inheritDoc + */ +ol.interaction.Extent.prototype.setMap = function(map) { + this.extentOverlay_.setMap(map); + this.vertexOverlay_.setMap(map); + ol.interaction.Pointer.prototype.setMap.call(this, map); +}; + +/** + * Returns the current drawn extent in the view projection + * + * @return {ol.Extent} Drawn extent in the view projection. + * @api + */ +ol.interaction.Extent.prototype.getExtent = function() { + return this.extent_; +}; + +/** + * Manually sets the drawn extent, using the view projection. + * + * @param {ol.Extent} extent Extent + * @api + */ +ol.interaction.Extent.prototype.setExtent = function(extent) { + //Null extent means no bbox + this.extent_ = extent ? extent : null; + this.createOrUpdateExtentFeature_(extent); + this.dispatchEvent(new ol.interaction.ExtentEvent(this.extent_)); +}; diff --git a/test/spec/ol/interaction/extent.test.js b/test/spec/ol/interaction/extent.test.js new file mode 100644 index 0000000000..38eba421e3 --- /dev/null +++ b/test/spec/ol/interaction/extent.test.js @@ -0,0 +1,139 @@ +goog.provide('ol.test.interaction.Extent'); + +goog.require('ol.Map'); +goog.require('ol.MapBrowserPointerEvent'); +goog.require('ol.View'); +goog.require('ol.interaction.Extent'); +goog.require('ol.pointer.PointerEvent'); + +describe('ol.interaction.Extent', function() { + + var target, map, interaction; + + var width = 360; + var height = 180; + + beforeEach(function(done) { + target = document.createElement('div'); + + var style = target.style; + style.position = 'absolute'; + style.left = '-1000px'; + style.top = '-1000px'; + style.width = width + 'px'; + style.height = height + 'px'; + document.body.appendChild(target); + + map = new ol.Map({ + target: target, + layers: [], + view: new ol.View({ + projection: 'EPSG:4326', + center: [0, 0], + resolution: 1 + }) + }); + + map.once('postrender', function() { + done(); + }); + + interaction = new ol.interaction.Extent(); + map.addInteraction(interaction); + }); + + afterEach(function() { + map.dispose(); + document.body.removeChild(target); + }); + + /** + * Simulates a browser event on the map viewport. The client x/y location + * will be adjusted as if the map were centered at 0,0. + * @param {string} type Event type. + * @param {number} x Horizontal offset from map center. + * @param {number} y Vertical offset from map center. + * @param {boolean=} opt_shiftKey Shift key is pressed. + * @param {number} button The mouse button. + */ + function simulateEvent(type, x, y, opt_shiftKey, button) { + var viewport = map.getViewport(); + // calculated in case body has top < 0 (test runner with small window) + var position = viewport.getBoundingClientRect(); + var shiftKey = opt_shiftKey !== undefined ? opt_shiftKey : false; + var pointerEvent = new ol.pointer.PointerEvent(type, { + type: type, + button: button, + clientX: position.left + x + width / 2, + clientY: position.top - y + height / 2, + shiftKey: shiftKey + }); + var event = new ol.MapBrowserPointerEvent(type, map, pointerEvent); + event.pointerEvent.pointerId = 1; + map.handleMapBrowserEvent(event); + } + describe('snap to vertex', function() { + it('snap to vertex works', function() { + interaction.setExtent([-50,-50,50,50]); + + expect(interaction.snapToVertex_([230,40], map)).to.eql([50,50]); + expect(interaction.snapToVertex_([231,41], map)).to.eql([50,50]); + }); + it('snap to edge works', function() { + interaction.setExtent([-50,-50,50,50]); + + expect(interaction.snapToVertex_([230,90], map)).to.eql([50,0]); + expect(interaction.snapToVertex_([230,89], map)).to.eql([50,1]); + expect(interaction.snapToVertex_([231,90], map)).to.eql([50,0]); + }); + }); + + describe('draw extent', function() { + + it('drawing extent works', function() { + simulateEvent('pointerdown', -50, -50, false, 0); + simulateEvent('pointerdrag', 50, 50, false, 0); + simulateEvent('pointerup', 50, 50, false, 0); + + expect(interaction.getExtent()).to.eql([-50,-50,50,50]); + }); + + it('clicking off extent nulls extent', function() { + interaction.setExtent([-50,-50,50,50]); + + simulateEvent('pointerdown', -10, -10, false, 0); + simulateEvent('pointerup', -10, -10, false, 0); + + expect(interaction.getExtent()).to.equal(null); + }); + + it('clicking on extent does not null extent', function() { + interaction.setExtent([-50,-50,50,50]); + + simulateEvent('pointerdown', 50, 50, false, 0); + simulateEvent('pointerup', 50, 50, false, 0); + + expect(interaction.getExtent()).to.eql([-50,-50,50,50]); + }); + + it('snap and drag vertex works', function() { + interaction.setExtent([-50,-50,50,50]); + + simulateEvent('pointerdown', 51, 49, false, 0); + simulateEvent('pointerdrag', -70, -40, false, 0); + simulateEvent('pointerup', -70, -40, false, 0); + + expect(interaction.getExtent()).to.eql([-70,-50,-50,-40]); + }); + + it('snap and drag edge works', function() { + interaction.setExtent([-50,-50,50,50]); + + simulateEvent('pointerdown', 51, 5, false, 0); + simulateEvent('pointerdrag', 20, -30, false, 0); + simulateEvent('pointerup', 20, -30, false, 0); + + expect(interaction.getExtent()).to.eql([-50,-50,20,50]); + }); + }); +});