diff --git a/changelog/upgrade-notes.md b/changelog/upgrade-notes.md
index 3c619165d2..a59c59f615 100644
--- a/changelog/upgrade-notes.md
+++ b/changelog/upgrade-notes.md
@@ -1,5 +1,13 @@
## Upgrade notes
+### Next
+
+#### New `layer.getData()` method
+
+Raster layers (static images, image tiles, data tiles) have a new `layer.getData(pixel)` method that returns the pixel data at the provided location. The return value depends on the underlying source data type. For example, a GeoTIFF may return a `Float32Array` with one value per band, while a PNG rendered from a tile layer will return a `Uint8ClampedArray` of RGBA values.
+
+If you were previously using the `map.forEachLayerAtPixel()` method, you should use the new `layer.getData()` method instead. The old method returns composite pixel values from multiple layers and is limited to RGBA values. The new method doesn't suffer from these shortcomings and is more performant.
+
### v6.12.0
No special changes are required when upgrading to the 6.12.0 release.
diff --git a/examples/cog-math.html b/examples/cog-math.html
index 318f626e11..17290679d6 100644
--- a/examples/cog-math.html
+++ b/examples/cog-math.html
@@ -3,9 +3,12 @@ layout: example.html
title: NDVI from a Sentinel 2 COG
shortdesc: Calculating NDVI and applying a custom color map.
docs: >
- The GeoTIFF layer in this example draws from two Sentinel 2 sources: a red band and a near infrared band.
+ The GeoTIFF layer in this example draws from two Sentinel 2 sources: a red band and a near-infrared band.
The layer style includes a `color` expression that calculates the Normalized Difference Vegetation Index (NDVI)
from values in the two bands. The `interpolate` expression is used to map NDVI values to colors.
+ The `layer.getData()` method can be used to retrieve pixel values from the GeoTIFF. Move your mouse
+ or tap on the map to see calculated NDVI values based on the red and near-infrared pixel values.
tags: "cog, ndvi"
---
+NDVI:
diff --git a/examples/cog-math.js b/examples/cog-math.js
index 1438e47637..32331ae636 100644
--- a/examples/cog-math.js
+++ b/examples/cog-math.js
@@ -17,65 +17,74 @@ const source = new GeoTIFF({
],
});
+const layer = new TileLayer({
+ style: {
+ color: [
+ 'interpolate',
+ ['linear'],
+ // calculate NDVI, bands come from the sources below
+ ['/', ['-', ['band', 2], ['band', 1]], ['+', ['band', 2], ['band', 1]]],
+ // color ramp for NDVI values, ranging from -1 to 1
+ -0.2,
+ [191, 191, 191],
+ -0.1,
+ [219, 219, 219],
+ 0,
+ [255, 255, 224],
+ 0.025,
+ [255, 250, 204],
+ 0.05,
+ [237, 232, 181],
+ 0.075,
+ [222, 217, 156],
+ 0.1,
+ [204, 199, 130],
+ 0.125,
+ [189, 184, 107],
+ 0.15,
+ [176, 194, 97],
+ 0.175,
+ [163, 204, 89],
+ 0.2,
+ [145, 191, 82],
+ 0.25,
+ [128, 179, 71],
+ 0.3,
+ [112, 163, 64],
+ 0.35,
+ [97, 150, 54],
+ 0.4,
+ [79, 138, 46],
+ 0.45,
+ [64, 125, 36],
+ 0.5,
+ [48, 110, 28],
+ 0.55,
+ [33, 97, 18],
+ 0.6,
+ [15, 84, 10],
+ 0.65,
+ [0, 69, 0],
+ ],
+ },
+ source: source,
+});
+
const map = new Map({
target: 'map',
- layers: [
- new TileLayer({
- style: {
- color: [
- 'interpolate',
- ['linear'],
- // calculate NDVI, bands come from the sources below
- [
- '/',
- ['-', ['band', 2], ['band', 1]],
- ['+', ['band', 2], ['band', 1]],
- ],
- // color ramp for NDVI values, ranging from -1 to 1
- -0.2,
- [191, 191, 191],
- -0.1,
- [219, 219, 219],
- 0,
- [255, 255, 224],
- 0.025,
- [255, 250, 204],
- 0.05,
- [237, 232, 181],
- 0.075,
- [222, 217, 156],
- 0.1,
- [204, 199, 130],
- 0.125,
- [189, 184, 107],
- 0.15,
- [176, 194, 97],
- 0.175,
- [163, 204, 89],
- 0.2,
- [145, 191, 82],
- 0.25,
- [128, 179, 71],
- 0.3,
- [112, 163, 64],
- 0.35,
- [97, 150, 54],
- 0.4,
- [79, 138, 46],
- 0.45,
- [64, 125, 36],
- 0.5,
- [48, 110, 28],
- 0.55,
- [33, 97, 18],
- 0.6,
- [15, 84, 10],
- 0.65,
- [0, 69, 0],
- ],
- },
- source: source,
- }),
- ],
+ layers: [layer],
view: source.getView(),
});
+
+const output = document.getElementById('output');
+function displayPixelValue(event) {
+ const data = layer.getData(event.pixel);
+ if (!data) {
+ return;
+ }
+ const red = data[0];
+ const nir = data[1];
+ const ndvi = (nir - red) / (nir + red);
+ output.textContent = ndvi.toFixed(2);
+}
+map.on(['pointermove', 'click'], displayPixelValue);
diff --git a/examples/getfeatureinfo-image.html b/examples/getfeatureinfo-image.html
index 55bd672071..ea7c8ed101 100644
--- a/examples/getfeatureinfo-image.html
+++ b/examples/getfeatureinfo-image.html
@@ -3,8 +3,8 @@ layout: example.html
title: WMS GetFeatureInfo (Image Layer)
shortdesc: Using an image WMS source with GetFeatureInfo requests
docs: >
- This example shows how to trigger WMS GetFeatureInfo requests on click for a WMS image layer. Additionally map.forEachLayerAtPixel is used to change the mouse pointer when hovering a non-transparent pixel on the map.
-tags: "getfeatureinfo, forEachLayerAtPixel"
+ This example shows how to trigger WMS GetFeatureInfo requests on click for a WMS image layer. Additionally `layer.getData(pixel)` is used to change the mouse pointer when hovering a non-transparent pixel on the map.
+tags: "getfeatureinfo, getData"
---
diff --git a/examples/getfeatureinfo-image.js b/examples/getfeatureinfo-image.js
index aa2cc832d7..11fb133b9f 100644
--- a/examples/getfeatureinfo-image.js
+++ b/examples/getfeatureinfo-image.js
@@ -47,9 +47,7 @@ map.on('pointermove', function (evt) {
if (evt.dragging) {
return;
}
- const pixel = map.getEventPixel(evt.originalEvent);
- const hit = map.forEachLayerAtPixel(pixel, function () {
- return true;
- });
+ const data = wmsLayer.getData(evt.pixel);
+ const hit = data && data[3] > 0; // transparent pixels have zero for data[3]
map.getTargetElement().style.cursor = hit ? 'pointer' : '';
});
diff --git a/examples/getfeatureinfo-tile.html b/examples/getfeatureinfo-tile.html
index fb41153e60..9790ac5ece 100644
--- a/examples/getfeatureinfo-tile.html
+++ b/examples/getfeatureinfo-tile.html
@@ -3,8 +3,8 @@ layout: example.html
title: WMS GetFeatureInfo (Tile Layer)
shortdesc: Issuing GetFeatureInfo requests with a WMS tiled source
docs: >
- This example shows how to trigger WMS GetFeatureInfo requests on click for a WMS tile layer. Additionally map.forEachLayerAtPixel is used to change the mouse pointer when hovering a non-transparent pixel on the map.
-tags: "getfeatureinfo, forEachLayerAtPixel"
+ This example shows how to trigger WMS GetFeatureInfo requests on click for a WMS tile layer. Additionally `layer.getData(pixel)` is used to change the mouse pointer when hovering a non-transparent pixel on the map.
+tags: "getfeatureinfo, getData"
---
diff --git a/examples/getfeatureinfo-tile.js b/examples/getfeatureinfo-tile.js
index db354c3808..531a4f7f70 100644
--- a/examples/getfeatureinfo-tile.js
+++ b/examples/getfeatureinfo-tile.js
@@ -47,9 +47,7 @@ map.on('pointermove', function (evt) {
if (evt.dragging) {
return;
}
- const pixel = map.getEventPixel(evt.originalEvent);
- const hit = map.forEachLayerAtPixel(pixel, function () {
- return true;
- });
+ const data = wmsLayer.getData(evt.pixel);
+ const hit = data && data[3] > 0; // transparent pixels have zero for data[3]
map.getTargetElement().style.cursor = hit ? 'pointer' : '';
});
diff --git a/src/ol/PluggableMap.js b/src/ol/PluggableMap.js
index 7789659751..ded8fb890f 100644
--- a/src/ol/PluggableMap.js
+++ b/src/ol/PluggableMap.js
@@ -685,6 +685,9 @@ class PluggableMap extends BaseObject {
}
/**
+ * Please the `layer.getData()` method for {@link module:ol/layer/Tile~TileLayer#getData tile layers} or
+ * {@link module:ol/layer/Image~ImageLayer#getData image layers} instead of using this method.
+ *
* Detect layers that have a color value at a pixel on the viewport, and
* execute a callback with each matching layer. Layers included in the
* detection can be configured through `opt_layerFilter`.
diff --git a/src/ol/layer/BaseTile.js b/src/ol/layer/BaseTile.js
index dfab212d2f..b41f3c9be2 100644
--- a/src/ol/layer/BaseTile.js
+++ b/src/ol/layer/BaseTile.js
@@ -136,6 +136,26 @@ class BaseTileLayer extends Layer {
setUseInterimTilesOnError(useInterimTilesOnError) {
this.set(TileProperty.USE_INTERIM_TILES_ON_ERROR, useInterimTilesOnError);
}
+
+ /**
+ * Get data for a pixel location. The return type depends on the source data. For image tiles,
+ * a four element RGBA array will be returned. For data tiles, the array length will match the
+ * number of bands in the dataset. For requests outside the layer extent, `null` will be returned.
+ * Data for a image tiles can only be retrieved if the source's `crossOrigin` property is set.
+ *
+ * ```js
+ * // display layer data on every pointer move
+ * map.on('pointermove', (event) => {
+ * console.log(layer.getData(event.pixel));
+ * });
+ * ```
+ * @param {import("../pixel").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray|Uint8Array|Float32Array|DataView|null} Pixel data.
+ * @api
+ */
+ getData(pixel) {
+ return super.getData(pixel);
+ }
}
export default BaseTileLayer;
diff --git a/src/ol/layer/Image.js b/src/ol/layer/Image.js
index fa26423ba7..5b0828e9aa 100644
--- a/src/ol/layer/Image.js
+++ b/src/ol/layer/Image.js
@@ -27,6 +27,25 @@ class ImageLayer extends BaseImageLayer {
createRenderer() {
return new CanvasImageLayerRenderer(this);
}
+
+ /**
+ * Get data for a pixel location. A four element RGBA array will be returned. For requests outside the
+ * layer extent, `null` will be returned. Data for an image can only be retrieved if the
+ * source's `crossOrigin` property is set.
+ *
+ * ```js
+ * // display layer data on every pointer move
+ * map.on('pointermove', (event) => {
+ * console.log(layer.getData(event.pixel));
+ * });
+ * ```
+ * @param {import("../pixel").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray|Uint8Array|Float32Array|DataView|null} Pixel data.
+ * @api
+ */
+ getData(pixel) {
+ return super.getData(pixel);
+ }
}
export default ImageLayer;
diff --git a/src/ol/layer/Layer.js b/src/ol/layer/Layer.js
index d788f3e7ab..e495b18a04 100644
--- a/src/ol/layer/Layer.js
+++ b/src/ol/layer/Layer.js
@@ -250,6 +250,17 @@ class Layer extends BaseLayer {
return this.renderer_.getFeatures(pixel);
}
+ /**
+ * @param {import("../pixel").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray|Uint8Array|Float32Array|DataView|null} Pixel data.
+ */
+ getData(pixel) {
+ if (!this.renderer_) {
+ return null;
+ }
+ return this.renderer_.getData(pixel);
+ }
+
/**
* In charge to manage the rendering of the layer. One layer type is
* bounded with one layer renderer.
diff --git a/src/ol/renderer/Layer.js b/src/ol/renderer/Layer.js
index eb65ebd6e3..ef0972ee1d 100644
--- a/src/ol/renderer/Layer.js
+++ b/src/ol/renderer/Layer.js
@@ -47,6 +47,14 @@ class LayerRenderer extends Observable {
return abstract();
}
+ /**
+ * @param {import("../pixel.js").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray|Uint8Array|Float32Array|DataView|null} Pixel data.
+ */
+ getData(pixel) {
+ return null;
+ }
+
/**
* Determine whether render should be called.
* @abstract
@@ -191,6 +199,14 @@ class LayerRenderer extends Observable {
layer.changed();
}
}
+
+ /**
+ * Clean up.
+ */
+ disposeInternal() {
+ delete this.layer_;
+ super.disposeInternal();
+ }
}
export default LayerRenderer;
diff --git a/src/ol/renderer/canvas/ImageLayer.js b/src/ol/renderer/canvas/ImageLayer.js
index 4543e852ab..4f035def52 100644
--- a/src/ol/renderer/canvas/ImageLayer.js
+++ b/src/ol/renderer/canvas/ImageLayer.js
@@ -5,15 +5,19 @@ import CanvasLayerRenderer from './Layer.js';
import ViewHint from '../../ViewHint.js';
import {ENABLE_RASTER_REPROJECTION} from '../../reproj/common.js';
import {IMAGE_SMOOTHING_DISABLED, IMAGE_SMOOTHING_ENABLED} from './common.js';
-import {assign} from '../../obj.js';
import {
+ apply as applyTransform,
compose as composeTransform,
makeInverse,
toString as toTransformString,
} from '../../transform.js';
+import {assign} from '../../obj.js';
import {
+ containsCoordinate,
containsExtent,
+ getHeight,
getIntersection,
+ getWidth,
intersects as intersectsExtent,
isEmpty,
} from '../../extent.js';
@@ -98,6 +102,51 @@ class CanvasImageLayerRenderer extends CanvasLayerRenderer {
return !!this.image_;
}
+ /**
+ * @param {import("../../pixel.js").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray} Data at the pixel location.
+ */
+ getData(pixel) {
+ const frameState = this.frameState;
+ if (!frameState) {
+ return null;
+ }
+
+ const layer = this.getLayer();
+ const coordinate = applyTransform(
+ frameState.pixelToCoordinateTransform,
+ pixel.slice()
+ );
+
+ const layerExtent = layer.getExtent();
+ if (layerExtent) {
+ if (!containsCoordinate(layerExtent, coordinate)) {
+ return null;
+ }
+ }
+
+ const imageExtent = this.image_.getExtent();
+ const img = this.image_.getImage();
+
+ const imageMapWidth = getWidth(imageExtent);
+ const col = Math.floor(
+ img.width * ((coordinate[0] - imageExtent[0]) / imageMapWidth)
+ );
+ if (col < 0 || col >= img.width) {
+ return null;
+ }
+
+ const imageMapHeight = getHeight(imageExtent);
+ const row = Math.floor(
+ img.height * ((imageExtent[3] - coordinate[1]) / imageMapHeight)
+ );
+ if (row < 0 || row >= img.height) {
+ return null;
+ }
+
+ return this.getImageData(img, col, row);
+ }
+
/**
* Render the layer.
* @param {import("../../PluggableMap.js").FrameState} frameState Frame state.
diff --git a/src/ol/renderer/canvas/Layer.js b/src/ol/renderer/canvas/Layer.js
index 982d688caf..f7bfa32164 100644
--- a/src/ol/renderer/canvas/Layer.js
+++ b/src/ol/renderer/canvas/Layer.js
@@ -20,6 +20,18 @@ import {
import {createCanvasContext2D} from '../../dom.js';
import {equals} from '../../array.js';
+/**
+ * @type {CanvasRenderingContext2D}
+ */
+let pixelContext = null;
+
+function createPixelContext() {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ pixelContext = canvas.getContext('2d');
+}
+
/**
* @abstract
* @template {import("../../layer/Layer.js").default} LayerType
@@ -83,6 +95,34 @@ class CanvasLayerRenderer extends LayerRenderer {
* @type {CanvasRenderingContext2D}
*/
this.pixelContext_ = null;
+
+ /**
+ * @protected
+ * @type {import("../../PluggableMap.js").FrameState|null}
+ */
+ this.frameState = null;
+ }
+
+ /**
+ * @param {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} image Image.
+ * @param {number} col The column index.
+ * @param {number} row The row index.
+ * @return {Uint8ClampedArray|null} The image data.
+ */
+ getImageData(image, col, row) {
+ if (!pixelContext) {
+ createPixelContext();
+ }
+ pixelContext.clearRect(0, 0, 1, 1);
+
+ let data;
+ try {
+ pixelContext.drawImage(image, col, row, 1, 1, 0, 0, 1, 1);
+ data = pixelContext.getImageData(0, 0, 1, 1).data;
+ } catch (err) {
+ return null;
+ }
+ return data;
}
/**
@@ -215,6 +255,7 @@ class CanvasLayerRenderer extends LayerRenderer {
* @protected
*/
preRender(context, frameState) {
+ this.frameState = frameState;
this.dispatchRenderEvent_(RenderEventType.PRERENDER, context, frameState);
}
@@ -324,6 +365,14 @@ class CanvasLayerRenderer extends LayerRenderer {
}
return data;
}
+
+ /**
+ * Clean up.
+ */
+ disposeInternal() {
+ delete this.frameState;
+ super.disposeInternal();
+ }
}
export default CanvasLayerRenderer;
diff --git a/src/ol/renderer/canvas/TileLayer.js b/src/ol/renderer/canvas/TileLayer.js
index ded6fea88c..b81eb9f8ae 100644
--- a/src/ol/renderer/canvas/TileLayer.js
+++ b/src/ol/renderer/canvas/TileLayer.js
@@ -2,6 +2,8 @@
* @module ol/renderer/canvas/TileLayer
*/
import CanvasLayerRenderer from './Layer.js';
+import ImageTile from '../../ImageTile.js';
+import ReprojTile from '../../reproj/Tile.js';
import TileRange from '../../TileRange.js';
import TileState from '../../TileState.js';
import {IMAGE_SMOOTHING_DISABLED, IMAGE_SMOOTHING_ENABLED} from './common.js';
@@ -13,6 +15,7 @@ import {
} from '../../transform.js';
import {assign} from '../../obj.js';
import {
+ containsCoordinate,
createEmpty,
equals,
getIntersection,
@@ -22,6 +25,7 @@ import {cssOpacity} from '../../css.js';
import {fromUserExtent} from '../../proj.js';
import {getUid} from '../../util.js';
import {numberSafeCompareFunction} from '../../array.js';
+import {toSize} from '../../size.js';
/**
* @classdesc
@@ -136,6 +140,79 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer {
return tile;
}
+ /**
+ * @param {import("../../pixel.js").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray} Data at the pixel location.
+ */
+ getData(pixel) {
+ const frameState = this.frameState;
+ if (!frameState) {
+ return null;
+ }
+
+ const layer = this.getLayer();
+ const coordinate = applyTransform(
+ frameState.pixelToCoordinateTransform,
+ pixel.slice()
+ );
+
+ const layerExtent = layer.getExtent();
+ if (layerExtent) {
+ if (!containsCoordinate(layerExtent, coordinate)) {
+ return null;
+ }
+ }
+
+ const pixelRatio = frameState.pixelRatio;
+ const projection = frameState.viewState.projection;
+ const viewState = frameState.viewState;
+ const source = layer.getRenderSource();
+ const tileGrid = source.getTileGridForProjection(viewState.projection);
+ const tilePixelRatio = source.getTilePixelRatio(frameState.pixelRatio);
+
+ for (
+ let z = tileGrid.getZForResolution(viewState.resolution);
+ z >= tileGrid.getMinZoom();
+ --z
+ ) {
+ const tileCoord = tileGrid.getTileCoordForCoordAndZ(coordinate, z);
+ const tile = source.getTile(
+ z,
+ tileCoord[1],
+ tileCoord[2],
+ pixelRatio,
+ projection
+ );
+ if (!(tile instanceof ImageTile || tile instanceof ReprojTile)) {
+ return null;
+ }
+
+ if (tile.getState() !== TileState.LOADED) {
+ continue;
+ }
+
+ const tileOrigin = tileGrid.getOrigin(z);
+ const tileSize = toSize(tileGrid.getTileSize(z));
+ const tileResolution = tileGrid.getResolution(z);
+
+ const col = Math.floor(
+ tilePixelRatio *
+ ((coordinate[0] - tileOrigin[0]) / tileResolution -
+ tileCoord[1] * tileSize[0])
+ );
+
+ const row = Math.floor(
+ tilePixelRatio *
+ ((tileOrigin[1] - coordinate[1]) / tileResolution -
+ tileCoord[2] * tileSize[1])
+ );
+
+ return this.getImageData(tile.getImage(), col, row);
+ }
+
+ return null;
+ }
+
/**
* @param {Object>} tiles Lookup of loaded tiles by zoom level.
* @param {number} zoom Zoom level.
diff --git a/src/ol/renderer/webgl/TileLayer.js b/src/ol/renderer/webgl/TileLayer.js
index af6670353a..e2622de247 100644
--- a/src/ol/renderer/webgl/TileLayer.js
+++ b/src/ol/renderer/webgl/TileLayer.js
@@ -11,9 +11,11 @@ import WebGLLayerRenderer from './Layer.js';
import {AttributeType} from '../../webgl/Helper.js';
import {ELEMENT_ARRAY_BUFFER, STATIC_DRAW} from '../../webgl.js';
import {
+ apply as applyTransform,
compose as composeTransform,
create as createTransform,
} from '../../transform.js';
+import {containsCoordinate, getIntersection, isEmpty} from '../../extent.js';
import {
create as createMat4,
fromTransform as mat4FromTransform,
@@ -23,7 +25,6 @@ import {
getKey as getTileCoordKey,
} from '../../tilecoord.js';
import {fromUserExtent} from '../../proj.js';
-import {getIntersection, isEmpty} from '../../extent.js';
import {getUid} from '../../util.js';
import {numberSafeCompareFunction} from '../../array.js';
import {toSize} from '../../size.js';
@@ -233,6 +234,12 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
* @private
*/
this.paletteTextures_ = options.paletteTextures || [];
+
+ /**
+ * @private
+ * @type {import("../../PluggableMap.js").FrameState|null}
+ */
+ this.frameState_ = null;
}
/**
@@ -355,13 +362,13 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
viewState.projection
);
if (!tileTexture) {
- tileTexture = new TileTexture(
- tile,
- tileGrid,
- this.helper,
- tilePixelRatio,
- gutter
- );
+ tileTexture = new TileTexture({
+ tile: tile,
+ grid: tileGrid,
+ helper: this.helper,
+ tilePixelRatio: tilePixelRatio,
+ gutter: gutter,
+ });
tileTextureCache.set(cacheKey, tileTexture);
} else {
if (this.isDrawableTile_(tile)) {
@@ -401,6 +408,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
* @return {HTMLElement} The rendered element.
*/
renderFrame(frameState) {
+ this.frameState_ = frameState;
this.renderComplete = true;
const gl = this.helper.getGL();
this.preRender(gl, frameState);
@@ -648,6 +656,83 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
return canvas;
}
+ /**
+ * @param {import("../../pixel.js").Pixel} pixel Pixel.
+ * @return {Uint8ClampedArray|Uint8Array|Float32Array|DataView} Data at the pixel location.
+ */
+ getData(pixel) {
+ const gl = this.helper.getGL();
+ if (!gl) {
+ return null;
+ }
+
+ const frameState = this.frameState_;
+ if (!frameState) {
+ return null;
+ }
+
+ const layer = this.getLayer();
+ const coordinate = applyTransform(
+ frameState.pixelToCoordinateTransform,
+ pixel.slice()
+ );
+
+ const viewState = frameState.viewState;
+ const layerExtent = layer.getExtent();
+ if (layerExtent) {
+ if (
+ !containsCoordinate(
+ fromUserExtent(layerExtent, viewState.projection),
+ coordinate
+ )
+ ) {
+ return null;
+ }
+ }
+
+ const source = layer.getRenderSource();
+ const tileGrid = source.getTileGridForProjection(viewState.projection);
+ if (!source.getWrapX()) {
+ const gridExtent = tileGrid.getExtent();
+ if (gridExtent) {
+ if (!containsCoordinate(gridExtent, coordinate)) {
+ return null;
+ }
+ }
+ }
+
+ const tileTextureCache = this.tileTextureCache_;
+ for (
+ let z = tileGrid.getZForResolution(viewState.resolution);
+ z >= tileGrid.getMinZoom();
+ --z
+ ) {
+ const tileCoord = tileGrid.getTileCoordForCoordAndZ(coordinate, z);
+ const cacheKey = getCacheKey(source, tileCoord);
+ if (!tileTextureCache.containsKey(cacheKey)) {
+ continue;
+ }
+ const tileTexture = tileTextureCache.get(cacheKey);
+ if (!tileTexture.loaded) {
+ continue;
+ }
+ const tileOrigin = tileGrid.getOrigin(z);
+ const tileSize = toSize(tileGrid.getTileSize(z));
+ const tileResolution = tileGrid.getResolution(z);
+
+ const col =
+ (coordinate[0] - tileOrigin[0]) / tileResolution -
+ tileCoord[1] * tileSize[0];
+
+ const row =
+ (tileOrigin[1] - coordinate[1]) / tileResolution -
+ tileCoord[2] * tileSize[1];
+
+ return tileTexture.getPixelData(col, row);
+ }
+ return null;
+ }
+
/**
* Look for tiles covering the provided tile coordinate at an alternate
* zoom level. Loaded tiles will be added to the provided tile texture lookup.
@@ -719,6 +804,7 @@ class WebGLTileLayerRenderer extends WebGLLayerRenderer {
delete this.indices_;
delete this.tileTextureCache_;
+ delete this.frameState_;
}
}
diff --git a/src/ol/source/DataTile.js b/src/ol/source/DataTile.js
index 6d23d3576e..3836457d7a 100644
--- a/src/ol/source/DataTile.js
+++ b/src/ol/source/DataTile.js
@@ -108,7 +108,6 @@ class DataTileSource extends TileSource {
}
/**
- * @abstract
* @param {number} z Tile coordinate z.
* @param {number} x Tile coordinate x.
* @param {number} y Tile coordinate y.
diff --git a/src/ol/webgl/TileTexture.js b/src/ol/webgl/TileTexture.js
index e76c81abdb..50a9053ed4 100644
--- a/src/ol/webgl/TileTexture.js
+++ b/src/ol/webgl/TileTexture.js
@@ -2,6 +2,7 @@
* @module ol/webgl/TileTexture
*/
+import DataTile from '../DataTile.js';
import EventTarget from '../events/Target.js';
import EventType from '../events/EventType.js';
import ImageTile from '../ImageTile.js';
@@ -117,19 +118,36 @@ function uploadDataTexture(
gl.pixelStorei(gl.UNPACK_ALIGNMENT, oldUnpackAlignment);
}
+/**
+ * @type {CanvasRenderingContext2D}
+ */
+let pixelContext = null;
+
+function createPixelContext() {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ pixelContext = canvas.getContext('2d');
+}
+
/**
* @typedef {import("../DataTile.js").default|ImageTile|ReprojTile} TileType
*/
+/**
+ * @typedef {Object} Options
+ * @property {TileType} tile The tile.
+ * @property {import("../tilegrid/TileGrid.js").default} grid Tile grid.
+ * @property {import("../webgl/Helper.js").default} helper WebGL helper.
+ * @property {number} [tilePixelRatio=1] Tile pixel ratio.
+ * @property {number} [gutter=0] The size in pixels of the gutter around image tiles to ignore.
+ */
+
class TileTexture extends EventTarget {
/**
- * @param {TileType} tile The tile.
- * @param {import("../tilegrid/TileGrid.js").default} grid Tile grid.
- * @param {import("../webgl/Helper.js").default} helper WebGL helper.
- * @param {number} [opt_tilePixelRatio=1] Tile pixel ratio.
- * @param {number} [opt_gutter=0] The size in pixels of the gutter around image tiles to ignore.
+ * @param {Options} options The tile texture options.
*/
- constructor(tile, grid, helper, opt_tilePixelRatio, opt_gutter) {
+ constructor(options) {
super();
/**
@@ -143,16 +161,33 @@ class TileTexture extends EventTarget {
this.textures = [];
this.handleTileChange_ = this.handleTileChange_.bind(this);
- this.size = toSize(grid.getTileSize(tile.tileCoord[0]));
+ /**
+ * @type {import("../size.js").Size}
+ */
+ this.size = toSize(options.grid.getTileSize(options.tile.tileCoord[0]));
- this.tilePixelRatio_ =
- opt_tilePixelRatio !== undefined ? opt_tilePixelRatio : 1;
+ /**
+ * @type {number}
+ * @private
+ */
+ this.tilePixelRatio_ = options.tilePixelRatio || 1;
- this.gutter_ = opt_gutter !== undefined ? opt_gutter : 0;
+ /**
+ * @type {number}
+ * @private
+ */
+ this.gutter_ = options.gutter || 0;
+ /**
+ * @type {number}
+ */
this.bandCount = NaN;
- this.helper_ = helper;
+ /**
+ * @type {import("../webgl/Helper.js").default}
+ * @private
+ */
+ this.helper_ = options.helper;
const coords = new WebGLArrayBuffer(ARRAY_BUFFER, STATIC_DRAW);
coords.fromArray([
@@ -165,10 +200,14 @@ class TileTexture extends EventTarget {
0, // P3
0,
]);
- helper.flushBufferData(coords);
+ this.helper_.flushBufferData(coords);
+ /**
+ * @type {WebGLArrayBuffer}
+ */
this.coords = coords;
- this.setTile(tile);
+
+ this.setTile(options.tile);
}
/**
@@ -320,6 +359,50 @@ class TileTexture extends EventTarget {
}
this.tile.removeEventListener(EventType.CHANGE, this.handleTileChange_);
}
+
+ /**
+ * Get data for a pixel. If the tile is not loaded, null is returned.
+ * @param {number} col The column index.
+ * @param {number} row The row index.
+ * @return {import("../DataTile.js").Data|null} The data.
+ */
+ getPixelData(col, row) {
+ if (!this.loaded) {
+ return null;
+ }
+
+ col = Math.floor(this.tilePixelRatio_ * col);
+ row = Math.floor(this.tilePixelRatio_ * row);
+
+ if (this.tile instanceof DataTile) {
+ const data = this.tile.getData();
+ const pixelsPerRow = Math.floor(this.tilePixelRatio_ * this.size[0]);
+ if (data instanceof DataView) {
+ const bytesPerPixel = data.byteLength / (this.size[0] * this.size[1]);
+ const offset = row * pixelsPerRow * bytesPerPixel + col * bytesPerPixel;
+ const buffer = data.buffer.slice(offset, offset + bytesPerPixel);
+ return new DataView(buffer);
+ }
+
+ const offset = row * pixelsPerRow * this.bandCount + col * this.bandCount;
+ return data.slice(offset, offset + this.bandCount);
+ }
+
+ if (!pixelContext) {
+ createPixelContext();
+ }
+ pixelContext.clearRect(0, 0, 1, 1);
+
+ let data;
+ const image = this.tile.getImage();
+ try {
+ pixelContext.drawImage(image, col, row, 1, 1, 0, 0, 1, 1);
+ data = pixelContext.getImageData(0, 0, 1, 1).data;
+ } catch (err) {
+ return null;
+ }
+ return data;
+ }
}
export default TileTexture;
diff --git a/test/browser/spec/ol/layer/Image.test.js b/test/browser/spec/ol/layer/Image.test.js
new file mode 100644
index 0000000000..14787dde4c
--- /dev/null
+++ b/test/browser/spec/ol/layer/Image.test.js
@@ -0,0 +1,75 @@
+import ImageLayer from '../../../../../src/ol/layer/Image.js';
+import Map from '../../../../../src/ol/Map.js';
+import Static from '../../../../../src/ol/source/ImageStatic.js';
+import View from '../../../../../src/ol/View.js';
+import {Projection} from '../../../../../src/ol/proj.js';
+
+describe('ol/layer/Image', () => {
+ describe('getData()', () => {
+ let map, target, layer;
+
+ beforeEach((done) => {
+ const projection = new Projection({
+ code: 'custom-image',
+ units: 'pixels',
+ extent: [0, 0, 200, 200],
+ });
+
+ target = document.createElement('div');
+ target.style.width = '100px';
+ target.style.height = '100px';
+ document.body.appendChild(target);
+
+ const imageExtent = [0, 0, 20, 20];
+ const source = new Static({
+ url: 'spec/ol/data/dot.png',
+ projection: projection,
+ imageExtent: imageExtent,
+ });
+
+ layer = new ImageLayer({
+ source: source,
+ extent: imageExtent,
+ });
+
+ map = new Map({
+ pixelRatio: 1,
+ target: target,
+ layers: [layer],
+ view: new View({
+ projection: projection,
+ center: [10, 10],
+ zoom: 1,
+ maxZoom: 8,
+ }),
+ });
+ map.once('rendercomplete', () => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ map.setTarget(null);
+ document.body.removeChild(target);
+ });
+
+ it('should not detect pixels outside of the layer extent', () => {
+ map.renderSync();
+ const pixel = [10, 10];
+ const data = layer.getData(pixel);
+ expect(data).to.be(null);
+ });
+
+ it('should detect pixels in the layer extent', () => {
+ map.renderSync();
+ const pixel = [50, 50];
+ const data = layer.getData(pixel);
+ expect(data).to.be.a(Uint8ClampedArray);
+ expect(data.length).to.be(4);
+ expect(data[0]).to.be(255);
+ expect(data[1]).to.be(255);
+ expect(data[2]).to.be(255);
+ expect(data[3]).to.be(255);
+ });
+ });
+});
diff --git a/test/browser/spec/ol/layer/tile.test.js b/test/browser/spec/ol/layer/Tile.test.js
similarity index 70%
rename from test/browser/spec/ol/layer/tile.test.js
rename to test/browser/spec/ol/layer/Tile.test.js
index bfe36b8683..21fd6eb490 100644
--- a/test/browser/spec/ol/layer/tile.test.js
+++ b/test/browser/spec/ol/layer/Tile.test.js
@@ -2,7 +2,7 @@ import TileLayer from '../../../../../src/ol/layer/Tile.js';
import {Map, View} from '../../../../../src/ol/index.js';
import {OSM, XYZ} from '../../../../../src/ol/source.js';
-describe('ol.layer.Tile', function () {
+describe('ol/layer/Tile', function () {
describe('constructor (defaults)', function () {
let layer;
@@ -29,6 +29,48 @@ describe('ol.layer.Tile', function () {
});
});
+ describe('getData()', () => {
+ let map, target, layer;
+ beforeEach((done) => {
+ target = document.createElement('div');
+ target.style.width = '100px';
+ target.style.height = '100px';
+ document.body.appendChild(target);
+
+ layer = new TileLayer({
+ source: new XYZ({
+ url: 'spec/ol/data/osm-0-0-0.png',
+ }),
+ });
+
+ map = new Map({
+ target: target,
+ layers: [layer],
+ view: new View({
+ center: [0, 0],
+ zoom: 0,
+ }),
+ });
+
+ map.once('rendercomplete', () => done());
+ });
+
+ afterEach(() => {
+ map.setTarget(null);
+ document.body.removeChild(target);
+ });
+
+ it('gets pixel data', () => {
+ const data = layer.getData([50, 50]);
+ expect(data).to.be.a(Uint8ClampedArray);
+ expect(data.length).to.be(4);
+ expect(data[0]).to.be(181);
+ expect(data[1]).to.be(208);
+ expect(data[2]).to.be(208);
+ expect(data[3]).to.be(255);
+ });
+ });
+
describe('frameState.animate after tile transition with layer opacity', function () {
let target, map;
diff --git a/test/browser/spec/ol/layer/WebGLTile.test.js b/test/browser/spec/ol/layer/WebGLTile.test.js
index de48290424..303f92691a 100644
--- a/test/browser/spec/ol/layer/WebGLTile.test.js
+++ b/test/browser/spec/ol/layer/WebGLTile.test.js
@@ -1,4 +1,5 @@
import DataTileSource from '../../../../../src/ol/source/DataTile.js';
+import GeoTIFF from '../../../../../src/ol/source/GeoTIFF.js';
import Map from '../../../../../src/ol/Map.js';
import View from '../../../../../src/ol/View.js';
import WebGLHelper from '../../../../../src/ol/webgl/Helper.js';
@@ -53,6 +54,80 @@ describe('ol/layer/WebGLTile', function () {
map.getLayers().forEach((layer) => layer.dispose());
});
+ describe('getData()', () => {
+ /** @type {Map} */
+ let map;
+ let target;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ target.style.width = '100px';
+ target.style.height = '100px';
+ document.body.appendChild(target);
+ map = new Map({
+ target: target,
+ });
+ });
+
+ afterEach(() => {
+ map.setTarget(null);
+ document.body.removeChild(target);
+ });
+
+ it('retrieves pixel data', (done) => {
+ const source = new GeoTIFF({
+ sources: [{url: 'spec/ol/source/images/0-0-0.tif'}],
+ });
+
+ const layer = new WebGLTileLayer({source: source});
+
+ map.addLayer(layer);
+ map.setView(source.getView());
+
+ map.once('rendercomplete', () => {
+ const data = layer.getData([50, 25]);
+ expect(data).to.be.a(Uint8Array);
+ expect(data.length).to.be(4);
+ expect(data[0]).to.be(255);
+ expect(data[1]).to.be(189);
+ expect(data[2]).to.be(103);
+ expect(data[3]).to.be(255);
+ done();
+ });
+ });
+
+ it('preserves the original data type', (done) => {
+ const layer = new WebGLTileLayer({
+ source: new DataTileSource({
+ tilePixelRatio: 1 / 256,
+ loader(z, x, y) {
+ return new Float32Array([1.11, 2.22, 3.33, 4.44, 5.55]);
+ },
+ }),
+ });
+
+ map.addLayer(layer);
+ map.setView(
+ new View({
+ center: [0, 0],
+ zoom: 0,
+ })
+ );
+
+ map.once('rendercomplete', () => {
+ const data = layer.getData([50, 25]);
+ expect(data).to.be.a(Float32Array);
+ expect(data.length).to.be(5);
+ expect(data[0]).to.roughlyEqual(1.11, 1e-5);
+ expect(data[1]).to.roughlyEqual(2.22, 1e-5);
+ expect(data[2]).to.roughlyEqual(3.33, 1e-5);
+ expect(data[3]).to.roughlyEqual(4.44, 1e-5);
+ expect(data[4]).to.roughlyEqual(5.55, 1e-5);
+ done();
+ });
+ });
+ });
+
describe('dispose()', () => {
it('calls dispose on the renderer', () => {
const renderer = layer.getRenderer();
diff --git a/test/browser/spec/ol/renderer/canvas/imagelayer.test.js b/test/browser/spec/ol/renderer/canvas/ImageLayer.test.js
similarity index 99%
rename from test/browser/spec/ol/renderer/canvas/imagelayer.test.js
rename to test/browser/spec/ol/renderer/canvas/ImageLayer.test.js
index e4faebac41..9cf68d423f 100644
--- a/test/browser/spec/ol/renderer/canvas/imagelayer.test.js
+++ b/test/browser/spec/ol/renderer/canvas/ImageLayer.test.js
@@ -10,7 +10,7 @@ import VectorSource from '../../../../../../src/ol/source/Vector.js';
import View from '../../../../../../src/ol/View.js';
import {get as getProj} from '../../../../../../src/ol/proj.js';
-describe('ol.renderer.canvas.ImageLayer', function () {
+describe('ol/renderer/canvas/ImageLayer', function () {
describe('#forEachLayerAtCoordinate', function () {
let map, target, source;
beforeEach(function (done) {
diff --git a/test/browser/spec/ol/webgl/TileTexture.test.js b/test/browser/spec/ol/webgl/TileTexture.test.js
index 4b67f4ee96..ee8ac39e4d 100644
--- a/test/browser/spec/ol/webgl/TileTexture.test.js
+++ b/test/browser/spec/ol/webgl/TileTexture.test.js
@@ -42,11 +42,11 @@ describe('ol/webgl/TileTexture', function () {
mapId: 'map-1',
});
- tileTexture = new TileTexture(
- layer.getSource().getTile(3, 2, 1),
- layer.getSource().getTileGrid(),
- renderer.helper
- );
+ tileTexture = new TileTexture({
+ tile: layer.getSource().getTile(3, 2, 1),
+ grid: layer.getSource().getTileGrid(),
+ helper: renderer.helper,
+ });
});
afterEach(() => {