From 791add0d73585860aaa1f240f8377dfb166b1b54 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Fri, 24 Apr 2020 17:15:32 -0600 Subject: [PATCH] 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;