642 lines
20 KiB
JavaScript
642 lines
20 KiB
JavaScript
/**
|
|
* @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<Sample>, Scenes, InputMetadata, CustomData, OutputMetadata): OutputValues|Array<number>|void} EvaluatePixel
|
|
*/
|
|
|
|
/**
|
|
* @typedef {function(Object<string, UpdatedOutputDescription>): void} UpdateOutput
|
|
*/
|
|
|
|
/**
|
|
* @typedef {function(Scenes, InputMetadata, OutputMetadata): void} UpdateOutputMetadata
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SetupResult
|
|
* @property {Array<string>|Array<InputDescription>} input Description of the input data.
|
|
* @property {OutputDescription|Array<OutputDescription>} 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<string>} bands Input band identifiers.
|
|
* @property {string|Array<string>} [units] Input band units.
|
|
* @property {Array<string>} [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<string, number>} 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>} [orbit] Information about scenes included in the tile when 'mosaicking' is 'ORBIT'.
|
|
* @property {Array<Tile>} [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<string, unknown>} CustomData
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} OutputMetadata
|
|
* @property {Object} userData Arbitrary user data.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object<string, Array<number>>} 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<ProcessRequestInputDataItem>} 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<string, unknown>} [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<number>} [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<ProcessRequestOutputResponse>} [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<HTMLImageElement>} 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<void>} 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<string>} 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<ProcessRequestInputDataItem>} [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<ProcessRequestInputDataItem>|null}
|
|
* @private
|
|
*/
|
|
this.inputData_ = null;
|
|
|
|
/**
|
|
* @type {string}
|
|
* @private
|
|
*/
|
|
this.processUrl_ = config.url || defaultProcessUrl;
|
|
|
|
/**
|
|
* @type {string}
|
|
* @private
|
|
*/
|
|
this.token_ = '';
|
|
|
|
/**
|
|
* @type {ReturnType<typeof setTimeout>}
|
|
* @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<ProcessRequestInputDataItem>} 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<import('../DataTile.js').Data>} 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;
|