/** * @module ol/source/SentinelHub */ import { equivalent as equivalentProjections, get as getProjection, } from '../proj.js'; import DataTileSource from './DataTile.js'; const defaultProcessUrl = 'https://services.sentinel-hub.com/api/v1/process'; const defaultTokenUrl = 'https://services.sentinel-hub.com/auth/realms/main/protocol/openid-connect/token'; const defaultEvalscriptVersion = '3'; /** * @type {import('../size.js').Size} */ const defaultTileSize = [512, 512]; const maxRetries = 10; const baseDelay = 500; /** * @typedef {Object} AuthConfig * @property {string} [tokenUrl='https://services.sentinel-hub.com/auth/realms/main/protocol/openid-connect/token'] The URL to get the authentication token. * @property {string} clientId The client ID. * @property {string} clientSecret The client secret. */ /** * @typedef {Object} AccessTokenClaims * @property {number} exp The expiration time of the token (in seconds). */ /** * @typedef {Object} Evalscript * @property {Setup} setup The setup function. * @property {EvaluatePixel} evaluatePixel The function to transform input samples into output values. * @property {UpdateOutput} [updateOutput] Optional function to adjust the output bands. * @property {UpdateOutputMetadata} [updateOutputMetadata] Optional function to update the output metadata. * @property {Collections} [preProcessScenes] Optional function called before processing. * @property {string} [version='3'] The Evalscript version. */ /** * @typedef {function(): SetupResult} Setup */ /** * @typedef {function(Sample|Array, Scenes, InputMetadata, CustomData, OutputMetadata): OutputValues|Array|void} EvaluatePixel */ /** * @typedef {function(Object): void} UpdateOutput */ /** * @typedef {function(Scenes, InputMetadata, OutputMetadata): void} UpdateOutputMetadata */ /** * @typedef {Object} SetupResult * @property {Array|Array} input Description of the input data. * @property {OutputDescription|Array} output Description of the output data. * @property {'SIMPLE'|'ORBIT'|'TILE'} [mosaicking='SIMPLE'] Control how samples from input scenes are composed. */ /** * @typedef {Object} InputDescription * @property {Array} bands Input band identifiers. * @property {string|Array} [units] Input band units. * @property {Array} [metadata] Properties to include in the input metadata. */ /** * @typedef {Object} OutputDescription * @property {string} [id='default'] Output identifier. * @property {number} bands Number of output bands. * @property {SampleType} [sampleType='AUTO'] Output sample type. * @property {number} [nodataValue] Output nodata value. */ /** * @typedef {Object} UpdatedOutputDescription * @property {number} bands Number of output bands. */ /** * @typedef {'INT8'|'UINT8'|'INT16'|'UINT16'|'FLOAT32'|'AUTO'} SampleType */ /** * @typedef {Object} Sample */ /** * @typedef {Object} Collections * @property {string} [from] For 'ORBIT' mosaicking, this will be the start of the search interval. * @property {string} [to] For 'ORBIT' mosaicking, this will be the end of the search interval. * @property {Scenes} scenes The scenes in the collection. */ /** * @typedef {Object} Scenes * @property {Array} [orbit] Information about scenes included in the tile when 'mosaicking' is 'ORBIT'. * @property {Array} [tiles] Information about scenes included in the tile when 'mosaicking' is 'TILE'. */ /** * @typedef {Object} Orbit * @property {string} dateFrom The earliest date for all scenes included in the tile. * @property {string} dateTo The latest date for scenes included in the tile. * @property {Array} tiles Metadata for each tile. */ /** * @typedef {Object} Tile * @property {string} date The date of scene used in the tile. * @property {number} cloudCoverage The estimated percentage of pixels obscured by clouds in the scene. * @property {string} dataPath The path to the data in storage. * @property {number} shId The internal identifier for the scene. */ /** * @typedef {Object} InputMetadata * @property {string} serviceVersion The version of the service used for processing. * @property {number} normalizationFactor The factor used to convert digital number (DN) values to reflectance. */ /** * @typedef {Object} CustomData */ /** * @typedef {Object} OutputMetadata * @property {Object} userData Arbitrary user data. */ /** * @typedef {Object>} OutputValues */ /** * @typedef {Object} ProcessRequest * @property {ProcessRequestInput} input Input data configuration. * @property {string} evalscript The Evalscript used for processing. * @property {ProcessRequestOutput} [output] The output configuration. */ /** * @typedef {Object} ProcessRequestInput * @property {ProcessRequestInputBounds} bounds The bounding box of the input data. * @property {Array} data The intput data. */ /** * @typedef {Object} ProcessRequestInputDataItem * @property {string} [type] The type of the input data. * @property {string} [id] The identifier of the input data. * @property {DataFilter} [dataFilter] The filter to apply to the input data. * @property {Object} [processing] The processing to apply to the input data. */ /** * @typedef {Object} DataFilter * @property {TimeRange} [timeRange] The data time range. * @property {number} [maxCloudCoverage] The maximum cloud coverage (0-100). */ /** * @typedef {Object} TimeRange * @property {string} [from] The start time (inclusive). * @property {string} [to] The end time (inclusive). */ /** * @typedef {Object} ProcessRequestInputBounds * @property {Array} [bbox] The bounding box of the input data. * @property {ProcessRequestInputBoundsProperties} [properties] The properties of the bounding box. * @property {import("geojson").Geometry} [geometry] The geometry of the bounding box. */ /** * @typedef {Object} ProcessRequestInputBoundsProperties * @property {string} crs The coordinate reference system of the bounding box. */ /** * @typedef {Object} ProcessRequestOutput * @property {number} [width] Image width in pixels. * @property {number} [height] Image height in pixels. * @property {number} [resx] Spatial resolution in the x direction. * @property {number} [resy] Spatial resolution in the y direction. * @property {Array} [responses] Response configuration. */ /** * @typedef {Object} ProcessRequestOutputResponse * @property {string} [identifier] Identifier used to connect results to outputs from the setup. * @property {ProcessRequestOutputFormat} [format] Response format. */ /** * @typedef {Object} ProcessRequestOutputFormat * @property {string} [type] The output format type. */ /** * @param {Evalscript} evalscript The object to serialize. * @return {string} The serialized Evalscript. */ function serializeEvalscript(evalscript) { const version = evalscript.version || defaultEvalscriptVersion; return `//VERSION=${version} ${serializeFunction('setup', evalscript.setup)} ${serializeFunction('evaluatePixel', evalscript.evaluatePixel)} ${serializeFunction('updateOutput', evalscript.updateOutput)} `; } /** * Get a loaded image given a response. * * @param {Response} response The response. * @return {Promise} The image. */ async function imageFromResponse(response) { const blob = await response.blob(); return new Promise((resolve, reject) => { const image = new Image(); const blobUrl = URL.createObjectURL(blob); image.onload = () => { URL.revokeObjectURL(blobUrl); resolve(image); }; image.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('Failed to load image')); }; image.src = blobUrl; }); } /** * @param {number} ms Milliseconds. * @return {Promise} A promise that resolves after the given time. */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * @param {AuthConfig} auth The authentication configuration. * @return {Promise} The authentication token. */ async function getToken(auth) { const url = auth.tokenUrl || defaultTokenUrl; const body = new URLSearchParams(); body.append('grant_type', 'client_credentials'); body.append('client_id', auth.clientId); body.append('client_secret', auth.clientSecret); /** * @type {RequestInit} */ const options = { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body, }; const response = await fetch(url, options); if (!response.ok) { if (response.status === 401) { throw new Error('Bad client id or secret'); } throw new Error('Failed to get token'); } const data = await response.json(); return data.access_token; } /** * @param {string} token The access token to parse. * @return {AccessTokenClaims} The parsed token claims. */ export function parseTokenClaims(token) { const base64EncodedClaims = token .split('.')[1] .replace(/-/g, '+') .replace(/_/g, '/'); const chars = atob(base64EncodedClaims).split(''); const count = chars.length; const uriEncodedChars = new Array(count); for (let i = 0; i < count; ++i) { const c = chars[i]; uriEncodedChars[i] = '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); } return JSON.parse(decodeURIComponent(uriEncodedChars.join(''))); } /** * Gets a CRS identifier accepted by Sentinel Hub. * See https://docs.sentinel-hub.com/api/latest/api/process/crs/. * * @param {import("../proj/Projection.js").default} projection The projection. * @return {string} The projection identifier accepted by Sentinel Hub. */ export function getProjectionIdentifier(projection) { const ogcId = 'http://www.opengis.net/def/crs/'; const code = projection.getCode(); if (code.startsWith(ogcId)) { return code; } if (code.startsWith('EPSG:')) { return `${ogcId}EPSG/0/${code.slice(5)}`; } if (equivalentProjections(projection, getProjection('EPSG:4326'))) { return `${ogcId}EPSG/0/4326`; } // hope for the best return code; } /** * This is intended to work with named functions, anonymous functions, arrow functions, and object methods. * Due to how the Evalscript is executed, these are serialized as function expressions using `var`. * * @param {string} name The name of the function. * @param {Function|undefined} func The function to serialize. * @return {string} The serialized function. */ export function serializeFunction(name, func) { if (!func) { return ''; } let expression = func.toString(); if ( func.name && func.name !== 'function' && expression.match(new RegExp('^' + func.name.replace('$', '\\$') + '\\b')) ) { // assume function came from an object property using method syntax expression = 'function ' + expression; } return `var ${name} = ${expression};`; } /** * @typedef {Object} Options * @property {AuthConfig|string} [auth] The authentication configuration with `clientId` and `clientSecret` or an access token. * See [Sentinel Hub authentication](https://docs.sentinel-hub.com/api/latest/api/overview/authentication/) * for details. If not provided in the constructor, the source will not be rendered until {@link module:ol/source/SentinelHub~SentinelHub#setAuth} * is called. * @property {Array} [data] The input data configuration. If not provided in the constructor, * the source will not be rendered until {@link module:ol/source/SentinelHub~SentinelHub#setData} is called. * @property {Evalscript|string} [evalscript] The process applied to the input data. If not provided in the constructor, * the source will not be rendered until {@link module:ol/source/SentinelHub~SentinelHub#setEvalscript} is called. See the * `setEvalscript` documentation for details on the restrictions when passing process functions. * @property {number|import("../size.js").Size} [tileSize=[512, 512]] The pixel width and height of the source tiles. * @property {string} [url='https://services.sentinel-hub.com/api/v1/process'] The Sentinel Hub Processing API URL. * @property {import("../proj.js").ProjectionLike} [projection] Projection. Default is the view projection. * @property {boolean} [attributionsCollapsible=true] Allow the attributions to be collapsed. * @property {boolean} [interpolate=true] Use interpolated values when resampling. By default, * linear interpolation is used when resampling. Set to false to use the nearest neighbor instead. * @property {boolean} [wrapX=true] Wrap the world horizontally. * @property {number} [transition] Duration of the opacity transition for rendering. * To disable the opacity transition, pass `transition: 0`. */ /** * @classdesc * A tile source that generates tiles using the Sentinel Hub [Processing API](https://docs.sentinel-hub.com/api/latest/api/process/). * All of the constructor options are optional, however the source will not be ready for rendering until the `auth`, `data`, * and `evalscript` properties are provided. These can be set after construction with the {@link module:ol/source/SentinelHub~SentinelHub#setAuth}, * {@link module:ol/source/SentinelHub~SentinelHub#setData}, and {@link module:ol/source/SentinelHub~SentinelHub#setEvalscript} * methods. * * If there are errors while configuring the source or fetching an access token, the `change` event will be fired and the * source state will be set to `error`. See the {@link module:ol/source/SentinelHub~SentinelHub#getError} method for * details on handling these errors. * @api */ class SentinelHub extends DataTileSource { /** * @param {Options} [options] Sentinel Hub options. */ constructor(options) { /** * @type {Options} */ const config = options || {}; super({ state: 'loading', projection: config.projection, attributionsCollapsible: config.attributionsCollapsible, interpolate: config.interpolate, tileSize: config.tileSize || defaultTileSize, wrapX: config.wrapX !== undefined ? config.wrapX : true, transition: config.transition, }); this.setLoader((x, y, z) => this.loadTile_(x, y, z, 1)); /** * @type {Error|null} */ this.error_ = null; /** * @type {string} * @private */ this.evalscript_ = ''; /** * @type {Array|null} * @private */ this.inputData_ = null; /** * @type {string} * @private */ this.processUrl_ = config.url || defaultProcessUrl; /** * @type {string} * @private */ this.token_ = ''; /** * @type {ReturnType} * @private */ this.tokenRenewalId_; if (config.auth) { this.setAuth(config.auth); } if (config.data) { this.setData(config.data); } if (config.evalscript) { this.setEvalscript(config.evalscript); } } /** * Set the authentication configuration for the source (if not provided in the constructor). * If an object with `clientId` and `clientSecret` is provided, an access token will be fetched * and used with processing requests. Alternatively, an access token can be supplied directly. * * @param {AuthConfig|string} auth The auth config or access token. * @api */ async setAuth(auth) { clearTimeout(this.tokenRenewalId_); if (typeof auth === 'string') { this.token_ = auth; this.fireWhenReady_(); return; } /** * @type {string} */ let token; /** * @type {AccessTokenClaims} */ let claims; try { token = await getToken(auth); claims = parseTokenClaims(token); } catch (error) { this.error_ = error; this.setState('error'); return; } this.token_ = token; const expiry = claims.exp * 1000; const timeout = Math.max(expiry - Date.now() - 60 * 1000, 1); this.tokenRenewalId_ = setTimeout(() => this.setAuth(auth), timeout); this.fireWhenReady_(); } /** * Set or update the input data used. * * @param {Array} data The input data configuration. * @api */ setData(data) { this.inputData_ = data; this.fireWhenReady_(); } /** * Set or update the Evalscript used to process the data. Either a process object or a string * Evalscript can be provided. If a process object is provided, it will be serialized to produce the * Evalscript string. Because these functions will be serialized and executed by the Processing API, * they cannot refer to other variables or functions that are not provided by the Processing API * context. * * @param {Evalscript|string} evalscript The process to apply to the input data. * @api */ setEvalscript(evalscript) { let script; if (typeof evalscript === 'string') { script = evalscript; } else { try { script = serializeEvalscript(evalscript); } catch (error) { this.error_ = error; this.setState('error'); return; } } this.evalscript_ = script; this.fireWhenReady_(); } fireWhenReady_() { if (!this.token_ || !this.evalscript_ || !this.inputData_) { return; } const state = this.getState(); if (state === 'ready') { this.changed(); return; } this.setState('ready'); } /** * @param {number} z The z tile index. * @param {number} x The x tile index. * @param {number} y The y tile index. * @param {number} attempt The attempt number (starting with 1). Incremented with retries. * @return {Promise} The composed tile data. * @private */ async loadTile_(z, x, y, attempt) { const tileGrid = this.getTileGrid(); const extent = tileGrid.getTileCoordExtent([z, x, y]); const tileSize = this.getTileSize(z); const projection = this.getProjection(); /** * @type {ProcessRequest} */ const body = { input: { bounds: { bbox: extent, properties: {crs: getProjectionIdentifier(projection)}, }, data: this.inputData_, }, output: { width: tileSize[0], height: tileSize[1], }, evalscript: this.evalscript_, }; /** * @type {RequestInit} */ const options = { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token_}`, 'Access-Control-Request-Headers': 'Retry-After', }, body: JSON.stringify(body), credentials: 'include', }; const response = await fetch(this.processUrl_, options); if (!response.ok) { if (response.status === 429 && attempt < maxRetries - 1) { // The Retry-After header includes unreasonable wait times, instead use exponential backoff. const retryAfter = baseDelay * 2 ** attempt; await delay(retryAfter); return this.loadTile_(x, y, z, attempt + 1); } throw new Error(`Failed to get tile: ${response.statusText}`); } return imageFromResponse(response); } /** * When the source state is `error`, use this function to get more information about the error. * To debug a faulty configuration, you may want to use a listener like this: * ```js * source.on('change', () => { * if (source.getState() === 'error') { * console.error(source.getError()); * } * }); * ``` * * @return {Error|null} A source loading error. * @api */ getError() { return this.error_; } /** * Clean up. * @override */ disposeInternal() { clearTimeout(this.tokenRenewalId_); super.disposeInternal(); } } export default SentinelHub;