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(() => {