diff --git a/config/jsdoc/api/index.md b/config/jsdoc/api/index.md index a8087bbda3..22e7043176 100644 --- a/config/jsdoc/api/index.md +++ b/config/jsdoc/api/index.md @@ -27,7 +27,8 @@ ol/layer/Tile
ol/layer/Image
ol/layer/Vector
- ol/layer/VectorTile + ol/layer/VectorTile
+ ol/layer/WebGLTile @@ -58,7 +59,7 @@

Sources and formats

- Tile sources for ol/layer/Tile + Tile sources for ol/layer/Tile or ol/layer/WebGLTile
Image sources for ol/layer/Image
Vector sources for ol/layer/Vector
Vector tile sources for ol/layer/VectorTile @@ -71,7 +72,7 @@

Projections

-

All coordinates and extents need to be provided in view projection (default: EPSG:3857). To transform, use ol/proj#transform() and ol/proj#transformExtent().

+

All coordinates and extents need to be provided in view projection (default: EPSG:3857). To transform coordinates from and to geographic, use ol/proj#fromLonLat() and ol/proj#toLonLat(). For extents and other projections, use ol/proj#transformExtent() and ol/proj#transform().

ol/proj

diff --git a/config/webpack-config-legacy-build.mjs b/config/webpack-config-legacy-build.mjs index 89786cf0a0..ffc2aef1a8 100644 --- a/config/webpack-config-legacy-build.mjs +++ b/config/webpack-config-legacy-build.mjs @@ -32,6 +32,11 @@ export default { ], }, resolve: { + fallback: { + fs: false, + http: false, + https: false, + }, alias: { ol: path.resolve('./build/ol'), }, diff --git a/examples/cog-math-multisource.html b/examples/cog-math-multisource.html new file mode 100644 index 0000000000..b5889c5bfe --- /dev/null +++ b/examples/cog-math-multisource.html @@ -0,0 +1,13 @@ +--- +layout: example.html +title: NDVI+NDWI from two 16-bit COGs +shortdesc: Calculating NDVI+NDWI as green and blue values. +docs: > + The GeoTIFF layer in this example calculates the Normalized Difference Vegetation Index (NDVI) + and Normalized Difference Water Index (NDWI) from two cloud-optimized Sentinel 2 GeoTIFFs: one + with 10 m resolution and red and a near infrared bands, and one with 60 m resolution and a short + wave infrared channel. The NDVI is shown as green, the NDWI as blue. The 4th band is the alpha + band, which gets added when a source has a `nodata` value configured. +tags: "cog, ndvi, ndwi, sentinel, geotiff" +--- +
diff --git a/examples/cog-math-multisource.js b/examples/cog-math-multisource.js new file mode 100644 index 0000000000..1f1d6f3583 --- /dev/null +++ b/examples/cog-math-multisource.js @@ -0,0 +1,65 @@ +import GeoTIFF from '../src/ol/source/GeoTIFF.js'; +import Map from '../src/ol/Map.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; + +const source = new GeoTIFF({ + sources: [ + { + url: 'https://s2downloads.eox.at/demo/Sentinel-2/3857/R10m.tif', + bands: [2, 3], + min: 0, + nodata: 0, + max: 65535, + }, + { + url: 'https://s2downloads.eox.at/demo/Sentinel-2/3857/R60m.tif', + bands: [8], + min: 0, + nodata: 0, + max: 65535, + }, + ], +}); +source.setAttributions( + "Sentinel-2 cloudless by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2019)" +); + +const ndvi = [ + '/', + ['-', ['band', 2], ['band', 1]], + ['+', ['band', 2], ['band', 1]], +]; + +const ndwi = [ + '/', + ['-', ['band', 3], ['band', 1]], + ['+', ['band', 3], ['band', 1]], +]; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + style: { + color: [ + 'color', + // red: | NDVI - NDWI | + ['*', 255, ['abs', ['-', ndvi, ndwi]]], + // green: NDVI + ['*', 255, ndvi], + // blue: NDWI + ['*', 255, ndwi], + // alpha + ['band', 4], + ], + }, + source, + }), + ], + view: new View({ + center: [1900000, 6100000], + zoom: 13, + minZoom: 10, + }), +}); diff --git a/examples/cog-math.html b/examples/cog-math.html new file mode 100644 index 0000000000..318f626e11 --- /dev/null +++ b/examples/cog-math.html @@ -0,0 +1,11 @@ +--- +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 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. +tags: "cog, ndvi" +--- +
diff --git a/examples/cog-math.js b/examples/cog-math.js new file mode 100644 index 0000000000..ca9dcdfc9f --- /dev/null +++ b/examples/cog-math.js @@ -0,0 +1,101 @@ +import GeoTIFF from '../src/ol/source/GeoTIFF.js'; +import Map from '../src/ol/Map.js'; +import Projection from '../src/ol/proj/Projection.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; +import proj4 from 'proj4'; +import {getCenter} from '../src/ol/extent.js'; +import {register} from '../src/ol/proj/proj4.js'; + +proj4.defs('EPSG:32636', '+proj=utm +zone=36 +datum=WGS84 +units=m +no_defs'); +register(proj4); + +const projection = new Projection({ + code: 'EPSG:32636', + extent: [166021.44, 0.0, 534994.66, 9329005.18], +}); + +// metadata from https://s3.us-west-2.amazonaws.com/sentinel-cogs/sentinel-s2-l2a-cogs/2020/S2A_36QWD_20200701_0_L2A/S2A_36QWD_20200701_0_L2A.json +const sourceExtent = [499980, 1790220, 609780, 1900020]; + +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: new GeoTIFF({ + sources: [ + { + // visible red, band 1 in the style expression above + url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/2020/S2A_36QWD_20200701_0_L2A/B04.tif', + max: 10000, + }, + { + // near infrared, band 2 in the style expression above + url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/2020/S2A_36QWD_20200701_0_L2A/B08.tif', + max: 10000, + }, + ], + }), + extent: sourceExtent, + }), + ], + view: new View({ + center: getCenter(sourceExtent), + extent: sourceExtent, + zoom: 9, + projection: projection, + }), +}); diff --git a/examples/cog-overviews.html b/examples/cog-overviews.html new file mode 100644 index 0000000000..757d03225d --- /dev/null +++ b/examples/cog-overviews.html @@ -0,0 +1,12 @@ +--- +layout: example.html +title: GeoTIFF with Overviews +shortdesc: Rendering a GeoTIFF with external overviews as a layer. +docs: > + In some cases, a GeoTIFF may have external overviews. This example uses the + `overviews` property to provide URLs for the external overviews. The example + composes a false color composite using shortwave infrared (B6), near infrared (B5), + and visible green (B3) bands from a Landsat 8 image. +tags: "cog" +--- +
diff --git a/examples/cog-overviews.js b/examples/cog-overviews.js new file mode 100644 index 0000000000..d0e46697dc --- /dev/null +++ b/examples/cog-overviews.js @@ -0,0 +1,68 @@ +import GeoTIFF from '../src/ol/source/GeoTIFF.js'; +import Map from '../src/ol/Map.js'; +import Projection from '../src/ol/proj/Projection.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; +import proj4 from 'proj4'; +import {getCenter} from '../src/ol/extent.js'; +import {register} from '../src/ol/proj/proj4.js'; + +proj4.defs('EPSG:32645', '+proj=utm +zone=45 +datum=WGS84 +units=m +no_defs'); +register(proj4); + +const projection = new Projection({ + code: 'EPSG:32645', + extent: [166021.44, 0.0, 534994.66, 9329005.18], +}); + +const sourceExtent = [382200, 2279370, 610530, 2512500]; + +const base = + 'https://landsat-pds.s3.amazonaws.com/c1/L8/139/045/LC08_L1TP_139045_20170304_20170316_01_T1/LC08_L1TP_139045_20170304_20170316_01_T1'; + +// scale values in this range to 0 - 1 +const min = 10000; +const max = 15000; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + extent: sourceExtent, + style: { + saturation: -0.3, + }, + source: new GeoTIFF({ + sources: [ + { + url: `${base}_B6.TIF`, + overviews: [`${base}_B6.TIF.ovr`], + min: min, + max: max, + nodata: 0, + }, + { + url: `${base}_B5.TIF`, + overviews: [`${base}_B5.TIF.ovr`], + min: min, + max: max, + nodata: 0, + }, + { + url: `${base}_B3.TIF`, + overviews: [`${base}_B3.TIF.ovr`], + min: min, + max: max, + nodata: 0, + }, + ], + }), + }), + ], + view: new View({ + center: getCenter(sourceExtent), + extent: sourceExtent, + zoom: 8, + projection: projection, + }), +}); diff --git a/examples/cog-pyramid.html b/examples/cog-pyramid.html new file mode 100644 index 0000000000..3e32e650ad --- /dev/null +++ b/examples/cog-pyramid.html @@ -0,0 +1,12 @@ +--- +layout: example.html +title: GeoTIFF tile pyramid +shortdesc: Rendering a COG tile pyramid as layer group. +docs: > + Data from a Cloud Optimized GeoTIFF (COG) tile pyramid can be rendered as a set of layers. In this + example, a pyramid of 3-band GeoTIFFs is used to render RGB data. For each tile of the pyramid, a + separate layer is created on demand. The lowest resolution layer serves as preview while higher resolutions are + loading. +tags: "cog, tilepyramid, stac" +--- +
diff --git a/examples/cog-pyramid.js b/examples/cog-pyramid.js new file mode 100644 index 0000000000..ed63732a49 --- /dev/null +++ b/examples/cog-pyramid.js @@ -0,0 +1,71 @@ +import GeoTIFF from '../src/ol/source/GeoTIFF.js'; +import LayerGroup from '../src/ol/layer/Group.js'; +import Map from '../src/ol/Map.js'; +import TileGrid from '../src/ol/tilegrid/TileGrid.js'; +import View from '../src/ol/View.js'; +import WebGLTileLayer from '../src/ol/layer/WebGLTile.js'; +import {getIntersection} from '../src/ol/extent.js'; + +// Metadata from https://s2downloads.eox.at/demo/EOxCloudless/2019/rgb/2019_EOxCloudless_rgb.json + +// Tile grid of the GeoTIFF pyramid layout +const tileGrid = new TileGrid({ + origin: [-180, 90], + resolutions: [0.703125, 0.3515625, 0.17578125, 8.7890625e-2, 4.39453125e-2], + tileSizes: [ + [512, 256], + [1024, 512], + [2048, 1024], + [4096, 2048], + [4096, 4096], + ], +}); + +const pyramid = new LayerGroup(); +const layerForUrl = {}; +const zs = tileGrid.getResolutions().length; + +function useLayer(z, x, y) { + const url = `https://s2downloads.eox.at/demo/EOxCloudless/2019/rgb/${z}/${y}/${x}.tif`; + if (!(url in layerForUrl)) { + pyramid.getLayers().push( + new WebGLTileLayer({ + minZoom: z, + maxZoom: z === 0 || z === zs - 1 ? undefined : z + 1, + extent: tileGrid.getTileCoordExtent([z, x, y]), + source: new GeoTIFF({ + sources: [ + { + url: url, + }, + ], + }), + }) + ); + layerForUrl[url] = true; + } +} + +const map = new Map({ + target: 'map', + layers: [pyramid], + view: new View({ + projection: 'EPSG:4326', + center: [0, 0], + zoom: 0, + showFullExtent: true, + }), +}); + +// Add overview layer +useLayer(0, 0, 0); + +// Add layer for specific extent on demand +map.on('moveend', () => { + const view = map.getView(); + tileGrid.forEachTileCoord( + getIntersection([-180, -90, 180, 90], view.calculateExtent()), + tileGrid.getZForResolution(view.getResolution()), + ([z, x, y]) => useLayer(z, x, y) + ); +}); diff --git a/examples/cog.html b/examples/cog.html new file mode 100644 index 0000000000..708b47bdde --- /dev/null +++ b/examples/cog.html @@ -0,0 +1,10 @@ +--- +layout: example.html +title: Cloud Optimized GeoTIFF (COG) +shortdesc: Rendering a COG as a tiled layer. +docs: > + Tiled data from a Cloud Optimized GeoTIFF (COG) can be rendered as a layer. In this + example, a single 3-band GeoTIFF is used to render RGB data. +tags: "cog" +--- +
diff --git a/examples/cog.js b/examples/cog.js new file mode 100644 index 0000000000..9dc5bc4afb --- /dev/null +++ b/examples/cog.js @@ -0,0 +1,41 @@ +import GeoTIFF from '../src/ol/source/GeoTIFF.js'; +import Map from '../src/ol/Map.js'; +import Projection from '../src/ol/proj/Projection.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; +import proj4 from 'proj4'; +import {getCenter} from '../src/ol/extent.js'; +import {register} from '../src/ol/proj/proj4.js'; + +proj4.defs('EPSG:32636', '+proj=utm +zone=36 +datum=WGS84 +units=m +no_defs'); +register(proj4); + +const projection = new Projection({ + code: 'EPSG:32636', + extent: [166021.44, 0.0, 534994.66, 9329005.18], +}); + +// metadata from https://s3.us-west-2.amazonaws.com/sentinel-cogs/sentinel-s2-l2a-cogs/2020/S2A_36QWD_20200701_0_L2A/S2A_36QWD_20200701_0_L2A.json +const sourceExtent = [499980, 1790220, 609780, 1900020]; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new GeoTIFF({ + sources: [ + { + url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/2020/S2A_36QWD_20200701_0_L2A/TCI.tif', + }, + ], + }), + extent: sourceExtent, + }), + ], + view: new View({ + center: getCenter(sourceExtent), + extent: sourceExtent, + zoom: 9, + projection: projection, + }), +}); diff --git a/examples/data-tiles.html b/examples/data-tiles.html new file mode 100644 index 0000000000..99ed41bebc --- /dev/null +++ b/examples/data-tiles.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: Data Tiles +shortdesc: Generating tile data from scratch. +docs: > + This example generates RGBA tile data from scratch. +tags: "data tiles" +--- +
diff --git a/examples/data-tiles.js b/examples/data-tiles.js new file mode 100644 index 0000000000..6148d81d0a --- /dev/null +++ b/examples/data-tiles.js @@ -0,0 +1,45 @@ +import DataTile from '../src/ol/source/DataTile.js'; +import Map from '../src/ol/Map.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; + +const size = 256; + +const canvas = document.createElement('canvas'); +canvas.width = size; +canvas.height = size; + +const context = canvas.getContext('2d'); +context.strokeStyle = 'white'; +context.textAlign = 'center'; +context.font = '24px sans-serif'; +const lineHeight = 30; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new DataTile({ + loader: function (z, x, y) { + const half = size / 2; + context.clearRect(0, 0, size, size); + context.fillStyle = 'rgba(100, 100, 100, 0.5)'; + context.fillRect(0, 0, size, size); + context.fillStyle = 'black'; + context.fillText(`z: ${z}`, half, half - lineHeight); + context.fillText(`x: ${x}`, half, half); + context.fillText(`y: ${y}`, half, half + lineHeight); + context.strokeRect(0, 0, size, size); + const data = context.getImageData(0, 0, size, size).data; + return Promise.resolve(data); + }, + // disable opacity transition to avoid overlapping labels during tile loading + transition: 0, + }), + }), + ], + view: new View({ + center: [0, 0], + zoom: 0, + }), +}); diff --git a/examples/webgl-sea-level.css b/examples/webgl-sea-level.css new file mode 100644 index 0000000000..56b832a021 --- /dev/null +++ b/examples/webgl-sea-level.css @@ -0,0 +1,13 @@ +#level { + display: inline-block; + width: 150px; + vertical-align: text-bottom; +} + +a.location { + cursor: pointer; +} + +#map { + background: #8bd4ff; +} diff --git a/examples/webgl-sea-level.html b/examples/webgl-sea-level.html new file mode 100644 index 0000000000..71dcc94131 --- /dev/null +++ b/examples/webgl-sea-level.html @@ -0,0 +1,37 @@ +--- +layout: example.html +title: Sea Level (with WebGL) +shortdesc: Render sea level at different elevations +docs: > +

+ The style property of a WebGL tile layer accepts a color expression that + can be used to modify pixel values before rendering. Here, RGB tiles representing elevation + data are loaded and rendered so that values at or below sea level are blue, and values + above sea level are transparent. The color expression operates on normalized pixel + values ranging from 0 to 1. The band operator is used to select normalized values + from a single band. +

+ After converting the normalized RGB values to elevation, the interpolate expression + is used to pick colors to apply at a given elevation. Instead of using constant + numeric values as the stops in the colors array, the var operator allows you to + use a value that can be modified by your application. When the user drags the + sea level slider, the layer.updateStyleVariables() function is called to update + the level style variable with the value from the slider. +

+tags: "webgl, math, flood" +cloak: + - key: get_your_own_D6rA4zTHduk6KOKTXzGB + value: Get your own API key at https://www.maptiler.com/cloud/ +--- +
+ +
+Go to +San Francisco, +New York, +Mumbai, or +Shanghai diff --git a/examples/webgl-sea-level.js b/examples/webgl-sea-level.js new file mode 100644 index 0000000000..7490d17b5f --- /dev/null +++ b/examples/webgl-sea-level.js @@ -0,0 +1,90 @@ +import Map from '../src/ol/Map.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; +import XYZ from '../src/ol/source/XYZ.js'; +import {fromLonLat} from '../src/ol/proj.js'; + +const key = 'get_your_own_D6rA4zTHduk6KOKTXzGB'; +const attributions = + '© MapTiler ' + + '© OpenStreetMap contributors'; + +const elevation = new TileLayer({ + opacity: 0.6, + source: new XYZ({ + url: + 'https://api.maptiler.com/tiles/terrain-rgb/{z}/{x}/{y}.png?key=' + key, + maxZoom: 10, + tileSize: 512, + crossOrigin: 'anonymous', + }), + style: { + variables: { + level: 0, + }, + color: [ + 'interpolate', + ['linear'], + // band math operates on normalized values from 0-1 + // so we scale by 255 to align with the elevation formula + // from https://cloud.maptiler.com/tiles/terrain-rgb/ + [ + '+', + -10000, + [ + '*', + 0.1 * 255, + [ + '+', + ['*', 256 * 256, ['band', 1]], + ['+', ['*', 256, ['band', 2]], ['band', 3]], + ], + ], + ], + // use the `level` style variable as a stop in the color ramp + ['var', 'level'], + [139, 212, 255, 1], + ['+', 0.01, ['var', 'level']], + [139, 212, 255, 0], + ], + }, +}); + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new XYZ({ + url: 'https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=' + key, + attributions: attributions, + crossOrigin: 'anonymous', + tileSize: 512, + }), + }), + elevation, + ], + view: new View({ + center: fromLonLat([-122.3267, 37.8377]), + zoom: 11, + }), +}); + +const control = document.getElementById('level'); +const output = document.getElementById('output'); +control.addEventListener('input', function () { + output.innerText = control.value; + elevation.updateStyleVariables({level: parseFloat(control.value)}); +}); +output.innerText = control.value; + +const locations = document.getElementsByClassName('location'); +for (let i = 0, ii = locations.length; i < ii; ++i) { + locations[i].addEventListener('click', relocate); +} + +function relocate(event) { + const data = event.target.dataset; + const view = map.getView(); + view.setCenter(fromLonLat(data.center.split(',').map(Number))); + view.setZoom(Number(data.zoom)); +} diff --git a/examples/webgl-shaded-relief.css b/examples/webgl-shaded-relief.css new file mode 100644 index 0000000000..71748fd2dc --- /dev/null +++ b/examples/webgl-shaded-relief.css @@ -0,0 +1,7 @@ +table.controls td { + padding: 2px 5px; +} +table.controls td:nth-child(3) { + text-align: right; + min-width: 3em; +} diff --git a/examples/webgl-shaded-relief.html b/examples/webgl-shaded-relief.html new file mode 100644 index 0000000000..469918cf52 --- /dev/null +++ b/examples/webgl-shaded-relief.html @@ -0,0 +1,32 @@ +--- +layout: example.html +title: Shaded Relief (with WebGL) +shortdesc: Calculate shaded relief from elevation data +docs: > +

+ For the shaded relief, a single tiled source of elevation data is used as input. + The shaded relief is calculated by the layer's style with a color + expression. The style variables are updated when the user drags one of the sliders. The + band operator is used to sample data from neighboring pixels for calculating slope and + aspect, which is done with the ['band', bandIndex, xOffset, yOffset] syntax. +

+tags: "webgl, shaded relief" +--- +
+ + + + + + + + + + + + + + + + +
x
°
°
diff --git a/examples/webgl-shaded-relief.js b/examples/webgl-shaded-relief.js new file mode 100644 index 0000000000..44cb70c8f0 --- /dev/null +++ b/examples/webgl-shaded-relief.js @@ -0,0 +1,91 @@ +import Map from '../src/ol/Map.js'; +import View from '../src/ol/View.js'; +import {OSM, XYZ} from '../src/ol/source.js'; +import {WebGLTile as TileLayer} from '../src/ol/layer.js'; + +const variables = {}; + +// The method used to extract elevations from the DEM. +// In this case the format used is +// red + green * 2 + blue * 3 +// +// Other frequently used methods include the Mapbox format +// (red * 256 * 256 + green * 256 + blue) * 0.1 - 10000 +// and the Terrarium format +// (red * 256 + green + blue / 256) - 32768 +function elevation(xOffset, yOffset) { + return [ + '+', + ['*', 256, ['band', 1, xOffset, yOffset]], + [ + '+', + ['*', 2 * 256, ['band', 2, xOffset, yOffset]], + ['*', 3 * 256, ['band', 3, xOffset, yOffset]], + ], + ]; +} + +// Generates a shaded relief image given elevation data. Uses a 3x3 +// neighborhood for determining slope and aspect. +const dp = ['*', 2, ['resolution']]; +const z0x = ['*', ['var', 'vert'], elevation(-1, 0)]; +const z1x = ['*', ['var', 'vert'], elevation(1, 0)]; +const dzdx = ['/', ['-', z1x, z0x], dp]; +const z0y = ['*', ['var', 'vert'], elevation(0, -1)]; +const z1y = ['*', ['var', 'vert'], elevation(0, 1)]; +const dzdy = ['/', ['-', z1y, z0y], dp]; +const slope = ['atan', ['^', ['+', ['^', dzdx, 2], ['^', dzdy, 2]], 0.5]]; +const aspect = ['clamp', ['atan', ['-', 0, dzdx], dzdy], -Math.PI, Math.PI]; +const sunEl = ['*', Math.PI / 180, ['var', 'sunEl']]; +const sunAz = ['*', Math.PI / 180, ['var', 'sunAz']]; + +const cosIncidence = [ + '+', + ['*', ['sin', sunEl], ['cos', slope]], + ['*', ['*', ['cos', sunEl], ['sin', slope]], ['cos', ['-', sunAz, aspect]]], +]; +const scaled = ['*', 255, cosIncidence]; + +const shadedRelief = new TileLayer({ + opacity: 0.3, + source: new XYZ({ + url: 'https://{a-d}.tiles.mapbox.com/v3/aj.sf-dem/{z}/{x}/{y}.png', + crossOrigin: 'anonymous', + }), + style: { + variables: variables, + color: ['color', scaled, scaled, scaled], + }, +}); + +const controlIds = ['vert', 'sunEl', 'sunAz']; +controlIds.forEach(function (id) { + const control = document.getElementById(id); + const output = document.getElementById(id + 'Out'); + function updateValues() { + output.innerText = control.value; + variables[id] = Number(control.value); + } + updateValues(); + control.addEventListener('input', () => { + updateValues(); + shadedRelief.updateStyleVariables(variables); + }); +}); + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new OSM(), + }), + shadedRelief, + ], + view: new View({ + extent: [-13675026, 4439648, -13580856, 4580292], + center: [-13615645, 4497969], + minZoom: 10, + maxZoom: 16, + zoom: 13, + }), +}); diff --git a/examples/webgl-tile-style.css b/examples/webgl-tile-style.css new file mode 100644 index 0000000000..a24af258c9 --- /dev/null +++ b/examples/webgl-tile-style.css @@ -0,0 +1,4 @@ +#controls { + display: flex; + justify-content: space-around; +} diff --git a/examples/webgl-tile-style.html b/examples/webgl-tile-style.html new file mode 100644 index 0000000000..e94e9b17d7 --- /dev/null +++ b/examples/webgl-tile-style.html @@ -0,0 +1,31 @@ +--- +layout: example.html +title: WebGL Tile Layer Styles +shortdesc: Styling raster tiles with WebGL. +docs: > + The `style` property of a WebGL tile layer can be used to adjust properties like + `exposure`, `contrast`, and `saturation`. Typically those values would be set to + numeric constants to apply a filter to imagery. In this example, the style properties + are set to variables that can be updated based on application state. Adjusting the + sliders results in a call to `layer.updateStyleVariables()` to use new values for the + associated style properties. +tags: "webgl, style" +cloak: + - key: get_your_own_D6rA4zTHduk6KOKTXzGB + value: Get your own API key at https://www.maptiler.com/cloud/ +--- +
+
+ + + +
\ No newline at end of file diff --git a/examples/webgl-tile-style.js b/examples/webgl-tile-style.js new file mode 100644 index 0000000000..fce4addba1 --- /dev/null +++ b/examples/webgl-tile-style.js @@ -0,0 +1,55 @@ +import Map from '../src/ol/Map.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; +import XYZ from '../src/ol/source/XYZ.js'; + +const key = 'get_your_own_D6rA4zTHduk6KOKTXzGB'; +const attributions = + '© MapTiler ' + + '© OpenStreetMap contributors'; + +const variables = { + exposure: 0, + contrast: 0, + saturation: 0, +}; + +const layer = new TileLayer({ + style: { + exposure: ['var', 'exposure'], + contrast: ['var', 'contrast'], + saturation: ['var', 'saturation'], + variables: variables, + }, + source: new XYZ({ + crossOrigin: 'anonymous', // TODO: determine if we can avoid this + attributions: attributions, + url: 'https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key=' + key, + maxZoom: 20, + }), +}); + +const map = new Map({ + target: 'map', + layers: [layer], + view: new View({ + center: [0, 0], + zoom: 0, + }), +}); + +for (const name in variables) { + const element = document.getElementById(name); + const value = variables[name]; + element.value = value.toString(); + document.getElementById(`${name}-value`).innerText = `(${value})`; + + element.addEventListener('input', function (event) { + const value = parseFloat(event.target.value); + document.getElementById(`${name}-value`).innerText = `(${value})`; + + const updates = {}; + updates[name] = value; + layer.updateStyleVariables(updates); + }); +} diff --git a/examples/webgl-tiles.html b/examples/webgl-tiles.html new file mode 100644 index 0000000000..31b40ffcf0 --- /dev/null +++ b/examples/webgl-tiles.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: WebGL Tiles +shortdesc: Rendering raster data with WebGL. +docs: > + This example uses WebGL to raster tiles on a map. +tags: "webgl, osm" +--- +
diff --git a/examples/webgl-tiles.js b/examples/webgl-tiles.js new file mode 100644 index 0000000000..4353f3043c --- /dev/null +++ b/examples/webgl-tiles.js @@ -0,0 +1,17 @@ +import Map from '../src/ol/Map.js'; +import OSM from '../src/ol/source/OSM.js'; +import TileLayer from '../src/ol/layer/WebGLTile.js'; +import View from '../src/ol/View.js'; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new OSM(), + }), + ], + view: new View({ + center: [0, 0], + zoom: 0, + }), +}); diff --git a/examples/webpack/config.mjs b/examples/webpack/config.mjs index 4f02305c56..81445dc823 100644 --- a/examples/webpack/config.mjs +++ b/examples/webpack/config.mjs @@ -101,6 +101,8 @@ export default { resolve: { fallback: { fs: false, + http: false, + https: false, }, alias: { // allow imports from 'ol/module' instead of specifiying the source path diff --git a/examples/webpack/worker-loader.cjs b/examples/webpack/worker-loader.cjs index 15128ea840..891d60bafd 100644 --- a/examples/webpack/worker-loader.cjs +++ b/examples/webpack/worker-loader.cjs @@ -9,7 +9,12 @@ module.exports = function loader() { build(this.resource, {minify}) .then((chunk) => { for (const filePath in chunk.modules) { - this.addDependency(filePath); + try { + const dependency = require.resolve(filePath); + this.addDependency(dependency); + } catch (e) { + // empty catch block + } } callback(null, chunk.code); }) diff --git a/package-lock.json b/package-lock.json index d16cab342f..d681b85f7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "6.6.2-dev", "license": "BSD-2-Clause", "dependencies": { + "geotiff": "^1.0.4", "ol-mapbox-style": "^6.4.1", "pbf": "3.2.1", "rbush": "^3.0.1" @@ -62,6 +63,7 @@ "pngjs": "^6.0.0", "proj4": "^2.7.5", "puppeteer": "10.2.0", + "regenerator-runtime": "^0.13.9", "rollup": "^2.42.3", "rollup-plugin-terser": "^7.0.2", "serve-static": "^1.14.0", @@ -1837,6 +1839,15 @@ "eslint": ">=5.1.0" } }, + "node_modules/@petamoriken/float16": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-1.1.1.tgz", + "integrity": "sha512-0r8nE5Q60tj3FbWWYLjAdGnWZgP7CMWXNaI5UsNzypRyxLDb/uvOl5SDw8GcPNu6pSTOt+KSI+0oL6fhSpNOFQ==", + "dependencies": { + "lodash": ">=4.17.5 <5.0.0", + "lodash-es": ">=4.17.5 <5.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", @@ -2968,7 +2979,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -3336,6 +3346,11 @@ "node": ">= 0.6" } }, + "node_modules/content-type-parser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/content-type-parser/-/content-type-parser-1.0.2.tgz", + "integrity": "sha512-lM4l4CnMEwOLHAHr/P6MEZwZFPJFtAAKgL6pogbXmVZggIqXhdB6RbBtPOTsw2FcXwYhehRGERJmRrjOiIB8pQ==" + }, "node_modules/convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -3576,7 +3591,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -4659,6 +4673,15 @@ "node": ">=8" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", @@ -5330,6 +5353,24 @@ "node": ">=6.9.0" } }, + "node_modules/geotiff": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-1.0.4.tgz", + "integrity": "sha512-JmtpvVHlxvyrWgT6Uf0sy7flmXhjWtG0cqVv+G9fMcupV4DAPdTv7tkhsoMnn9RpIIwolveB/VnyII8cRMOD7A==", + "dependencies": { + "@petamoriken/float16": "^1.0.7", + "content-type-parser": "^1.0.2", + "lru-cache": "^6.0.0", + "pako": "^1.0.11", + "parse-headers": "^2.0.2", + "threads": "^1.3.1", + "txml": "^3.1.2" + }, + "engines": { + "browsers": "defaults", + "node": ">=10.19" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -5924,8 +5965,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-ip": { "version": "6.2.0", @@ -6190,6 +6230,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -7123,8 +7174,12 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", @@ -7292,7 +7347,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7699,8 +7753,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multicast-dns": { "version": "6.2.3", @@ -7947,6 +8000,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==" + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -8172,6 +8230,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8184,6 +8247,11 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.3.tgz", + "integrity": "sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA==" + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -8926,7 +8994,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8979,9 +9046,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", "dev": true }, "node_modules/regenerator-transform": { @@ -9965,7 +10032,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -9974,7 +10040,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -10314,18 +10379,53 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/threads": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.6.5.tgz", + "integrity": "sha512-yL1NN4qZ25crW8wDoGn7TqbENJ69w3zCEjIGXpbqmQ4I+QHrG8+DLaZVKoX74OQUXWCI2lbbrUxDxAbr1xjDGQ==", + "dependencies": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1" + }, + "funding": { + "url": "https://github.com/andywer/threads.js?sponsor=1" + }, + "optionalDependencies": { + "tiny-worker": ">= 2" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "node_modules/through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "optional": true, + "dependencies": { + "esm": "^3.2.25" + } + }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -10423,6 +10523,14 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, + "node_modules/txml": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/txml/-/txml-3.2.5.tgz", + "integrity": "sha512-AtN8AgJLiDanttIXJaQlxH8/R0NOCNwto8kcO7BaxdLgsN9b7itM9lnTD7c2O3TadP+hHB9j7ra5XGFRPNnk/g==", + "dependencies": { + "through2": "^3.0.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10630,8 +10738,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -11374,8 +11481,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yargs": { "version": "17.1.1", @@ -12680,6 +12786,15 @@ "minimatch": "^3.0.4" } }, + "@petamoriken/float16": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-1.1.1.tgz", + "integrity": "sha512-0r8nE5Q60tj3FbWWYLjAdGnWZgP7CMWXNaI5UsNzypRyxLDb/uvOl5SDw8GcPNu6pSTOt+KSI+0oL6fhSpNOFQ==", + "requires": { + "lodash": ">=4.17.5 <5.0.0", + "lodash-es": ">=4.17.5 <5.0.0" + } + }, "@rollup/plugin-babel": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", @@ -13604,8 +13719,7 @@ "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, "camelcase": { "version": "6.2.0", @@ -13901,6 +14015,11 @@ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", "dev": true }, + "content-type-parser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/content-type-parser/-/content-type-parser-1.0.2.tgz", + "integrity": "sha512-lM4l4CnMEwOLHAHr/P6MEZwZFPJFtAAKgL6pogbXmVZggIqXhdB6RbBtPOTsw2FcXwYhehRGERJmRrjOiIB8pQ==" + }, "convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -14082,7 +14201,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -14922,6 +15040,12 @@ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "optional": true + }, "espree": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", @@ -15448,6 +15572,20 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true }, + "geotiff": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-1.0.4.tgz", + "integrity": "sha512-JmtpvVHlxvyrWgT6Uf0sy7flmXhjWtG0cqVv+G9fMcupV4DAPdTv7tkhsoMnn9RpIIwolveB/VnyII8cRMOD7A==", + "requires": { + "@petamoriken/float16": "^1.0.7", + "content-type-parser": "^1.0.2", + "lru-cache": "^6.0.0", + "pako": "^1.0.11", + "parse-headers": "^2.0.2", + "threads": "^1.3.1", + "txml": "^3.1.2" + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -15895,8 +16033,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-ip": { "version": "6.2.0", @@ -16071,6 +16208,11 @@ "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==", "dev": true }, + "is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==" + }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -16802,8 +16944,12 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -16939,7 +17085,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -17249,8 +17394,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "multicast-dns": { "version": "6.2.3", @@ -17441,6 +17585,11 @@ "es-abstract": "^1.18.2" } }, + "observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==" + }, "obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -17599,6 +17748,11 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -17608,6 +17762,11 @@ "callsites": "^3.0.0" } }, + "parse-headers": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.3.tgz", + "integrity": "sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA==" + }, "parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -18178,7 +18337,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -18219,9 +18377,9 @@ } }, "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", "dev": true }, "regenerator-transform": { @@ -19015,7 +19173,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "requires": { "safe-buffer": "~5.2.0" }, @@ -19023,8 +19180,7 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" } } }, @@ -19274,18 +19430,48 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "threads": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.6.5.tgz", + "integrity": "sha512-yL1NN4qZ25crW8wDoGn7TqbENJ69w3zCEjIGXpbqmQ4I+QHrG8+DLaZVKoX74OQUXWCI2lbbrUxDxAbr1xjDGQ==", + "requires": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1", + "tiny-worker": ">= 2" + } + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "requires": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, "thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "optional": true, + "requires": { + "esm": "^3.2.25" + } + }, "tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -19364,6 +19550,14 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, + "txml": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/txml/-/txml-3.2.5.tgz", + "integrity": "sha512-AtN8AgJLiDanttIXJaQlxH8/R0NOCNwto8kcO7BaxdLgsN9b7itM9lnTD7c2O3TadP+hHB9j7ra5XGFRPNnk/g==", + "requires": { + "through2": "^3.0.1" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19518,8 +19712,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "utils-merge": { "version": "1.0.1", @@ -20044,8 +20237,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yargs": { "version": "17.1.1", diff --git a/package.json b/package.json index 51a215d57e..390c70101f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "url": "https://opencollective.com/openlayers" }, "dependencies": { + "geotiff": "^1.0.4", "ol-mapbox-style": "^6.4.1", "pbf": "3.2.1", "rbush": "^3.0.1" @@ -98,6 +99,7 @@ "pngjs": "^6.0.0", "proj4": "^2.7.5", "puppeteer": "10.2.0", + "regenerator-runtime": "^0.13.9", "rollup": "^2.42.3", "rollup-plugin-terser": "^7.0.2", "serve-static": "^1.14.0", diff --git a/src/ol/DataTile.js b/src/ol/DataTile.js new file mode 100644 index 0000000000..c63eeb5b49 --- /dev/null +++ b/src/ol/DataTile.js @@ -0,0 +1,76 @@ +/** + * @module ol/DataTile + */ +import Tile from './Tile.js'; +import TileState from './TileState.js'; + +/** + * Data that can be used with a DataTile. + * @typedef {Uint8Array|Uint8ClampedArray|DataView} Data + */ + +/** + * @typedef {Object} Options + * @property {import("./tilecoord.js").TileCoord} tileCoord Tile coordinate. + * @property {function() : Promise} loader Data loader. + * @property {number} [transition=250] A duration for tile opacity + * transitions in milliseconds. A duration of 0 disables the opacity transition. + * @api + */ + +class DataTile extends Tile { + /** + * @param {Options} options Tile options. + */ + constructor(options) { + const state = TileState.IDLE; + + super(options.tileCoord, state, {transition: options.transition}); + + this.loader_ = options.loader; + this.data_ = null; + this.error_ = null; + } + + /** + * Get the data for the tile. + * @return {Data} Tile data. + * @api + */ + getData() { + return this.data_; + } + + /** + * Get any loading error. + * @return {Error} Loading error. + * @api + */ + getError() { + return this.error_; + } + + /** + * Load not yet loaded URI. + * @api + */ + load() { + this.state = TileState.LOADING; + this.changed(); + + const self = this; + this.loader_() + .then(function (data) { + self.data_ = data; + self.state = TileState.LOADED; + self.changed(); + }) + .catch(function (error) { + self.error_ = error; + self.state = TileState.ERROR; + self.changed(); + }); + } +} + +export default DataTile; diff --git a/src/ol/layer.js b/src/ol/layer.js index 790d400f3a..7b51f75fb4 100644 --- a/src/ol/layer.js +++ b/src/ol/layer.js @@ -13,3 +13,4 @@ export {default as Vector} from './layer/Vector.js'; export {default as VectorImage} from './layer/VectorImage.js'; export {default as VectorTile} from './layer/VectorTile.js'; export {default as WebGLPoints} from './layer/WebGLPoints.js'; +export {default as WebGLTile} from './layer/WebGLTile.js'; diff --git a/src/ol/layer/WebGLTile.js b/src/ol/layer/WebGLTile.js new file mode 100644 index 0000000000..7080c7ce3c --- /dev/null +++ b/src/ol/layer/WebGLTile.js @@ -0,0 +1,309 @@ +/** + * @module ol/layer/WebGLTile + */ +import BaseTileLayer from './BaseTile.js'; +import WebGLTileLayerRenderer, { + Attributes, + Uniforms, +} from '../renderer/webgl/TileLayer.js'; +import { + ValueTypes, + expressionToGlsl, + getStringNumberEquivalent, + uniformNameForVariable, +} from '../style/expressions.js'; +import {assign} from '../obj.js'; + +/** + * @typedef {Object} Style + * Translates tile data to rendered pixels. + * + * @property {Object} [variables] Style variables. Each variable must hold a number. These + * variables can be used in the `color`, `brightness`, `contrast`, `exposure`, `saturation` and `gamma` + * {@link import("../style/expressions.js").ExpressionValue expressions}, using the `['var', 'varName']` operator. + * To update style variables, use the {@link import("./WebGLTile.js").default#updateStyleVariables} method. + * @property {import("../style/expressions.js").ExpressionValue} [color] An expression applied to color values. + * @property {import("../style/expressions.js").ExpressionValue} [brightness=0] Value used to decrease or increase + * the layer brightness. Values range from -1 to 1. + * @property {import("../style/expressions.js").ExpressionValue} [contrast=0] Value used to decrease or increase + * the layer contrast. Values range from -1 to 1. + * @property {import("../style/expressions.js").ExpressionValue} [exposure=0] Value used to decrease or increase + * the layer exposure. Values range from -1 to 1. + * @property {import("../style/expressions.js").ExpressionValue} [saturation=0] Value used to decrease or increase + * the layer saturation. Values range from -1 to 1. + * @property {import("../style/expressions.js").ExpressionValue} [gamma=1] Apply a gamma correction to the layer. + * Values range from 0 to infinity. + */ + +/** + * @typedef {Object} Options + * @property {Style} [style] Style to apply to the layer. + * @property {string} [className='ol-layer'] A CSS class name to set to the layer element. + * @property {number} [opacity=1] Opacity (0, 1). + * @property {boolean} [visible=true] Visibility. + * @property {import("../extent.js").Extent} [extent] The bounding extent for layer rendering. The layer will not be + * rendered outside of this extent. + * @property {number} [zIndex] The z-index for layer rendering. At rendering time, the layers + * will be ordered, first by Z-index and then by position. When `undefined`, a `zIndex` of 0 is assumed + * for layers that are added to the map's `layers` collection, or `Infinity` when the layer's `setMap()` + * method was used. + * @property {number} [minResolution] The minimum resolution (inclusive) at which this layer will be + * visible. + * @property {number} [maxResolution] The maximum resolution (exclusive) below which this layer will + * be visible. + * @property {number} [minZoom] The minimum view zoom level (exclusive) above which this layer will be + * visible. + * @property {number} [maxZoom] The maximum view zoom level (inclusive) at which this layer will + * be visible. + * @property {number} [preload=0] Preload. Load low-resolution tiles up to `preload` levels. `0` + * means no preloading. + * @property {import("../source/Tile.js").default} [source] Source for this layer. + * @property {import("../PluggableMap.js").default} [map] Sets the layer as overlay on a map. The map will not manage + * this layer in its layers collection, and the layer will be rendered on top. This is useful for + * temporary layers. The standard way to add a layer to a map and have it managed by the map is to + * use {@link module:ol/Map#addLayer}. + * @property {boolean} [useInterimTilesOnError=true] Use interim tiles on error. + */ + +/** + * @typedef {Object} ParsedStyle + * @property {string} vertexShader The vertex shader. + * @property {string} fragmentShader The fragment shader. + * @property {Object} uniforms Uniform definitions. + */ + +/** + * @param {Style} style The layer style. + * @param {number} [bandCount] The number of bands. + * @return {ParsedStyle} Shaders and uniforms generated from the style. + */ +function parseStyle(style, bandCount) { + const vertexShader = ` + attribute vec2 ${Attributes.TEXTURE_COORD}; + uniform mat4 ${Uniforms.TILE_TRANSFORM}; + uniform float ${Uniforms.DEPTH}; + + varying vec2 v_textureCoord; + + void main() { + v_textureCoord = ${Attributes.TEXTURE_COORD}; + gl_Position = ${Uniforms.TILE_TRANSFORM} * vec4(${Attributes.TEXTURE_COORD}, ${Uniforms.DEPTH}, 1.0); + } + `; + + /** + * @type {import("../style/expressions.js").ParsingContext} + */ + const context = { + inFragmentShader: true, + variables: [], + attributes: [], + stringLiteralsMap: {}, + bandCount: bandCount, + }; + + const pipeline = []; + + if (style.color !== undefined) { + const color = expressionToGlsl(context, style.color, ValueTypes.COLOR); + pipeline.push(`color = ${color};`); + } + + if (style.contrast !== undefined) { + const contrast = expressionToGlsl( + context, + style.contrast, + ValueTypes.NUMBER + ); + pipeline.push( + `color.rgb = clamp((${contrast} + 1.0) * color.rgb - (${contrast} / 2.0), vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0));` + ); + } + + if (style.exposure !== undefined) { + const exposure = expressionToGlsl( + context, + style.exposure, + ValueTypes.NUMBER + ); + pipeline.push( + `color.rgb = clamp((${exposure} + 1.0) * color.rgb, vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0));` + ); + } + + if (style.saturation !== undefined) { + const saturation = expressionToGlsl( + context, + style.saturation, + ValueTypes.NUMBER + ); + pipeline.push(` + float saturation = ${saturation} + 1.0; + float sr = (1.0 - saturation) * 0.2126; + float sg = (1.0 - saturation) * 0.7152; + float sb = (1.0 - saturation) * 0.0722; + mat3 saturationMatrix = mat3( + sr + saturation, sr, sr, + sg, sg + saturation, sg, + sb, sb, sb + saturation + ); + color.rgb = clamp(saturationMatrix * color.rgb, vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0)); + `); + } + + if (style.gamma !== undefined) { + const gamma = expressionToGlsl(context, style.gamma, ValueTypes.NUMBER); + pipeline.push(`color.rgb = pow(color.rgb, vec3(1.0 / ${gamma}));`); + } + + if (style.brightness !== undefined) { + const brightness = expressionToGlsl( + context, + style.brightness, + ValueTypes.NUMBER + ); + pipeline.push( + `color.rgb = clamp(color.rgb + ${brightness}, vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0));` + ); + } + + /** @type {Object} */ + const uniforms = {}; + + const numVariables = context.variables.length; + if (numVariables > 1 && !style.variables) { + throw new Error( + `Missing variables in style (expected ${context.variables})` + ); + } + + for (let i = 0; i < numVariables; ++i) { + const variableName = context.variables[i]; + if (!(variableName in style.variables)) { + throw new Error(`Missing '${variableName}' in style variables`); + } + const uniformName = uniformNameForVariable(variableName); + uniforms[uniformName] = function () { + let value = style.variables[variableName]; + if (typeof value === 'string') { + value = getStringNumberEquivalent(context, value); + } + return value !== undefined ? value : -9999999; // to avoid matching with the first string literal + }; + } + + const uniformDeclarations = Object.keys(uniforms).map(function (name) { + return `uniform float ${name};`; + }); + + const textureCount = Math.ceil(bandCount / 4); + const colorAssignments = new Array(textureCount); + for (let textureIndex = 0; textureIndex < textureCount; ++textureIndex) { + const uniformName = Uniforms.TILE_TEXTURE_PREFIX + textureIndex; + uniformDeclarations.push(`uniform sampler2D ${uniformName};`); + colorAssignments[ + textureIndex + ] = `vec4 color${textureIndex} = texture2D(${uniformName}, v_textureCoord);`; + } + + const fragmentShader = ` + #ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + #else + precision mediump float; + #endif + + varying vec2 v_textureCoord; + uniform float ${Uniforms.TRANSITION_ALPHA}; + uniform float ${Uniforms.TEXTURE_PIXEL_WIDTH}; + uniform float ${Uniforms.TEXTURE_PIXEL_HEIGHT}; + uniform float ${Uniforms.RESOLUTION}; + uniform float ${Uniforms.ZOOM}; + + ${uniformDeclarations.join('\n')} + + void main() { + ${colorAssignments.join('\n')} + + vec4 color = color0; + + ${pipeline.join('\n')} + + if (color.a == 0.0) { + discard; + } + + gl_FragColor = color; + gl_FragColor.rgb *= gl_FragColor.a; + gl_FragColor *= ${Uniforms.TRANSITION_ALPHA}; + }`; + + return { + vertexShader: vertexShader, + fragmentShader: fragmentShader, + uniforms: uniforms, + }; +} + +/** + * @classdesc + * For layer sources that provide pre-rendered, tiled images in grids that are + * organized by zoom levels for specific resolutions. + * Note that any property set in the options is set as a {@link module:ol/Object~BaseObject} + * property on the layer object; for example, setting `title: 'My Title'` in the + * options means that `title` is observable, and has get/set accessors. + * + * @extends BaseTileLayer + * @api + */ +class WebGLTileLayer extends BaseTileLayer { + /** + * @param {Options} opt_options Tile layer options. + */ + constructor(opt_options) { + const options = opt_options ? assign({}, opt_options) : {}; + + const style = options.style || {}; + delete options.style; + super(options); + + /** + * @type {Style} + */ + this.style_ = style; + } + + /** + * Create a renderer for this layer. + * @return {import("../renderer/Layer.js").default} A layer renderer. + * @protected + */ + createRenderer() { + const source = this.getSource(); + const parsedStyle = parseStyle( + this.style_, + 'bandCount' in source ? source.bandCount : 4 + ); + + this.styleVariables_ = this.style_.variables || {}; + + return new WebGLTileLayerRenderer(this, { + vertexShader: parsedStyle.vertexShader, + fragmentShader: parsedStyle.fragmentShader, + uniforms: parsedStyle.uniforms, + className: this.getClassName(), + }); + } + + /** + * Update any variables used by the layer style and trigger a re-render. + * @param {Object} variables Variables to update. + * @api + */ + updateStyleVariables(variables) { + assign(this.styleVariables_, variables); + this.changed(); + } +} + +export default WebGLTileLayer; diff --git a/src/ol/renderer/webgl/TileLayer.js b/src/ol/renderer/webgl/TileLayer.js new file mode 100644 index 0000000000..48f0a197d9 --- /dev/null +++ b/src/ol/renderer/webgl/TileLayer.js @@ -0,0 +1,543 @@ +/** + * @module ol/renderer/webgl/TileLayer + */ +import LRUCache from '../../structs/LRUCache.js'; +import State from '../../source/State.js'; +import TileRange from '../../TileRange.js'; +import TileState from '../../TileState.js'; +import TileTexture from '../../webgl/TileTexture.js'; +import WebGLArrayBuffer from '../../webgl/Buffer.js'; +import WebGLLayerRenderer from './Layer.js'; +import {AttributeType} from '../../webgl/Helper.js'; +import {ELEMENT_ARRAY_BUFFER, STATIC_DRAW} from '../../webgl.js'; +import { + compose as composeTransform, + create as createTransform, +} from '../../transform.js'; +import { + create as createMat4, + fromTransform as mat4FromTransform, +} from '../../vec/mat4.js'; +import { + createOrUpdate as createTileCoord, + getKeyZXY, + getKey as getTileCoordKey, +} from '../../tilecoord.js'; +import {fromUserExtent} from '../../proj.js'; +import {getIntersection} from '../../extent.js'; +import {getUid} from '../../util.js'; +import {isEmpty} from '../../extent.js'; +import {numberSafeCompareFunction} from '../../array.js'; +import {toSize} from '../../size.js'; + +export const Uniforms = { + TILE_TEXTURE_PREFIX: 'u_tileTexture', + TILE_TRANSFORM: 'u_tileTransform', + TRANSITION_ALPHA: 'u_transitionAlpha', + DEPTH: 'u_depth', + TEXTURE_PIXEL_WIDTH: 'u_texturePixelWidth', + TEXTURE_PIXEL_HEIGHT: 'u_texturePixelHeight', + RESOLUTION: 'u_resolution', + ZOOM: 'u_zoom', +}; + +export const Attributes = { + TEXTURE_COORD: 'a_textureCoord', +}; + +/** + * @type {Array} + */ +const attributeDescriptions = [ + { + name: Attributes.TEXTURE_COORD, + size: 2, + type: AttributeType.FLOAT, + }, +]; + +/** + * Transform a zoom level into a depth value ranging from -1 to 1. + * @param {number} z A zoom level. + * @return {number} A depth value. + */ +function depthForZ(z) { + return 2 * (1 - 1 / (z + 1)) - 1; +} + +/** + * Add a tile texture to the lookup. + * @param {Object>} tileTexturesByZ Lookup of + * tile textures by zoom level. + * @param {import("../../webgl/TileTexture.js").default} tileTexture A tile texture. + * @param {number} z The zoom level. + */ +function addTileTextureToLookup(tileTexturesByZ, tileTexture, z) { + if (!(z in tileTexturesByZ)) { + tileTexturesByZ[z] = []; + } + tileTexturesByZ[z].push(tileTexture); +} + +/** + * + * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. + * @return {import("../../extent.js").Extent} Extent. + */ +function getRenderExtent(frameState) { + const layerState = frameState.layerStatesArray[frameState.layerIndex]; + let extent = frameState.extent; + if (layerState.extent) { + extent = getIntersection( + extent, + fromUserExtent(layerState.extent, frameState.viewState.projection) + ); + } + return extent; +} + +/** + * @typedef {Object} Options + * @property {string} vertexShader Vertex shader source. + * @property {string} fragmentShader Fragment shader source. + * @property {Object} [uniforms] Additional uniforms + * made available to shaders. + * @property {string} [className='ol-layer'] A CSS class name to set to the canvas element. + */ + +/** + * @classdesc + * WebGL renderer for tile layers. + * @api + */ +class WebGLTileLayerRenderer extends WebGLLayerRenderer { + /** + * @param {import("../../layer/WebGLTile.js").default} tileLayer Tile layer. + * @param {Options} options Options. + */ + constructor(tileLayer, options) { + super(tileLayer, { + uniforms: options.uniforms, + className: options.className, + }); + + /** + * This transform converts tile i, j coordinates to screen coordinates. + * @type {import("../../transform.js").Transform} + * @private + */ + this.tileTransform_ = createTransform(); + + /** + * @type {Array} + * @private + */ + this.tempMat4_ = createMat4(); + + /** + * @type {import("../../TileRange.js").default} + * @private + */ + this.tempTileRange_ = new TileRange(0, 0, 0, 0); + + /** + * @type {import("../../tilecoord.js").TileCoord} + * @private + */ + this.tempTileCoord_ = createTileCoord(0, 0, 0); + + /** + * @type {import("../../size.js").Size} + * @private + */ + this.tempSize_ = [0, 0]; + + this.program_ = this.helper.getProgram( + options.fragmentShader, + options.vertexShader + ); + + /** + * Tiles are rendered as a quad with the following structure: + * + * [P3]---------[P2] + * |` | + * | ` B | + * | ` | + * | ` | + * | A ` | + * | ` | + * [P0]---------[P1] + * + * Triangle A: P0, P1, P3 + * Triangle B: P1, P2, P3 + */ + const indices = new WebGLArrayBuffer(ELEMENT_ARRAY_BUFFER, STATIC_DRAW); + indices.fromArray([0, 1, 3, 1, 2, 3]); + this.helper.flushBufferData(indices); + this.indices_ = indices; + + this.tileTextureCache_ = new LRUCache(512); + + this.renderedOpacity_ = NaN; + } + + /** + * @protected + * @param {import("../../Tile.js").default} tile Tile. + * @return {boolean} Tile is drawable. + */ + isDrawableTile(tile) { + const tileLayer = this.getLayer(); + const tileState = tile.getState(); + const useInterimTilesOnError = tileLayer.getUseInterimTilesOnError(); + return ( + tileState == TileState.LOADED || + tileState == TileState.EMPTY || + (tileState == TileState.ERROR && !useInterimTilesOnError) + ); + } + + /** + * Determine whether render should be called. + * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. + * @return {boolean} Layer is ready to be rendered. + */ + prepareFrame(frameState) { + if (isEmpty(getRenderExtent(frameState))) { + return false; + } + const source = this.getLayer().getSource(); + if (!source) { + return false; + } + return source.getState() === State.READY; + } + + /** + * Render the layer. + * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. + * @return {HTMLElement} The rendered element. + */ + renderFrame(frameState) { + this.preRender(frameState); + + const viewState = frameState.viewState; + const layerState = frameState.layerStatesArray[frameState.layerIndex]; + const extent = getRenderExtent(frameState); + const tileLayer = this.getLayer(); + const tileSource = tileLayer.getSource(); + const tileGrid = tileSource.getTileGridForProjection(viewState.projection); + const z = tileGrid.getZForResolution( + viewState.resolution, + tileSource.zDirection + ); + + /** + * @type {Object>} + */ + const tileTexturesByZ = {}; + + const tileTextureCache = this.tileTextureCache_; + const tileRange = tileGrid.getTileRangeForExtentAndZ(extent, z); + + const tileSourceKey = getUid(tileSource); + if (!(tileSourceKey in frameState.wantedTiles)) { + frameState.wantedTiles[tileSourceKey] = {}; + } + + const wantedTiles = frameState.wantedTiles[tileSourceKey]; + + const tileResolution = tileGrid.getResolution(z); + + for (let x = tileRange.minX; x <= tileRange.maxX; ++x) { + for (let y = tileRange.minY; y <= tileRange.maxY; ++y) { + const tileCoord = createTileCoord(z, x, y, this.tempTileCoord_); + const tileCoordKey = getTileCoordKey(tileCoord); + + let tileTexture, tile; + if (tileTextureCache.containsKey(tileCoordKey)) { + tileTexture = tileTextureCache.get(tileCoordKey); + tile = tileTexture.tile; + } + if (!tileTexture || tileTexture.tile.key !== tileSource.getKey()) { + tile = tileSource.getTile( + z, + x, + y, + frameState.pixelRatio, + viewState.projection + ); + if (!tileTexture) { + tileTexture = new TileTexture(tile, tileGrid, this.helper); + tileTextureCache.set(tileCoordKey, tileTexture); + } else { + tileTexture.setTile( + this.isDrawableTile(tile) ? tile : tile.getInterimTile() + ); + } + } + + addTileTextureToLookup(tileTexturesByZ, tileTexture, z); + + const tileQueueKey = tile.getKey(); + wantedTiles[tileQueueKey] = true; + + if (tile.getState() === TileState.IDLE) { + if (!frameState.tileQueue.isKeyQueued(tileQueueKey)) { + frameState.tileQueue.enqueue([ + tile, + tileSourceKey, + tileGrid.getTileCoordCenter(tileCoord), + tileResolution, + ]); + } + } + } + } + + /** + * A lookup of alpha values for tiles at the target rendering resolution + * for tiles that are in transition. If a tile coord key is absent from + * this lookup, the tile should be rendered at alpha 1. + * @type {Object} + */ + const alphaLookup = {}; + + const uid = getUid(this); + const time = frameState.time; + let blend = false; + + // look for cached tiles to use if a target tile is not ready + const tileTextures = tileTexturesByZ[z]; + for (let i = 0, ii = tileTextures.length; i < ii; ++i) { + const tileTexture = tileTextures[i]; + const tile = tileTexture.tile; + const tileCoord = tile.tileCoord; + + if (tileTexture.loaded) { + const alpha = tile.getAlpha(uid, time); + if (alpha === 1) { + // no need to look for alt tiles + tile.endTransition(uid); + continue; + } + blend = true; + const tileCoordKey = getTileCoordKey(tileCoord); + alphaLookup[tileCoordKey] = alpha; + } + + // first look for child tiles (at z + 1) + const coveredByChildren = this.findAltTiles_( + tileGrid, + tileCoord, + z + 1, + tileTexturesByZ + ); + + if (coveredByChildren) { + continue; + } + + // next look for parent tiles + for (let parentZ = z - 1; parentZ >= tileGrid.minZoom; --parentZ) { + const coveredByParent = this.findAltTiles_( + tileGrid, + tileCoord, + parentZ, + tileTexturesByZ + ); + + if (coveredByParent) { + break; + } + } + } + + this.helper.useProgram(this.program_); + this.helper.prepareDraw(frameState, !blend); + + const zs = Object.keys(tileTexturesByZ) + .map(Number) + .sort(numberSafeCompareFunction); + + const gl = this.helper.getGL(); + + const centerX = viewState.center[0]; + const centerY = viewState.center[1]; + + for (let j = 0, jj = zs.length; j < jj; ++j) { + const tileZ = zs[j]; + const tileResolution = tileGrid.getResolution(tileZ); + const tileSize = toSize(tileGrid.getTileSize(tileZ), this.tempSize_); + const tileOrigin = tileGrid.getOrigin(tileZ); + + const centerI = + (centerX - tileOrigin[0]) / (tileSize[0] * tileResolution); + const centerJ = + (tileOrigin[1] - centerY) / (tileSize[1] * tileResolution); + + const tileScale = viewState.resolution / tileResolution; + + const depth = depthForZ(tileZ); + const tileTextures = tileTexturesByZ[tileZ]; + for (let i = 0, ii = tileTextures.length; i < ii; ++i) { + const tileTexture = tileTextures[i]; + if (!tileTexture.loaded) { + continue; + } + const tile = tileTexture.tile; + const tileCoord = tile.tileCoord; + const tileCoordKey = getTileCoordKey(tileCoord); + + const tileCenterI = tileCoord[1]; + const tileCenterJ = tileCoord[2]; + + composeTransform( + this.tileTransform_, + 0, + 0, + 2 / ((frameState.size[0] * tileScale) / tileSize[0]), + -2 / ((frameState.size[1] * tileScale) / tileSize[1]), + viewState.rotation, + -(centerI - tileCenterI), + -(centerJ - tileCenterJ) + ); + + this.helper.setUniformMatrixValue( + Uniforms.TILE_TRANSFORM, + mat4FromTransform(this.tempMat4_, this.tileTransform_) + ); + + this.helper.bindBuffer(tileTexture.coords); + this.helper.bindBuffer(this.indices_); + this.helper.enableAttributes(attributeDescriptions); + + for ( + let textureIndex = 0; + textureIndex < tileTexture.textures.length; + ++textureIndex + ) { + const textureProperty = 'TEXTURE' + textureIndex; + const uniformName = Uniforms.TILE_TEXTURE_PREFIX + textureIndex; + gl.activeTexture(gl[textureProperty]); + gl.bindTexture(gl.TEXTURE_2D, tileTexture.textures[textureIndex]); + gl.uniform1i(this.helper.getUniformLocation(uniformName), 0); + } + + const alpha = + tileCoordKey in alphaLookup ? alphaLookup[tileCoordKey] : 1; + + if (alpha < 1) { + frameState.animate = true; + } + + this.helper.setUniformFloatValue(Uniforms.TRANSITION_ALPHA, alpha); + this.helper.setUniformFloatValue(Uniforms.DEPTH, depth); + this.helper.setUniformFloatValue( + Uniforms.TEXTURE_PIXEL_WIDTH, + tileSize[0] + ); + this.helper.setUniformFloatValue( + Uniforms.TEXTURE_PIXEL_HEIGHT, + tileSize[1] + ); + this.helper.setUniformFloatValue( + Uniforms.RESOLUTION, + viewState.resolution + ); + this.helper.setUniformFloatValue(Uniforms.ZOOM, viewState.zoom); + + this.helper.drawElements(0, this.indices_.getSize()); + } + } + + this.helper.finalizeDraw(frameState); + + const canvas = this.helper.getCanvas(); + + const opacity = layerState.opacity; + if (this.renderedOpacity_ !== opacity) { + canvas.style.opacity = String(opacity); + this.renderedOpacity_ = opacity; + } + + while (tileTextureCache.canExpireCache()) { + const tileTexture = tileTextureCache.pop(); + tileTexture.dispose(); + } + + // TODO: let the renderers manage their own cache instead of managing the source cache + if (tileSource.canExpireCache()) { + /** + * @param {import("../../PluggableMap.js").default} map Map. + * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. + */ + const postRenderFunction = function (map, frameState) { + const tileSourceKey = getUid(tileSource); + if (tileSourceKey in frameState.usedTiles) { + tileSource.expireCache( + frameState.viewState.projection, + frameState.usedTiles[tileSourceKey] + ); + } + }; + + frameState.postRenderFunctions.push(postRenderFunction); + } + + this.postRender(frameState); + return canvas; + } + + /** + * Look for tiles covering the provided tile coordinate at an alternate + * zoom level. Loaded tiles will be added to the provided tile texture lookup. + * @param {import("../../tilegrid/TileGrid.js").default} tileGrid The tile grid. + * @param {import("../../tilecoord.js").TileCoord} tileCoord The target tile coordinate. + * @param {number} altZ The alternate zoom level. + * @param {Object>} tileTexturesByZ Lookup of + * tile textures by zoom level. + * @return {boolean} The tile coordinate is covered by loaded tiles at the alternate zoom level. + * @private + */ + findAltTiles_(tileGrid, tileCoord, altZ, tileTexturesByZ) { + const tileRange = tileGrid.getTileRangeForTileCoordAndZ( + tileCoord, + altZ, + this.tempTileRange_ + ); + + if (!tileRange) { + return false; + } + + let covered = true; + const tileTextureCache = this.tileTextureCache_; + for (let x = tileRange.minX; x <= tileRange.maxX; ++x) { + for (let y = tileRange.minY; y <= tileRange.maxY; ++y) { + const cacheKey = getKeyZXY(altZ, x, y); + let loaded = false; + if (tileTextureCache.containsKey(cacheKey)) { + const tileTexture = tileTextureCache.get(cacheKey); + if (tileTexture.loaded) { + addTileTextureToLookup(tileTexturesByZ, tileTexture, altZ); + loaded = true; + } + } + if (!loaded) { + covered = false; + } + } + } + return covered; + } +} + +/** + * @function + * @return {import("../../layer/WebGLTile.js").default} + */ +WebGLTileLayerRenderer.prototype.getLayer; + +export default WebGLTileLayerRenderer; diff --git a/src/ol/source.js b/src/ol/source.js index 155d039c5b..c1efbce95f 100644 --- a/src/ol/source.js +++ b/src/ol/source.js @@ -5,6 +5,8 @@ export {default as BingMaps} from './source/BingMaps.js'; export {default as CartoDB} from './source/CartoDB.js'; export {default as Cluster} from './source/Cluster.js'; +export {default as DataTile} from './source/DataTile.js'; +export {default as GeoTIFF} from './source/GeoTIFF.js'; export {default as IIIF} from './source/IIIF.js'; export {default as Image} from './source/Image.js'; export {default as ImageArcGISRest} from './source/ImageArcGISRest.js'; diff --git a/src/ol/source/DataTile.js b/src/ol/source/DataTile.js new file mode 100644 index 0000000000..3a813aa34e --- /dev/null +++ b/src/ol/source/DataTile.js @@ -0,0 +1,153 @@ +/** + * @module ol/source/DataTile + */ +import DataTile from '../DataTile.js'; +import EventType from '../events/EventType.js'; +import TileEventType from './TileEventType.js'; +import TileSource, {TileSourceEvent} from './Tile.js'; +import TileState from '../TileState.js'; +import {assign} from '../obj.js'; +import {createXYZ, extentFromProjection} from '../tilegrid.js'; +import {getKeyZXY} from '../tilecoord.js'; +import {getUid} from '../util.js'; + +/** + * @typedef {Object} Options + * @property {function(number, number, number) : Promise} [loader] Data loader. Called with z, x, and y tile coordinates. + * Returns a promise that resolves to a {@link import("../DataTile.js").Data}. + * @property {number} [maxZoom=42] Optional max zoom level. Not used if `tileGrid` is provided. + * @property {number} [minZoom=0] Optional min zoom level. Not used if `tileGrid` is provided. + * @property {number|import("../size.js").Size} [tileSize=[256, 256]] The pixel width and height of the tiles. + * @property {number} [maxResolution] Optional tile grid resolution at level zero. Not used if `tileGrid` is provided. + * @property {import("../proj.js").ProjectionLike} [projection='EPSG:3857'] Tile projection. + * @property {import("../tilegrid/TileGrid.js").default} [tileGrid] Tile grid. + * @property {boolean} [opaque=false] Whether the layer is opaque. + * @property {import("./State.js").default} [state] The source state. + * @property {number} [cacheSize] Number of tiles to retain in the cache. + * @property {number} [tilePixelRatio] Tile pixel ratio. + * @property {boolean} [wrapX=true] Render tiles beyond the antimeridian. + * @property {number} [transition] Transition time when fading in new tiles (in miliseconds). + */ + +/** + * @classdesc + * Base class for sources providing tiles divided into a tile grid. + * + * @fires import("./Tile.js").TileSourceEvent + * @api + */ +class DataTileSource extends TileSource { + /** + * @param {Options} options Image tile options. + */ + constructor(options) { + const projection = + options.projection === undefined ? 'EPSG:3857' : options.projection; + + let tileGrid = options.tileGrid; + if (tileGrid === undefined && projection) { + tileGrid = createXYZ({ + extent: extentFromProjection(projection), + maxResolution: options.maxResolution, + maxZoom: options.maxZoom, + minZoom: options.minZoom, + tileSize: options.tileSize, + }); + } + + super({ + cacheSize: options.cacheSize, + projection: projection, + tileGrid: tileGrid, + opaque: options.opaque, + state: options.state, + tilePixelRatio: options.tilePixelRatio, + wrapX: options.wrapX, + transition: options.transition, + }); + + /** + * @private + * @type {!Object} + */ + this.tileLoadingKeys_ = {}; + + /** + * @private + */ + this.loader_ = options.loader; + + this.handleTileChange_ = this.handleTileChange_.bind(this); + + /** + * @type {number} + */ + this.bandCount = 4; // assume RGBA + } + + /** + * @param {function(number, number, number) : Promise} loader The data loader. + * @protected + */ + setLoader(loader) { + this.loader_ = loader; + } + + /** + * @abstract + * @param {number} z Tile coordinate z. + * @param {number} x Tile coordinate x. + * @param {number} y Tile coordinate y. + * @param {number} pixelRatio Pixel ratio. + * @param {import("../proj/Projection.js").default} projection Projection. + * @return {!DataTile} Tile. + */ + getTile(z, x, y, pixelRatio, projection) { + const tileCoordKey = getKeyZXY(z, x, y); + if (this.tileCache.containsKey(tileCoordKey)) { + return this.tileCache.get(tileCoordKey); + } + + const sourceLoader = this.loader_; + function loader() { + return sourceLoader(z, x, y); + } + + const tile = new DataTile( + assign({tileCoord: [z, x, y], loader: loader}, this.tileOptions) + ); + tile.key = this.getKey(); + tile.addEventListener(EventType.CHANGE, this.handleTileChange_); + + this.tileCache.set(tileCoordKey, tile); + return tile; + } + + /** + * Handle tile change events. + * @param {import("../events/Event.js").default} event Event. + */ + handleTileChange_(event) { + const tile = /** @type {import("../Tile.js").default} */ (event.target); + const uid = getUid(tile); + const tileState = tile.getState(); + let type; + if (tileState == TileState.LOADING) { + this.tileLoadingKeys_[uid] = true; + type = TileEventType.TILELOADSTART; + } else if (uid in this.tileLoadingKeys_) { + delete this.tileLoadingKeys_[uid]; + type = + tileState == TileState.ERROR + ? TileEventType.TILELOADERROR + : tileState == TileState.LOADED + ? TileEventType.TILELOADEND + : undefined; + } + if (type) { + this.dispatchEvent(new TileSourceEvent(type, tile)); + } + } +} + +export default DataTileSource; diff --git a/src/ol/source/GeoTIFF.js b/src/ol/source/GeoTIFF.js new file mode 100644 index 0000000000..af20f311d2 --- /dev/null +++ b/src/ol/source/GeoTIFF.js @@ -0,0 +1,517 @@ +/** + * @module ol/source/GeoTIFF + */ +import DataTile from './DataTile.js'; +import State from './State.js'; +import TileGrid from '../tilegrid/TileGrid.js'; +import {Pool, fromUrl as tiffFromUrl, fromUrls as tiffFromUrls} from 'geotiff'; +import {create as createDecoderWorker} from '../worker/geotiff-decoder.js'; +import {getIntersection} from '../extent.js'; +import {get as getProjection} from '../proj.js'; +import {toSize} from '../size.js'; + +/** + * @typedef {Object} SourceInfo + * @property {string} url URL for the source GeoTIFF. + * @property {Array} [overviews] List of any overview URLs. + * @property {number} [min=0] The minimum source data value. Rendered values are scaled from 0 to 1 based on + * the configured min and max. + * @property {number} [max] The maximum source data value. Rendered values are scaled from 0 to 1 based on + * the configured min and max. + * @property {number} [nodata] Values to discard. When provided, an additional band (alpha) will be added + * to the data. + * @property {Array} [bands] Indices of the bands to be read from. If not provided, all bands will + * be read. If, for example, a GeoTIFF has red, green, blue and near-infrared bands and you only need the + * infrared band, configure `bands: [3]`. + */ + +let workerPool; +function getWorkerPool() { + if (!workerPool) { + workerPool = new Pool(undefined, createDecoderWorker()); + } + return workerPool; +} + +/** + * @param {import("geotiff/src/geotiff.js").GeoTIFF|import("geotiff/src/geotiff.js").MultiGeoTIFF} tiff A GeoTIFF. + * @return {Promise>} Resolves to a list of images. + */ +function getImagesForTIFF(tiff) { + return tiff.getImageCount().then(function (count) { + const requests = new Array(count); + for (let i = 0; i < count; ++i) { + requests[i] = tiff.getImage(i); + } + return Promise.all(requests); + }); +} + +/** + * @param {SourceInfo} source The GeoTIFF source. + * @return {Promise>} Resolves to a list of images. + */ +function getImagesForSource(source) { + let request; + if (source.overviews) { + request = tiffFromUrls(source.url, source.overviews); + } else { + request = tiffFromUrl(source.url); + } + return request.then(getImagesForTIFF); +} + +/** + * @param {number|Array|Array>} expected Expected value. + * @param {number|Array|Array>} got Actual value. + * @param {number} tolerance Accepted tolerance in fraction of expected between expected and got. + * @param {string} message The error message. + */ +function assertEqual(expected, got, tolerance, message) { + if (Array.isArray(expected)) { + const length = expected.length; + if (!Array.isArray(got) || length != got.length) { + throw new Error(message); + } + for (let i = 0; i < length; ++i) { + assertEqual(expected[i], got[i], tolerance, message); + } + return; + } + + got = /** @type {number} */ (got); + if (Math.abs(expected - got) > tolerance * expected) { + throw new Error(message); + } +} + +/** + * @param {Array} array The data array. + * @return {number} The minimum value. + */ +function getMinForDataType(array) { + if (array instanceof Int8Array) { + return -128; + } + if (array instanceof Int16Array) { + return -32768; + } + if (array instanceof Int32Array) { + return -2147483648; + } + if (array instanceof Float32Array) { + return 1.2e-38; + } + return 0; +} + +/** + * @param {Array} array The data array. + * @return {number} The maximum value. + */ +function getMaxForDataType(array) { + if (array instanceof Int8Array) { + return 127; + } + if (array instanceof Uint8Array) { + return 255; + } + if (array instanceof Uint8ClampedArray) { + return 255; + } + if (array instanceof Int16Array) { + return 32767; + } + if (array instanceof Uint16Array) { + return 65535; + } + if (array instanceof Int32Array) { + return 2147483647; + } + if (array instanceof Uint32Array) { + return 4294967295; + } + if (array instanceof Float32Array) { + return 3.4e38; + } + return 255; +} + +/** + * @typedef {Object} Options + * @property {Array} sources List of information about GeoTIFF sources. + * Multiple sources can be combined when their resolution sets are equal after applying a scale. + * The list of sources defines a mapping between input bands as they are read from each GeoTIFF, and + * the output bands that are provided by data tiles. To control which bands to read from each GeoTIFF, + * use the {@link import("./GeoTIFF.js").SourceInfo bands} property. If, for example, you spedify two + * sources, one with 3 bands and {@link import("./GeoTIFF.js").SourceInfo nodata} configured, and + * another with 1 band, the resulting data tiles will have 5 bands: 3 from the first source, 1 alpha + * band from the first source, and 1 band from the second source. + */ + +/** + * @classdesc + * A source for working with GeoTIFF data. + * @api + */ +class GeoTIFFSource extends DataTile { + /** + * @param {Options} options Data tile options. + */ + constructor(options) { + super({ + state: State.LOADING, + tileGrid: null, + projection: null, + }); + + /** + * @type {Array} + * @private + */ + this.sourceInfo_ = options.sources; + + const numSources = this.sourceInfo_.length; + + /** + * @type {Array>} + * @private + */ + this.sourceImagery_ = new Array(numSources); + + /** + * @type {Array} + * @private + */ + this.resolutionFactors_ = new Array(numSources); + + /** + * @type {Array} + * @private + */ + this.samplesPerPixel_; + + /** + * @type {Array>} + * @private + */ + this.nodataValues_; + + /** + * @type {boolean} + * @private + */ + this.addAlpha_ = false; + + /** + * @type {Error} + * @private + */ + this.error_ = null; + + this.setKey(this.sourceInfo_.map((source) => source.url).join(',')); + + const self = this; + const requests = new Array(numSources); + for (let i = 0; i < numSources; ++i) { + requests[i] = getImagesForSource(this.sourceInfo_[i]); + } + Promise.all(requests) + .then(function (sources) { + self.configure_(sources); + }) + .catch(function (error) { + self.error_ = error; + self.setState(State.ERROR); + }); + } + + /** + * @return {Error} A source loading error. When the source state is `error`, use this function + * to get more information about the error. To debug a faulty configuration, you may want to use + * a listener like + * ```js + * geotiffSource.on('change', () => { + * if (geotiffSource.getState() === 'error') { + * console.error(geotiffSource.getError()); + * } + * }); + * ``` + */ + getError() { + return this.error_; + } + + /** + * Configure the tile grid based on images within the source GeoTIFFs. Each GeoTIFF + * must have the same internal tiled structure. + * @param {Array>} sources Each source is a list of images + * from a single GeoTIFF. + * @private + */ + configure_(sources) { + let extent; + let origin; + let tileSizes; + let resolutions; + const samplesPerPixel = new Array(sources.length); + const nodataValues = new Array(sources.length); + let minZoom = 0; + + const sourceCount = sources.length; + for (let sourceIndex = 0; sourceIndex < sourceCount; ++sourceIndex) { + const images = sources[sourceIndex]; + const imageCount = images.length; + + let sourceExtent; + let sourceOrigin; + const sourceTileSizes = new Array(imageCount); + const sourceResolutions = new Array(imageCount); + + nodataValues[sourceIndex] = new Array(imageCount); + + for (let imageIndex = 0; imageIndex < imageCount; ++imageIndex) { + const image = images[imageIndex]; + const nodataValue = image.getGDALNoData(); + nodataValues[sourceIndex][imageIndex] = + nodataValue === null ? NaN : nodataValue; + + const wantedSamples = this.sourceInfo_[sourceIndex].bands; + samplesPerPixel[sourceIndex] = wantedSamples + ? wantedSamples.length + : image.getSamplesPerPixel(); + const level = imageCount - (imageIndex + 1); + + if (!sourceExtent) { + sourceExtent = image.getBoundingBox(); + } + + if (!sourceOrigin) { + sourceOrigin = image.getOrigin().slice(0, 2); + } + + sourceResolutions[level] = image.getResolution(images[0])[0]; + sourceTileSizes[level] = [image.getTileWidth(), image.getTileHeight()]; + } + + if (!extent) { + extent = sourceExtent; + } else { + getIntersection(extent, sourceExtent, extent); + } + + if (!origin) { + origin = sourceOrigin; + } else { + const message = `Origin mismatch for source ${sourceIndex}, got [${sourceOrigin}] but expected [${origin}]`; + assertEqual(origin, sourceOrigin, 0, message); + } + + if (!resolutions) { + resolutions = sourceResolutions; + this.resolutionFactors_[sourceIndex] = 1; + } else { + if (resolutions.length - minZoom > sourceResolutions.length) { + minZoom = resolutions.length - sourceResolutions.length; + } + const resolutionFactor = + resolutions[resolutions.length - 1] / + sourceResolutions[sourceResolutions.length - 1]; + this.resolutionFactors_[sourceIndex] = resolutionFactor; + const scaledSourceResolutions = sourceResolutions.map( + (resolution) => (resolution *= resolutionFactor) + ); + const message = `Resolution mismatch for source ${sourceIndex}, got [${scaledSourceResolutions}] but expected [${resolutions}]`; + assertEqual( + resolutions.slice(minZoom, resolutions.length), + scaledSourceResolutions, + 0.005, + message + ); + } + + if (!tileSizes) { + tileSizes = sourceTileSizes; + } else { + assertEqual( + tileSizes.slice(minZoom, tileSizes.length), + sourceTileSizes, + 0, + `Tile size mismatch for source ${sourceIndex}` + ); + } + + this.sourceImagery_[sourceIndex] = images.reverse(); + } + + for (let i = 0, ii = this.sourceImagery_.length; i < ii; ++i) { + const sourceImagery = this.sourceImagery_[i]; + while (sourceImagery.length < resolutions.length) { + sourceImagery.unshift(undefined); + } + } + + if (!this.getProjection()) { + const firstImage = sources[0][0]; + if (firstImage.geoKeys) { + const code = + firstImage.geoKeys.ProjectedCSTypeGeoKey || + firstImage.geoKeys.GeographicTypeGeoKey; + if (code) { + this.projection = getProjection(`EPSG:${code}`); + } + } + } + + this.samplesPerPixel_ = samplesPerPixel; + this.nodataValues_ = nodataValues; + + // decide if we need to add an alpha band to handle nodata + outer: for (let sourceIndex = 0; sourceIndex < sourceCount; ++sourceIndex) { + // option 1: source is configured with a nodata value + if (this.sourceInfo_[sourceIndex].nodata !== undefined) { + this.addAlpha_ = true; + break; + } + + const values = nodataValues[sourceIndex]; + + // option 2: check image metadata for limited bands + const bands = this.sourceInfo_[sourceIndex].bands; + if (bands) { + for (let i = 0; i < bands.length; ++i) { + if (!isNaN(values[bands[i]])) { + this.addAlpha_ = true; + break outer; + } + } + continue; + } + + // option 3: check image metadata for all bands + for (let imageIndex = 0; imageIndex < values.length; ++imageIndex) { + if (!isNaN(values[imageIndex])) { + this.addAlpha_ = true; + break outer; + } + } + } + + const additionalBands = this.addAlpha_ ? 1 : 0; + this.bandCount = + samplesPerPixel.reduce((accumulator, value) => { + accumulator += value; + return accumulator; + }, 0) + additionalBands; + + const tileGrid = new TileGrid({ + extent: extent, + minZoom: minZoom, + origin: origin, + resolutions: resolutions, + tileSizes: tileSizes, + }); + + this.tileGrid = tileGrid; + + this.setLoader(this.loadTile_.bind(this)); + this.setState(State.READY); + } + + loadTile_(z, x, y) { + const size = toSize(this.tileGrid.getTileSize(z)); + + const sourceCount = this.sourceImagery_.length; + const requests = new Array(sourceCount); + const addAlpha = this.addAlpha_; + const bandCount = this.bandCount; + const samplesPerPixel = this.samplesPerPixel_; + const sourceInfo = this.sourceInfo_; + for (let sourceIndex = 0; sourceIndex < sourceCount; ++sourceIndex) { + const source = sourceInfo[sourceIndex]; + const resolutionFactor = this.resolutionFactors_[sourceIndex]; + const pixelBounds = [ + Math.round(x * (size[0] * resolutionFactor)), + Math.round(y * (size[1] * resolutionFactor)), + Math.round((x + 1) * (size[0] * resolutionFactor)), + Math.round((y + 1) * (size[1] * resolutionFactor)), + ]; + const image = this.sourceImagery_[sourceIndex][z]; + requests[sourceIndex] = image.readRasters({ + window: pixelBounds, + width: size[0], + height: size[1], + samples: source.bands, + fillValue: source.nodata, + pool: getWorkerPool(), + }); + } + + const pixelCount = size[0] * size[1]; + const dataLength = pixelCount * bandCount; + const nodataValues = this.nodataValues_; + + return Promise.all(requests).then(function (sourceSamples) { + const data = new Uint8ClampedArray(dataLength); + let dataIndex = 0; + for (let pixelIndex = 0; pixelIndex < pixelCount; ++pixelIndex) { + let transparent = addAlpha; + for (let sourceIndex = 0; sourceIndex < sourceCount; ++sourceIndex) { + const source = sourceInfo[sourceIndex]; + let min = source.min; + if (min === undefined) { + min = getMinForDataType(sourceSamples[sourceIndex][0]); + } + let max = source.max; + if (max === undefined) { + max = getMaxForDataType(sourceSamples[sourceIndex][0]); + } + + const gain = 255 / (max - min); + const bias = -min * gain; + + for ( + let sampleIndex = 0; + sampleIndex < samplesPerPixel[sourceIndex]; + ++sampleIndex + ) { + const sourceValue = + sourceSamples[sourceIndex][sampleIndex][pixelIndex]; + + const value = gain * sourceValue + bias; + if (!addAlpha) { + data[dataIndex] = value; + } else { + let nodata = source.nodata; + if (nodata === undefined) { + let bandIndex; + if (source.bands) { + bandIndex = source.bands[sampleIndex]; + } else { + bandIndex = sampleIndex; + } + nodata = nodataValues[sourceIndex][bandIndex]; + } + + if (sourceValue !== nodata) { + transparent = false; + data[dataIndex] = value; + } + } + dataIndex++; + } + } + if (addAlpha) { + if (!transparent) { + data[dataIndex] = 255; + } + dataIndex++; + } + } + + return data; + }); + } +} + +export default GeoTIFFSource; diff --git a/src/ol/style/expressions.js b/src/ol/style/expressions.js index 106190727e..37e371e6f2 100644 --- a/src/ol/style/expressions.js +++ b/src/ol/style/expressions.js @@ -3,21 +3,29 @@ * @module ol/style/expressions */ +import {Uniforms} from '../renderer/webgl/TileLayer.js'; import {asArray, isStringColor} from '../color.js'; /** * Base type used for literal style parameters; can be a number literal or the output of an operator, - * which in turns takes {@link ExpressionValue} arguments. + * which in turns takes {@link import("./expressions.js").ExpressionValue} arguments. * * The following operators can be used: * * * Reading operators: + * * `['band', bandIndex, xOffset, yOffset]` For tile layers only. Fetches pixel values from band + * `bandIndex` of the source's data. The first `bandIndex` of the source data is `1`. Fetched values + * are in the 0..1 range. {@link import("../source/TileImage.js").default} sources have 4 bands: red, + * green, blue and alpha. {@link import("../source/DataTile.js").default} sources can have any number + * of bands, depending on the underlying data source and + * {@link import("../source/GeoTIFF.js").Options configuration}. `xOffset` and `yOffset` are optional + * and allow specifying pixel offsets for x and y. This is used for sampling data from neighboring pixels. * * `['get', 'attributeName']` fetches a feature attribute (it will be prefixed by `a_` in the shader) * Note: those will be taken from the attributes provided to the renderer - * * `['var', 'varName']` fetches a value from the style variables, or 0 if undefined - * * `['time']` returns the time in seconds since the creation of the layer - * * `['zoom']` returns the current zoom level * * `['resolution']` returns the current resolution + * * `['time']` returns the time in seconds since the creation of the layer + * * `['var', 'varName']` fetches a value from the style variables, or 0 if undefined + * * `['zoom']` returns the current zoom level * * * Math operators: * * `['*', value1, value2]` multiplies `value1` by `value2` @@ -27,6 +35,10 @@ import {asArray, isStringColor} from '../color.js'; * * `['clamp', value, low, high]` clamps `value` between `low` and `high` * * `['%', value1, value2]` returns the result of `value1 % value2` (modulo) * * `['^', value1, value2]` returns the value of `value1` raised to the `value2` power + * * `['abs', value1]` returns the absolute value of `value1` + * * `['sin', value1]` returns the sine of `value1` + * * `['cos', value1]` returns the cosine of `value1` + * * `['atan', value1, value2]` returns `atan2(value1, value2)`. If `value2` is not provided, returns `atan(value1)` * * * Transform operators: * * `['case', condition1, output1, ...conditionN, outputN, fallback]` selects the first output whose corresponding @@ -73,6 +85,7 @@ import {asArray, isStringColor} from '../color.js'; * * {@link module:ol/color~Color} * * @typedef {Array<*>|import("../color.js").Color|string|number|boolean} ExpressionValue + * @api */ /** @@ -170,6 +183,7 @@ export function isTypeUnique(valueType) { * @property {Array} variables List of variables used in the expression; contains **unprefixed names** * @property {Array} attributes List of attributes used in the expression; contains **unprefixed names** * @property {Object} stringLiteralsMap This object maps all encountered string values to a number + * @property {number} [bandCount] Number of bands per pixel. */ /** @@ -402,6 +416,43 @@ Operators['var'] = { }, }; +Operators['band'] = { + getReturnType: function (args) { + return ValueTypes.NUMBER; + }, + toGlsl: function (context, args) { + assertArgsMinCount(args, 1); + assertArgsMaxCount(args, 3); + const band = args[0]; + if (typeof band !== 'number') { + throw new Error('Band index must be a number'); + } + const zeroBasedBand = band - 1; + const colorIndex = Math.floor(zeroBasedBand / 4); + let bandIndex = zeroBasedBand % 4; + if (band === context.bandCount && bandIndex === 1) { + // LUMINANCE_ALPHA - band 1 assigned to rgb and band 2 assigned to alpha + bandIndex = 3; + } + if (args.length === 1) { + return `color${colorIndex}[${bandIndex}]`; + } else { + const xOffset = args[1]; + const yOffset = args[2] || 0; + assertNumber(xOffset); + assertNumber(yOffset); + const uniformName = Uniforms.TILE_TEXTURE_PREFIX + colorIndex; + return `texture2D(${uniformName}, v_textureCoord + vec2(${expressionToGlsl( + context, + xOffset + )} / ${Uniforms.TEXTURE_PIXEL_WIDTH}, ${expressionToGlsl( + context, + yOffset + )} / ${Uniforms.TEXTURE_PIXEL_HEIGHT}))[${bandIndex}]`; + } + }, +}; + Operators['time'] = { getReturnType: function (args) { return ValueTypes.NUMBER; @@ -529,6 +580,56 @@ Operators['^'] = { }, }; +Operators['abs'] = { + getReturnType: function (args) { + return ValueTypes.NUMBER; + }, + toGlsl: function (context, args) { + assertArgsCount(args, 1); + assertNumbers(args); + return `abs(${expressionToGlsl(context, args[0])})`; + }, +}; + +Operators['sin'] = { + getReturnType: function (args) { + return ValueTypes.NUMBER; + }, + toGlsl: function (context, args) { + assertArgsCount(args, 1); + assertNumbers(args); + return `sin(${expressionToGlsl(context, args[0])})`; + }, +}; + +Operators['cos'] = { + getReturnType: function (args) { + return ValueTypes.NUMBER; + }, + toGlsl: function (context, args) { + assertArgsCount(args, 1); + assertNumbers(args); + return `cos(${expressionToGlsl(context, args[0])})`; + }, +}; + +Operators['atan'] = { + getReturnType: function (args) { + return ValueTypes.NUMBER; + }, + toGlsl: function (context, args) { + assertArgsMinCount(args, 1); + assertArgsMaxCount(args, 2); + assertNumbers(args); + return args.length === 2 + ? `atan(${expressionToGlsl(context, args[0])}, ${expressionToGlsl( + context, + args[1] + )})` + : `atan(${expressionToGlsl(context, args[0])})`; + }, +}; + Operators['>'] = { getReturnType: function (args) { return ValueTypes.BOOLEAN; @@ -748,17 +849,16 @@ Operators['interpolate'] = { assertUniqueInferredType(args, outputType); const input = expressionToGlsl(context, args[1]); - let result = null; + const exponent = numberToGlsl(interpolation); + + let result = ''; for (let i = 2; i < args.length - 2; i += 2) { const stop1 = expressionToGlsl(context, args[i]); - const output1 = expressionToGlsl(context, args[i + 1], outputType); + const output1 = + result || expressionToGlsl(context, args[i + 1], outputType); const stop2 = expressionToGlsl(context, args[i + 2]); const output2 = expressionToGlsl(context, args[i + 3], outputType); - result = `mix(${ - result || output1 - }, ${output2}, pow(clamp((${input} - ${stop1}) / (${stop2} - ${stop1}), 0.0, 1.0), ${numberToGlsl( - interpolation - )}))`; + result = `mix(${output1}, ${output2}, pow(clamp((${input} - ${stop1}) / (${stop2} - ${stop1}), 0.0, 1.0), ${exponent}))`; } return result; }, diff --git a/src/ol/webgl/Helper.js b/src/ol/webgl/Helper.js index 69001218c2..25e21de657 100644 --- a/src/ol/webgl/Helper.js +++ b/src/ol/webgl/Helper.js @@ -434,9 +434,10 @@ class WebGLHelper extends Disposable { * Post process passes will be initialized here, the first one being bound as a render target for * subsequent draw calls. * @param {import("../PluggableMap.js").FrameState} frameState current frame state + * @param {boolean} [opt_disableAlphaBlend] If true, no alpha blending will happen. * @api */ - prepareDraw(frameState) { + prepareDraw(frameState, opt_disableAlphaBlend) { const gl = this.getGL(); const canvas = this.getCanvas(); const size = frameState.size; @@ -459,7 +460,10 @@ class WebGLHelper extends Disposable { gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.blendFunc( + gl.ONE, + opt_disableAlphaBlend ? gl.ZERO : gl.ONE_MINUS_SRC_ALPHA + ); gl.useProgram(this.currentProgram_); this.applyFrameState(frameState); diff --git a/src/ol/webgl/TileTexture.js b/src/ol/webgl/TileTexture.js new file mode 100644 index 0000000000..794c646464 --- /dev/null +++ b/src/ol/webgl/TileTexture.js @@ -0,0 +1,213 @@ +/** + * @module ol/webgl/TileTexture + */ + +import EventTarget from '../events/Target.js'; +import EventType from '../events/EventType.js'; +import ImageTile from '../ImageTile.js'; +import TileState from '../TileState.js'; +import WebGLArrayBuffer from './Buffer.js'; +import {ARRAY_BUFFER, STATIC_DRAW} from '../webgl.js'; +import {toSize} from '../size.js'; + +function bindAndConfigure(gl, texture) { + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); +} + +/** + * @param {WebGLRenderingContext} gl The WebGL context. + * @param {WebGLTexture} texture The texture. + * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} image The image. + */ +function uploadImageTexture(gl, texture, image) { + bindAndConfigure(gl, texture); + + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); +} + +/** + * @param {WebGLRenderingContext} gl The WebGL context. + * @param {WebGLTexture} texture The texture. + * @param {import("../DataTile.js").Data} data The pixel data. + * @param {import("../size.js").Size} size The pixel size. + * @param {number} bandCount The band count. + */ +function uploadDataTexture(gl, texture, data, size, bandCount) { + bindAndConfigure(gl, texture); + + let format; + switch (bandCount) { + case 1: { + format = gl.LUMINANCE; + break; + } + case 2: { + format = gl.LUMINANCE_ALPHA; + break; + } + case 3: { + format = gl.RGB; + break; + } + case 4: { + format = gl.RGBA; + break; + } + default: { + throw new Error(`Unsupported number of bands: ${bandCount}`); + } + } + + gl.texImage2D( + gl.TEXTURE_2D, + 0, + format, + size[0], + size[1], + 0, + format, + gl.UNSIGNED_BYTE, + data + ); +} + +class TileTexture extends EventTarget { + /** + * @param {import("../DataTile.js").default|import("../ImageTile.js").default} tile The tile. + * @param {import("../tilegrid/TileGrid.js").default} grid Tile grid. + * @param {import("../webgl/Helper.js").default} helper WebGL helper. + */ + constructor(tile, grid, helper) { + super(); + + /** + * @type {import("../DataTile.js").default|import("../ImageTile.js").default} + */ + this.tile; + + /** + * @type {Array} + */ + this.textures = []; + this.handleTileChange_ = this.handleTileChange_.bind(this); + + this.size = toSize(grid.getTileSize(tile.tileCoord[0])); + + this.bandCount = NaN; + + this.helper_ = helper; + + const coords = new WebGLArrayBuffer(ARRAY_BUFFER, STATIC_DRAW); + coords.fromArray([ + 0, // P0 + 1, + 1, // P1 + 1, + 1, // P2 + 0, + 0, // P3 + 0, + ]); + helper.flushBufferData(coords); + + this.coords = coords; + this.setTile(tile); + } + + /** + * @param {import("../DataTile.js").default|import("../ImageTile.js").default} tile Tile. + */ + setTile(tile) { + if (tile !== this.tile) { + if (this.tile) { + this.tile.removeEventListener(EventType.CHANGE, this.handleTileChange_); + } + this.tile = tile; + this.textures.length = 0; + this.loaded = tile.getState() === TileState.LOADED; + if (this.loaded) { + this.uploadTile_(); + } else { + tile.addEventListener(EventType.CHANGE, this.handleTileChange_); + } + } + } + + uploadTile_() { + const gl = this.helper_.getGL(); + const tile = this.tile; + + if (tile instanceof ImageTile) { + const texture = gl.createTexture(); + this.textures.push(texture); + this.bandCount = 4; + uploadImageTexture(gl, texture, tile.getImage()); + return; + } + + const data = tile.getData(); + const pixelCount = this.size[0] * this.size[1]; + this.bandCount = data.byteLength / pixelCount; + const textureCount = Math.ceil(this.bandCount / 4); + + if (textureCount === 1) { + const texture = gl.createTexture(); + this.textures.push(texture); + uploadDataTexture(gl, texture, data, this.size, this.bandCount); + return; + } + + const textureDataArrays = new Array(textureCount); + for (let textureIndex = 0; textureIndex < textureCount; ++textureIndex) { + const texture = gl.createTexture(); + this.textures.push(texture); + + const bandCount = + textureIndex < textureCount - 1 ? 4 : this.bandCount % 4; + textureDataArrays[textureIndex] = new Uint8Array(pixelCount * bandCount); + } + + const valueCount = pixelCount * this.bandCount; + for (let dataIndex = 0; dataIndex < valueCount; ++dataIndex) { + const bandIndex = dataIndex % this.bandCount; + const textureBandIndex = bandIndex % 4; + const textureIndex = Math.floor(bandIndex / 4); + const bandCount = + textureIndex < textureCount - 1 ? 4 : this.bandCount % 4; + const pixelIndex = Math.floor(dataIndex / this.bandCount); + textureDataArrays[textureIndex][ + pixelIndex * bandCount + textureBandIndex + ] = data[dataIndex]; + } + + for (let textureIndex = 0; textureIndex < textureCount; ++textureIndex) { + const bandCount = + textureIndex < textureCount - 1 ? 4 : this.bandCount % 4; + const texture = this.textures[textureIndex]; + const data = textureDataArrays[textureIndex]; + uploadDataTexture(gl, texture, data, this.size, bandCount); + } + } + + handleTileChange_() { + if (this.tile.getState() === TileState.LOADED) { + this.loaded = true; + this.uploadTile_(); + this.dispatchEvent(EventType.CHANGE); + } + } + + disposeInternal() { + const gl = this.helper_.getGL(); + this.helper_.deleteBuffer(this.coords); + for (let i = 0; i < this.textures.length; ++i) { + gl.deleteTexture(this.textures[i]); + } + this.tile.removeEventListener(EventType.CHANGE, this.handleTileChange_); + } +} + +export default TileTexture; diff --git a/src/ol/worker/geotiff-decoder.js b/src/ol/worker/geotiff-decoder.js new file mode 100644 index 0000000000..3dff2cce22 --- /dev/null +++ b/src/ol/worker/geotiff-decoder.js @@ -0,0 +1,5 @@ +/* eslint-disable sort-imports-es6-autofix/sort-imports-es6 */ +import 'regenerator-runtime/runtime.js'; +import 'geotiff/src/decoder.worker.js'; + +export let create; diff --git a/test/browser/karma.config.cjs b/test/browser/karma.config.cjs index 296d201efe..9cba92f393 100644 --- a/test/browser/karma.config.cjs +++ b/test/browser/karma.config.cjs @@ -71,6 +71,13 @@ module.exports = function (karma) { webpack: { devtool: 'inline-source-map', mode: 'development', + resolve: { + fallback: { + fs: false, + http: false, + https: false, + }, + }, module: { rules: [ { diff --git a/test/browser/spec/ol/datatile.test.js b/test/browser/spec/ol/datatile.test.js new file mode 100644 index 0000000000..6ec7c2168c --- /dev/null +++ b/test/browser/spec/ol/datatile.test.js @@ -0,0 +1,51 @@ +import DataTile from '../../../../src/ol/DataTile.js'; +import TileState from '../../../../src/ol/TileState.js'; + +describe('ol.DataTile', function () { + /** @type {Promise { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + context.fillStyle = 'red'; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }; + }); + + describe('constructor', function () { + it('sets options', function () { + const tileCoord = [0, 0, 0]; + const tile = new DataTile({ + tileCoord: tileCoord, + loader: loader, + transition: 200, + }); + expect(tile.tileCoord).to.equal(tileCoord); + expect(tile.transition_).to.be(200); + expect(tile.loader_).to.equal(loader); + }); + }); + + describe('#load()', function () { + it('handles loading states correctly', function (done) { + const tileCoord = [0, 0, 0]; + const tile = new DataTile({ + tileCoord: tileCoord, + loader: loader, + }); + expect(tile.getState()).to.be(TileState.IDLE); + tile.load(); + expect(tile.getState()).to.be(TileState.LOADING); + setTimeout(() => { + expect(tile.getState()).to.be(TileState.LOADED); + done(); + }, 16); + }); + }); +}); diff --git a/test/browser/spec/ol/layer/webgltile.test.js b/test/browser/spec/ol/layer/webgltile.test.js new file mode 100644 index 0000000000..1fae9b1771 --- /dev/null +++ b/test/browser/spec/ol/layer/webgltile.test.js @@ -0,0 +1,139 @@ +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import Map from '../../../../../src/ol/Map.js'; +import View from '../../../../../src/ol/View.js'; +import WebGLHelper from '../../../../../src/ol/webgl/Helper.js'; +import WebGLTileLayer from '../../../../../src/ol/layer/WebGLTile.js'; +import {createCanvasContext2D} from '../../../../../src/ol/dom.js'; + +describe('ol.layer.Tile', function () { + /** @type {WebGLTileLayer} */ + let layer; + /** @type {Map} */ + let map, target; + + beforeEach(function (done) { + layer = new WebGLTileLayer({ + className: 'testlayer', + source: new DataTileSource({ + loader(z, x, y) { + return new Promise((resolve) => { + resolve(new ImageData(256, 256)); + }); + }, + }), + style: { + variables: { + r: 0, + g: 255, + b: 0, + }, + color: ['color', ['var', 'r'], ['var', 'g'], ['var', 'b']], + }, + }); + target = document.createElement('div'); + target.style.width = '100px'; + target.style.height = '100px'; + document.body.appendChild(target); + map = new Map({ + target: target, + layers: [layer], + view: new View({ + center: [0, 0], + zoom: 2, + }), + }); + map.once('rendercomplete', () => done()); + }); + + afterEach(function () { + map.setTarget(null); + document.body.removeChild(target); + }); + + it('creates fragment and vertex shaders', function () { + const compileShaderSpy = sinon.spy(WebGLHelper.prototype, 'compileShader'); + layer.createRenderer(); + compileShaderSpy.restore(); + expect(compileShaderSpy.callCount).to.be(2); + expect(compileShaderSpy.getCall(0).args[0].replace(/[ \n]+/g, ' ')).to.be( + ` + #ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + #else + precision mediump float; + #endif + varying vec2 v_textureCoord; + uniform float u_transitionAlpha; + uniform float u_texturePixelWidth; + uniform float u_texturePixelHeight; + uniform float u_resolution; + uniform float u_zoom; + uniform float u_var_r; + uniform float u_var_g; + uniform float u_var_b; + uniform sampler2D u_tileTexture0; + void main() { + vec4 color0 = texture2D(u_tileTexture0, v_textureCoord); + vec4 color = color0; + color = vec4(u_var_r / 255.0, u_var_g / 255.0, u_var_b / 255.0, 1.0); + if (color.a == 0.0) { + discard; + } + gl_FragColor = color; + gl_FragColor.rgb *= gl_FragColor.a; + gl_FragColor *= u_transitionAlpha; + }`.replace(/[ \n]+/g, ' ') + ); + + expect(compileShaderSpy.getCall(1).args[0].replace(/[ \n]+/g, ' ')).to.be( + ` + attribute vec2 a_textureCoord; + uniform mat4 u_tileTransform; + uniform float u_depth; + + varying vec2 v_textureCoord; + void main() { + v_textureCoord = a_textureCoord; + gl_Position = u_tileTransform * vec4(a_textureCoord, u_depth, 1.0); + } + `.replace(/[ \n]+/g, ' ') + ); + }); + + it('updates style variables', function (done) { + layer.updateStyleVariables({ + r: 255, + g: 0, + b: 255, + }); + expect(layer.styleVariables_['r']).to.be(255); + const targetContext = createCanvasContext2D(100, 100); + layer.on('postrender', () => { + targetContext.clearRect(0, 0, 100, 100); + targetContext.drawImage(target.querySelector('.testlayer'), 0, 0); + }); + map.once('rendercomplete', () => { + expect(Array.from(targetContext.getImageData(0, 0, 1, 1).data)).to.eql([ + 255, 0, 255, 255, + ]); + done(); + }); + }); + + it('throws on incorrect style configs', function () { + function incorrectStyle() { + layer.style_ = { + variables: { + 'red': 25, + 'green': 200, + }, + exposure: 0, + contrast: 0, + saturation: 0, + color: ['color', ['var', 'red'], ['var', 'green'], ['var', 'blue']], + }; + layer.createRenderer(); + } + expect(incorrectStyle).to.throwException(); // missing 'blue' in styleVariables + }); +}); diff --git a/test/browser/spec/ol/renderer/webgl/tilelayer.test.js b/test/browser/spec/ol/renderer/webgl/tilelayer.test.js new file mode 100644 index 0000000000..d1dc885246 --- /dev/null +++ b/test/browser/spec/ol/renderer/webgl/tilelayer.test.js @@ -0,0 +1,95 @@ +import TileQueue from '../../../../../../src/ol/TileQueue.js'; +import TileState from '../../../../../../src/ol/TileState.js'; +import WebGLTileLayer from '../../../../../../src/ol/layer/WebGLTile.js'; +import {DataTile} from '../../../../../../src/ol/source.js'; +import {VOID} from '../../../../../../src/ol/functions.js'; +import {create} from '../../../../../../src/ol/transform.js'; +import {createCanvasContext2D} from '../../../../../../src/ol/dom.js'; +import {get} from '../../../../../../src/ol/proj.js'; + +describe('ol.renderer.webgl.TileLayer', function () { + /** @type {import("../../../../../../src/ol/renderer/webgl/TileLayer.js").default} */ + let renderer; + /** @type {WebGLTileLayer} */ + let tileLayer; + /** @type {import('../../../../../../src/ol/PluggableMap.js').FrameState} */ + let frameState; + beforeEach(function () { + const size = 256; + const context = createCanvasContext2D(size, size); + + tileLayer = new WebGLTileLayer({ + source: new DataTile({ + loader: function (z, x, y) { + context.clearRect(0, 0, size, size); + context.fillStyle = 'rgba(100, 100, 100, 0.5)'; + context.fillRect(0, 0, size, size); + const data = context.getImageData(0, 0, size, size).data; + return Promise.resolve(data); + }, + }), + }); + + renderer = tileLayer.createRenderer(); + + const proj = get('EPSG:3857'); + frameState = { + layerStatesArray: [tileLayer.getLayerState()], + layerIndex: 0, + extent: proj.getExtent(), + pixelRatio: 1, + pixelToCoordinateTransform: create(), + postRenderFunctions: [], + time: Date.now(), + viewHints: [], + viewState: { + center: [0, 0], + resolution: 156543.03392804097, + projection: proj, + }, + size: [256, 256], + usedTiles: {}, + wantedTiles: {}, + tileQueue: new TileQueue(VOID, VOID), + }; + }); + + it('#prepareFrame()', function () { + const source = tileLayer.getSource(); + tileLayer.setSource(null); + expect(renderer.prepareFrame(frameState)).to.be(false); + tileLayer.setSource(source); + expect(renderer.prepareFrame(frameState)).to.be(true); + const tileGrid = source.getTileGrid(); + tileLayer.setExtent(tileGrid.getTileCoordExtent([2, 0, 0])); + frameState.resolution = tileGrid.getResolution(2); + frameState.extent = tileGrid.getTileCoordExtent([2, 2, 2]); + frameState.layerStatesArray = [tileLayer.getLayerState()]; + expect(renderer.prepareFrame(frameState)).to.be(false); + }); + + it('#renderFrame()', function () { + const rendered = renderer.renderFrame(frameState); + expect(rendered).to.be.a(HTMLCanvasElement); + expect(frameState.tileQueue.getCount()).to.be(1); + expect(Object.keys(frameState.wantedTiles).length).to.be(1); + expect(frameState.postRenderFunctions.length).to.be(0); // no tile expired + expect(renderer.tileTextureCache_.count_).to.be(1); + }); + + it('#isDrawableTile()', function (done) { + const tile = tileLayer.getSource().getTile(0, 0, 0); + expect(renderer.isDrawableTile(tile)).to.be(false); + tileLayer.getSource().on('tileloadend', () => { + expect(renderer.isDrawableTile(tile)).to.be(true); + done(); + }); + tile.load(); + const errorTile = tileLayer.getSource().getTile(1, 0, 1); + errorTile.setState(TileState.ERROR); + tileLayer.setUseInterimTilesOnError(false); + expect(renderer.isDrawableTile(errorTile)).to.be(true); + tileLayer.setUseInterimTilesOnError(true); + expect(renderer.isDrawableTile(errorTile)).to.be(false); + }); +}); diff --git a/test/browser/spec/ol/source/datatile.test.js b/test/browser/spec/ol/source/datatile.test.js new file mode 100644 index 0000000000..0b3aafd3ef --- /dev/null +++ b/test/browser/spec/ol/source/datatile.test.js @@ -0,0 +1,42 @@ +import DataTile from '../../../../../src/ol/DataTile.js'; +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import TileState from '../../../../../src/ol/TileState.js'; + +describe('ol.source.DataTile', function () { + /** @type {DataTileSource} */ + let source; + beforeEach(function () { + const loader = function (z, x, y) { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + // encode tile coordinate in rgb + context.fillStyle = `rgb(${z}, ${x % 255}, ${y % 255})`; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }; + source = new DataTileSource({ + loader: loader, + }); + }); + + describe('#getTile()', function () { + it('gets tiles and fires a tileloadend event', function (done) { + const tile = source.getTile(3, 2, 1); + expect(tile).to.be.a(DataTile); + expect(tile.state).to.be(TileState.IDLE); + + source.on('tileloadend', () => { + expect(tile.state).to.be(TileState.LOADED); + // decode tile coordinate from rgb + expect(Array.from(tile.getData().slice(0, 3))).to.eql([3, 2, 1]); + done(); + }); + + tile.load(); + }); + }); +}); diff --git a/test/browser/spec/ol/source/geotiff.test.js b/test/browser/spec/ol/source/geotiff.test.js new file mode 100644 index 0000000000..de76b67e98 --- /dev/null +++ b/test/browser/spec/ol/source/geotiff.test.js @@ -0,0 +1,47 @@ +import GeoTIFFSource from '../../../../../src/ol/source/GeoTIFF.js'; +import State from '../../../../../src/ol/source/State.js'; +import TileState from '../../../../../src/ol/TileState.js'; + +describe('ol.source.GeoTIFF', function () { + /** @type {GeoTIFFSource} */ + let source; + beforeEach(function () { + source = new GeoTIFFSource({ + sources: [ + { + url: 'spec/ol/source/images/0-0-0.tif', + }, + ], + }); + }); + + it('manages load states', function (done) { + expect(source.getState()).to.be(State.LOADING); + source.on('change', () => { + expect(source.getState()).to.be(State.READY); + done(); + }); + }); + + it('configures itself from source metadata', function (done) { + source.on('change', () => { + expect(source.addAlpha_).to.be(true); + expect(source.bandCount).to.be(4); + expect(source.nodataValues_).to.eql([[0]]); + expect(source.getTileGrid().getResolutions().length).to.be(1); + expect(source.projection.getCode()).to.be('EPSG:4326'); + done(); + }); + }); + + it('loads tiles', function (done) { + source.on('change', () => { + const tile = source.getTile(0, 0, 0); + source.on('tileloadend', () => { + expect(tile.getState()).to.be(TileState.LOADED); + done(); + }); + tile.load(); + }); + }); +}); diff --git a/test/browser/spec/ol/source/images/0-0-0.tif b/test/browser/spec/ol/source/images/0-0-0.tif new file mode 100644 index 0000000000..3507f24029 Binary files /dev/null and b/test/browser/spec/ol/source/images/0-0-0.tif differ diff --git a/test/browser/spec/ol/style/expressions.test.js b/test/browser/spec/ol/style/expressions.test.js index caabe63156..d2e6980b0c 100644 --- a/test/browser/spec/ol/style/expressions.test.js +++ b/test/browser/spec/ol/style/expressions.test.js @@ -235,6 +235,18 @@ describe('ol.style.expressions', function () { expect(expressionToGlsl(context, ['^', ['%', ['time'], 10], 2])).to.eql( 'pow(mod(u_time, 10.0), 2.0)' ); + expect( + expressionToGlsl(context, [ + 'abs', + ['-', ['get', 'attr3'], ['get', 'attr2']], + ]) + ).to.eql('abs((a_attr3 - a_attr2))'); + expect(expressionToGlsl(context, ['sin', 1])).to.eql('sin(1.0)'); + expect(expressionToGlsl(context, ['cos', 1])).to.eql('cos(1.0)'); + expect(expressionToGlsl(context, ['atan', 1])).to.eql('atan(1.0)'); + expect(expressionToGlsl(context, ['atan', 1, 0.5])).to.eql( + 'atan(1.0, 0.5)' + ); expect(expressionToGlsl(context, ['>', 10, ['get', 'attr4']])).to.eql( '(10.0 > a_attr4)' ); @@ -277,6 +289,10 @@ describe('ol.style.expressions', function () { expect( expressionToGlsl(context, ['color', ['get', 'attr4'], 1, 2, 0.5]) ).to.eql('vec4(a_attr4 / 255.0, 1.0 / 255.0, 2.0 / 255.0, 0.5)'); + expect(expressionToGlsl(context, ['band', 1])).to.eql('color0[0]'); + expect(expressionToGlsl(context, ['band', 1, -1, 2])).to.eql( + 'texture2D(u_tileTexture0, v_textureCoord + vec2(-1.0 / u_texturePixelWidth, 2.0 / u_texturePixelHeight))[0]' + ); }); it('throws if the value does not match the type', function () { diff --git a/test/browser/spec/ol/webgl/tiletexture.test.js b/test/browser/spec/ol/webgl/tiletexture.test.js new file mode 100644 index 0000000000..ef6bc29733 --- /dev/null +++ b/test/browser/spec/ol/webgl/tiletexture.test.js @@ -0,0 +1,86 @@ +import DataTile from '../../../../../src/ol/DataTile.js'; +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import ImageTile from '../../../../../src/ol/ImageTile.js'; +import TileState from '../../../../../src/ol/TileState.js'; +import TileTexture from '../../../../../src/ol/webgl/TileTexture.js'; +import WebGLArrayBuffer from '../../../../../src/ol/webgl/Buffer.js'; +import WebGLTileLayer from '../../../../../src/ol/layer/WebGLTile.js'; +import {createCanvasContext2D} from '../../../../../src/ol/dom.js'; + +describe('ol.webgl.TileTexture', function () { + /** @type {TileTexture} */ + let tileTexture; + + beforeEach(function () { + const layer = new WebGLTileLayer({ + source: new DataTileSource({ + loader(z, x, y) { + return new Promise((resolve) => { + const context = createCanvasContext2D(256, 256); + context.fillStyle = `rgb(${z}, ${x % 255}, ${y % 255})`; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }, + }), + }); + const renderer = + /** @type {import("../../../../../src/ol/renderer/webgl/TileLayer.js").default} */ ( + layer.createRenderer() + ); + tileTexture = new TileTexture( + layer.getSource().getTile(3, 2, 1), + layer.getSource().getTileGrid(), + renderer.helper + ); + }); + + it('constructor', function () { + expect(tileTexture.tile.tileCoord).to.eql([3, 2, 1]); + expect(tileTexture.coords).to.be.a(WebGLArrayBuffer); + }); + + it('handles data tiles', function (done) { + const dataTile = tileTexture.tile; + expect(tileTexture.loaded).to.be(false); + expect(dataTile.getState()).to.be(TileState.IDLE); + tileTexture.addEventListener('change', () => { + if (dataTile.getState() === TileState.LOADED) { + expect(tileTexture.loaded).to.be(true); + done(); + } + }); + dataTile.load(); + }); + + it('handles image tiles', function () { + const imageTile = new ImageTile([0, 0, 0], TileState.LOADED); + tileTexture.setTile(imageTile); + expect(tileTexture.loaded).to.be(true); + }); + + it('registers and unregisters change listener', function () { + const tile = tileTexture.tile; + expect(tile.getListeners('change').length).to.be(2); + tileTexture.dispose(); + expect(tile.getListeners('change').length).to.be(1); + }); + + it('updates metadata and unregisters change listener when setting a different tile', function (done) { + const tile = tileTexture.tile; + expect(tile.getListeners('change').length).to.be(2); + const differentTile = new DataTile({ + tileCoord: [1, 0, 1], + loader(z, x, y) { + return Promise.resolve(new Uint8Array(256 * 256 * 3)); + }, + }); + tileTexture.setTile(differentTile); + expect(tile.getListeners('change').length).to.be(1); + tileTexture.addEventListener('change', () => { + expect(tileTexture.bandCount).to.be(3); + done(); + }); + differentTile.load(); + }); +}); diff --git a/test/rendering/cases/layer-tile-webgl/expected.png b/test/rendering/cases/layer-tile-webgl/expected.png new file mode 100644 index 0000000000..5d7011ff7d Binary files /dev/null and b/test/rendering/cases/layer-tile-webgl/expected.png differ diff --git a/test/rendering/cases/layer-tile-webgl/main.js b/test/rendering/cases/layer-tile-webgl/main.js new file mode 100644 index 0000000000..6eb87d2786 --- /dev/null +++ b/test/rendering/cases/layer-tile-webgl/main.js @@ -0,0 +1,26 @@ +import Map from '../../../../src/ol/Map.js'; +import TileLayer from '../../../../src/ol/layer/WebGLTile.js'; +import View from '../../../../src/ol/View.js'; +import XYZ from '../../../../src/ol/source/XYZ.js'; +import {fromLonLat} from '../../../../src/ol/proj.js'; + +const center = fromLonLat([8.6, 50.1]); + +new Map({ + layers: [ + new TileLayer({ + source: new XYZ({ + url: '/data/tiles/satellite/{z}/{x}/{y}.jpg', + transition: 0, + crossOrigin: 'anonymous', + }), + }), + ], + target: 'map', + view: new View({ + center: center, + zoom: 3, + }), +}); + +render();