From 791add0d73585860aaa1f240f8377dfb166b1b54 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Fri, 24 Apr 2020 17:15:32 -0600 Subject: [PATCH 1/8] OGC map tile source --- examples/ogc-map-tiles-geographic.html | 9 + examples/ogc-map-tiles-geographic.js | 20 ++ examples/ogc-map-tiles.html | 9 + examples/ogc-map-tiles.js | 19 ++ src/ol/source/OGCMapTile.js | 385 +++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 examples/ogc-map-tiles-geographic.html create mode 100644 examples/ogc-map-tiles-geographic.js create mode 100644 examples/ogc-map-tiles.html create mode 100644 examples/ogc-map-tiles.js create mode 100644 src/ol/source/OGCMapTile.js diff --git a/examples/ogc-map-tiles-geographic.html b/examples/ogc-map-tiles-geographic.html new file mode 100644 index 0000000000..f54bebb0ea --- /dev/null +++ b/examples/ogc-map-tiles-geographic.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: OGC Map Tiles (Geographic) +shortdesc: Rendering map tiles from an OGC API – Tiles service. +docs: > + The OGC API – Tiles specification describes how a service can provide map tiles. +tags: "ogc" +--- +
diff --git a/examples/ogc-map-tiles-geographic.js b/examples/ogc-map-tiles-geographic.js new file mode 100644 index 0000000000..5f3a1ebdb9 --- /dev/null +++ b/examples/ogc-map-tiles-geographic.js @@ -0,0 +1,20 @@ +import Map from '../src/ol/Map.js'; +import OGCMapTile from '../src/ol/source/OGCMapTile.js'; +import TileLayer from '../src/ol/layer/Tile.js'; +import View from '../src/ol/View.js'; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new OGCMapTile({ + url: 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WorldCRS84Quad', + }), + }), + ], + view: new View({ + projection: 'EPSG:4326', + center: [0, 0], + zoom: 1, + }), +}); diff --git a/examples/ogc-map-tiles.html b/examples/ogc-map-tiles.html new file mode 100644 index 0000000000..7054fe6c3a --- /dev/null +++ b/examples/ogc-map-tiles.html @@ -0,0 +1,9 @@ +--- +layout: example.html +title: OGC Map Tiles +shortdesc: Rendering map tiles from an OGC API – Tiles service. +docs: > + The OGC API – Tiles specification describes how a service can provide map tiles. +tags: "ogc" +--- +
diff --git a/examples/ogc-map-tiles.js b/examples/ogc-map-tiles.js new file mode 100644 index 0000000000..379a6ff147 --- /dev/null +++ b/examples/ogc-map-tiles.js @@ -0,0 +1,19 @@ +import Map from '../src/ol/Map.js'; +import OGCMapTile from '../src/ol/source/OGCMapTile.js'; +import TileLayer from '../src/ol/layer/Tile.js'; +import View from '../src/ol/View.js'; + +const map = new Map({ + target: 'map', + layers: [ + new TileLayer({ + source: new OGCMapTile({ + url: 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad', + }), + }), + ], + view: new View({ + center: [0, 0], + zoom: 1, + }), +}); diff --git a/src/ol/source/OGCMapTile.js b/src/ol/source/OGCMapTile.js new file mode 100644 index 0000000000..5604ab4bc5 --- /dev/null +++ b/src/ol/source/OGCMapTile.js @@ -0,0 +1,385 @@ +/** + * @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(); +} + +/** + * @typedef {Object} Options + * @property {string} url URL to the OGC Map 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 {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 + * 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. + * @property {null|string} [crossOrigin] The `crossOrigin` attribute for loaded images. Note that + * you must provide a `crossOrigin` value if you want to access pixel data with the Canvas renderer. + * See https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image for more detail. + * @property {boolean} [imageSmoothing=true] Enable image smoothing. + * @property {number} [reprojectionErrorThreshold=0.5] Maximum allowed reprojection error (in pixels). + * Higher values can increase reprojection performance, but decrease precision. + * @property {import("../Tile.js").LoadFunction} [tileLoadFunction] Optional function to load a tile given a URL. The default is + * ```js + * function(tile, src) { + * tile.getImage().src = src; + * }; + * ``` + * @property {boolean} [wrapX=true] Whether to wrap the world horizontally. + * @property {number} [transition] Duration of the opacity transition for rendering. + * To disable the opacity transition, pass `transition: 0`. + */ + +/** + * @classdesc + * Layer source for map tiles from an OGC API - Tiles service that provides "map" type tiles. + * The service must conform to at least the core (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core) + * and tileset (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset) conformance classes. + * @api + */ +class OGCMapTile extends TileImage { + /** + * @param {Options} options OGC map tile options. + */ + constructor(options) { + super({ + attributions: options.attributions, + cacheSize: options.cacheSize, + crossOrigin: options.crossOrigin, + imageSmoothing: options.imageSmoothing, + projection: options.projection, + reprojectionErrorThreshold: options.reprojectionErrorThreshold, + state: SourceState.LOADING, + tileLoadFunction: options.tileLoadFunction, + wrapX: options.wrapX !== undefined ? options.wrapX : true, + transition: options.transition, + }); + + /** + * @type {string} + * @private + */ + this.baseUrl_ = options.url; + + /** + * @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) + ); + } + + /** + * @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); + + this.setState(SourceState.READY); + } + + /** + * @private + * @param {Error} error The error. + */ + handleError_(error) { + console.error(error); // eslint-disable-line + this.setState(SourceState.ERROR); + } +} + +export default OGCMapTile; From 4099f607793099b3ac46dc6706b0d494764c66d0 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Sun, 29 Aug 2021 15:34:29 -0600 Subject: [PATCH 2/8] OGC vector tile source --- examples/ogc-vector-tiles.html | 9 + examples/ogc-vector-tiles.js | 22 + src/ol/net.js | 107 +++++ src/ol/source/OGCMapTile.js | 321 +------------ src/ol/source/OGCVectorTile.js | 90 ++++ src/ol/source/ogcTileUtil.js | 279 +++++++++++ test/browser/spec/ol/net.test.js | 37 +- .../blueMarble/map/tiles/WebMercatorQuad.json | 85 ++++ .../tiles/WebMercatorQuad.json | 229 +++++++++ .../tileMatrixSets/WebMercatorQuad.json | 433 ++++++++++++++++++ test/node/ol/source/ogcTileUtil.test.js | 208 +++++++++ 11 files changed, 1512 insertions(+), 308 deletions(-) create mode 100644 examples/ogc-vector-tiles.html create mode 100644 examples/ogc-vector-tiles.js create mode 100644 src/ol/source/OGCVectorTile.js create mode 100644 src/ol/source/ogcTileUtil.js create mode 100644 test/node/ol/source/data/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad.json create mode 100644 test/node/ol/source/data/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad.json create mode 100644 test/node/ol/source/data/ogcapi/tileMatrixSets/WebMercatorQuad.json create mode 100644 test/node/ol/source/ogcTileUtil.test.js 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'); + }); + }); +}); From 58cf9f5f6d67fa55ab6e7e04ec63dd8760f93efb Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Sun, 29 Aug 2021 15:40:20 -0600 Subject: [PATCH 3/8] Leave as experimental until spec is finalized --- examples/ogc-map-tiles-geographic.html | 4 +++- examples/ogc-map-tiles.html | 4 +++- examples/ogc-vector-tiles.html | 4 +++- src/ol/source/OGCMapTile.js | 5 ++--- src/ol/source/OGCVectorTile.js | 8 +++++++- test/node/ol/source/ogcTileUtil.test.js | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/examples/ogc-map-tiles-geographic.html b/examples/ogc-map-tiles-geographic.html index f54bebb0ea..6a3e202709 100644 --- a/examples/ogc-map-tiles-geographic.html +++ b/examples/ogc-map-tiles-geographic.html @@ -3,7 +3,9 @@ layout: example.html title: OGC Map Tiles (Geographic) shortdesc: Rendering map tiles from an OGC API – Tiles service. docs: > - The OGC API – Tiles specification describes how a service can provide map tiles. + The OGC API – Tiles specification describes how a service can provide map tiles. Because the specification + has not yet been finalized, the OGCMapTile source is not yet part of the stable API. tags: "ogc" +experimental: true ---
diff --git a/examples/ogc-map-tiles.html b/examples/ogc-map-tiles.html index 7054fe6c3a..85ae0d4d76 100644 --- a/examples/ogc-map-tiles.html +++ b/examples/ogc-map-tiles.html @@ -3,7 +3,9 @@ layout: example.html title: OGC Map Tiles shortdesc: Rendering map tiles from an OGC API – Tiles service. docs: > - The OGC API – Tiles specification describes how a service can provide map tiles. + The OGC API – Tiles specification describes how a service can provide map tiles. Because the specification + has not yet been finalized, the OGCMapTile source is not yet part of the stable API. tags: "ogc" +experimental: true ---
diff --git a/examples/ogc-vector-tiles.html b/examples/ogc-vector-tiles.html index e9d562f457..f0b84d6a59 100644 --- a/examples/ogc-vector-tiles.html +++ b/examples/ogc-vector-tiles.html @@ -3,7 +3,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. + The OGC API – Tiles specification describes how a service can provide vector tiles. Because the specification + has not yet been finalized, the OGCVectorTile source is not yet part of the stable API. tags: "ogc, vector" +experimental: true ---
diff --git a/src/ol/source/OGCMapTile.js b/src/ol/source/OGCMapTile.js index 1ff2702765..6b1e557db8 100644 --- a/src/ol/source/OGCMapTile.js +++ b/src/ol/source/OGCMapTile.js @@ -36,10 +36,9 @@ import {getTileSetInfo} from './ogcTileUtil.js'; /** * @classdesc - * Layer source for map tiles from an OGC API - Tiles service that provides "map" type tiles. + * Layer source for map tiles from an [OGC API - Tiles](https://ogcapi.ogc.org/tiles/) service that provides "map" type tiles. * The service must conform to at least the core (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core) * and tileset (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset) conformance classes. - * @api */ class OGCMapTile extends TileImage { /** @@ -86,7 +85,7 @@ class OGCMapTile extends TileImage { * @param {Error} error The error. */ handleError_(error) { - console.error(error); // eslint-disable-line + console.error(error); // eslint-disable-line no-console this.setState(SourceState.ERROR); } } diff --git a/src/ol/source/OGCVectorTile.js b/src/ol/source/OGCVectorTile.js index 04193d07ac..b6e75cb8d5 100644 --- a/src/ol/source/OGCVectorTile.js +++ b/src/ol/source/OGCVectorTile.js @@ -36,6 +36,12 @@ import {getTileSetInfo} from './ogcTileUtil.js'; * zoom levels. See {@link module:ol/tilegrid/TileGrid~TileGrid#getZForResolution}. */ +/** + * @classdesc + * Layer source for map tiles from an [OGC API - Tiles](https://ogcapi.ogc.org/tiles/) service that provides "vector" type tiles. + * The service must conform to at least the core (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core) + * and tileset (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset) conformance classes. + */ class OGCVectorTile extends VectorTile { /** * @param {Options} options OGC vector tile options. @@ -82,7 +88,7 @@ class OGCVectorTile extends VectorTile { * @param {Error} error The error. */ handleError_(error) { - console.error(error); // eslint-disable-line + console.error(error); // eslint-disable-line no-console this.setState(SourceState.ERROR); } } diff --git a/test/node/ol/source/ogcTileUtil.test.js b/test/node/ol/source/ogcTileUtil.test.js index 570dd184a2..21fcb1715b 100644 --- a/test/node/ol/source/ogcTileUtil.test.js +++ b/test/node/ol/source/ogcTileUtil.test.js @@ -46,7 +46,7 @@ class MockXHR extends events.EventEmitter { this.emit('load', {target: this}); }, (err) => { - console.error(err); // eslint-disable-line + console.error(err); // eslint-disable-line no-console this.emit('error', {target: this}); } ); From d8baa87e251b2626fc8064d6a5f75e1655d33cf1 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Fri, 3 Sep 2021 14:04:33 -0600 Subject: [PATCH 4/8] Respect tile matrix set limits --- src/ol/source/ogcTileUtil.js | 116 +++++++++++++++++++++--- test/node/ol/source/ogcTileUtil.test.js | 28 ++++-- 2 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js index 0284326e13..ba4eaacb84 100644 --- a/src/ol/source/ogcTileUtil.js +++ b/src/ol/source/ogcTileUtil.js @@ -6,6 +6,7 @@ import TileGrid from '../tilegrid/TileGrid.js'; import {assign} from '../obj.js'; import {getJSON, resolveUrl} from '../net.js'; import {get as getProjection} from '../proj.js'; +import {getIntersection as intersectExtents} from '../extent.js'; /** * See https://ogcapi.ogc.org/tiles/. @@ -24,7 +25,7 @@ const TileType = { * @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} [tileMatrixSetLimits] Tile matrix set limits. * @property {Array} links Tileset links. */ @@ -36,7 +37,7 @@ const TileType = { */ /** - * @typedef {Object} TileMatrixSetLimits + * @typedef {Object} TileMatrixSetLimit * @property {string} tileMatrix The tile matrix id. * @property {number} minTileRow The minimum tile row. * @property {number} maxTileRow The maximum tile row. @@ -169,13 +170,17 @@ export function getVectorTileUrlTemplate(links, mediaType) { * @return {Promise} Tile set info. */ export function getTileSetInfo(sourceInfo) { - let tileUrlTemplate; - /** * @param {TileMatrixSet} tileMatrixSet Tile matrix set. + * @param {string} tileUrlTemplate Tile URL template. + * @param {Array} [tileMatrixSetLimits] Tile matrix set limits. * @return {TileSetInfo} Tile set info. */ - function parseTileMatrixSet(tileMatrixSet) { + function parseTileMatrixSet( + tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits + ) { let projection = sourceInfo.projection; if (!projection) { projection = getProjection(tileMatrixSet.crs); @@ -185,15 +190,51 @@ export function getTileSetInfo(sourceInfo) { } const backwards = projection.getAxisOrientation().substr(0, 2) !== 'en'; - // TODO: deal with limits const matrices = tileMatrixSet.tileMatrices; - const length = matrices.length; + + /** + * @type {Object} + */ + const matrixLookup = {}; + for (let i = 0; i < matrices.length; ++i) { + const matrix = matrices[i]; + matrixLookup[matrix.id] = matrix; + } + + /** + * @type {Object} + */ + const limitLookup = {}; + + /** + * @type {Array} + */ + const matrixIds = []; + + if (tileMatrixSetLimits) { + for (let i = 0; i < tileMatrixSetLimits.length; ++i) { + const limit = tileMatrixSetLimits[i]; + const id = limit.tileMatrix; + matrixIds.push(id); + limitLookup[id] = limit; + } + } else { + for (let i = 0; i < matrices.length; ++i) { + const id = matrices[i].id; + matrixIds.push(id); + } + } + + const length = matrixIds.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 extent = [-Infinity, -Infinity, Infinity, Infinity]; + + for (let i = 0; i < length; ++i) { + const id = matrixIds[i]; + const matrix = matrixLookup[id]; const origin = matrix.pointOfOrigin; if (backwards) { origins[i] = [origin[1], origin[0]]; @@ -203,6 +244,27 @@ export function getTileSetInfo(sourceInfo) { resolutions[i] = matrix.cellSize; sizes[i] = [matrix.matrixWidth, matrix.matrixHeight]; tileSizes[i] = [matrix.tileWidth, matrix.tileHeight]; + const limit = limitLookup[id]; + if (limit) { + const tileMapWidth = matrix.cellSize * matrix.tileWidth; + const minX = origins[i][0] + limit.minTileCol * tileMapWidth; + const maxX = origins[i][0] + (limit.maxTileCol + 1) * tileMapWidth; + + const tileMapHeight = matrix.cellSize * matrix.tileHeight; + const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; + + let minY; + let maxY; + if (upsideDown) { + minY = origins[i][1] + limit.minTileRow * tileMapHeight; + maxY = origins[i][1] + (limit.maxTileRow + 1) * tileMapHeight; + } else { + minY = origins[i][1] - (limit.maxTileRow + 1) * tileMapHeight; + maxY = origins[i][1] - limit.minTileRow * tileMapHeight; + } + + intersectExtents(extent, [minX, minY, maxX, maxY], extent); + } } const tileGrid = new TileGrid({ @@ -210,6 +272,7 @@ export function getTileSetInfo(sourceInfo) { resolutions: resolutions, sizes: sizes, tileSizes: tileSizes, + extent: tileMatrixSetLimits ? extent : undefined, }); const context = sourceInfo.context; @@ -220,14 +283,28 @@ export function getTileSetInfo(sourceInfo) { return undefined; } - const matrix = matrices[tileCoord[0]]; + const id = matrixIds[tileCoord[0]]; + const matrix = matrixLookup[id]; const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; const localContext = { - tileMatrix: matrix.id, + tileMatrix: id, tileCol: tileCoord[1], tileRow: upsideDown ? -tileCoord[2] - 1 : tileCoord[2], }; + + if (tileMatrixSetLimits) { + const limit = limitLookup[matrix.id]; + if ( + localContext.tileCol < limit.minTileCol || + localContext.tileCol > limit.maxTileCol || + localContext.tileRow < limit.minTileRow || + localContext.tileRow > limit.maxTileRow + ) { + return undefined; + } + } + assign(localContext, context); const url = tileUrlTemplate.replace(/\{(\w+?)\}/g, function (m, p) { @@ -249,6 +326,9 @@ export function getTileSetInfo(sourceInfo) { * @return {TileSetInfo|Promise} Tile set info. */ function parseTileSetMetadata(tileSet) { + const tileMatrixSetLimits = tileSet.tileMatrixSetLimits; + let tileUrlTemplate; + if (tileSet.dataType === TileType.MAP) { tileUrlTemplate = getMapTileUrlTemplate( tileSet.links, @@ -264,7 +344,11 @@ export function getTileSetInfo(sourceInfo) { } if (tileSet.tileMatrixSet) { - return parseTileMatrixSet(tileSet.tileMatrixSet); + return parseTileMatrixSet( + tileSet.tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits + ); } if (!tileSet.tileMatrixSetDefinition) { @@ -272,7 +356,13 @@ export function getTileSetInfo(sourceInfo) { } const url = resolveUrl(sourceInfo.url, tileSet.tileMatrixSetDefinition); - return getJSON(url).then(parseTileMatrixSet); + return getJSON(url).then(function (tileMatrixSet) { + return parseTileMatrixSet( + tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits + ); + }); } return getJSON(sourceInfo.url).then(parseTileSetMetadata); diff --git a/test/node/ol/source/ogcTileUtil.test.js b/test/node/ol/source/ogcTileUtil.test.js index 21fcb1715b..ed4cee5eaa 100644 --- a/test/node/ol/source/ogcTileUtil.test.js +++ b/test/node/ol/source/ogcTileUtil.test.js @@ -76,11 +76,15 @@ describe('ol/source/ogcTileUtil.js', () => { ); 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.grid.getResolutions()).to.have.length(10); 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' + expect(tileInfo.urlFunction([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/3/1/2.jpg' ); + expect(tileInfo.urlFunction([3, -1, 0])).to.be(undefined); // below min x + expect(tileInfo.urlFunction([3, 8, 0])).to.be(undefined); // above max x + expect(tileInfo.urlFunction([3, 0, -1])).to.be(undefined); // below min y + expect(tileInfo.urlFunction([3, 0, 8])).to.be(undefined); // above max y }); it('allows preferred media type to be configured', async () => { @@ -95,8 +99,8 @@ describe('ol/source/ogcTileUtil.js', () => { '/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' + expect(tileInfo.urlFunction([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad/3/1/2.png' ); }); @@ -112,11 +116,15 @@ describe('ol/source/ogcTileUtil.js', () => { ); 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.grid.getResolutions()).to.have.length(8); 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' + expect(tileInfo.urlFunction([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/3/1/2.json' ); + expect(tileInfo.urlFunction([2, -1, 0])).to.be(undefined); // below min x + expect(tileInfo.urlFunction([2, 4, 0])).to.be(undefined); // above max x + expect(tileInfo.urlFunction([2, 0, -1])).to.be(undefined); // below min y + expect(tileInfo.urlFunction([2, 0, 4])).to.be(undefined); // above max y }); it('allows preferred media type to be configured', async () => { @@ -131,8 +139,8 @@ describe('ol/source/ogcTileUtil.js', () => { '/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' + expect(tileInfo.urlFunction([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/3/1/2.mvt' ); }); }); From 12795e3923ae7308f6678bc1306391e11a4abf7a Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Fri, 3 Sep 2021 14:21:22 -0600 Subject: [PATCH 5/8] Slightly smaller functions --- src/ol/source/ogcTileUtil.js | 399 ++++++++++++++++++----------------- 1 file changed, 203 insertions(+), 196 deletions(-) diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js index ba4eaacb84..f4216d8760 100644 --- a/src/ol/source/ogcTileUtil.js +++ b/src/ol/source/ogcTileUtil.js @@ -165,205 +165,212 @@ export function getVectorTileUrlTemplate(links, mediaType) { return tileUrlTemplate; } +/** + * @param {SourceInfo} sourceInfo The source info. + * @param {TileMatrixSet} tileMatrixSet Tile matrix set. + * @param {string} tileUrlTemplate Tile URL template. + * @param {Array} [tileMatrixSetLimits] Tile matrix set limits. + * @return {TileSetInfo} Tile set info. + */ +function parseTileMatrixSet( + sourceInfo, + tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits +) { + 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'; + + const matrices = tileMatrixSet.tileMatrices; + + /** + * @type {Object} + */ + const matrixLookup = {}; + for (let i = 0; i < matrices.length; ++i) { + const matrix = matrices[i]; + matrixLookup[matrix.id] = matrix; + } + + /** + * @type {Object} + */ + const limitLookup = {}; + + /** + * @type {Array} + */ + const matrixIds = []; + + if (tileMatrixSetLimits) { + for (let i = 0; i < tileMatrixSetLimits.length; ++i) { + const limit = tileMatrixSetLimits[i]; + const id = limit.tileMatrix; + matrixIds.push(id); + limitLookup[id] = limit; + } + } else { + for (let i = 0; i < matrices.length; ++i) { + const id = matrices[i].id; + matrixIds.push(id); + } + } + + const length = matrixIds.length; + const origins = new Array(length); + const resolutions = new Array(length); + const sizes = new Array(length); + const tileSizes = new Array(length); + const extent = [-Infinity, -Infinity, Infinity, Infinity]; + + for (let i = 0; i < length; ++i) { + const id = matrixIds[i]; + const matrix = matrixLookup[id]; + 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 limit = limitLookup[id]; + if (limit) { + const tileMapWidth = matrix.cellSize * matrix.tileWidth; + const minX = origins[i][0] + limit.minTileCol * tileMapWidth; + const maxX = origins[i][0] + (limit.maxTileCol + 1) * tileMapWidth; + + const tileMapHeight = matrix.cellSize * matrix.tileHeight; + const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; + + let minY; + let maxY; + if (upsideDown) { + minY = origins[i][1] + limit.minTileRow * tileMapHeight; + maxY = origins[i][1] + (limit.maxTileRow + 1) * tileMapHeight; + } else { + minY = origins[i][1] - (limit.maxTileRow + 1) * tileMapHeight; + maxY = origins[i][1] - limit.minTileRow * tileMapHeight; + } + + intersectExtents(extent, [minX, minY, maxX, maxY], extent); + } + } + + const tileGrid = new TileGrid({ + origins: origins, + resolutions: resolutions, + sizes: sizes, + tileSizes: tileSizes, + extent: tileMatrixSetLimits ? extent : undefined, + }); + + const context = sourceInfo.context; + const base = sourceInfo.url; + + function tileUrlFunction(tileCoord, pixelRatio, projection) { + if (!tileCoord) { + return undefined; + } + + const id = matrixIds[tileCoord[0]]; + const matrix = matrixLookup[id]; + const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; + + const localContext = { + tileMatrix: id, + tileCol: tileCoord[1], + tileRow: upsideDown ? -tileCoord[2] - 1 : tileCoord[2], + }; + + if (tileMatrixSetLimits) { + const limit = limitLookup[matrix.id]; + if ( + localContext.tileCol < limit.minTileCol || + localContext.tileCol > limit.maxTileCol || + localContext.tileRow < limit.minTileRow || + localContext.tileRow > limit.maxTileRow + ) { + return undefined; + } + } + + 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 {SourceInfo} sourceInfo The source info. + * @param {TileSet} tileSet Tile set. + * @return {TileSetInfo|Promise} Tile set info. + */ +function parseTileSetMetadata(sourceInfo, tileSet) { + const tileMatrixSetLimits = tileSet.tileMatrixSetLimits; + let tileUrlTemplate; + + 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( + sourceInfo, + tileSet.tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits + ); + } + + if (!tileSet.tileMatrixSetDefinition) { + throw new Error('Expected tileMatrixSetDefinition or tileMatrixSet'); + } + + const url = resolveUrl(sourceInfo.url, tileSet.tileMatrixSetDefinition); + return getJSON(url).then(function (tileMatrixSet) { + return parseTileMatrixSet( + sourceInfo, + tileMatrixSet, + tileUrlTemplate, + tileMatrixSetLimits + ); + }); +} + /** * @param {SourceInfo} sourceInfo Source info. * @return {Promise} Tile set info. */ export function getTileSetInfo(sourceInfo) { - /** - * @param {TileMatrixSet} tileMatrixSet Tile matrix set. - * @param {string} tileUrlTemplate Tile URL template. - * @param {Array} [tileMatrixSetLimits] Tile matrix set limits. - * @return {TileSetInfo} Tile set info. - */ - function parseTileMatrixSet( - tileMatrixSet, - tileUrlTemplate, - tileMatrixSetLimits - ) { - 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'; - - const matrices = tileMatrixSet.tileMatrices; - - /** - * @type {Object} - */ - const matrixLookup = {}; - for (let i = 0; i < matrices.length; ++i) { - const matrix = matrices[i]; - matrixLookup[matrix.id] = matrix; - } - - /** - * @type {Object} - */ - const limitLookup = {}; - - /** - * @type {Array} - */ - const matrixIds = []; - - if (tileMatrixSetLimits) { - for (let i = 0; i < tileMatrixSetLimits.length; ++i) { - const limit = tileMatrixSetLimits[i]; - const id = limit.tileMatrix; - matrixIds.push(id); - limitLookup[id] = limit; - } - } else { - for (let i = 0; i < matrices.length; ++i) { - const id = matrices[i].id; - matrixIds.push(id); - } - } - - const length = matrixIds.length; - const origins = new Array(length); - const resolutions = new Array(length); - const sizes = new Array(length); - const tileSizes = new Array(length); - const extent = [-Infinity, -Infinity, Infinity, Infinity]; - - for (let i = 0; i < length; ++i) { - const id = matrixIds[i]; - const matrix = matrixLookup[id]; - 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 limit = limitLookup[id]; - if (limit) { - const tileMapWidth = matrix.cellSize * matrix.tileWidth; - const minX = origins[i][0] + limit.minTileCol * tileMapWidth; - const maxX = origins[i][0] + (limit.maxTileCol + 1) * tileMapWidth; - - const tileMapHeight = matrix.cellSize * matrix.tileHeight; - const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; - - let minY; - let maxY; - if (upsideDown) { - minY = origins[i][1] + limit.minTileRow * tileMapHeight; - maxY = origins[i][1] + (limit.maxTileRow + 1) * tileMapHeight; - } else { - minY = origins[i][1] - (limit.maxTileRow + 1) * tileMapHeight; - maxY = origins[i][1] - limit.minTileRow * tileMapHeight; - } - - intersectExtents(extent, [minX, minY, maxX, maxY], extent); - } - } - - const tileGrid = new TileGrid({ - origins: origins, - resolutions: resolutions, - sizes: sizes, - tileSizes: tileSizes, - extent: tileMatrixSetLimits ? extent : undefined, - }); - - const context = sourceInfo.context; - const base = sourceInfo.url; - - function tileUrlFunction(tileCoord, pixelRatio, projection) { - if (!tileCoord) { - return undefined; - } - - const id = matrixIds[tileCoord[0]]; - const matrix = matrixLookup[id]; - const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; - - const localContext = { - tileMatrix: id, - tileCol: tileCoord[1], - tileRow: upsideDown ? -tileCoord[2] - 1 : tileCoord[2], - }; - - if (tileMatrixSetLimits) { - const limit = limitLookup[matrix.id]; - if ( - localContext.tileCol < limit.minTileCol || - localContext.tileCol > limit.maxTileCol || - localContext.tileRow < limit.minTileRow || - localContext.tileRow > limit.maxTileRow - ) { - return undefined; - } - } - - 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) { - const tileMatrixSetLimits = tileSet.tileMatrixSetLimits; - let tileUrlTemplate; - - 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, - tileUrlTemplate, - tileMatrixSetLimits - ); - } - - if (!tileSet.tileMatrixSetDefinition) { - throw new Error('Expected tileMatrixSetDefinition or tileMatrixSet'); - } - - const url = resolveUrl(sourceInfo.url, tileSet.tileMatrixSetDefinition); - return getJSON(url).then(function (tileMatrixSet) { - return parseTileMatrixSet( - tileMatrixSet, - tileUrlTemplate, - tileMatrixSetLimits - ); - }); - } - - return getJSON(sourceInfo.url).then(parseTileSetMetadata); + return getJSON(sourceInfo.url).then(function (tileSet) { + return parseTileSetMetadata(sourceInfo, tileSet); + }); } From 7168a26cab3469b41291f59a79ecf32b58654883 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Sat, 4 Sep 2021 10:16:28 -0600 Subject: [PATCH 6/8] Use union types Co-authored-by: Andreas Hocevar --- src/ol/source/ogcTileUtil.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js index f4216d8760..de5fae0f5e 100644 --- a/src/ol/source/ogcTileUtil.js +++ b/src/ol/source/ogcTileUtil.js @@ -13,12 +13,8 @@ import {getIntersection as intersectExtents} from '../extent.js'; */ /** - * @enum {string} + * @typedef {'map' | 'vector'} TileType */ -const TileType = { - MAP: 'map', - VECTOR: 'vector', -}; /** * @typedef {Object} TileSet @@ -327,12 +323,12 @@ function parseTileSetMetadata(sourceInfo, tileSet) { const tileMatrixSetLimits = tileSet.tileMatrixSetLimits; let tileUrlTemplate; - if (tileSet.dataType === TileType.MAP) { + if (tileSet.dataType === 'map') { tileUrlTemplate = getMapTileUrlTemplate( tileSet.links, sourceInfo.mediaType ); - } else if (tileSet.dataType === TileType.VECTOR) { + } else if (tileSet.dataType === 'vector') { tileUrlTemplate = getVectorTileUrlTemplate( tileSet.links, sourceInfo.mediaType From ac4e472353834aad59de62f095e418080ade50b5 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Sat, 4 Sep 2021 10:31:36 -0600 Subject: [PATCH 7/8] Use union type for corner of origin --- src/ol/source/ogcTileUtil.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js index de5fae0f5e..6ad4ace43a 100644 --- a/src/ol/source/ogcTileUtil.js +++ b/src/ol/source/ogcTileUtil.js @@ -16,6 +16,10 @@ import {getIntersection as intersectExtents} from '../extent.js'; * @typedef {'map' | 'vector'} TileType */ +/** + * @typedef {'topLeft' | 'bottomLeft'} CornerOfOrigin + */ + /** * @typedef {Object} TileSet * @property {TileType} dataType Type of data represented in the tileset. @@ -53,7 +57,7 @@ import {getIntersection as intersectExtents} from '../extent.js'; * @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 {CornerOfOrigin} [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. @@ -93,8 +97,6 @@ const knownVectorMediaTypes = { * @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. @@ -244,7 +246,7 @@ function parseTileMatrixSet( const maxX = origins[i][0] + (limit.maxTileCol + 1) * tileMapWidth; const tileMapHeight = matrix.cellSize * matrix.tileHeight; - const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; + const upsideDown = matrix.cornerOfOrigin === 'bottomLeft'; let minY; let maxY; @@ -278,7 +280,7 @@ function parseTileMatrixSet( const id = matrixIds[tileCoord[0]]; const matrix = matrixLookup[id]; - const upsideDown = matrix.cornerOfOrigin === BOTTOM_LEFT_ORIGIN; + const upsideDown = matrix.cornerOfOrigin === 'bottomLeft'; const localContext = { tileMatrix: id, From c8067bebbb8b283e737188767b42ab8aba4814ab Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Sat, 4 Sep 2021 11:06:52 -0600 Subject: [PATCH 8/8] Add supported media type list to feature formats --- examples/ogc-vector-tiles.js | 1 - src/ol/format/Feature.js | 6 +++ src/ol/format/GMLBase.js | 2 + src/ol/format/GeoJSON.js | 5 +++ src/ol/format/KML.js | 2 + src/ol/format/MVT.js | 5 +++ src/ol/source/OGCVectorTile.js | 8 +++- src/ol/source/ogcTileUtil.js | 29 +++++++++++++- test/node/ol/source/ogcTileUtil.test.js | 51 +++++++++++++++++++++++++ 9 files changed, 104 insertions(+), 5 deletions(-) diff --git a/examples/ogc-vector-tiles.js b/examples/ogc-vector-tiles.js index 408af880f3..a52d31a39a 100644 --- a/examples/ogc-vector-tiles.js +++ b/examples/ogc-vector-tiles.js @@ -10,7 +10,6 @@ const map = new Map({ 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(), }), }), diff --git a/src/ol/format/Feature.js b/src/ol/format/Feature.js index 9f629c776a..a53bce0b04 100644 --- a/src/ol/format/Feature.js +++ b/src/ol/format/Feature.js @@ -76,6 +76,12 @@ class FeatureFormat { * @type {import("../proj/Projection.js").default|undefined} */ this.defaultFeatureProjection = undefined; + + /** + * A list media types supported by the format in descending order of preference. + * @type {Array} + */ + this.supportedMediaTypes = null; } /** diff --git a/src/ol/format/GMLBase.js b/src/ol/format/GMLBase.js index d67249db04..859f642338 100644 --- a/src/ol/format/GMLBase.js +++ b/src/ol/format/GMLBase.js @@ -132,6 +132,8 @@ class GMLBase extends XMLFeature { 'featureMember': makeArrayPusher(this.readFeaturesInternal), 'featureMembers': makeReplacer(this.readFeaturesInternal), }; + + this.supportedMediaTypes = ['application/gml+xml']; } /** diff --git a/src/ol/format/GeoJSON.js b/src/ol/format/GeoJSON.js index 4de6ee2885..a4cb0c7bb3 100644 --- a/src/ol/format/GeoJSON.js +++ b/src/ol/format/GeoJSON.js @@ -82,6 +82,11 @@ class GeoJSON extends JSONFeature { * @private */ this.extractGeometryName_ = options.extractGeometryName; + + this.supportedMediaTypes = [ + 'application/geo+json', + 'application/vnd.geo+json', + ]; } /** diff --git a/src/ol/format/KML.js b/src/ol/format/KML.js index 8e86545c71..27c00fd9ee 100644 --- a/src/ol/format/KML.js +++ b/src/ol/format/KML.js @@ -489,6 +489,8 @@ class KML extends XMLFeature { this.iconUrlFunction_ = options.iconUrlFunction ? options.iconUrlFunction : defaultIconUrlFunction; + + this.supportedMediaTypes = ['application/vnd.google-earth.kml+xml']; } /** diff --git a/src/ol/format/MVT.js b/src/ol/format/MVT.js index 5fa9305c36..1298b47f7a 100644 --- a/src/ol/format/MVT.js +++ b/src/ol/format/MVT.js @@ -89,6 +89,11 @@ class MVT extends FeatureFormat { * @type {string} */ this.idProperty_ = options.idProperty; + + this.supportedMediaTypes = [ + 'application/vnd.mapbox-vector-tile', + 'application/x-protobuf', + ]; } /** diff --git a/src/ol/source/OGCVectorTile.js b/src/ol/source/OGCVectorTile.js index b6e75cb8d5..da5d5c451f 100644 --- a/src/ol/source/OGCVectorTile.js +++ b/src/ol/source/OGCVectorTile.js @@ -13,8 +13,7 @@ import {getTileSetInfo} from './ogcTileUtil.js'; * (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. + * the source will try to find a link with rel="item" that uses a vector type supported 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. @@ -41,6 +40,10 @@ import {getTileSetInfo} from './ogcTileUtil.js'; * Layer source for map tiles from an [OGC API - Tiles](https://ogcapi.ogc.org/tiles/) service that provides "vector" type tiles. * The service must conform to at least the core (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core) * and tileset (http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset) conformance classes. + * + * Vector tile sets may come in a variety of formats (e.g. GeoJSON, MVT). The `format` option is used to determine + * which of the advertised media types is used. If you need to force the use of a particular media type, you can + * provide the `mediaType` option. */ class OGCVectorTile extends VectorTile { /** @@ -65,6 +68,7 @@ class OGCVectorTile extends VectorTile { url: options.url, projection: this.getProjection(), mediaType: options.mediaType, + supportedMediaTypes: options.format.supportedMediaTypes, context: options.context || null, }; diff --git a/src/ol/source/ogcTileUtil.js b/src/ol/source/ogcTileUtil.js index 6ad4ace43a..7fe318489a 100644 --- a/src/ol/source/ogcTileUtil.js +++ b/src/ol/source/ogcTileUtil.js @@ -93,6 +93,7 @@ const knownVectorMediaTypes = { * @typedef {Object} SourceInfo * @property {string} url The tile set URL. * @property {string} mediaType The preferred tile media type. + * @property {Array} [supportedMediaTypes] The supported media types. * @property {import("../proj/Projection.js").default} projection The source projection. * @property {Object} [context] Optional context for constructing the URL. */ @@ -134,13 +135,26 @@ export function getMapTileUrlTemplate(links, mediaType) { /** * @param {Array} links Tileset links. * @param {string} [mediaType] The preferred media type. + * @param {Array} [supportedMediaTypes] The media types supported by the parser. * @return {string} The tile URL template. */ -export function getVectorTileUrlTemplate(links, mediaType) { +export function getVectorTileUrlTemplate( + links, + mediaType, + supportedMediaTypes +) { let tileUrlTemplate; let fallbackUrlTemplate; + + /** + * Lookup of URL by media type. + * @type {Object} + */ + const hrefLookup = {}; + for (let i = 0; i < links.length; ++i) { const link = links[i]; + hrefLookup[link.type] = link.href; if (link.rel === 'item') { if (link.type === mediaType) { tileUrlTemplate = link.href; @@ -152,6 +166,16 @@ export function getVectorTileUrlTemplate(links, mediaType) { } } + if (!tileUrlTemplate && supportedMediaTypes) { + for (let i = 0; i < supportedMediaTypes.length; ++i) { + const supportedMediaType = supportedMediaTypes[i]; + if (hrefLookup[supportedMediaType]) { + tileUrlTemplate = hrefLookup[supportedMediaType]; + break; + } + } + } + if (!tileUrlTemplate) { if (fallbackUrlTemplate) { tileUrlTemplate = fallbackUrlTemplate; @@ -333,7 +357,8 @@ function parseTileSetMetadata(sourceInfo, tileSet) { } else if (tileSet.dataType === 'vector') { tileUrlTemplate = getVectorTileUrlTemplate( tileSet.links, - sourceInfo.mediaType + sourceInfo.mediaType, + sourceInfo.supportedMediaTypes ); } else { throw new Error('Expected tileset data type to be "map" or "vector"'); diff --git a/test/node/ol/source/ogcTileUtil.test.js b/test/node/ol/source/ogcTileUtil.test.js index ed4cee5eaa..b0219fb3a7 100644 --- a/test/node/ol/source/ogcTileUtil.test.js +++ b/test/node/ol/source/ogcTileUtil.test.js @@ -143,6 +143,48 @@ describe('ol/source/ogcTileUtil.js', () => { 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/3/1/2.mvt' ); }); + + it('uses supported media types if available', async () => { + baseUrl = 'https://maps.ecere.com/'; + const sourceInfo = { + url: 'https://maps.ecere.com/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad', + supportedMediaTypes: [ + 'bogus-media-type', + 'application/vnd.mapbox-vector-tile', + 'application/geo+json', // should not be used + ], + }; + 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([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/3/1/2.mvt' + ); + }); + + it('treats supported media types in descending order of priority', async () => { + baseUrl = 'https://maps.ecere.com/'; + const sourceInfo = { + url: 'https://maps.ecere.com/ogcapi/collections/ne_10m_admin_0_countries/tiles/WebMercatorQuad', + supportedMediaTypes: [ + 'bogus-media-type', + 'application/geo+json', // should be preferred + '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}.json' + ); + expect(tileInfo.urlFunction).to.be.a(Function); + expect(tileInfo.urlFunction([3, 2, 1])).to.be( + 'https://maps.ecere.com/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad/3/1/2.json' + ); + }); }); describe('getVectorTileUrlTemplate()', () => { @@ -173,6 +215,15 @@ describe('ol/source/ogcTileUtil.js', () => { ); }); + it('uses supported media types is preferred media type is not given', () => { + const urlTemplate = getVectorTileUrlTemplate(links, undefined, [ + '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');