diff --git a/changelog/upgrade-notes.md b/changelog/upgrade-notes.md index 22f5e10096..513021d1a3 100644 --- a/changelog/upgrade-notes.md +++ b/changelog/upgrade-notes.md @@ -2,6 +2,32 @@ ### Next release +#### `ol.Map#forEachFeatureAtPixel` and `ol.Map#hasFeatureAtPixel` parameters have changed + +If you are using the layer filter of one of these methods, please note that you now have to pass in the layer filter via an `ol.AtPixelOptions` object. If you are not using the layer filter the usage has not changed. + +Old syntax: +``` + map.forEachFeatureAtPixel(pixel, callback, callbackThis, layerFilterFn, layerFilterThis); + + map.hasFeatureAtPixel(pixel, layerFilterFn, layerFilterThis); +``` + +New syntax: +``` + map.forEachFeatureAtPixel(pixel, callback, { + layerFilter: layerFilterFn + }); + + map.hasFeatureAtPixel(pixel, { + layerFilter: layerFilterFn + }); +``` + +To bind a function to a this, please use the bind method of the function (See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). + +This change is due to the introduction of the `hitTolerance` parameter which can be passed in via this `ol.AtPixelOptions` object, too. + #### Use `view.animate()` instead of `map.beforeRender()` and `ol.animation` functions The `map.beforeRender()` and `ol.animation` functions have been deprecated in favor of a new `view.animate()` function. Use of the deprecated functions will result in a warning during development. These functions are subject to removal in an upcoming release. diff --git a/examples/select-features.html b/examples/select-features.html index 55481d0372..697b309a39 100644 --- a/examples/select-features.html +++ b/examples/select-features.html @@ -18,5 +18,13 @@ tags: "select, vector" -  0 selected features +  0 selected features +
+ + + diff --git a/examples/select-features.js b/examples/select-features.js index 8d68284e2c..5d0a658f7a 100644 --- a/examples/select-features.js +++ b/examples/select-features.js @@ -31,23 +31,28 @@ var map = new ol.Map({ var select = null; // ref to currently selected interaction // select interaction working on "singleclick" -var selectSingleClick = new ol.interaction.Select(); +var selectSingleClick = new ol.interaction.Select({ + multi: true // multi is used in this example if hitTolerance > 0 +}); // select interaction working on "click" var selectClick = new ol.interaction.Select({ - condition: ol.events.condition.click + condition: ol.events.condition.click, + multi: true }); // select interaction working on "pointermove" var selectPointerMove = new ol.interaction.Select({ - condition: ol.events.condition.pointerMove + condition: ol.events.condition.pointerMove, + multi: true }); var selectAltClick = new ol.interaction.Select({ condition: function(mapBrowserEvent) { return ol.events.condition.click(mapBrowserEvent) && ol.events.condition.altKeyOnly(mapBrowserEvent); - } + }, + multi: true }); var selectElement = document.getElementById('type'); @@ -85,3 +90,27 @@ var changeInteraction = function() { */ selectElement.onchange = changeInteraction; changeInteraction(); + +var selectHitToleranceElement = document.getElementById('hitTolerance'); +var circleCanvas = document.getElementById('circle'); + +var changeHitTolerance = function() { + var value = parseInt(selectHitToleranceElement.value, 10); + selectSingleClick.setHitTolerance(value); + selectClick.setHitTolerance(value); + selectPointerMove.setHitTolerance(value); + selectAltClick.setHitTolerance(value); + + var size = 2 * value + 2; + circleCanvas.width = size; + circleCanvas.height = size; + var ctx = circleCanvas.getContext('2d'); + ctx.clearRect(0, 0, size, size); + ctx.beginPath(); + ctx.arc(value + 1, value + 1, value + 0.5, 0, 2 * Math.PI); + ctx.fill(); + ctx.stroke(); +}; + +selectHitToleranceElement.onchange = changeHitTolerance; +changeHitTolerance(); diff --git a/externs/olx.js b/externs/olx.js index a255777607..2ae1aab718 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -302,6 +302,36 @@ olx.MapOptions.prototype.target; olx.MapOptions.prototype.view; +/** + * Object literal with options for the {@link ol.Map#forEachFeatureAtPixel} and + * {@link ol.Map#hasFeatureAtPixel} methods. + * @typedef {{layerFilter: ((function(ol.layer.Layer): boolean)|undefined), + * hitTolerance: (number|undefined)}} + */ +olx.AtPixelOptions; + + +/** + * Layer filter function. The filter function will receive one argument, the + * {@link ol.layer.Layer layer-candidate} and it should return a boolean value. + * Only layers which are visible and for which this function returns `true` + * will be tested for features. By default, all visible layers will be tested. + * @type {((function(ol.layer.Layer): boolean)|undefined)} + * @api stable + */ +olx.AtPixelOptions.prototype.layerFilter; + + +/** + * Hit-detection tolerance in pixels. Pixels inside the radius around the given position + * will be checked for features. This only works for the canvas renderer and + * not for WebGL. + * @type {number|undefined} + * @api + */ +olx.AtPixelOptions.prototype.hitTolerance; + + /** * Object literal with config options for the overlay. * @typedef {{id: (number|string|undefined), @@ -2871,7 +2901,8 @@ olx.interaction.ExtentOptions.prototype.wrapX; /** * @typedef {{ * features: (ol.Collection.|undefined), - * layers: (undefined|Array.|function(ol.layer.Layer): boolean) + * layers: (undefined|Array.|function(ol.layer.Layer): boolean), + * hitTolerance: (number|undefined) * }} */ olx.interaction.TranslateOptions; @@ -2898,6 +2929,16 @@ olx.interaction.TranslateOptions.prototype.features; olx.interaction.TranslateOptions.prototype.layers; +/** + * Hit-detection tolerance. Pixels inside the radius around the given position + * will be checked for features. This only works for the canvas renderer and + * not for WebGL. + * @type {number|undefined} + * @api + */ +olx.interaction.TranslateOptions.prototype.hitTolerance; + + /** * @typedef {{condition: (ol.EventsConditionType|undefined), * duration: (number|undefined), @@ -3173,7 +3214,8 @@ olx.interaction.PointerOptions.prototype.handleUpEvent; * multi: (boolean|undefined), * features: (ol.Collection.|undefined), * filter: (ol.SelectFilterFunction|undefined), - * wrapX: (boolean|undefined)}} + * wrapX: (boolean|undefined), + * hitTolerance: (number|undefined)}} */ olx.interaction.SelectOptions; @@ -3287,6 +3329,16 @@ olx.interaction.SelectOptions.prototype.filter; olx.interaction.SelectOptions.prototype.wrapX; +/** + * Hit-detection tolerance. Pixels inside the radius around the given position + * will be checked for features. This only works for the canvas renderer and + * not for WebGL. + * @type {number|undefined} + * @api + */ +olx.interaction.SelectOptions.prototype.hitTolerance; + + /** * Options for snap * @typedef {{ @@ -7623,7 +7675,7 @@ olx.view.FitOptions.prototype.maxZoom; /** - * The duration of the animation in milliseconds. By default, there is no + * The duration of the animation in milliseconds. By default, there is no * animations. * @type {number|undefined} * @api diff --git a/src/ol/interaction/select.js b/src/ol/interaction/select.js index 7817067c3d..c4959c354f 100644 --- a/src/ol/interaction/select.js +++ b/src/ol/interaction/select.js @@ -82,6 +82,12 @@ ol.interaction.Select = function(opt_options) { this.filter_ = options.filter ? options.filter : ol.functions.TRUE; + /** + * @private + * @type {number} + */ + this.hitTolerance_ = options.hitTolerance ? options.hitTolerance : 0; + var featureOverlay = new ol.layer.Vector({ source: new ol.source.Vector({ useSpatialIndex: false, @@ -160,6 +166,16 @@ ol.interaction.Select.prototype.getFeatures = function() { }; +/** + * Returns the Hit-detection tolerance. + * @returns {number} Hit tolerance in pixels. + * @api + */ +ol.interaction.Select.prototype.getHitTolerance = function() { + return this.hitTolerance_; +}; + + /** * Returns the associated {@link ol.layer.Vector vectorlayer} of * the (last) selected feature. Note that this will not work with any @@ -201,7 +217,7 @@ ol.interaction.Select.handleEvent = function(mapBrowserEvent) { // the pixel. ol.obj.clear(this.featureLayerAssociation_); map.forEachFeatureAtPixel(mapBrowserEvent.pixel, - /** + (/** * @param {ol.Feature|ol.render.Feature} feature Feature. * @param {ol.layer.Layer} layer Layer. * @return {boolean|undefined} Continue to iterate over the features. @@ -212,7 +228,10 @@ ol.interaction.Select.handleEvent = function(mapBrowserEvent) { this.addFeatureLayerAssociation_(feature, layer); return !this.multi_; } - }, this, this.layerFilter_); + }).bind(this), { + layerFilter: this.layerFilter_, + hitTolerance: this.hitTolerance_ + }); var i; for (i = features.getLength() - 1; i >= 0; --i) { var feature = features.item(i); @@ -231,7 +250,7 @@ ol.interaction.Select.handleEvent = function(mapBrowserEvent) { } else { // Modify the currently selected feature(s). map.forEachFeatureAtPixel(mapBrowserEvent.pixel, - /** + (/** * @param {ol.Feature|ol.render.Feature} feature Feature. * @param {ol.layer.Layer} layer Layer. * @return {boolean|undefined} Continue to iterate over the features. @@ -249,7 +268,10 @@ ol.interaction.Select.handleEvent = function(mapBrowserEvent) { } return !this.multi_; } - }, this, this.layerFilter_); + }).bind(this), { + layerFilter: this.layerFilter_, + hitTolerance: this.hitTolerance_ + }); var j; for (j = deselected.length - 1; j >= 0; --j) { features.remove(deselected[j]); @@ -265,6 +287,18 @@ ol.interaction.Select.handleEvent = function(mapBrowserEvent) { }; +/** + * Hit-detection tolerance. Pixels inside the radius around the given position + * will be checked for features. This only works for the canvas renderer and + * not for WebGL. + * @param {number} hitTolerance Hit tolerance in pixels. + * @api + */ +ol.interaction.Select.prototype.setHitTolerance = function(hitTolerance) { + this.hitTolerance_ = hitTolerance; +}; + + /** * Remove the interaction from its current map, if any, and attach it to a new * map, if any. Pass `null` to just remove the interaction from the current map. diff --git a/src/ol/interaction/translate.js b/src/ol/interaction/translate.js index 3b61e333dc..9d57e4d37b 100644 --- a/src/ol/interaction/translate.js +++ b/src/ol/interaction/translate.js @@ -70,6 +70,12 @@ ol.interaction.Translate = function(opt_options) { */ this.layerFilter_ = layerFilter; + /** + * @private + * @type {number} + */ + this.hitTolerance_ = options.hitTolerance ? options.hitTolerance : 0; + /** * @type {ol.Feature} * @private @@ -197,7 +203,32 @@ ol.interaction.Translate.prototype.featuresAtPixel_ = function(pixel, map) { ol.array.includes(this.features_.getArray(), feature)) { return feature; } - }, this, this.layerFilter_); + }.bind(this), { + layerFilter: this.layerFilter_, + hitTolerance: this.hitTolerance_ + }); +}; + + +/** + * Returns the Hit-detection tolerance. + * @returns {number} Hit tolerance in pixels. + * @api + */ +ol.interaction.Translate.prototype.getHitTolerance = function() { + return this.hitTolerance_; +}; + + +/** + * Hit-detection tolerance. Pixels inside the radius around the given position + * will be checked for features. This only works for the canvas renderer and + * not for WebGL. + * @param {number} hitTolerance Hit tolerance in pixels. + * @api + */ +ol.interaction.Translate.prototype.setHitTolerance = function(hitTolerance) { + this.hitTolerance_ = hitTolerance; }; diff --git a/src/ol/map.js b/src/ol/map.js index 8acbe9a76a..9703f39d09 100644 --- a/src/ol/map.js +++ b/src/ol/map.js @@ -567,31 +567,25 @@ ol.Map.prototype.disposeInternal = function() { * the {@link ol.layer.Layer layer} of the feature and will be null for * unmanaged layers. To stop detection, callback functions can return a * truthy value. - * @param {S=} opt_this Value to use as `this` when executing `callback`. - * @param {(function(this: U, ol.layer.Layer): boolean)=} opt_layerFilter Layer - * filter function. The filter function will receive one argument, the - * {@link ol.layer.Layer layer-candidate} and it should return a boolean - * value. Only layers which are visible and for which this function returns - * `true` will be tested for features. By default, all visible layers will - * be tested. - * @param {U=} opt_this2 Value to use as `this` when executing `layerFilter`. + * @param {olx.AtPixelOptions=} opt_options Optional options. * @return {T|undefined} Callback result, i.e. the return value of last * callback execution, or the first truthy callback return value. - * @template S,T,U + * @template S,T * @api stable */ -ol.Map.prototype.forEachFeatureAtPixel = function(pixel, callback, opt_this, opt_layerFilter, opt_this2) { +ol.Map.prototype.forEachFeatureAtPixel = function(pixel, callback, opt_options) { if (!this.frameState_) { return; } var coordinate = this.getCoordinateFromPixel(pixel); - var thisArg = opt_this !== undefined ? opt_this : null; - var layerFilter = opt_layerFilter !== undefined ? - opt_layerFilter : ol.functions.TRUE; - var thisArg2 = opt_this2 !== undefined ? opt_this2 : null; + opt_options = opt_options !== undefined ? opt_options : {}; + var hitTolerance = opt_options.hitTolerance !== undefined ? + opt_options.hitTolerance * this.frameState_.pixelRatio : 0; + var layerFilter = opt_options.layerFilter !== undefined ? + opt_options.layerFilter : ol.functions.TRUE; return this.renderer_.forEachFeatureAtCoordinate( - coordinate, this.frameState_, callback, thisArg, - layerFilter, thisArg2); + coordinate, this.frameState_, hitTolerance, callback, null, + layerFilter, null); }; @@ -637,27 +631,23 @@ ol.Map.prototype.forEachLayerAtPixel = function(pixel, callback, opt_this, opt_l * Detect if features intersect a pixel on the viewport. Layers included in the * detection can be configured through `opt_layerFilter`. * @param {ol.Pixel} pixel Pixel. - * @param {(function(this: U, ol.layer.Layer): boolean)=} opt_layerFilter Layer - * filter function. The filter function will receive one argument, the - * {@link ol.layer.Layer layer-candidate} and it should return a boolean - * value. Only layers which are visible and for which this function returns - * `true` will be tested for features. By default, all visible layers will - * be tested. - * @param {U=} opt_this Value to use as `this` when executing `layerFilter`. + * @param {olx.AtPixelOptions=} opt_options Optional options. * @return {boolean} Is there a feature at the given pixel? * @template U * @api */ -ol.Map.prototype.hasFeatureAtPixel = function(pixel, opt_layerFilter, opt_this) { +ol.Map.prototype.hasFeatureAtPixel = function(pixel, opt_options) { if (!this.frameState_) { return false; } var coordinate = this.getCoordinateFromPixel(pixel); - var layerFilter = opt_layerFilter !== undefined ? - opt_layerFilter : ol.functions.TRUE; - var thisArg = opt_this !== undefined ? opt_this : null; + opt_options = opt_options !== undefined ? opt_options : {}; + var layerFilter = opt_options.layerFilter !== undefined ? + opt_options.layerFilter : ol.functions.TRUE; + var hitTolerance = opt_options.hitTolerance !== undefined ? + opt_options.hitTolerance * this.frameState_.pixelRatio : 0; return this.renderer_.hasFeatureAtCoordinate( - coordinate, this.frameState_, layerFilter, thisArg); + coordinate, this.frameState_, hitTolerance, layerFilter, null); }; diff --git a/src/ol/render/canvas/replaygroup.js b/src/ol/render/canvas/replaygroup.js index fae6101bd2..2a7983b14e 100644 --- a/src/ol/render/canvas/replaygroup.js +++ b/src/ol/render/canvas/replaygroup.js @@ -77,11 +77,90 @@ ol.render.canvas.ReplayGroup = function( * @type {ol.Transform} */ this.hitDetectionTransform_ = ol.transform.create(); - }; ol.inherits(ol.render.canvas.ReplayGroup, ol.render.ReplayGroup); +/** + * This cache is used for storing calculated pixel circles for increasing performance. + * It is a static property to allow each Replaygroup to access it. + * @type {Object.>>} + * @private + */ +ol.render.canvas.ReplayGroup.circleArrayCache_ = { + 0: [[true]] +}; + + +/** + * This method fills a row in the array from the given coordinate to the + * middle with `true`. + * @param {Array.>} array The array that will be altered. + * @param {number} x X coordinate. + * @param {number} y Y coordinate. + * @private + */ +ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_ = function(array, x, y) { + var i; + var radius = Math.floor(array.length / 2); + if (x >= radius) { + for (i = radius; i < x; i++) { + array[i][y] = true; + } + } else if (x < radius) { + for (i = x + 1; i < radius; i++) { + array[i][y] = true; + } + } +}; + + +/** + * This methods creates a circle inside a fitting array. Points inside the + * circle are marked by true, points on the outside are undefined. + * It uses the midpoint circle algorithm. + * A cache is used to increase performance. + * @param {number} radius Radius. + * @returns {Array.>} An array with marked circle points. + * @private + */ +ol.render.canvas.ReplayGroup.getCircleArray_ = function(radius) { + if (ol.render.canvas.ReplayGroup.circleArrayCache_[radius] !== undefined) { + return ol.render.canvas.ReplayGroup.circleArrayCache_[radius]; + } + + var arraySize = radius * 2 + 1; + var arr = new Array(arraySize); + for (var i = 0; i < arraySize; i++) { + arr[i] = new Array(arraySize); + } + + var x = radius; + var y = 0; + var error = 0; + + while (x >= y) { + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius + x, radius + y); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius + y, radius + x); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius - y, radius + x); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius - x, radius + y); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius - x, radius - y); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius - y, radius - x); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius + y, radius - x); + ol.render.canvas.ReplayGroup.fillCircleArrayRowToMiddle_(arr, radius + x, radius - y); + + y++; + error += 1 + 2 * y; + if (2 * (error - x) + 1 > 0) { + x -= 1; + error += 1 - 2 * x; + } + } + + ol.render.canvas.ReplayGroup.circleArrayCache_[radius] = arr; + return arr; +}; + /** * FIXME empty description for jsdoc */ @@ -101,6 +180,7 @@ ol.render.canvas.ReplayGroup.prototype.finish = function() { * @param {ol.Coordinate} coordinate Coordinate. * @param {number} resolution Resolution. * @param {number} rotation Rotation. + * @param {number} hitTolerance Hit tolerance in pixels. * @param {Object.} skippedFeaturesHash Ids of features * to skip. * @param {function((ol.Feature|ol.render.Feature)): T} callback Feature @@ -109,16 +189,23 @@ ol.render.canvas.ReplayGroup.prototype.finish = function() { * @template T */ ol.render.canvas.ReplayGroup.prototype.forEachFeatureAtCoordinate = function( - coordinate, resolution, rotation, skippedFeaturesHash, callback) { + coordinate, resolution, rotation, hitTolerance, skippedFeaturesHash, callback) { + hitTolerance = Math.round(hitTolerance); + var contextSize = hitTolerance * 2 + 1; var transform = ol.transform.compose(this.hitDetectionTransform_, - 0.5, 0.5, + hitTolerance + 0.5, hitTolerance + 0.5, 1 / resolution, -1 / resolution, -rotation, -coordinate[0], -coordinate[1]); - var context = this.hitDetectionContext_; - context.clearRect(0, 0, 1, 1); + + if (context.canvas.width !== contextSize || context.canvas.height !== contextSize) { + context.canvas.width = contextSize; + context.canvas.height = contextSize; + } else { + context.clearRect(0, 0, contextSize, contextSize); + } /** * @type {ol.Extent} @@ -127,9 +214,11 @@ ol.render.canvas.ReplayGroup.prototype.forEachFeatureAtCoordinate = function( if (this.renderBuffer_ !== undefined) { hitExtent = ol.extent.createEmpty(); ol.extent.extendCoordinate(hitExtent, coordinate); - ol.extent.buffer(hitExtent, resolution * this.renderBuffer_, hitExtent); + ol.extent.buffer(hitExtent, resolution * (this.renderBuffer_ + hitTolerance), hitExtent); } + var mask = ol.render.canvas.ReplayGroup.getCircleArray_(hitTolerance); + return this.replayHitDetection_(context, transform, rotation, skippedFeaturesHash, /** @@ -137,13 +226,21 @@ ol.render.canvas.ReplayGroup.prototype.forEachFeatureAtCoordinate = function( * @return {?} Callback result. */ function(feature) { - var imageData = context.getImageData(0, 0, 1, 1).data; - if (imageData[3] > 0) { - var result = callback(feature); - if (result) { - return result; + var imageData = context.getImageData(0, 0, contextSize, contextSize).data; + for (var i = 0; i < contextSize; i++) { + for (var j = 0; j < contextSize; j++) { + if (mask[i][j]) { + if (imageData[(j * contextSize + i) * 4 + 3] > 0) { + var result = callback(feature); + if (result) { + return result; + } else { + context.clearRect(0, 0, contextSize, contextSize); + return undefined; + } + } + } } - context.clearRect(0, 0, 1, 1); } }, hitExtent); }; diff --git a/src/ol/renderer/canvas/intermediatecanvas.js b/src/ol/renderer/canvas/intermediatecanvas.js index fd8e8d0447..694697c939 100644 --- a/src/ol/renderer/canvas/intermediatecanvas.js +++ b/src/ol/renderer/canvas/intermediatecanvas.js @@ -98,14 +98,14 @@ ol.renderer.canvas.IntermediateCanvas.prototype.getImageTransform = function() { /** * @inheritDoc */ -ol.renderer.canvas.IntermediateCanvas.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg) { +ol.renderer.canvas.IntermediateCanvas.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg) { var layer = this.getLayer(); var source = layer.getSource(); var resolution = frameState.viewState.resolution; var rotation = frameState.viewState.rotation; var skippedFeatureUids = frameState.skippedFeatureUids; return source.forEachFeatureAtCoordinate( - coordinate, resolution, rotation, skippedFeatureUids, + coordinate, resolution, rotation, hitTolerance, skippedFeatureUids, /** * @param {ol.Feature|ol.render.Feature} feature Feature. * @return {?} Callback result. diff --git a/src/ol/renderer/canvas/layer.js b/src/ol/renderer/canvas/layer.js index 9d8ecc632c..7601e7403e 100644 --- a/src/ol/renderer/canvas/layer.js +++ b/src/ol/renderer/canvas/layer.js @@ -106,7 +106,7 @@ ol.renderer.canvas.Layer.prototype.dispatchComposeEvent_ = function(type, contex */ ol.renderer.canvas.Layer.prototype.forEachLayerAtCoordinate = function(coordinate, frameState, callback, thisArg) { var hasFeature = this.forEachFeatureAtCoordinate( - coordinate, frameState, ol.functions.TRUE, this); + coordinate, frameState, 0, ol.functions.TRUE, this); if (hasFeature) { return callback.call(thisArg, this.getLayer(), null); diff --git a/src/ol/renderer/canvas/vectorlayer.js b/src/ol/renderer/canvas/vectorlayer.js index cd375e0f94..449df71c7a 100644 --- a/src/ol/renderer/canvas/vectorlayer.js +++ b/src/ol/renderer/canvas/vectorlayer.js @@ -177,7 +177,7 @@ ol.renderer.canvas.VectorLayer.prototype.composeFrame = function(frameState, lay /** * @inheritDoc */ -ol.renderer.canvas.VectorLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg) { +ol.renderer.canvas.VectorLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg) { if (!this.replayGroup_) { return undefined; } else { @@ -187,7 +187,7 @@ ol.renderer.canvas.VectorLayer.prototype.forEachFeatureAtCoordinate = function(c /** @type {Object.} */ var features = {}; return this.replayGroup_.forEachFeatureAtCoordinate(coordinate, resolution, - rotation, {}, + rotation, hitTolerance, {}, /** * @param {ol.Feature|ol.render.Feature} feature Feature. * @return {?} Callback result. diff --git a/src/ol/renderer/canvas/vectortilelayer.js b/src/ol/renderer/canvas/vectortilelayer.js index ee4e14a14c..0999ebbb13 100644 --- a/src/ol/renderer/canvas/vectortilelayer.js +++ b/src/ol/renderer/canvas/vectortilelayer.js @@ -176,9 +176,10 @@ ol.renderer.canvas.VectorTileLayer.prototype.drawTileImage = function( /** * @inheritDoc */ -ol.renderer.canvas.VectorTileLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg) { +ol.renderer.canvas.VectorTileLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg) { var resolution = frameState.viewState.resolution; var rotation = frameState.viewState.rotation; + hitTolerance = hitTolerance == undefined ? 0 : hitTolerance; var layer = this.getLayer(); /** @type {Object.} */ var features = {}; @@ -195,7 +196,7 @@ ol.renderer.canvas.VectorTileLayer.prototype.forEachFeatureAtCoordinate = functi tile = replayables[i]; tileCoord = tile.tileCoord; tileExtent = source.getTileGrid().getTileCoordExtent(tileCoord, this.tmpExtent); - if (!ol.extent.containsCoordinate(tileExtent, coordinate)) { + if (!ol.extent.containsCoordinate(ol.extent.buffer(tileExtent, hitTolerance * resolution), coordinate)) { continue; } if (tile.getProjection().getUnits() === ol.proj.Units.TILE_PIXELS) { @@ -212,7 +213,7 @@ ol.renderer.canvas.VectorTileLayer.prototype.forEachFeatureAtCoordinate = functi } replayGroup = tile.getReplayState().replayGroup; found = found || replayGroup.forEachFeatureAtCoordinate( - tileSpaceCoordinate, resolution, rotation, {}, + tileSpaceCoordinate, resolution, rotation, hitTolerance, {}, /** * @param {ol.Feature|ol.render.Feature} feature Feature. * @return {?} Callback result. diff --git a/src/ol/renderer/layer.js b/src/ol/renderer/layer.js index 2e96ea86d3..198d91ca37 100644 --- a/src/ol/renderer/layer.js +++ b/src/ol/renderer/layer.js @@ -35,6 +35,7 @@ ol.inherits(ol.renderer.Layer, ol.Observable); /** * @param {ol.Coordinate} coordinate Coordinate. * @param {olx.FrameState} frameState Frame state. + * @param {number} hitTolerance Hit tolerance in pixels. * @param {function(this: S, (ol.Feature|ol.render.Feature), ol.layer.Layer): T} * callback Feature callback. * @param {S} thisArg Value to use as `this` when executing `callback`. diff --git a/src/ol/renderer/map.js b/src/ol/renderer/map.js index e9f0d8bae0..de571dd3ed 100644 --- a/src/ol/renderer/map.js +++ b/src/ol/renderer/map.js @@ -100,6 +100,7 @@ ol.renderer.Map.expireIconCache_ = function(map, frameState) { /** * @param {ol.Coordinate} coordinate Coordinate. * @param {olx.FrameState} frameState FrameState. + * @param {number} hitTolerance Hit tolerance in pixels. * @param {function(this: S, (ol.Feature|ol.render.Feature), * ol.layer.Layer): T} callback Feature callback. * @param {S} thisArg Value to use as `this` when executing `callback`. @@ -111,7 +112,7 @@ ol.renderer.Map.expireIconCache_ = function(map, frameState) { * @return {T|undefined} Callback result. * @template S,T,U */ -ol.renderer.Map.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg, +ol.renderer.Map.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg, layerFilter, thisArg2) { var result; var viewState = frameState.viewState; @@ -155,7 +156,7 @@ ol.renderer.Map.prototype.forEachFeatureAtCoordinate = function(coordinate, fram if (layer.getSource()) { result = layerRenderer.forEachFeatureAtCoordinate( layer.getSource().getWrapX() ? translatedCoordinate : coordinate, - frameState, forEachFeatureAtCoordinate, thisArg); + frameState, hitTolerance, forEachFeatureAtCoordinate, thisArg); } if (result) { return result; @@ -188,6 +189,7 @@ ol.renderer.Map.prototype.forEachLayerAtPixel = function(pixel, frameState, call /** * @param {ol.Coordinate} coordinate Coordinate. * @param {olx.FrameState} frameState FrameState. + * @param {number} hitTolerance Hit tolerance in pixels. * @param {function(this: U, ol.layer.Layer): boolean} layerFilter Layer filter * function, only layers which are visible and for which this function * returns `true` will be tested for features. By default, all visible @@ -196,9 +198,9 @@ ol.renderer.Map.prototype.forEachLayerAtPixel = function(pixel, frameState, call * @return {boolean} Is there a feature at the given coordinate? * @template U */ -ol.renderer.Map.prototype.hasFeatureAtCoordinate = function(coordinate, frameState, layerFilter, thisArg) { +ol.renderer.Map.prototype.hasFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, layerFilter, thisArg) { var hasFeature = this.forEachFeatureAtCoordinate( - coordinate, frameState, ol.functions.TRUE, this, layerFilter, thisArg); + coordinate, frameState, hitTolerance, ol.functions.TRUE, this, layerFilter, thisArg); return hasFeature !== undefined; }; diff --git a/src/ol/renderer/webgl/imagelayer.js b/src/ol/renderer/webgl/imagelayer.js index 8df0c036df..2218f42ede 100644 --- a/src/ol/renderer/webgl/imagelayer.js +++ b/src/ol/renderer/webgl/imagelayer.js @@ -68,14 +68,14 @@ ol.renderer.webgl.ImageLayer.prototype.createTexture_ = function(image) { /** * @inheritDoc */ -ol.renderer.webgl.ImageLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg) { +ol.renderer.webgl.ImageLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg) { var layer = this.getLayer(); var source = layer.getSource(); var resolution = frameState.viewState.resolution; var rotation = frameState.viewState.rotation; var skippedFeatureUids = frameState.skippedFeatureUids; return source.forEachFeatureAtCoordinate( - coordinate, resolution, rotation, skippedFeatureUids, + coordinate, resolution, rotation, hitTolerance, skippedFeatureUids, /** * @param {ol.Feature|ol.render.Feature} feature Feature. @@ -213,7 +213,7 @@ ol.renderer.webgl.ImageLayer.prototype.updateProjectionMatrix_ = function(canvas */ ol.renderer.webgl.ImageLayer.prototype.hasFeatureAtCoordinate = function(coordinate, frameState) { var hasFeature = this.forEachFeatureAtCoordinate( - coordinate, frameState, ol.functions.TRUE, this); + coordinate, frameState, 0, ol.functions.TRUE, this); return hasFeature !== undefined; }; @@ -232,7 +232,7 @@ ol.renderer.webgl.ImageLayer.prototype.forEachLayerAtPixel = function(pixel, fra var coordinate = ol.transform.apply( frameState.pixelToCoordinateTransform, pixel.slice()); var hasFeature = this.forEachFeatureAtCoordinate( - coordinate, frameState, ol.functions.TRUE, this); + coordinate, frameState, 0, ol.functions.TRUE, this); if (hasFeature) { return callback.call(thisArg, this.getLayer(), null); diff --git a/src/ol/renderer/webgl/map.js b/src/ol/renderer/webgl/map.js index 5844610adf..7359ce0d16 100644 --- a/src/ol/renderer/webgl/map.js +++ b/src/ol/renderer/webgl/map.js @@ -502,7 +502,7 @@ ol.renderer.webgl.Map.prototype.renderFrame = function(frameState) { /** * @inheritDoc */ -ol.renderer.webgl.Map.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg, +ol.renderer.webgl.Map.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg, layerFilter, thisArg2) { var result; @@ -522,7 +522,7 @@ ol.renderer.webgl.Map.prototype.forEachFeatureAtCoordinate = function(coordinate layerFilter.call(thisArg2, layer)) { var layerRenderer = this.getLayerRenderer(layer); result = layerRenderer.forEachFeatureAtCoordinate( - coordinate, frameState, callback, thisArg); + coordinate, frameState, hitTolerance, callback, thisArg); if (result) { return result; } @@ -535,7 +535,7 @@ ol.renderer.webgl.Map.prototype.forEachFeatureAtCoordinate = function(coordinate /** * @inheritDoc */ -ol.renderer.webgl.Map.prototype.hasFeatureAtCoordinate = function(coordinate, frameState, layerFilter, thisArg) { +ol.renderer.webgl.Map.prototype.hasFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, layerFilter, thisArg) { var hasFeature = false; if (this.getGL().isContextLost()) { diff --git a/src/ol/renderer/webgl/vectorlayer.js b/src/ol/renderer/webgl/vectorlayer.js index d94975f8a6..e0a6b701e0 100644 --- a/src/ol/renderer/webgl/vectorlayer.js +++ b/src/ol/renderer/webgl/vectorlayer.js @@ -106,7 +106,7 @@ ol.renderer.webgl.VectorLayer.prototype.disposeInternal = function() { /** * @inheritDoc */ -ol.renderer.webgl.VectorLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, callback, thisArg) { +ol.renderer.webgl.VectorLayer.prototype.forEachFeatureAtCoordinate = function(coordinate, frameState, hitTolerance, callback, thisArg) { if (!this.replayGroup_ || !this.layerState_) { return undefined; } else { diff --git a/src/ol/source/imagevector.js b/src/ol/source/imagevector.js index 2eac2bccb0..42d54d58b1 100644 --- a/src/ol/source/imagevector.js +++ b/src/ol/source/imagevector.js @@ -155,14 +155,14 @@ ol.source.ImageVector.prototype.canvasFunctionInternal_ = function(extent, resol * @inheritDoc */ ol.source.ImageVector.prototype.forEachFeatureAtCoordinate = function( - coordinate, resolution, rotation, skippedFeatureUids, callback) { + coordinate, resolution, rotation, hitTolerance, skippedFeatureUids, callback) { if (!this.replayGroup_) { return undefined; } else { /** @type {Object.} */ var features = {}; return this.replayGroup_.forEachFeatureAtCoordinate( - coordinate, resolution, 0, skippedFeatureUids, + coordinate, resolution, 0, hitTolerance, skippedFeatureUids, /** * @param {ol.Feature|ol.render.Feature} feature Feature. * @return {?} Callback result. diff --git a/src/ol/source/source.js b/src/ol/source/source.js index 6fd4cdf57f..596794566c 100644 --- a/src/ol/source/source.js +++ b/src/ol/source/source.js @@ -94,6 +94,7 @@ ol.source.Source.toAttributionsArray_ = function(attributionLike) { * @param {ol.Coordinate} coordinate Coordinate. * @param {number} resolution Resolution. * @param {number} rotation Rotation. + * @param {number} hitTolerance Hit tolerance in pixels. * @param {Object.} skippedFeatureUids Skipped feature uids. * @param {function((ol.Feature|ol.render.Feature)): T} callback Feature * callback. diff --git a/test/spec/ol/render/canvas/replaygroup.test.js b/test/spec/ol/render/canvas/replaygroup.test.js new file mode 100644 index 0000000000..7de4117e8d --- /dev/null +++ b/test/spec/ol/render/canvas/replaygroup.test.js @@ -0,0 +1,33 @@ +goog.provide('ol.test.render.canvas.ReplayGroup'); + +goog.require('ol.render.canvas.ReplayGroup'); + + +describe('ol.render.canvas.ReplayGroup', function() { + + describe('#getCircleArray_', function() { + it('creates an array with a pixelated circle marked with true', function() { + var radius = 10; + var minRadiusSq = Math.pow(radius - Math.SQRT2, 2); + var maxRadiusSq = Math.pow(radius + Math.SQRT2, 2); + var circleArray = ol.render.canvas.ReplayGroup.getCircleArray_(radius); + var size = radius * 2 + 1; + expect(circleArray.length).to.be(size); + + for (var i = 0; i < size; i++) { + expect(circleArray[i].length).to.be(size); + for (var j = 0; j < size; j++) { + var dx = Math.abs(radius - i); + var dy = Math.abs(radius - j); + var distanceSq = Math.pow(dx, 2) + Math.pow(dy, 2); + if (circleArray[i][j] === true) { + expect(distanceSq).to.be.within(0, maxRadiusSq); + } else { + expect(distanceSq).to.be.within(minRadiusSq, Infinity); + } + } + } + }); + }); + +}); diff --git a/test/spec/ol/renderer/canvas/map.test.js b/test/spec/ol/renderer/canvas/map.test.js index 42311d8e79..dea6a791f8 100644 --- a/test/spec/ol/renderer/canvas/map.test.js +++ b/test/spec/ol/renderer/canvas/map.test.js @@ -30,7 +30,7 @@ describe('ol.renderer.canvas.Map', function() { var layer, map, target; - beforeEach(function() { + beforeEach(function(done) { target = document.createElement('div'); target.style.width = '100px'; target.style.height = '100px'; @@ -42,6 +42,14 @@ describe('ol.renderer.canvas.Map', function() { zoom: 0 }) }); + + // 1 x 1 pixel black icon + var img = document.createElement('img'); + img.onload = function() { + done(); + }; + img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg=='; + layer = new ol.layer.Vector({ source: new ol.source.Vector({ features: [ @@ -49,6 +57,12 @@ describe('ol.renderer.canvas.Map', function() { geometry: new ol.geom.Point([0, 0]) }) ] + }), + style: new ol.style.Style({ + image: new ol.style.Icon({ + img : img, + imgSize: [1, 1] + }) }) }); }); @@ -97,10 +111,11 @@ describe('ol.renderer.canvas.Map', function() { map.addLayer(layer); map.renderSync(); var cb = sinon.spy(); - map.forEachFeatureAtPixel(map.getPixelFromCoordinate([0, 0]), cb, null, - function() { - return false; - }); + map.forEachFeatureAtPixel(map.getPixelFromCoordinate([0, 0]), cb, { + layerFilter: function() { + return false; + } + }); expect(cb).to.not.be.called(); }); @@ -113,6 +128,39 @@ describe('ol.renderer.canvas.Map', function() { }).to.not.throwException(); }); + it('calls callback for clicks inside of the hitTolerance', function() { + map.addLayer(layer); + map.renderSync(); + var cb1 = sinon.spy(); + var cb2 = sinon.spy(); + + var pixel = map.getPixelFromCoordinate([0, 0]); + + var pixelsInside = [ + [pixel[0] + 9, pixel[1]], + [pixel[0] - 9, pixel[1]], + [pixel[0], pixel[1] + 9], + [pixel[0], pixel[1] - 9] + ]; + + var pixelsOutside = [ + [pixel[0] + 9, pixel[1] + 9], + [pixel[0] - 9, pixel[1] + 9], + [pixel[0] + 9, pixel[1] - 9], + [pixel[0] - 9, pixel[1] - 9] + ]; + + for (var i = 0; i < 4; i++) { + map.forEachFeatureAtPixel(pixelsInside[i], cb1, {hitTolerance:10}); + } + expect(cb1.callCount).to.be(4); + expect(cb1.firstCall.args[1]).to.be(layer); + + for (var j = 0; j < 4; j++) { + map.forEachFeatureAtPixel(pixelsOutside[j], cb2, {hitTolerance:10}); + } + expect(cb2).not.to.be.called(); + }); }); describe('#renderFrame()', function() { diff --git a/test/spec/ol/renderer/canvas/vectorlayer.test.js b/test/spec/ol/renderer/canvas/vectorlayer.test.js index a6bab3856d..6fd6750cf4 100644 --- a/test/spec/ol/renderer/canvas/vectorlayer.test.js +++ b/test/spec/ol/renderer/canvas/vectorlayer.test.js @@ -79,7 +79,7 @@ describe('ol.renderer.canvas.VectorLayer', function() { var replayGroup = {}; renderer.replayGroup_ = replayGroup; replayGroup.forEachFeatureAtCoordinate = function(coordinate, - resolution, rotation, skippedFeaturesUids, callback) { + resolution, rotation, hitTolerance, skippedFeaturesUids, callback) { var feature = new ol.Feature(); callback(feature); callback(feature); @@ -99,7 +99,7 @@ describe('ol.renderer.canvas.VectorLayer', function() { }; frameState.layerStates[ol.getUid(layer)] = {}; renderer.forEachFeatureAtCoordinate( - coordinate, frameState, spy, undefined); + coordinate, frameState, 0, spy, undefined); expect(spy.callCount).to.be(1); expect(spy.getCall(0).args[1]).to.equal(layer); }); diff --git a/test/spec/ol/renderer/canvas/vectortilelayer.test.js b/test/spec/ol/renderer/canvas/vectortilelayer.test.js index 7979fa3d19..0ff44349d2 100644 --- a/test/spec/ol/renderer/canvas/vectortilelayer.test.js +++ b/test/spec/ol/renderer/canvas/vectortilelayer.test.js @@ -169,7 +169,7 @@ describe('ol.renderer.canvas.VectorTileLayer', function() { }); renderer = new ol.renderer.canvas.VectorTileLayer(layer); replayGroup.forEachFeatureAtCoordinate = function(coordinate, - resolution, rotation, skippedFeaturesUids, callback) { + resolution, rotation, hitTolerance, skippedFeaturesUids, callback) { var feature = new ol.Feature(); callback(feature); callback(feature); @@ -190,7 +190,7 @@ describe('ol.renderer.canvas.VectorTileLayer', function() { frameState.layerStates[ol.getUid(layer)] = {}; renderer.renderedTiles = [new TileClass([0, 0, -1])]; renderer.forEachFeatureAtCoordinate( - coordinate, frameState, spy, undefined); + coordinate, frameState, 0, spy, undefined); expect(spy.callCount).to.be(1); expect(spy.getCall(0).args[1]).to.equal(layer); });