diff --git a/examples/hitdetect-vector.html b/examples/hitdetect-vector.html new file mode 100644 index 0000000000..8186f940f6 --- /dev/null +++ b/examples/hitdetect-vector.html @@ -0,0 +1,15 @@ +--- +layout: example.html +title: Vector Layer Hit Detection +shortdesc: Example of hit detection on a countries vector layer with country information. +docs: > + The countries are loaded from a GeoJSON file. Information about countries is + on hover and click is retrieved using the layer's `getFeatures()` method. For + vector layers, this function resolves with an array of only the topmost + feature. It uses a very efficient hit detection algorithm, at the cost of + accuracy. For pixel exact hit detection, when performance is not a concern, + use the map's `getFeaturesAtPixel()` or `forEachFeatureAtPixel()` methods. +tags: "vector, geojson, click, hover, hit detection" +--- +
+
 
diff --git a/examples/hitdetect-vector.js b/examples/hitdetect-vector.js new file mode 100644 index 0000000000..d09cc96c4e --- /dev/null +++ b/examples/hitdetect-vector.js @@ -0,0 +1,113 @@ +import Map from '../src/ol/Map.js'; +import View from '../src/ol/View.js'; +import GeoJSON from '../src/ol/format/GeoJSON.js'; +import VectorLayer from '../src/ol/layer/Vector.js'; +import VectorSource from '../src/ol/source/Vector.js'; +import {Fill, Stroke, Style, Text} from '../src/ol/style.js'; + + +const style = new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.6)' + }), + stroke: new Stroke({ + color: '#319FD3', + width: 1 + }), + text: new Text({ + font: '12px Calibri,sans-serif', + fill: new Fill({ + color: '#000' + }), + stroke: new Stroke({ + color: '#fff', + width: 3 + }) + }) +}); + +const vectorLayer = new VectorLayer({ + source: new VectorSource({ + url: 'data/geojson/countries.geojson', + format: new GeoJSON() + }), + style: function(feature) { + style.getText().setText(feature.get('name')); + return style; + } +}); + +const map = new Map({ + layers: [vectorLayer], + target: 'map', + view: new View({ + center: [0, 0], + zoom: 1 + }) +}); + +const highlightStyle = new Style({ + stroke: new Stroke({ + color: '#f00', + width: 1 + }), + fill: new Fill({ + color: 'rgba(255,0,0,0.1)' + }), + text: new Text({ + font: '12px Calibri,sans-serif', + fill: new Fill({ + color: '#000' + }), + stroke: new Stroke({ + color: '#f00', + width: 3 + }) + }) +}); + +const featureOverlay = new VectorLayer({ + source: new VectorSource(), + map: map, + style: function(feature) { + highlightStyle.getText().setText(feature.get('name')); + return highlightStyle; + } +}); + +let highlight; +const displayFeatureInfo = function(pixel) { + + vectorLayer.getFeatures(pixel).then(function(features) { + const feature = features.length ? features[0] : undefined; + const info = document.getElementById('info'); + if (features.length) { + info.innerHTML = feature.getId() + ': ' + feature.get('name'); + } else { + info.innerHTML = ' '; + } + + if (feature !== highlight) { + if (highlight) { + featureOverlay.getSource().removeFeature(highlight); + } + if (feature) { + featureOverlay.getSource().addFeature(feature); + } + highlight = feature; + } + }); + +}; + +map.on('pointermove', function(evt) { + if (evt.dragging) { + return; + } + const pixel = map.getEventPixel(evt.originalEvent); + displayFeatureInfo(pixel); +}); + +map.on('click', function(evt) { + displayFeatureInfo(evt.pixel); +}); diff --git a/examples/vector-tile-selection.js b/examples/vector-tile-selection.js index 56c43da2db..2e541e965d 100644 --- a/examples/vector-tile-selection.js +++ b/examples/vector-tile-selection.js @@ -45,26 +45,27 @@ const map = new Map({ const selectElement = document.getElementById('type'); map.on('click', function(event) { - const features = map.getFeaturesAtPixel(event.pixel); - if (!features) { - selection = {}; + vtLayer.getFeatures(event.pixel).then(function(features) { + if (!features.length) { + selection = {}; + // force redraw of layer style + vtLayer.setStyle(vtLayer.getStyle()); + return; + } + const feature = features[0]; + if (!feature) { + return; + } + const fid = feature.get(idProp); + + if (selectElement.value === 'singleselect') { + selection = {}; + } + // add selected feature to lookup + selection[fid] = feature; + // force redraw of layer style vtLayer.setStyle(vtLayer.getStyle()); - return; - } - const feature = features[0]; - if (!feature) { - return; - } + }); - const fid = feature.get(idProp); - - if (selectElement.value === 'singleselect') { - selection = {}; - } - // add selected feature to lookup - selection[fid] = feature; - - // force redraw of layer style - vtLayer.setStyle(vtLayer.getStyle()); }); diff --git a/src/ol/Tile.js b/src/ol/Tile.js index e0a393854c..29caf105b7 100644 --- a/src/ol/Tile.js +++ b/src/ol/Tile.js @@ -87,6 +87,11 @@ class Tile extends EventTarget { const options = opt_options ? opt_options : {}; + /** + * @type {ImageData} + */ + this.hitDetectionImageData = null; + /** * @type {import("./tilecoord.js").TileCoord} */ diff --git a/src/ol/layer/BaseVector.js b/src/ol/layer/BaseVector.js index 8c2024ffd6..7201c448b1 100644 --- a/src/ol/layer/BaseVector.js +++ b/src/ol/layer/BaseVector.js @@ -135,6 +135,24 @@ class BaseVectorLayer extends Layer { return this.declutter_; } + /** + * Get the topmost feature that intersects the given pixel on the viewport. Returns a promise + * that resolves with an array of features. The array will either contain the topmost feature + * when a hit was detected, or it will be empty. + * + * The hit detection algorithm used for this method is optimized for performance, but is less + * accurate than the one used in {@link import("../PluggableMap.js").default#getFeaturesAtPixel}: Text + * is not considered, and icons are only represented by their bounding box instead of the exact + * image. + * + * @param {import("../pixel.js").Pixel} pixel Pixel. + * @return {Promise>} Promise that resolves with an array of features. + * @api + */ + getFeatures(pixel) { + return super.getFeatures(pixel); + } + /** * @return {number|undefined} Render buffer. */ diff --git a/src/ol/layer/Layer.js b/src/ol/layer/Layer.js index 1e0491608c..d7e54859fa 100644 --- a/src/ol/layer/Layer.js +++ b/src/ol/layer/Layer.js @@ -192,6 +192,15 @@ class Layer extends BaseLayer { this.changed(); } + /** + * @param {import("../pixel").Pixel} pixel Pixel. + * @return {Promise>} Promise that resolves with + * an array of features. + */ + getFeatures(pixel) { + return this.renderer_.getFeatures(pixel); + } + /** * In charge to manage the rendering of the layer. One layer type is * bounded with one layer renderer. diff --git a/src/ol/layer/VectorTile.js b/src/ol/layer/VectorTile.js index 644ac16c48..8ede1b7c81 100644 --- a/src/ol/layer/VectorTile.js +++ b/src/ol/layer/VectorTile.js @@ -116,6 +116,24 @@ class VectorTileLayer extends BaseVectorLayer { return new CanvasVectorTileLayerRenderer(this); } + /** + * Get the topmost feature that intersects the given pixel on the viewport. Returns a promise + * that resolves with an array of features. The array will either contain the topmost feature + * when a hit was detected, or it will be empty. + * + * The hit detection algorithm used for this method is optimized for performance, but is less + * accurate than the one used in {@link import("../PluggableMap.js").default#getFeaturesAtPixel}: Text + * is not considered, and icons are only represented by their bounding box instead of the exact + * image. + * + * @param {import("../pixel.js").Pixel} pixel Pixel. + * @return {Promise>} Promise that resolves with an array of features. + * @api + */ + getFeatures(pixel) { + return super.getFeatures(pixel); + } + /** * @return {VectorTileRenderType} The render mode. */ diff --git a/src/ol/render/canvas/Immediate.js b/src/ol/render/canvas/Immediate.js index 2f0894b4f1..2d9bfc8046 100644 --- a/src/ol/render/canvas/Immediate.js +++ b/src/ol/render/canvas/Immediate.js @@ -15,6 +15,7 @@ import VectorContext from '../VectorContext.js'; import {defaultTextAlign, defaultFillStyle, defaultLineCap, defaultLineDash, defaultLineDashOffset, defaultLineJoin, defaultLineWidth, defaultMiterLimit, defaultStrokeStyle, defaultTextBaseline, defaultFont} from '../canvas.js'; import {create as createTransform, compose as composeTransform} from '../../transform.js'; + /** * @classdesc * A concrete subclass of {@link module:ol/render/VectorContext} that implements @@ -438,6 +439,13 @@ class CanvasImmediateRenderer extends VectorContext { this.setTextStyle(style.getText()); } + /** + * @param {import("../../transform.js").Transform} transform Transform. + */ + setTransform(transform) { + this.transform_ = transform; + } + /** * Render a geometry into the canvas. Call * {@link module:ol/render/canvas/Immediate#setStyle} first to set the rendering style. diff --git a/src/ol/render/canvas/hitdetect.js b/src/ol/render/canvas/hitdetect.js new file mode 100644 index 0000000000..33a892364b --- /dev/null +++ b/src/ol/render/canvas/hitdetect.js @@ -0,0 +1,146 @@ +/** + * @module ol/render/canvas/hitdetet + */ + +import CanvasImmediateRenderer from './Immediate.js'; +import {createCanvasContext2D} from '../../dom.js'; +import {Icon} from '../../style.js'; +import IconAnchorUnits from '../../style/IconAnchorUnits.js'; +import GeometryType from '../../geom/GeometryType.js'; +import {intersects} from '../../extent.js'; +import {numberSafeCompareFunction} from '../../array.js'; + +/** + * @param {import("../../size.js").Size} size Canvas size in css pixels. + * @param {Array} transforms Transforms + * for rendering features to all worlds of the viewport, from coordinates to css + * pixels. + * @param {Array} features + * Features to consider for hit detection. + * @param {import("../../style/Style.js").StyleFunction|undefined} styleFunction + * Layer style function. + * @param {import("../../extent.js").Extent} extent Extent. + * @param {number} resolution Resolution. + * @param {number} rotation Rotation. + * @return {ImageData} Hit detection image data. + */ +export function createHitDetectionImageData(size, transforms, features, styleFunction, extent, resolution, rotation) { + const width = size[0] / 2; + const height = size[1] / 2; + const context = createCanvasContext2D(width, height); + context.imageSmoothingEnabled = false; + const canvas = context.canvas; + const renderer = new CanvasImmediateRenderer(context, 0.5, extent, null, rotation); + const featureCount = features.length; + // Stretch hit detection index to use the whole available color range + const indexFactor = Math.ceil((256 * 256 * 256) / featureCount); + const featuresByZIndex = {}; + for (let i = 0; i < featureCount; ++i) { + const feature = features[i]; + const featureStyleFunction = feature.getStyleFunction() || styleFunction; + if (!styleFunction) { + continue; + } + let styles = featureStyleFunction(feature, resolution); + if (!Array.isArray(styles)) { + styles = [styles]; + } + const index = i * indexFactor; + const color = '#' + ('000000' + index.toString(16)).slice(-6); + for (let j = 0, jj = styles.length; j < jj; ++j) { + const originalStyle = styles[j]; + const style = originalStyle.clone(); + const fill = style.getFill(); + if (fill) { + fill.setColor(color); + } + const stroke = style.getStroke(); + if (stroke) { + stroke.setColor(color); + } + style.setText(undefined); + const image = originalStyle.getImage(); + if (image) { + const imgSize = image.getImageSize(); + const imgContext = createCanvasContext2D(imgSize[0], imgSize[1]); + imgContext.fillStyle = color; + const img = imgContext.canvas; + imgContext.fillRect(0, 0, img.width, img.height); + const width = imgSize ? imgSize[0] : img.width; + const height = imgSize ? imgSize[1] : img.height; + const iconContext = createCanvasContext2D(width, height); + iconContext.drawImage(img, 0, 0); + style.setImage(new Icon({ + img: img, + imgSize: imgSize, + anchor: image.getAnchor(), + anchorXUnits: IconAnchorUnits.PIXELS, + anchorYUnits: IconAnchorUnits.PIXELS, + offset: image.getOrigin(), + size: image.getSize(), + opacity: image.getOpacity(), + scale: image.getScale(), + rotation: image.getRotation(), + rotateWithView: image.getRotateWithView() + })); + } + const zIndex = Number(style.getZIndex()); + let byGeometryType = featuresByZIndex[zIndex]; + if (!byGeometryType) { + byGeometryType = featuresByZIndex[zIndex] = {}; + byGeometryType[GeometryType.POLYGON] = []; + byGeometryType[GeometryType.CIRCLE] = []; + byGeometryType[GeometryType.LINE_STRING] = []; + byGeometryType[GeometryType.POINT] = []; + } + const geometry = style.getGeometryFunction()(feature); + if (geometry && intersects(extent, geometry.getExtent())) { + byGeometryType[geometry.getType().replace('Multi', '')].push(geometry, style); + } + } + } + + const zIndexKeys = Object.keys(featuresByZIndex).map(Number).sort(numberSafeCompareFunction); + for (let i = 0, ii = zIndexKeys.length; i < ii; ++i) { + const byGeometryType = featuresByZIndex[zIndexKeys[i]]; + for (const type in byGeometryType) { + const geomAndStyle = byGeometryType[type]; + for (let j = 0, jj = geomAndStyle.length; j < jj; j += 2) { + renderer.setStyle(geomAndStyle[j + 1]); + for (let k = 0, kk = transforms.length; k < kk; ++k) { + renderer.setTransform(transforms[k]); + renderer.drawGeometry(geomAndStyle[j]); + } + } + } + } + return context.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * @param {import("../../pixel").Pixel} pixel Pixel coordinate on the hit + * detection canvas in css pixels. + * @param {Array} features Features. Has to + * match the `features` array that was passed to `createHitDetectionImageData()`. + * @param {ImageData} imageData Hit detection image data generated by + * `createHitDetectionImageData()`. + * @return {Array} features Features. + */ +export function hitDetect(pixel, features, imageData) { + const resultFeatures = []; + if (imageData) { + const index = (Math.round(pixel[0] / 2) + Math.round(pixel[1] / 2) * imageData.width) * 4; + const r = imageData.data[index]; + const g = imageData.data[index + 1]; + const b = imageData.data[index + 2]; + const a = imageData.data[index + 3]; + if (a === 255) { + const i = b + (256 * (g + (256 * r))); + const indexFactor = Math.ceil((256 * 256 * 256) / features.length); + if (i % indexFactor === 0) { + resultFeatures.push(features[i / indexFactor]); + } + } + } + return resultFeatures; +} diff --git a/src/ol/renderer/Layer.js b/src/ol/renderer/Layer.js index d5860dcaa6..072e043906 100644 --- a/src/ol/renderer/Layer.js +++ b/src/ol/renderer/Layer.js @@ -30,6 +30,16 @@ class LayerRenderer extends Observable { } + /** + * Asynchronous layer level hit detection. + * @param {import("../pixel.js").Pixel} pixel Pixel. + * @return {Promise>} Promise that resolves with + * an array of features. + */ + getFeatures(pixel) { + return abstract(); + } + /** * Determine whether render should be called. * @abstract diff --git a/src/ol/renderer/canvas/Layer.js b/src/ol/renderer/canvas/Layer.js index 47223a9fea..b6a4fa8dc7 100644 --- a/src/ol/renderer/canvas/Layer.js +++ b/src/ol/renderer/canvas/Layer.js @@ -214,23 +214,24 @@ class CanvasLayerRenderer extends LayerRenderer { /** * Creates a transform for rendering to an element that will be rotated after rendering. - * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. + * @param {import("../../coordinate.js").Coordinate} center Center. + * @param {number} resolution Resolution. + * @param {number} rotation Rotation. + * @param {number} pixelRatio Pixel ratio. * @param {number} width Width of the rendered element (in pixels). * @param {number} height Height of the rendered element (in pixels). * @param {number} offsetX Offset on the x-axis in view coordinates. * @protected * @return {!import("../../transform.js").Transform} Transform. */ - getRenderTransform(frameState, width, height, offsetX) { - const viewState = frameState.viewState; - const pixelRatio = frameState.pixelRatio; + getRenderTransform(center, resolution, rotation, pixelRatio, width, height, offsetX) { const dx1 = width / 2; const dy1 = height / 2; - const sx = pixelRatio / viewState.resolution; + const sx = pixelRatio / resolution; const sy = -sx; - const dx2 = -viewState.center[0] + offsetX; - const dy2 = -viewState.center[1]; - return composeTransform(this.tempTransform_, dx1, dy1, sx, sy, -viewState.rotation, dx2, dy2); + const dx2 = -center[0] + offsetX; + const dy2 = -center[1]; + return composeTransform(this.tempTransform_, dx1, dy1, sx, sy, -rotation, dx2, dy2); } /** diff --git a/src/ol/renderer/canvas/TileLayer.js b/src/ol/renderer/canvas/TileLayer.js index 6eba0a2096..cdf1683b21 100644 --- a/src/ol/renderer/canvas/TileLayer.js +++ b/src/ol/renderer/canvas/TileLayer.js @@ -35,6 +35,18 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { */ this.renderedExtent_ = null; + /** + * @protected + * @type {number} + */ + this.renderedPixelRatio; + + /** + * @protected + * @type {import("../../proj/Projection.js").default} + */ + this.renderedProjection = null; + /** * @protected * @type {number} @@ -342,6 +354,8 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { this.renderedResolution = tileResolution; this.extentChanged = !this.renderedExtent_ || !equals(this.renderedExtent_, canvasExtent); this.renderedExtent_ = canvasExtent; + this.renderedPixelRatio = pixelRatio; + this.renderedProjection = projection; this.manageTilePyramid(frameState, tileSource, tileGrid, pixelRatio, projection, extent, z, tileLayer.getPreload()); diff --git a/src/ol/renderer/canvas/VectorLayer.js b/src/ol/renderer/canvas/VectorLayer.js index 4cb11e29da..56474d02c7 100644 --- a/src/ol/renderer/canvas/VectorLayer.js +++ b/src/ol/renderer/canvas/VectorLayer.js @@ -9,7 +9,8 @@ import CanvasBuilderGroup from '../../render/canvas/BuilderGroup.js'; import ExecutorGroup, {replayDeclutter} from '../../render/canvas/ExecutorGroup.js'; import CanvasLayerRenderer from './Layer.js'; import {defaultOrder as defaultRenderOrder, getTolerance as getRenderTolerance, getSquaredTolerance as getSquaredRenderTolerance, renderFeature} from '../vector.js'; -import {toString as transformToString, makeScale, makeInverse} from '../../transform.js'; +import {toString as transformToString, makeScale, makeInverse, apply} from '../../transform.js'; +import {createHitDetectionImageData, hitDetect} from '../../render/canvas/hitdetect.js'; /** * @classdesc @@ -28,6 +29,10 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { /** @private */ this.boundHandleStyleImageChange_ = this.handleStyleImageChange_.bind(this); + /** + * @type {boolean} + */ + this.animatingOrInteracting_; /** * @private @@ -35,6 +40,16 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { */ this.dirty_ = false; + /** + * @type {ImageData} + */ + this.hitDetectionImageData_ = null; + + /** + * @type {Array} + */ + this.renderedFeatures_ = null; + /** * @private * @type {number} @@ -53,6 +68,24 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { */ this.renderedExtent_ = createEmpty(); + /** + * @private + * @type {number} + */ + this.renderedRotation_; + + /** + * @private + * @type {import("../../coordinate").Coordinate} + */ + this.renderedCenter_ = null; + + /** + * @private + * @type {import("../../proj/Projection").default} + */ + this.renderedProjection_ = null; + /** * @private * @type {function(import("../../Feature.js").default, import("../../Feature.js").default): number|null} @@ -124,6 +157,8 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const extent = frameState.extent; const viewState = frameState.viewState; + const center = viewState.center; + const resolution = viewState.resolution; const projection = viewState.projection; const rotation = viewState.rotation; const projectionExtent = projection.getExtent(); @@ -143,7 +178,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const viewHints = frameState.viewHints; const snapToPixel = !(viewHints[ViewHint.ANIMATING] || viewHints[ViewHint.INTERACTING]); - const transform = this.getRenderTransform(frameState, width, height, 0); + const transform = this.getRenderTransform(center, resolution, rotation, pixelRatio, width, height, 0); const declutterReplays = this.getLayer().getDeclutter() ? {} : null; replayGroup.execute(context, transform, rotation, snapToPixel, undefined, declutterReplays); @@ -155,7 +190,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { while (startX < projectionExtent[0]) { --world; offsetX = worldWidth * world; - const transform = this.getRenderTransform(frameState, width, height, offsetX); + const transform = this.getRenderTransform(center, resolution, rotation, pixelRatio, width, height, offsetX); replayGroup.execute(context, transform, rotation, snapToPixel, undefined, declutterReplays); startX += worldWidth; } @@ -164,7 +199,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { while (startX > projectionExtent[2]) { ++world; offsetX = worldWidth * world; - const transform = this.getRenderTransform(frameState, width, height, offsetX); + const transform = this.getRenderTransform(center, resolution, rotation, pixelRatio, width, height, offsetX); replayGroup.execute(context, transform, rotation, snapToPixel, undefined, declutterReplays); startX -= worldWidth; } @@ -190,6 +225,58 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { return this.container; } + /** + * @inheritDoc + */ + getFeatures(pixel) { + return new Promise(function(resolve, reject) { + if (!this.hitDetectionImageData_ && !this.animatingOrInteracting_) { + requestAnimationFrame(function() { + const size = [this.context.canvas.width, this.context.canvas.height]; + apply(this.pixelTransform, size); + const center = this.renderedCenter_; + const resolution = this.renderedResolution_; + const rotation = this.renderedRotation_; + const projection = this.renderedProjection_; + const extent = this.renderedExtent_; + const layer = this.getLayer(); + const transforms = []; + const width = size[0] / 2; + const height = size[1] / 2; + transforms.push(this.getRenderTransform(center, resolution, rotation, 0.5, width, height, 0).slice()); + const source = layer.getSource(); + const projectionExtent = projection.getExtent(); + if (source.getWrapX() && projection.canWrapX() && !containsExtent(projectionExtent, extent)) { + let startX = extent[0]; + const worldWidth = getWidth(projectionExtent); + let world = 0; + let offsetX; + while (startX < projectionExtent[0]) { + --world; + offsetX = worldWidth * world; + transforms.push(this.getRenderTransform(center, resolution, rotation, 0.5, width, height, offsetX).slice()); + startX += worldWidth; + } + world = 0; + startX = extent[2]; + while (startX > projectionExtent[2]) { + ++world; + offsetX = worldWidth * world; + transforms.push(this.getRenderTransform(center, resolution, rotation, 0.5, width, height, offsetX).slice()); + startX -= worldWidth; + } + } + + this.hitDetectionImageData_ = createHitDetectionImageData(size, transforms, + this.renderedFeatures_, layer.getStyleFunction(), extent, resolution, rotation); + resolve(hitDetect(pixel, this.renderedFeatures_, this.hitDetectionImageData_)); + }.bind(this)); + } else { + resolve(hitDetect(pixel, this.renderedFeatures_, this.hitDetectionImageData_)); + } + }.bind(this)); + } + /** * @inheritDoc */ @@ -253,8 +340,10 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { if (!this.dirty_ && (!updateWhileAnimating && animating) || (!updateWhileInteracting && interacting)) { + this.animatingOrInteracting_ = true; return true; } + this.animatingOrInteracting_ = false; const frameStateExtent = frameState.extent; const viewState = frameState.viewState; @@ -269,6 +358,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { vectorLayerRenderOrder = defaultRenderOrder; } + const center = viewState.center.slice(); const extent = buffer(frameStateExtent, vectorLayerRenderBuffer * resolution); const projectionExtent = viewState.projection.getExtent(); @@ -284,6 +374,8 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const gutter = Math.max(getWidth(extent) / 2, worldWidth); extent[0] = projectionExtent[0] - gutter; extent[2] = projectionExtent[2] + gutter; + const worldsAway = Math.floor((center[0] - projectionExtent[0]) / worldWidth); + center[0] -= (worldsAway * worldWidth); } if (!this.dirty_ && @@ -334,23 +426,15 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { }.bind(this); const userExtent = toUserExtent(extent, projection); + /** @type {Array} */ + const features = vectorSource.getFeaturesInExtent(userExtent); if (vectorLayerRenderOrder) { - /** @type {Array} */ - const features = []; - vectorSource.forEachFeatureInExtent(userExtent, - /** - * @param {import("../../Feature.js").default} feature Feature. - */ - function(feature) { - features.push(feature); - }); features.sort(vectorLayerRenderOrder); - for (let i = 0, ii = features.length; i < ii; ++i) { - render(features[i]); - } - } else { - vectorSource.forEachFeatureInExtent(userExtent, render); } + for (let i = 0, ii = features.length; i < ii; ++i) { + render(features[i]); + } + this.renderedFeatures_ = features; const replayGroupInstructions = replayGroup.finish(); const executorGroup = new ExecutorGroup(extent, resolution, @@ -361,7 +445,11 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { this.renderedRevision_ = vectorLayerRevision; this.renderedRenderOrder_ = vectorLayerRenderOrder; this.renderedExtent_ = extent; + this.renderedRotation_ = viewState.rotation; + this.renderedCenter_ = center; + this.renderedProjection_ = projection; this.replayGroup_ = executorGroup; + this.hitDetectionImageData_ = null; this.replayGroupChanged = true; return true; diff --git a/src/ol/renderer/canvas/VectorTileLayer.js b/src/ol/renderer/canvas/VectorTileLayer.js index 12cbaf72f7..bed9d34d81 100644 --- a/src/ol/renderer/canvas/VectorTileLayer.js +++ b/src/ol/renderer/canvas/VectorTileLayer.js @@ -7,11 +7,12 @@ import TileState from '../../TileState.js'; import ViewHint from '../../ViewHint.js'; import {listen, unlistenByKey} from '../../events.js'; import EventType from '../../events/EventType.js'; -import {buffer, containsCoordinate, equals, getIntersection, intersects} from '../../extent.js'; +import {buffer, containsCoordinate, equals, getIntersection, intersects, containsExtent, getWidth, getTopLeft} from '../../extent.js'; import VectorTileRenderType from '../../layer/VectorTileRenderType.js'; import ReplayType from '../../render/canvas/BuilderType.js'; import CanvasBuilderGroup from '../../render/canvas/BuilderGroup.js'; import CanvasTileLayerRenderer from './TileLayer.js'; +import {toSize} from '../../size.js'; import {getSquaredTolerance as getSquaredRenderTolerance, renderFeature} from '../vector.js'; import { apply as applyTransform, @@ -25,6 +26,7 @@ import { } from '../../transform.js'; import CanvasExecutorGroup, {replayDeclutter} from '../../render/canvas/ExecutorGroup.js'; import {clear} from '../../obj.js'; +import {createHitDetectionImageData, hitDetect} from '../../render/canvas/hitdetect.js'; /** @@ -99,6 +101,18 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { */ this.renderedLayerRevision_; + /** + * @private + * @type {import("../../transform").Transform} + */ + this.renderedPixelToCoordinateTransform_ = null; + + /** + * @private + * @type {number} + */ + this.renderedRotation_; + /** * @private * @type {!Object} @@ -304,6 +318,7 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { if (renderOrder && renderOrder !== builderState.renderedRenderOrder) { features.sort(renderOrder); } + sourceTile.hitDetectionImageData = null; for (let i = 0, ii = features.length; i < ii; ++i) { const feature = features[i]; if (!bufferedExtent || intersects(bufferedExtent, feature.getGeometry().getExtent())) { @@ -380,6 +395,73 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { return found; } + /** + * @inheritDoc + */ + getFeatures(pixel) { + return new Promise(function(resolve, reject) { + const layer = /** @type {import("../../layer/VectorTile.js").default} */ (this.getLayer()); + const source = layer.getSource(); + const projection = this.renderedProjection; + const projectionExtent = projection.getExtent(); + const resolution = this.renderedResolution; + const pixelRatio = this.renderedPixelRatio; + const tileGrid = source.getTileGridForProjection(projection); + const sourceTileGrid = source.getTileGrid(); + const coordinate = applyTransform(this.renderedPixelToCoordinateTransform_, pixel.slice()); + const tileCoord = tileGrid.getTileCoordForCoordAndResolution(coordinate, resolution); + let sourceTile; + for (let i = 0, ii = this.renderedTiles.length; i < ii; ++i) { + if (tileCoord.toString() === this.renderedTiles[i].tileCoord.toString()) { + const tile = this.renderedTiles[i]; + if (tile.getState() === TileState.LOADED && tile.hifi) { + const extent = tileGrid.getTileCoordExtent(tileCoord); + if (source.getWrapX() && projection.canWrapX() && !containsExtent(projectionExtent, extent)) { + const worldWidth = getWidth(projectionExtent); + const worldsAway = Math.floor((coordinate[0] - projectionExtent[0]) / worldWidth); + coordinate[0] -= (worldsAway * worldWidth); + } + const sourceTiles = source.getSourceTiles(pixelRatio, projection, tile); + const sourceTileCoord = sourceTileGrid.getTileCoordForCoordAndResolution(coordinate, resolution); + for (let j = 0, jj = sourceTiles.length; j < jj; ++j) { + if (sourceTileCoord.toString() === sourceTiles[j].tileCoord.toString()) { + sourceTile = sourceTiles[j]; + break; + } + } + } + break; + } + } + if (!sourceTile) { + resolve([]); + return; + } + const corner = getTopLeft(tileGrid.getTileCoordExtent(sourceTile.tileCoord)); + const tilePixel = [ + (coordinate[0] - corner[0]) / resolution, + (corner[1] - coordinate[1]) / resolution + ]; + if (!sourceTile.hitDetectionImageData) { + const tileSize = toSize(sourceTileGrid.getTileSize(sourceTileGrid.getZForResolution(resolution))); + const size = [tileSize[0] / 2, tileSize[1] / 2]; + const rotation = this.renderedRotation_; + const transforms = [ + this.getRenderTransform(tileGrid.getTileCoordCenter(sourceTile.tileCoord), + resolution, 0, 0.5, size[0], size[1], 0) + ]; + requestAnimationFrame(function() { + sourceTile.hitDetectionImageData = createHitDetectionImageData(tileSize, transforms, + sourceTile.getFeatures(), layer.getStyleFunction(), + tileGrid.getTileCoordExtent(sourceTile.tileCoord), resolution, rotation); + resolve(hitDetect(tilePixel, sourceTile.getFeatures(), sourceTile.hitDetectionImageData)); + }); + } else { + resolve(hitDetect(tilePixel, sourceTile.getFeatures(), sourceTile.hitDetectionImageData)); + } + }.bind(this)); + } + /** * @inheritDoc */ @@ -409,6 +491,9 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { this.renderQueuedTileImages_(hifi, frameState); super.renderFrame(frameState, target); + this.renderedPixelToCoordinateTransform_ = frameState.pixelToCoordinateTransform.slice(); + this.renderedRotation_ = frameState.viewState.rotation; + const layer = /** @type {import("../../layer/VectorTile.js").default} */ (this.getLayer()); const renderMode = layer.getRenderMode(); @@ -429,7 +514,10 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { const declutterReplays = layer.getDeclutter() ? {} : null; const replayTypes = VECTOR_REPLAYS[renderMode]; const pixelRatio = frameState.pixelRatio; - const rotation = frameState.viewState.rotation; + const viewState = frameState.viewState; + const center = viewState.center; + const resolution = viewState.resolution; + const rotation = viewState.rotation; const size = frameState.size; // set forward and inverse pixel transforms @@ -463,7 +551,7 @@ class CanvasVectorTileLayerRenderer extends CanvasTileLayerRenderer { const tileCoord = tile.tileCoord; const tileExtent = tileGrid.getTileCoordExtent(tile.wrappedTileCoord); const worldOffset = tileGrid.getTileCoordExtent(tileCoord, this.tmpExtent)[0] - tileExtent[0]; - const transform = this.getRenderTransform(frameState, width, height, worldOffset); + const transform = this.getRenderTransform(center, resolution, rotation, pixelRatio, width, height, worldOffset); const executorGroups = tile.executorGroups[getUid(layer)]; let clipped = false; for (let t = 0, tt = executorGroups.length; t < tt; ++t) { diff --git a/src/ol/source/Vector.js b/src/ol/source/Vector.js index fc2d7f938a..0d0905e5c2 100644 --- a/src/ol/source/Vector.js +++ b/src/ol/source/Vector.js @@ -675,14 +675,21 @@ class VectorSource extends Source { * all features intersecting the given extent in random order (so it may include * features whose geometries do not intersect the extent). * - * This method is not available when the source is configured with - * `useSpatialIndex` set to `false`. + * When `useSpatialIndex` is set to false, this method will return all + * features. + * * @param {import("../extent.js").Extent} extent Extent. * @return {Array>} Features. * @api */ getFeaturesInExtent(extent) { - return this.featuresRtree_.getInExtent(extent); + if (this.featuresRtree_) { + return this.featuresRtree_.getInExtent(extent); + } else if (this.featuresCollection_) { + return this.featuresCollection_.getArray(); + } else { + return []; + } } diff --git a/test/spec/ol/layer/vector.test.js b/test/spec/ol/layer/vector.test.js index 9f88d9c66c..e53523166c 100644 --- a/test/spec/ol/layer/vector.test.js +++ b/test/spec/ol/layer/vector.test.js @@ -2,6 +2,10 @@ import Layer from '../../../../src/ol/layer/Layer.js'; import VectorLayer from '../../../../src/ol/layer/Vector.js'; import VectorSource from '../../../../src/ol/source/Vector.js'; import Style, {createDefaultStyle} from '../../../../src/ol/style/Style.js'; +import Feature from '../../../../src/ol/Feature.js'; +import Point from '../../../../src/ol/geom/Point.js'; +import Map from '../../../../src/ol/Map.js'; +import View from '../../../../src/ol/View.js'; describe('ol.layer.Vector', function() { @@ -123,4 +127,55 @@ describe('ol.layer.Vector', function() { }); + describe('#getFeatures()', function() { + + let map, layer; + + beforeEach(function() { + layer = new VectorLayer({ + source: new VectorSource({ + features: [ + new Feature({ + geometry: new Point([-1000000, 0]), + name: 'feature1' + }), + new Feature({ + geometry: new Point([1000000, 0]), + name: 'feture2' + }) + ] + }) + }); + const container = document.createElement('div'); + container.style.width = '256px'; + container.style.height = '256px'; + document.body.appendChild(container); + map = new Map({ + target: container, + layers: [ + layer + ], + view: new View({ + zoom: 2, + center: [0, 0] + }) + }); + }); + + afterEach(function() { + document.body.removeChild(map.getTargetElement()); + map.setTarget(null); + }); + + it('detects features properly', function(done) { + map.renderSync(); + const pixel = map.getPixelFromCoordinate([-1000000, 0]); + layer.getFeatures(pixel).then(function(features) { + expect(features[0].get('name')).to.be('feature1'); + done(); + }); + }); + + }); + }); diff --git a/test/spec/ol/layer/vectortile.test.js b/test/spec/ol/layer/vectortile.test.js index 60e9332a53..171b7c5f64 100644 --- a/test/spec/ol/layer/vectortile.test.js +++ b/test/spec/ol/layer/vectortile.test.js @@ -1,5 +1,9 @@ import VectorTileLayer from '../../../../src/ol/layer/VectorTile.js'; import VectorTileSource from '../../../../src/ol/source/VectorTile.js'; +import GeoJSON from '../../../../src/ol/format/GeoJSON.js'; +import View from '../../../../src/ol/View.js'; +import Map from '../../../../src/ol/Map.js'; +import {fromLonLat} from '../../../../src/ol/proj.js'; describe('ol.layer.VectorTile', function() { @@ -57,4 +61,74 @@ describe('ol.layer.VectorTile', function() { }); }); + describe('#getFeatures()', function() { + + let map, layer; + + beforeEach(function() { + layer = new VectorTileLayer({ + source: new VectorTileSource({ + format: new GeoJSON(), + url: `data:application/json;charset=utf-8, + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-36, 0] + }, + "properties": { + "name": "feature1" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [36, 0] + }, + "properties": { + "name": "feature2" + } + } + ] + } + ` + }) + }); + const container = document.createElement('div'); + container.style.width = '256px'; + container.style.height = '256px'; + document.body.appendChild(container); + map = new Map({ + target: container, + layers: [ + layer + ], + view: new View({ + zoom: 0, + center: [0, 0] + }) + }); + }); + + afterEach(function() { + document.body.removeChild(map.getTargetElement()); + map.setTarget(null); + }); + + it('detects features properly', function(done) { + map.once('rendercomplete', function() { + const pixel = map.getPixelFromCoordinate(fromLonLat([-36, 0])); + layer.getFeatures(pixel).then(function(features) { + expect(features[0].get('name')).to.be('feature1'); + done(); + }); + }); + }); + + }); + }); diff --git a/test/spec/ol/renderer/canvas/vectorimage.test.js b/test/spec/ol/renderer/canvas/vectorimage.test.js index fcb6fe505c..98d41e1fda 100644 --- a/test/spec/ol/renderer/canvas/vectorimage.test.js +++ b/test/spec/ol/renderer/canvas/vectorimage.test.js @@ -40,6 +40,7 @@ describe('ol/renderer/canvas/VectorImageLayer', function() { extent: extent, viewHints: [], viewState: { + center: [0, 0], projection: projection, resolution: 1, rotation: 0 diff --git a/test/spec/ol/renderer/canvas/vectorlayer.test.js b/test/spec/ol/renderer/canvas/vectorlayer.test.js index 368a4c5ade..280fc4d8c6 100644 --- a/test/spec/ol/renderer/canvas/vectorlayer.test.js +++ b/test/spec/ol/renderer/canvas/vectorlayer.test.js @@ -208,6 +208,7 @@ describe('ol.renderer.canvas.VectorLayer', function() { const frameState = { layerStatesArray: [{}], viewState: { + center: [0, 0], resolution: 1, rotation: 0 } @@ -234,6 +235,7 @@ describe('ol.renderer.canvas.VectorLayer', function() { frameState = { viewHints: [], viewState: { + center: [0, 0], projection: projection, resolution: 1, rotation: 0 diff --git a/test/spec/ol/renderer/canvas/vectortilelayer.test.js b/test/spec/ol/renderer/canvas/vectortilelayer.test.js index 926c2d1724..7d91af8e51 100644 --- a/test/spec/ol/renderer/canvas/vectortilelayer.test.js +++ b/test/spec/ol/renderer/canvas/vectortilelayer.test.js @@ -22,6 +22,7 @@ import VectorTileRenderType from '../../../../../src/ol/layer/VectorTileRenderTy import {getUid} from '../../../../../src/ol/util.js'; import TileLayer from '../../../../../src/ol/layer/Tile.js'; import XYZ from '../../../../../src/ol/source/XYZ.js'; +import {create} from '../../../../../src/ol/transform.js'; describe('ol.renderer.canvas.VectorTileLayer', function() { @@ -262,6 +263,7 @@ describe('ol.renderer.canvas.VectorTileLayer', function() { layerIndex: 0, extent: proj.getExtent(), pixelRatio: 1, + pixelToCoordinateTransform: create(), time: Date.now(), viewHints: [], viewState: {