diff --git a/examples/ogc-vector-tiles.html b/examples/ogc-vector-tiles.html
new file mode 100644
index 0000000000..e9d562f457
--- /dev/null
+++ b/examples/ogc-vector-tiles.html
@@ -0,0 +1,9 @@
+---
+layout: example.html
+title: OGC Vector Tiles
+shortdesc: Rendering vector tiles from an OGC API – Tiles service.
+docs: >
+ The OGC API – Tiles specification describes how a service can provide vector tiles.
+tags: "ogc, vector"
+---
+
diff --git a/examples/ogc-vector-tiles.js b/examples/ogc-vector-tiles.js
new file mode 100644
index 0000000000..408af880f3
--- /dev/null
+++ b/examples/ogc-vector-tiles.js
@@ -0,0 +1,22 @@
+import MVT from '../src/ol/format/MVT.js';
+import Map from '../src/ol/Map.js';
+import OGCVectorTile from '../src/ol/source/OGCVectorTile.js';
+import VectorTileLayer from '../src/ol/layer/VectorTile.js';
+import View from '../src/ol/View.js';
+
+const map = new Map({
+ target: 'map',
+ layers: [
+ new VectorTileLayer({
+ source: new OGCVectorTile({
+ url: 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad',
+ mediaType: 'application/vnd.mapbox-vector-tile',
+ format: new MVT(),
+ }),
+ }),
+ ],
+ view: new View({
+ center: [0, 0],
+ zoom: 1,
+ }),
+});
diff --git a/src/ol/net.js b/src/ol/net.js
index 70409178ec..3dd14df8a3 100644
--- a/src/ol/net.js
+++ b/src/ol/net.js
@@ -41,3 +41,110 @@ export function jsonp(url, callback, opt_errback, opt_callbackParam) {
};
document.getElementsByTagName('head')[0].appendChild(script);
}
+
+export class ResponseError extends Error {
+ /**
+ * @param {XMLHttpRequest} response The XHR object.
+ */
+ constructor(response) {
+ const message = 'Unexpected response status: ' + response.status;
+ super(message);
+
+ /**
+ * @type {string}
+ */
+ this.name = 'ResponseError';
+
+ /**
+ * @type {XMLHttpRequest}
+ */
+ this.response = response;
+ }
+}
+
+export class ClientError extends Error {
+ /**
+ * @param {XMLHttpRequest} client The XHR object.
+ */
+ constructor(client) {
+ super('Failed to issue request');
+
+ /**
+ * @type {string}
+ */
+ this.name = 'ClientError';
+
+ /**
+ * @type {XMLHttpRequest}
+ */
+ this.client = client;
+ }
+}
+
+/**
+ * @param {string} url The URL.
+ * @return {Promise} A promise that resolves to the JSON response.
+ */
+export function getJSON(url) {
+ return new Promise(function (resolve, reject) {
+ /**
+ * @param {ProgressEvent} event The load event.
+ */
+ function onLoad(event) {
+ const client = event.target;
+ // status will be 0 for file:// urls
+ if (!client.status || (client.status >= 200 && client.status < 300)) {
+ let data;
+ try {
+ data = JSON.parse(client.responseText);
+ } catch (err) {
+ const message = 'Error parsing response text as JSON: ' + err.message;
+ reject(new Error(message));
+ return;
+ }
+ resolve(data);
+ return;
+ }
+
+ reject(new ResponseError(client));
+ }
+
+ /**
+ * @param {ProgressEvent} event The error event.
+ */
+ function onError(event) {
+ reject(new ClientError(event.target));
+ }
+
+ const client = new XMLHttpRequest();
+ client.addEventListener('load', onLoad);
+ client.addEventListener('error', onError);
+ client.open('GET', url);
+ client.setRequestHeader('Accept', 'application/json');
+ client.send();
+ });
+}
+
+/**
+ * @param {string} base The base URL.
+ * @param {string} url The potentially relative URL.
+ * @return {string} The full URL.
+ */
+export function resolveUrl(base, url) {
+ if (url.indexOf('://') >= 0) {
+ return url;
+ }
+ return new URL(url, base).href;
+}
+
+let originalXHR;
+export function overrideXHR(xhr) {
+ if (typeof XMLHttpRequest !== 'undefined') {
+ originalXHR = XMLHttpRequest;
+ }
+ global.XMLHttpRequest = xhr;
+}
+
+export function restoreXHR() {
+ global.XMLHttpRequest = originalXHR;
+}
diff --git a/src/ol/source/OGCMapTile.js b/src/ol/source/OGCMapTile.js
index 5604ab4bc5..1ff2702765 100644
--- a/src/ol/source/OGCMapTile.js
+++ b/src/ol/source/OGCMapTile.js
@@ -2,96 +2,8 @@
* @module ol/source/OGCMapTile
*/
import SourceState from './State.js';
-import TileGrid from '../tilegrid/TileGrid.js';
import TileImage from './TileImage.js';
-import {assign} from '../obj.js';
-import {get as getProjection} from '../proj.js';
-
-/**
- * See https://ogcapi.ogc.org/tiles/.
- */
-
-/**
- * @typedef {Object} TileSet
- * @property {string} dataType Type of data represented in the tileset (must be "map").
- * @property {string} [tileMatrixSetDefinition] Reference to a tile matrix set definition.
- * @property {TileMatrixSet} [tileMatrixSet] Tile matrix set definition.
- * @property {Array} [tileMatrixSetLimits] Tile matrix set limits.
- * @property {Array } links Tileset links.
- */
-
-/**
- * @typedef {Object} Link
- * @property {string} rel The link rel attribute.
- * @property {string} href The link URL.
- * @property {string} type The link type.
- */
-
-/**
- * @typedef {Object} TileMatrixSetLimits
- * @property {string} tileMatrix The tile matrix id.
- * @property {number} minTileRow The minimum tile row.
- * @property {number} maxTileRow The maximum tile row.
- * @property {number} minTileCol The minimum tile column.
- * @property {number} maxTileCol The maximum tile column.
- */
-
-/**
- * @typedef {Object} TileMatrixSet
- * @property {string} id The tile matrix set identifier.
- * @property {string} crs The coordinate reference system.
- * @property {Array} tileMatrices Array of tile matrices.
- */
-
-/**
- * @typedef {Object} TileMatrix
- * @property {string} id The tile matrix identifier.
- * @property {number} cellSize The pixel resolution (map units per pixel).
- * @property {Array} pointOfOrigin The map location of the matrix origin.
- * @property {string} [cornerOfOrigin='topLeft'] The corner of the matrix that represents the origin ('topLeft' or 'bottomLeft').
- * @property {number} matrixWidth The number of columns.
- * @property {number} matrixHeight The number of rows.
- * @property {number} tileWidth The pixel width of a tile.
- * @property {number} tileHeight The pixel height of a tile.
- */
-
-const BOTTOM_LEFT_ORIGIN = 'bottomLeft';
-
-/**
- * @type {Object}
- */
-const knownImageTypes = {
- 'image/png': true,
- 'image/jpeg': true,
- 'image/gif': true,
- 'image/webp': true,
-};
-
-/**
- * @param {string} base The base URL.
- * @param {string} url The potentially relative URL.
- * @return {string} The full URL.
- */
-function resolveUrl(base, url) {
- if (url.indexOf('://') >= 0) {
- return url;
- }
- return new URL(url, base).href;
-}
-
-/**
- * @param {string} url The URL.
- * @param {function(ProgressEvent): void} onLoad The load callback.
- * @param {function(ProgressEvent): void} onError The error callback.
- */
-function getJSON(url, onLoad, onError) {
- const client = new XMLHttpRequest();
- client.addEventListener('load', onLoad);
- client.addEventListener('error', onError);
- client.open('GET', url);
- client.setRequestHeader('Accept', 'application/json');
- client.send();
-}
+import {getTileSetInfo} from './ogcTileUtil.js';
/**
* @typedef {Object} Options
@@ -101,7 +13,7 @@ function getJSON(url, onLoad, onError) {
* @property {string} [mediaType] The content type for the tiles (e.g. "image/png"). If not provided,
* the source will try to find a link with rel="item" that uses a supported image type.
* @property {import("../proj.js").ProjectionLike} [projection] Projection. By default, the projection
- * will be derived from the `supportedCRS` of the `tileMatrixSet`. You can override this by supplying
+ * will be derived from the `crs` of the `tileMatrixSet`. You can override this by supplying
* a projection to the constructor.
* @property {import("./Source.js").AttributionLike} [attributions] Attributions.
* @property {number} [cacheSize] Tile cache size. The default depends on the screen size. Will be ignored if too small.
@@ -147,228 +59,25 @@ class OGCMapTile extends TileImage {
transition: options.transition,
});
- /**
- * @type {string}
- * @private
- */
- this.baseUrl_ = options.url;
+ const sourceInfo = {
+ url: options.url,
+ projection: this.getProjection(),
+ mediaType: options.mediaType,
+ context: options.context || null,
+ };
- /**
- * @private
- * @type {string}
- */
- this.mediaType_ = options.mediaType;
-
- /**
- * @private
- * @type {Object}
- */
- this.context_ = options.context || null;
-
- /**
- * @private
- * @type {string}
- */
- this.tileUrlTemplate_;
-
- /**
- * @private
- * @type {Array}
- */
- this.tileMatrixSetLimits_ = null;
-
- getJSON(
- this.baseUrl_,
- this.onTileSetMetadataLoad_.bind(this),
- this.onTileSetMetadataError_.bind(this)
- );
+ getTileSetInfo(sourceInfo)
+ .then(this.handleTileSetInfo_.bind(this))
+ .catch(this.handleError_.bind(this));
}
/**
+ * @param {import("./ogcTileUtil.js").TileSetInfo} tileSetInfo Tile set info.
* @private
- * @param {ProgressEvent} event The load event.
*/
- onTileSetMetadataLoad_(event) {
- const client = event.target;
- // status will be 0 for file:// urls
- if (!client.status || (client.status >= 200 && client.status < 300)) {
- let response;
- try {
- response = /** @type {TileSet} */ (JSON.parse(client.responseText));
- } catch (err) {
- this.handleError_(err);
- return;
- }
- this.parseTileSetMetadata_(response);
- } else {
- this.handleError_(
- new Error(`Unexpected status for tiles info: ${client.status}`)
- );
- }
- }
-
- /**
- * @private
- * @param {ProgressEvent} event The error event.
- */
- onTileSetMetadataError_(event) {
- this.handleError_(new Error('Client error loading tiles info'));
- }
-
- /**
- * @private
- * @param {TileSet} info Tile set metadata.
- */
- parseTileSetMetadata_(info) {
- let tileUrlTemplate;
- let fallbackUrlTemplate;
- for (let i = 0; i < info.links.length; ++i) {
- const link = info.links[i];
- if (link.rel === 'item') {
- if (link.type === this.mediaType_) {
- tileUrlTemplate = link.href;
- break;
- }
- if (knownImageTypes[link.type]) {
- fallbackUrlTemplate = link.href;
- } else if (!fallbackUrlTemplate && link.type.indexOf('image/') === 0) {
- fallbackUrlTemplate = link.href;
- }
- }
- }
-
- if (!tileUrlTemplate) {
- if (fallbackUrlTemplate) {
- tileUrlTemplate = fallbackUrlTemplate;
- } else {
- this.handleError_(new Error('Could not find "item" link'));
- return;
- }
- }
- this.tileUrlTemplate_ = tileUrlTemplate;
-
- if (info.tileMatrixSet) {
- this.parseTileMatrixSet_(info.tileMatrixSet);
- return;
- }
-
- if (!info.tileMatrixSetDefinition) {
- this.handleError_(
- new Error('Expected tileMatrixSetDefinition or tileMatrixSet')
- );
- return;
- }
-
- getJSON(
- resolveUrl(this.baseUrl_, info.tileMatrixSetDefinition),
- this.onTilesTileMatrixSetLoad_.bind(this),
- this.onTilesTileMatrixSetError_.bind(this)
- );
- }
-
- /**
- * @private
- * @param {ProgressEvent} event The load event.
- */
- onTilesTileMatrixSetLoad_(event) {
- const client = event.target;
- // status will be 0 for file:// urls
- if (!client.status || (client.status >= 200 && client.status < 300)) {
- let response;
- try {
- response = /** @type {TileMatrixSet} */ (
- JSON.parse(client.responseText)
- );
- } catch (err) {
- this.handleError_(err);
- return;
- }
- this.parseTileMatrixSet_(response);
- } else {
- this.handleError_(
- new Error(`Unexpected status for tile matrix set: ${client.status}`)
- );
- }
- }
-
- /**
- * @private
- * @param {ProgressEvent} event The error event.
- */
- onTilesTileMatrixSetError_(event) {
- this.handleError_(new Error('Client error loading tile matrix set'));
- }
-
- /**
- * @private
- * @param {TileMatrixSet} tileMatrixSet Tile matrix set.
- */
- parseTileMatrixSet_(tileMatrixSet) {
- let projection = this.getProjection();
- if (!projection) {
- projection = getProjection(tileMatrixSet.crs);
- if (!projection) {
- this.handleError_(new Error(`Unsupported CRS: ${tileMatrixSet.crs}`));
- return;
- }
- }
- const backwards = projection.getAxisOrientation().substr(0, 2) !== 'en';
-
- // TODO: deal with limits
- const matrices = tileMatrixSet.tileMatrices;
- const length = matrices.length;
- const origins = new Array(length);
- const resolutions = new Array(length);
- const sizes = new Array(length);
- const tileSizes = new Array(length);
- for (let i = 0; i < matrices.length; ++i) {
- const matrix = matrices[i];
- const origin = matrix.pointOfOrigin;
- if (backwards) {
- origins[i] = [origin[1], origin[0]];
- } else {
- origins[i] = origin;
- }
- resolutions[i] = matrix.cellSize;
- sizes[i] = [matrix.matrixWidth, matrix.matrixHeight];
- tileSizes[i] = [matrix.tileWidth, matrix.tileHeight];
- }
-
- const tileGrid = new TileGrid({
- origins: origins,
- resolutions: resolutions,
- sizes: sizes,
- tileSizes: tileSizes,
- });
-
- this.tileGrid = tileGrid;
-
- const tileUrlTemplate = this.tileUrlTemplate_;
- const context = this.context_;
- const base = this.baseUrl_;
-
- this.setTileUrlFunction(function (tileCoord, pixelRatio, projection) {
- if (!tileCoord) {
- return undefined;
- }
-
- const matrix = matrices[tileCoord[0]];
- const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN;
-
- const localContext = {
- tileMatrix: matrix.id,
- tileCol: tileCoord[1],
- tileRow: upsideDown ? -tileCoord[2] - 1 : tileCoord[2],
- };
- assign(localContext, context);
-
- const url = tileUrlTemplate.replace(/\{(\w+?)\}/g, function (m, p) {
- return localContext[p];
- });
-
- return resolveUrl(base, url);
- }, tileUrlTemplate);
-
+ handleTileSetInfo_(tileSetInfo) {
+ this.tileGrid = tileSetInfo.grid;
+ this.setTileUrlFunction(tileSetInfo.urlFunction, tileSetInfo.urlTemplate);
this.setState(SourceState.READY);
}
diff --git a/src/ol/source/OGCVectorTile.js b/src/ol/source/OGCVectorTile.js
new file mode 100644
index 0000000000..04193d07ac
--- /dev/null
+++ b/src/ol/source/OGCVectorTile.js
@@ -0,0 +1,90 @@
+/**
+ * @module ol/source/OGCVectorTile
+ */
+
+import SourceState from './State.js';
+import VectorTile from './VectorTile.js';
+import {getTileSetInfo} from './ogcTileUtil.js';
+
+/**
+ * @typedef {Object} Options
+ * @property {string} url URL to the OGC Vector Tileset endpoint.
+ * @property {Object} [context] A lookup of values to use in the tile URL template. The `{tileMatrix}`
+ * (zoom level), `{tileRow}`, and `{tileCol}` variables in the URL will always be provided by the source.
+ * @property {import("../format/Feature.js").default} format Feature parser for tiles.
+ * @property {string} [mediaType] The content type for the tiles (e.g. "application/vnd.mapbox-vector-tile"). If not provided,
+ * the source will try to find a link with rel="item" that uses a supported vector type. The chosen media type
+ * must be parseable by the configured format.
+ * @property {import("./Source.js").AttributionLike} [attributions] Attributions.
+ * @property {boolean} [attributionsCollapsible=true] Attributions are collapsible.
+ * @property {number} [cacheSize] Initial tile cache size. Will auto-grow to hold at least twice the number of tiles in the viewport.
+ * @property {boolean} [overlaps=true] This source may have overlapping geometries. Setting this
+ * to `false` (e.g. for sources with polygons that represent administrative
+ * boundaries or TopoJSON sources) allows the renderer to optimise fill and
+ * stroke operations.
+ * @property {import("../proj.js").ProjectionLike} [projection='EPSG:3857'] Projection of the tile grid.
+ * @property {typeof import("../VectorTile.js").default} [tileClass] Class used to instantiate image tiles.
+ * Default is {@link module:ol/VectorTile}.
+ * @property {number} [transition] A duration for tile opacity
+ * transitions in milliseconds. A duration of 0 disables the opacity transition.
+ * @property {boolean} [wrapX=true] Whether to wrap the world horizontally.
+ * When set to `false`, only one world
+ * will be rendered. When set to `true`, tiles will be wrapped horizontally to
+ * render multiple worlds.
+ * @property {number|import("../array.js").NearestDirectionFunction} [zDirection=1]
+ * Choose whether to use tiles with a higher or lower zoom level when between integer
+ * zoom levels. See {@link module:ol/tilegrid/TileGrid~TileGrid#getZForResolution}.
+ */
+
+class OGCVectorTile extends VectorTile {
+ /**
+ * @param {Options} options OGC vector tile options.
+ */
+ constructor(options) {
+ super({
+ attributions: options.attributions,
+ attributionsCollapsible: options.attributionsCollapsible,
+ cacheSize: options.cacheSize,
+ format: options.format,
+ overlaps: options.overlaps,
+ projection: options.projection,
+ tileClass: options.tileClass,
+ transition: options.transition,
+ wrapX: options.wrapX,
+ zDirection: options.zDirection,
+ state: SourceState.LOADING,
+ });
+
+ const sourceInfo = {
+ url: options.url,
+ projection: this.getProjection(),
+ mediaType: options.mediaType,
+ context: options.context || null,
+ };
+
+ getTileSetInfo(sourceInfo)
+ .then(this.handleTileSetInfo_.bind(this))
+ .catch(this.handleError_.bind(this));
+ }
+
+ /**
+ * @param {import("./ogcTileUtil.js").TileSetInfo} tileSetInfo Tile set info.
+ * @private
+ */
+ handleTileSetInfo_(tileSetInfo) {
+ this.tileGrid = tileSetInfo.grid;
+ this.setTileUrlFunction(tileSetInfo.urlFunction, tileSetInfo.urlTemplate);
+ this.setState(SourceState.READY);
+ }
+
+ /**
+ * @private
+ * @param {Error} error The error.
+ */
+ handleError_(error) {
+ console.error(error); // eslint-disable-line
+ this.setState(SourceState.ERROR);
+ }
+}
+
+export default OGCVectorTile;
diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js
new file mode 100644
index 0000000000..0284326e13
--- /dev/null
+++ b/src/ol/source/ogcTileUtil.js
@@ -0,0 +1,279 @@
+/**
+ * @module ol/source/ogcTileUtil
+ */
+
+import TileGrid from '../tilegrid/TileGrid.js';
+import {assign} from '../obj.js';
+import {getJSON, resolveUrl} from '../net.js';
+import {get as getProjection} from '../proj.js';
+
+/**
+ * See https://ogcapi.ogc.org/tiles/.
+ */
+
+/**
+ * @enum {string}
+ */
+const TileType = {
+ MAP: 'map',
+ VECTOR: 'vector',
+};
+
+/**
+ * @typedef {Object} TileSet
+ * @property {TileType} dataType Type of data represented in the tileset.
+ * @property {string} [tileMatrixSetDefinition] Reference to a tile matrix set definition.
+ * @property {TileMatrixSet} [tileMatrixSet] Tile matrix set definition.
+ * @property {Array} [tileMatrixSetLimits] Tile matrix set limits.
+ * @property {Array } links Tileset links.
+ */
+
+/**
+ * @typedef {Object} Link
+ * @property {string} rel The link rel attribute.
+ * @property {string} href The link URL.
+ * @property {string} type The link type.
+ */
+
+/**
+ * @typedef {Object} TileMatrixSetLimits
+ * @property {string} tileMatrix The tile matrix id.
+ * @property {number} minTileRow The minimum tile row.
+ * @property {number} maxTileRow The maximum tile row.
+ * @property {number} minTileCol The minimum tile column.
+ * @property {number} maxTileCol The maximum tile column.
+ */
+
+/**
+ * @typedef {Object} TileMatrixSet
+ * @property {string} id The tile matrix set identifier.
+ * @property {string} crs The coordinate reference system.
+ * @property {Array} tileMatrices Array of tile matrices.
+ */
+
+/**
+ * @typedef {Object} TileMatrix
+ * @property {string} id The tile matrix identifier.
+ * @property {number} cellSize The pixel resolution (map units per pixel).
+ * @property {Array} pointOfOrigin The map location of the matrix origin.
+ * @property {string} [cornerOfOrigin='topLeft'] The corner of the matrix that represents the origin ('topLeft' or 'bottomLeft').
+ * @property {number} matrixWidth The number of columns.
+ * @property {number} matrixHeight The number of rows.
+ * @property {number} tileWidth The pixel width of a tile.
+ * @property {number} tileHeight The pixel height of a tile.
+ */
+
+/**
+ * @type {Object}
+ */
+const knownMapMediaTypes = {
+ 'image/png': true,
+ 'image/jpeg': true,
+ 'image/gif': true,
+ 'image/webp': true,
+};
+
+/**
+ * @type {Object}
+ */
+const knownVectorMediaTypes = {
+ 'application/vnd.mapbox-vector-tile': true,
+ 'application/geo+json': true,
+};
+
+/**
+ * @typedef {Object} TileSetInfo
+ * @property {string} urlTemplate The tile URL template.
+ * @property {import("../tilegrid/TileGrid.js").default} grid The tile grid.
+ * @property {import("../Tile.js").UrlFunction} urlFunction The tile URL function.
+ */
+
+/**
+ * @typedef {Object} SourceInfo
+ * @property {string} url The tile set URL.
+ * @property {string} mediaType The preferred tile media type.
+ * @property {import("../proj/Projection.js").default} projection The source projection.
+ * @property {Object} [context] Optional context for constructing the URL.
+ */
+
+const BOTTOM_LEFT_ORIGIN = 'bottomLeft';
+
+/**
+ * @param {Array } links Tileset links.
+ * @param {string} [mediaType] The preferred media type.
+ * @return {string} The tile URL template.
+ */
+export function getMapTileUrlTemplate(links, mediaType) {
+ let tileUrlTemplate;
+ let fallbackUrlTemplate;
+ for (let i = 0; i < links.length; ++i) {
+ const link = links[i];
+ if (link.rel === 'item') {
+ if (link.type === mediaType) {
+ tileUrlTemplate = link.href;
+ break;
+ }
+ if (knownMapMediaTypes[link.type]) {
+ fallbackUrlTemplate = link.href;
+ } else if (!fallbackUrlTemplate && link.type.indexOf('image/') === 0) {
+ fallbackUrlTemplate = link.href;
+ }
+ }
+ }
+
+ if (!tileUrlTemplate) {
+ if (fallbackUrlTemplate) {
+ tileUrlTemplate = fallbackUrlTemplate;
+ } else {
+ throw new Error('Could not find "item" link');
+ }
+ }
+
+ return tileUrlTemplate;
+}
+
+/**
+ * @param {Array } links Tileset links.
+ * @param {string} [mediaType] The preferred media type.
+ * @return {string} The tile URL template.
+ */
+export function getVectorTileUrlTemplate(links, mediaType) {
+ let tileUrlTemplate;
+ let fallbackUrlTemplate;
+ for (let i = 0; i < links.length; ++i) {
+ const link = links[i];
+ if (link.rel === 'item') {
+ if (link.type === mediaType) {
+ tileUrlTemplate = link.href;
+ break;
+ }
+ if (knownVectorMediaTypes[link.type]) {
+ fallbackUrlTemplate = link.href;
+ }
+ }
+ }
+
+ if (!tileUrlTemplate) {
+ if (fallbackUrlTemplate) {
+ tileUrlTemplate = fallbackUrlTemplate;
+ } else {
+ throw new Error('Could not find "item" link');
+ }
+ }
+
+ return tileUrlTemplate;
+}
+
+/**
+ * @param {SourceInfo} sourceInfo Source info.
+ * @return {Promise} Tile set info.
+ */
+export function getTileSetInfo(sourceInfo) {
+ let tileUrlTemplate;
+
+ /**
+ * @param {TileMatrixSet} tileMatrixSet Tile matrix set.
+ * @return {TileSetInfo} Tile set info.
+ */
+ function parseTileMatrixSet(tileMatrixSet) {
+ let projection = sourceInfo.projection;
+ if (!projection) {
+ projection = getProjection(tileMatrixSet.crs);
+ if (!projection) {
+ throw new Error(`Unsupported CRS: ${tileMatrixSet.crs}`);
+ }
+ }
+ const backwards = projection.getAxisOrientation().substr(0, 2) !== 'en';
+
+ // TODO: deal with limits
+ const matrices = tileMatrixSet.tileMatrices;
+ const length = matrices.length;
+ const origins = new Array(length);
+ const resolutions = new Array(length);
+ const sizes = new Array(length);
+ const tileSizes = new Array(length);
+ for (let i = 0; i < matrices.length; ++i) {
+ const matrix = matrices[i];
+ const origin = matrix.pointOfOrigin;
+ if (backwards) {
+ origins[i] = [origin[1], origin[0]];
+ } else {
+ origins[i] = origin;
+ }
+ resolutions[i] = matrix.cellSize;
+ sizes[i] = [matrix.matrixWidth, matrix.matrixHeight];
+ tileSizes[i] = [matrix.tileWidth, matrix.tileHeight];
+ }
+
+ const tileGrid = new TileGrid({
+ origins: origins,
+ resolutions: resolutions,
+ sizes: sizes,
+ tileSizes: tileSizes,
+ });
+
+ const context = sourceInfo.context;
+ const base = sourceInfo.url;
+
+ function tileUrlFunction(tileCoord, pixelRatio, projection) {
+ if (!tileCoord) {
+ return undefined;
+ }
+
+ const matrix = matrices[tileCoord[0]];
+ const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN;
+
+ const localContext = {
+ tileMatrix: matrix.id,
+ tileCol: tileCoord[1],
+ tileRow: upsideDown ? -tileCoord[2] - 1 : tileCoord[2],
+ };
+ assign(localContext, context);
+
+ const url = tileUrlTemplate.replace(/\{(\w+?)\}/g, function (m, p) {
+ return localContext[p];
+ });
+
+ return resolveUrl(base, url);
+ }
+
+ return {
+ grid: tileGrid,
+ urlTemplate: tileUrlTemplate,
+ urlFunction: tileUrlFunction,
+ };
+ }
+
+ /**
+ * @param {TileSet} tileSet Tile set.
+ * @return {TileSetInfo|Promise} Tile set info.
+ */
+ function parseTileSetMetadata(tileSet) {
+ if (tileSet.dataType === TileType.MAP) {
+ tileUrlTemplate = getMapTileUrlTemplate(
+ tileSet.links,
+ sourceInfo.mediaType
+ );
+ } else if (tileSet.dataType === TileType.VECTOR) {
+ tileUrlTemplate = getVectorTileUrlTemplate(
+ tileSet.links,
+ sourceInfo.mediaType
+ );
+ } else {
+ throw new Error('Expected tileset data type to be "map" or "vector"');
+ }
+
+ if (tileSet.tileMatrixSet) {
+ return parseTileMatrixSet(tileSet.tileMatrixSet);
+ }
+
+ if (!tileSet.tileMatrixSetDefinition) {
+ throw new Error('Expected tileMatrixSetDefinition or tileMatrixSet');
+ }
+
+ const url = resolveUrl(sourceInfo.url, tileSet.tileMatrixSetDefinition);
+ return getJSON(url).then(parseTileMatrixSet);
+ }
+
+ return getJSON(sourceInfo.url).then(parseTileSetMetadata);
+}
diff --git a/test/browser/spec/ol/net.test.js b/test/browser/spec/ol/net.test.js
index 3276d482ef..697910e431 100644
--- a/test/browser/spec/ol/net.test.js
+++ b/test/browser/spec/ol/net.test.js
@@ -1,7 +1,40 @@
+import {
+ getJSON,
+ jsonp as requestJSONP,
+ resolveUrl,
+} from '../../../../src/ol/net.js';
import {getUid} from '../../../../src/ol/util.js';
-import {jsonp as requestJSONP} from '../../../../src/ol/net.js';
-describe('ol.net', function () {
+describe('ol/net', function () {
+ describe('getJSON()', function () {
+ it('returns a promise that resolves to a parsed JSON object', function (done) {
+ const url = 'spec/ol/data/point.json';
+ const result = getJSON(url);
+ expect(result).to.be.a(Promise);
+ result.then(function (json) {
+ expect(json).to.be.an(Object);
+ expect(json.type).to.be('FeatureCollection');
+ done();
+ });
+ result.catch(done);
+ });
+ });
+
+ describe('resolveUrl()', function () {
+ it('resolves an absolute URL given a base and relative URL', function () {
+ const url = resolveUrl('https://example.com/base/', 'relative/path');
+ expect(url).to.be('https://example.com/base/relative/path');
+ });
+
+ it('returns the second arg if it is an absolute URL', function () {
+ const url = resolveUrl(
+ 'https://example.com',
+ 'https://other-example.com'
+ );
+ expect(url).to.be('https://other-example.com');
+ });
+ });
+
describe('jsonp()', function () {
const head = document.getElementsByTagName('head')[0];
const origAppendChild = head.appendChild;
diff --git a/test/node/ol/source/data/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad.json b/test/node/ol/source/data/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad.json
new file mode 100644
index 0000000000..e0a57720bd
--- /dev/null
+++ b/test/node/ol/source/data/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad.json
@@ -0,0 +1,85 @@
+{
+ "title" : "blueMarble",
+ "dataType" : "map",
+ "tileMatrixSetURI" : "http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad",
+ "tileMatrixSetDefinition" : "https://maps.ecere.com/ogcapi/tileMatrixSets/WebMercatorQuad",
+ "tileMatrixSetLimits" : [
+ { "tileMatrix" : "0", "minTileRow" : 0, "maxTileRow" : 0, "minTileCol" : 0, "maxTileCol" : 0 },
+ { "tileMatrix" : "1", "minTileRow" : 0, "maxTileRow" : 1, "minTileCol" : 0, "maxTileCol" : 1 },
+ { "tileMatrix" : "2", "minTileRow" : 0, "maxTileRow" : 3, "minTileCol" : 0, "maxTileCol" : 3 },
+ { "tileMatrix" : "3", "minTileRow" : 0, "maxTileRow" : 7, "minTileCol" : 0, "maxTileCol" : 7 },
+ { "tileMatrix" : "4", "minTileRow" : 0, "maxTileRow" : 15, "minTileCol" : 0, "maxTileCol" : 15 },
+ { "tileMatrix" : "5", "minTileRow" : 0, "maxTileRow" : 31, "minTileCol" : 0, "maxTileCol" : 31 },
+ { "tileMatrix" : "6", "minTileRow" : 0, "maxTileRow" : 63, "minTileCol" : 0, "maxTileCol" : 63 },
+ { "tileMatrix" : "7", "minTileRow" : 0, "maxTileRow" : 127, "minTileCol" : 0, "maxTileCol" : 127 },
+ { "tileMatrix" : "8", "minTileRow" : 0, "maxTileRow" : 255, "minTileCol" : 0, "maxTileCol" : 255 },
+ { "tileMatrix" : "9", "minTileRow" : 0, "maxTileRow" : 511, "minTileCol" : 0, "maxTileCol" : 511 }
+ ],
+ "centerPoint" : {
+ "coordinates" : [
+ 0,
+ 0
+ ],
+ "tileMatrix" : "4",
+ "scaleDenominator" : 34942641.501794859767,
+ "cellSize" : 9783.9396205025605
+ },
+ "links" : [
+ {
+ "rel" : "self",
+ "type" : "application/json",
+ "title" : "The JSON representation of the WebMercatorQuad map tileset for blueMarble",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad?f=json"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "text/plain",
+ "title" : "The ECON representation of the WebMercatorQuad map tileset for blueMarble",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad?f=econ"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "text/html",
+ "title" : "The HTML representation of the WebMercatorQuad map tileset for blueMarble",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad?=html"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "application/json+tile",
+ "title" : "The TileJSON representation of the WebMercatorQuad map tileset for blueMarble",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad?f=tilejson"
+ },
+ {
+ "rel" : "http://www.opengis.net/def/rel/ogc/1.0/geodata",
+ "href" : "/ogcapi/collections/blueMarble"
+ },
+ {
+ "rel" : "item",
+ "type" : "application/vnd.gnosis-map-tile",
+ "title" : "WebMercatorQuad map tiles for blueMarble (as GNOSIS Map Tiles)",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.gmt",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "image/png",
+ "title" : "WebMercatorQuad map tiles for blueMarble (as PNG)",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.png",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "image/jpeg",
+ "title" : "WebMercatorQuad map tiles for blueMarble (as JPG)",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.jpg",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "image/tiff; application=geotiff",
+ "title" : "WebMercatorQuad map tiles for blueMarble (as GeoTIFF)",
+ "href" : "/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.tif",
+ "templated" : true
+ }
+ ]
+}
diff --git a/test/node/ol/source/data/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad.json b/test/node/ol/source/data/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad.json
new file mode 100644
index 0000000000..c8c90232aa
--- /dev/null
+++ b/test/node/ol/source/data/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad.json
@@ -0,0 +1,229 @@
+{
+ "title" : "ne_10m_admin_0_countries",
+ "dataType" : "vector",
+ "tileMatrixSetURI" : "http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad",
+ "tileMatrixSetDefinition" : "https://maps.ecere.com/ogcapi/tileMatrixSets/WebMercatorQuad",
+ "tileMatrixSetLimits" : [
+ { "tileMatrix" : "0", "minTileRow" : 0, "maxTileRow" : 0, "minTileCol" : 0, "maxTileCol" : 0 },
+ { "tileMatrix" : "1", "minTileRow" : 0, "maxTileRow" : 1, "minTileCol" : 0, "maxTileCol" : 1 },
+ { "tileMatrix" : "2", "minTileRow" : 0, "maxTileRow" : 3, "minTileCol" : 0, "maxTileCol" : 3 },
+ { "tileMatrix" : "3", "minTileRow" : 0, "maxTileRow" : 7, "minTileCol" : 0, "maxTileCol" : 7 },
+ { "tileMatrix" : "4", "minTileRow" : 0, "maxTileRow" : 15, "minTileCol" : 0, "maxTileCol" : 15 },
+ { "tileMatrix" : "5", "minTileRow" : 0, "maxTileRow" : 31, "minTileCol" : 0, "maxTileCol" : 31 },
+ { "tileMatrix" : "6", "minTileRow" : 0, "maxTileRow" : 63, "minTileCol" : 0, "maxTileCol" : 63 },
+ { "tileMatrix" : "7", "minTileRow" : 0, "maxTileRow" : 127, "minTileCol" : 0, "maxTileCol" : 127 }
+ ],
+ "layers" : [
+ {
+ "id" : "ne_10m_admin_0_countries",
+ "dataType" : "vector",
+ "geometryType" : "polygon",
+ "minScaleDenominator" : 4367830.1877243574709,
+ "minCellSize" : 1222.9924525628201,
+ "maxTileMatrix" : "7",
+ "links" : [
+ {
+ "rel" : "http://www.opengis.net/def/rel/ogc/1.0/geodata",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries"
+ }
+ ],
+ "propertiesSchema" : {
+ "type" : "object",
+ "properties" : { "abbrev" : {
+ "type" : "string"
+ }, "abbrev_len" : {
+ "type" : "integer"
+ }, "adm0_a3" : {
+ "type" : "string"
+ }, "adm0_a3_is" : {
+ "type" : "string"
+ }, "adm0_a3_un" : {
+ "type" : "integer"
+ }, "adm0_a3_us" : {
+ "type" : "string"
+ }, "adm0_a3_wb" : {
+ "type" : "integer"
+ }, "adm0_dif" : {
+ "type" : "integer"
+ }, "admin" : {
+ "type" : "string"
+ }, "brk_a3" : {
+ "type" : "string"
+ }, "brk_diff" : {
+ "type" : "integer"
+ }, "brk_group" : {
+ "type" : "string"
+ }, "brk_name" : {
+ "type" : "string"
+ }, "continent" : {
+ "type" : "string"
+ }, "economy" : {
+ "type" : "string"
+ }, "featurecla" : {
+ "type" : "string"
+ }, "fips_10" : {
+ "type" : "string"
+ }, "formal_en" : {
+ "type" : "string"
+ }, "formal_fr" : {
+ "type" : "string"
+ }, "gdp_md_est" : {
+ "type" : "integer"
+ }, "gdp_year" : {
+ "type" : "integer"
+ }, "geou_dif" : {
+ "type" : "integer"
+ }, "geounit" : {
+ "type" : "string"
+ }, "gu_a3" : {
+ "type" : "string"
+ }, "homepart" : {
+ "type" : "integer"
+ }, "income_grp" : {
+ "type" : "string"
+ }, "iso_a2" : {
+ "type" : "string"
+ }, "iso_a3" : {
+ "type" : "string"
+ }, "iso_n3" : {
+ "type" : "string"
+ }, "labelrank" : {
+ "type" : "integer"
+ }, "lastcensus" : {
+ "type" : "integer"
+ }, "level" : {
+ "type" : "integer"
+ }, "long_len" : {
+ "type" : "integer"
+ }, "mapcolor13" : {
+ "type" : "integer"
+ }, "mapcolor7" : {
+ "type" : "integer"
+ }, "mapcolor8" : {
+ "type" : "integer"
+ }, "mapcolor9" : {
+ "type" : "integer"
+ }, "name" : {
+ "type" : "string"
+ }, "name_alt" : {
+ "type" : "string"
+ }, "name_len" : {
+ "type" : "integer"
+ }, "name_long" : {
+ "type" : "string"
+ }, "name_sort" : {
+ "type" : "string"
+ }, "note_adm0" : {
+ "type" : "string"
+ }, "note_brk" : {
+ "type" : "string"
+ }, "pop_est" : {
+ "type" : "integer"
+ }, "pop_year" : {
+ "type" : "integer"
+ }, "postal" : {
+ "type" : "string"
+ }, "region_un" : {
+ "type" : "string"
+ }, "region_wb" : {
+ "type" : "string"
+ }, "scalerank" : {
+ "type" : "integer"
+ }, "sov_a3" : {
+ "type" : "string"
+ }, "sovereignt" : {
+ "type" : "string"
+ }, "su_a3" : {
+ "type" : "string"
+ }, "su_dif" : {
+ "type" : "integer"
+ }, "subregion" : {
+ "type" : "string"
+ }, "subunit" : {
+ "type" : "string"
+ }, "tiny" : {
+ "type" : "integer"
+ }, "type" : {
+ "type" : "string"
+ }, "un_a3" : {
+ "type" : "string"
+ }, "wb_a2" : {
+ "type" : "string"
+ }, "wb_a3" : {
+ "type" : "string"
+ }, "wikipedia" : {
+ "type" : "integer"
+ }, "woe_id" : {
+ "type" : "integer"
+ } }
+ }
+ }
+ ],
+ "centerPoint" : {
+ "coordinates" : [
+ 0,
+ 0.000102911832
+ ],
+ "tileMatrix" : "4",
+ "scaleDenominator" : 34942641.501794859767,
+ "cellSize" : 9783.9396205025605
+ },
+ "links" : [
+ {
+ "rel" : "self",
+ "type" : "application/json",
+ "title" : "The JSON representation of the WebMercatorQuad vector tileset for NaturalEarth:cultural:ne_10m_admin_0_countries",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad?f=json"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "text/plain",
+ "title" : "The ECON representation of the WebMercatorQuad vector tileset for NaturalEarth:cultural:ne_10m_admin_0_countries",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad?f=econ"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "text/html",
+ "title" : "The HTML representation of the WebMercatorQuad vector tileset for NaturalEarth:cultural:ne_10m_admin_0_countries",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad?=html"
+ },
+ {
+ "rel" : "alternate",
+ "type" : "application/json+tile",
+ "title" : "The TileJSON representation of the WebMercatorQuad vector tileset for NaturalEarth:cultural:ne_10m_admin_0_countries",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad?f=tilejson"
+ },
+ {
+ "rel" : "http://www.opengis.net/def/rel/ogc/1.0/geodata",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries"
+ },
+ {
+ "rel" : "item",
+ "type" : "application/vnd.gnosis-map-tile",
+ "title" : "WebMercatorQuad vector tiles for NaturalEarth:cultural:ne_10m_admin_0_countries (as GNOSIS Map Tiles)",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.gmt",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "application/vnd.mapbox-vector-tile",
+ "title" : "WebMercatorQuad vector tiles for NaturalEarth:cultural:ne_10m_admin_0_countries (as Mapbox Vector Tiles)",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.mvt",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "application/geo+json",
+ "title" : "WebMercatorQuad vector tiles for NaturalEarth:cultural:ne_10m_admin_0_countries (as GeoJSON)",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.json",
+ "templated" : true
+ },
+ {
+ "rel" : "item",
+ "type" : "text/mapml",
+ "title" : "WebMercatorQuad vector tiles for NaturalEarth:cultural:ne_10m_admin_0_countries (as MapML)",
+ "href" : "/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.mapml",
+ "templated" : true
+ }
+ ]
+}
diff --git a/test/node/ol/source/data/ogcapi/tileMatrixSets/WebMercatorQuad.json b/test/node/ol/source/data/ogcapi/tileMatrixSets/WebMercatorQuad.json
new file mode 100644
index 0000000000..97c94c3a98
--- /dev/null
+++ b/test/node/ol/source/data/ogcapi/tileMatrixSets/WebMercatorQuad.json
@@ -0,0 +1,433 @@
+{
+ "id" : "WebMercatorQuad",
+ "title" : "WebMercatorQuad",
+ "uri" : "http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad",
+ "crs" : "http://www.opengis.net/def/crs/EPSG/0/3857",
+ "orderedAxes" : [
+ "E",
+ "N"
+ ],
+ "wellKnownScaleSet" : "http://www.opengis.net/def/wkss/OGC/1.0/GoogleMapsCompatible",
+ "tileMatrices" : [
+ {
+ "id" : "0",
+ "scaleDenominator" : 559082264.0287177562714,
+ "cellSize" : 156543.033928040968,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 1,
+ "matrixHeight" : 1,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "1",
+ "scaleDenominator" : 279541132.0143588781357,
+ "cellSize" : 78271.516964020484,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 2,
+ "matrixHeight" : 2,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "2",
+ "scaleDenominator" : 139770566.0071794390678,
+ "cellSize" : 39135.758482010242,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 4,
+ "matrixHeight" : 4,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "3",
+ "scaleDenominator" : 69885283.0035897195339,
+ "cellSize" : 19567.879241005121,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 8,
+ "matrixHeight" : 8,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "4",
+ "scaleDenominator" : 34942641.501794859767,
+ "cellSize" : 9783.9396205025605,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 16,
+ "matrixHeight" : 16,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "5",
+ "scaleDenominator" : 17471320.7508974298835,
+ "cellSize" : 4891.9698102512803,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 32,
+ "matrixHeight" : 32,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "6",
+ "scaleDenominator" : 8735660.3754487149417,
+ "cellSize" : 2445.9849051256401,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 64,
+ "matrixHeight" : 64,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "7",
+ "scaleDenominator" : 4367830.1877243574709,
+ "cellSize" : 1222.9924525628201,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 128,
+ "matrixHeight" : 128,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "8",
+ "scaleDenominator" : 2183915.0938621787354,
+ "cellSize" : 611.49622628141,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 256,
+ "matrixHeight" : 256,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "9",
+ "scaleDenominator" : 1091957.5469310893677,
+ "cellSize" : 305.748113140705,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 512,
+ "matrixHeight" : 512,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "10",
+ "scaleDenominator" : 545978.7734655446839,
+ "cellSize" : 152.8740565703525,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 1024,
+ "matrixHeight" : 1024,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "11",
+ "scaleDenominator" : 272989.3867327723419,
+ "cellSize" : 76.4370282851763,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 2048,
+ "matrixHeight" : 2048,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "12",
+ "scaleDenominator" : 136494.693366386171,
+ "cellSize" : 38.2185141425881,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 4096,
+ "matrixHeight" : 4096,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "13",
+ "scaleDenominator" : 68247.3466831930855,
+ "cellSize" : 19.1092570712941,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 8192,
+ "matrixHeight" : 8192,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "14",
+ "scaleDenominator" : 34123.6733415965427,
+ "cellSize" : 9.554628535647,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 16384,
+ "matrixHeight" : 16384,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "15",
+ "scaleDenominator" : 17061.8366707982714,
+ "cellSize" : 4.7773142678235,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 32768,
+ "matrixHeight" : 32768,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "16",
+ "scaleDenominator" : 8530.9183353991357,
+ "cellSize" : 2.3886571339118,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 65536,
+ "matrixHeight" : 65536,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "17",
+ "scaleDenominator" : 4265.4591676995678,
+ "cellSize" : 1.1943285669559,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 131072,
+ "matrixHeight" : 131072,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "18",
+ "scaleDenominator" : 2132.7295838497839,
+ "cellSize" : 0.5971642834779,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 262144,
+ "matrixHeight" : 262144,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "19",
+ "scaleDenominator" : 1066.364791924892,
+ "cellSize" : 0.298582141739,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 524288,
+ "matrixHeight" : 524288,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "20",
+ "scaleDenominator" : 533.182395962446,
+ "cellSize" : 0.1492910708695,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 1048576,
+ "matrixHeight" : 1048576,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "21",
+ "scaleDenominator" : 266.591197981223,
+ "cellSize" : 0.0746455354347,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 2097152,
+ "matrixHeight" : 2097152,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "22",
+ "scaleDenominator" : 133.2955989906115,
+ "cellSize" : 0.0373227677174,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 4194304,
+ "matrixHeight" : 4194304,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "23",
+ "scaleDenominator" : 66.6477994953057,
+ "cellSize" : 0.0186613838587,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 8388608,
+ "matrixHeight" : 8388608,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "24",
+ "scaleDenominator" : 33.3238997476529,
+ "cellSize" : 0.0093306919293,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 16777216,
+ "matrixHeight" : 16777216,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "25",
+ "scaleDenominator" : 16.6619498738264,
+ "cellSize" : 0.0046653459647,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 33554432,
+ "matrixHeight" : 33554432,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "26",
+ "scaleDenominator" : 8.3309749369132,
+ "cellSize" : 0.0023326729823,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 67108864,
+ "matrixHeight" : 67108864,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "27",
+ "scaleDenominator" : 4.1654874684566,
+ "cellSize" : 0.0011663364912,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 134217728,
+ "matrixHeight" : 134217728,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "28",
+ "scaleDenominator" : 2.0827437342283,
+ "cellSize" : 0.0005831682456,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 268435456,
+ "matrixHeight" : 268435456,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ },
+ {
+ "id" : "29",
+ "scaleDenominator" : 1.0413718671142,
+ "cellSize" : 0.0002915841228,
+ "cornerOfOrigin" : "topLeft",
+ "pointOfOrigin" : [
+ -20037508.3427892439067,
+ 20037508.3427892439067
+ ],
+ "matrixWidth" : 536870912,
+ "matrixHeight" : 536870912,
+ "tileWidth" : 256,
+ "tileHeight" : 256
+ }
+ ]
+}
diff --git a/test/node/ol/source/ogcTileUtil.test.js b/test/node/ol/source/ogcTileUtil.test.js
new file mode 100644
index 0000000000..570dd184a2
--- /dev/null
+++ b/test/node/ol/source/ogcTileUtil.test.js
@@ -0,0 +1,208 @@
+import TileGrid from '../../../../src/ol/tilegrid/TileGrid.js';
+import events from 'events';
+import expect from '../../expect.js';
+import fse from 'fs-extra';
+import path from 'path';
+import {fileURLToPath} from 'url';
+import {
+ getMapTileUrlTemplate,
+ getTileSetInfo,
+ getVectorTileUrlTemplate,
+} from '../../../../src/ol/source/ogcTileUtil.js';
+import {overrideXHR, restoreXHR} from '../../../../src/ol/net.js';
+
+function getDataDir() {
+ const modulePath = fileURLToPath(import.meta.url);
+ return path.join(path.dirname(modulePath), 'data');
+}
+
+let baseUrl;
+
+class MockXHR extends events.EventEmitter {
+ addEventListener(type, listener) {
+ this.addListener(type, listener);
+ }
+
+ open(method, url) {
+ if (url.startsWith(baseUrl)) {
+ url = url.slice(baseUrl.length);
+ }
+ this.url = url;
+ }
+
+ setRequestHeader(key, value) {
+ // no-op
+ }
+
+ send() {
+ let url = path.resolve(getDataDir(), this.url);
+ if (!url.endsWith('.json')) {
+ url = url + '.json';
+ }
+ fse.readJSON(url).then(
+ (data) => {
+ this.status = 200;
+ this.responseText = JSON.stringify(data);
+ this.emit('load', {target: this});
+ },
+ (err) => {
+ console.error(err); // eslint-disable-line
+ this.emit('error', {target: this});
+ }
+ );
+ }
+}
+
+describe('ol/source/ogcTileUtil.js', () => {
+ describe('getTileSetInfo()', () => {
+ beforeEach(() => {
+ overrideXHR(MockXHR);
+ });
+
+ afterEach(() => {
+ baseUrl = '';
+ restoreXHR();
+ });
+
+ it('fetches and parses map tile info', async () => {
+ baseUrl = 'https://maps.ecere.com/';
+ const sourceInfo = {
+ url: 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad',
+ };
+ const tileInfo = await getTileSetInfo(sourceInfo);
+ expect(tileInfo).to.be.an(Object);
+ expect(tileInfo.urlTemplate).to.be(
+ '/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.jpg'
+ );
+ expect(tileInfo.grid).to.be.a(TileGrid);
+ expect(tileInfo.grid.getTileSize(0)).to.eql([256, 256]);
+ expect(tileInfo.grid.getResolutions()).to.have.length(30);
+ expect(tileInfo.urlFunction).to.be.a(Function);
+ expect(tileInfo.urlFunction([1, 2, 3])).to.be(
+ 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/1/3/2.jpg'
+ );
+ });
+
+ it('allows preferred media type to be configured', async () => {
+ baseUrl = 'https://maps.ecere.com/';
+ const sourceInfo = {
+ url: 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad',
+ mediaType: 'image/png',
+ };
+ const tileInfo = await getTileSetInfo(sourceInfo);
+ expect(tileInfo).to.be.an(Object);
+ expect(tileInfo.urlTemplate).to.be(
+ '/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.png'
+ );
+ expect(tileInfo.urlFunction).to.be.a(Function);
+ expect(tileInfo.urlFunction([1, 2, 3])).to.be(
+ 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/1/3/2.png'
+ );
+ });
+
+ it('fetches and parses vector tile info', async () => {
+ baseUrl = 'https://maps.ecere.com/';
+ const sourceInfo = {
+ url: 'https://maps.ecere.com/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad',
+ };
+ const tileInfo = await getTileSetInfo(sourceInfo);
+ expect(tileInfo).to.be.an(Object);
+ expect(tileInfo.urlTemplate).to.be(
+ '/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.json'
+ );
+ expect(tileInfo.grid).to.be.a(TileGrid);
+ expect(tileInfo.grid.getTileSize(0)).to.eql([256, 256]);
+ expect(tileInfo.grid.getResolutions()).to.have.length(30);
+ expect(tileInfo.urlFunction).to.be.a(Function);
+ expect(tileInfo.urlFunction([1, 2, 3])).to.be(
+ 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/1/3/2.json'
+ );
+ });
+
+ it('allows preferred media type to be configured', async () => {
+ baseUrl = 'https://maps.ecere.com/';
+ const sourceInfo = {
+ url: 'https://maps.ecere.com/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad',
+ mediaType: 'application/vnd.mapbox-vector-tile',
+ };
+ const tileInfo = await getTileSetInfo(sourceInfo);
+ expect(tileInfo).to.be.an(Object);
+ expect(tileInfo.urlTemplate).to.be(
+ '/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.mvt'
+ );
+ expect(tileInfo.urlFunction).to.be.a(Function);
+ expect(tileInfo.urlFunction([1, 2, 3])).to.be(
+ 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/1/3/2.mvt'
+ );
+ });
+ });
+
+ describe('getVectorTileUrlTemplate()', () => {
+ let links;
+ before(async () => {
+ const url = path.join(
+ getDataDir(),
+ 'ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad.json'
+ );
+ const tileSet = await fse.readJSON(url);
+ links = tileSet.links;
+ });
+
+ it('gets the last known vector type if the preferred media type is absent', () => {
+ const urlTemplate = getVectorTileUrlTemplate(links);
+ expect(urlTemplate).to.be(
+ '/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.json'
+ );
+ });
+
+ it('gets the preferred media type if given', () => {
+ const urlTemplate = getVectorTileUrlTemplate(
+ links,
+ 'application/vnd.mapbox-vector-tile'
+ );
+ expect(urlTemplate).to.be(
+ '/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.mvt'
+ );
+ });
+
+ it('throws if it cannot find preferred media type or a known fallback', () => {
+ function call() {
+ getVectorTileUrlTemplate([], 'application/vnd.mapbox-vector-tile');
+ }
+ expect(call).to.throwException('Could not find "item" link');
+ });
+ });
+
+ describe('getMapTileUrlTemplate()', () => {
+ let links;
+ before(async () => {
+ const url = path.join(
+ getDataDir(),
+ 'ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad.json'
+ );
+ const tileSet = await fse.readJSON(url);
+ links = tileSet.links;
+ });
+
+ it('gets the last known image type if the preferred media type is absent', () => {
+ const urlTemplate = getMapTileUrlTemplate(links);
+ expect(urlTemplate).to.be(
+ '/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.jpg'
+ );
+ });
+
+ it('gets the preferred media type if given', () => {
+ const urlTemplate = getMapTileUrlTemplate(links, 'image/png');
+ expect(urlTemplate).to.be(
+ '/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}.png'
+ );
+ });
+
+ it('throws if it cannot find preferred media type or a known fallback', () => {
+ function call() {
+ getMapTileUrlTemplate([], 'image/png');
+ }
+ expect(call).to.throwException('Could not find "item" link');
+ });
+ });
+});