@itwin/core-frontend
Version:
iTwin.js frontend components
448 lines • 19.9 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module MapLayers
*/
import { assert, BeEvent } from "@itwin/core-bentley";
import { ImageSource, ImageSourceFormat } from "@itwin/core-common";
import { Angle } from "@itwin/core-geometry";
import { IModelApp } from "../../IModelApp";
import { NotifyMessageDetails, OutputMessagePriority } from "../../NotificationManager";
import { appendQueryParams, GeographicTilingScheme, WebMercatorTilingScheme } from "../internal";
import { headersIncludeAuthMethod, setBasicAuthorization, setRequestTimeout } from "../../request/utils";
/** @internal */
const tileImageSize = 256, untiledImageSize = 256;
const earthRadius = 6378137;
const doDebugToolTips = false;
/** The status of the map layer imagery provider that lets you know if authentication is needed to request tiles.
* @public
*/
export var MapLayerImageryProviderStatus;
(function (MapLayerImageryProviderStatus) {
MapLayerImageryProviderStatus[MapLayerImageryProviderStatus["Valid"] = 0] = "Valid";
MapLayerImageryProviderStatus[MapLayerImageryProviderStatus["RequireAuth"] = 1] = "RequireAuth";
})(MapLayerImageryProviderStatus || (MapLayerImageryProviderStatus = {}));
/** Abstract class for map layer imagery providers.
* Map layer imagery providers request and provide tile images and other data. Each map layer from a separate source needs its own imagery provider object.
* @beta
*/
export class MapLayerImageryProvider {
_settings;
_usesCachedTiles;
_hasSuccessfullyFetchedTile = false;
onStatusChanged = new BeEvent();
/** @internal */
_mercatorTilingScheme = new WebMercatorTilingScheme();
/** @internal */
_geographicTilingScheme = new GeographicTilingScheme();
/** @internal */
_status = MapLayerImageryProviderStatus.Valid;
/** @internal */
_includeUserCredentials = false;
/** @internal */
onFirstRequestCompleted = new BeEvent();
/** @internal */
_firstRequestPromise;
/**
* The status of the map layer imagery provider.
* @public @preview
*/
get status() { return this._status; }
/** Determine if this provider supports map feature info.
* For example, this can be used to show the map feature info tool only when a provider is registered to support it.
* @returns true if provider supports map feature info else return false.
* @public
*/
get supportsMapFeatureInfo() { return false; }
resetStatus() { this.setStatus(MapLayerImageryProviderStatus.Valid); }
/** @internal */
get tileSize() { return this._usesCachedTiles ? tileImageSize : untiledImageSize; }
/** @internal */
get maximumScreenSize() { return 2 * this.tileSize; }
get minimumZoomLevel() { return this.defaultMinimumZoomLevel; }
get maximumZoomLevel() { return this.defaultMaximumZoomLevel; }
/** @internal */
get usesCachedTiles() { return this._usesCachedTiles; }
get mutualExclusiveSubLayer() { return false; }
/** @internal */
get useGeographicTilingScheme() { return false; }
_cartoRange;
/** Validates a cartographic range for NaN and infinite values.
* @param range The cartographic range to validate.
* @returns true if the range is valid, false otherwise.
* @internal
*/
static isRangeValid(range) {
if (!range) {
return false;
}
return !Number.isNaN(range.low.x) && !Number.isNaN(range.low.y) &&
!Number.isNaN(range.high.x) && !Number.isNaN(range.high.y) &&
Number.isFinite(range.low.x) && Number.isFinite(range.low.y) &&
Number.isFinite(range.high.x) && Number.isFinite(range.high.y);
}
/** Gets or sets the cartographic range for this provider.
* When setting, if the range is invalid (contains NaN or infinite values), it will be stored as undefined.
* When getting, returns undefined if the range was set to an invalid value.
*/
get cartoRange() {
return this._cartoRange;
}
set cartoRange(range) {
this._cartoRange = MapLayerImageryProvider.isRangeValid(range) ? range : undefined;
}
/**
* This value is used internally for various computations, this should not get overriden.
* @internal
*/
defaultMinimumZoomLevel = 0;
/**
* This value is used internally for various computations, this should not get overriden.
* @internal
*/
defaultMaximumZoomLevel = 22;
/** @internal */
get _filterByCartoRange() { return true; }
constructor(_settings, _usesCachedTiles) {
this._settings = _settings;
this._usesCachedTiles = _usesCachedTiles;
this._mercatorTilingScheme = new WebMercatorTilingScheme();
this._geographicTilingScheme = new GeographicTilingScheme(2, 1, true);
}
/**
* Initialize the provider by loading the first tile at its default maximum zoom level.
* @beta
*/
async initialize() {
this.loadTile(0, 0, this.defaultMaximumZoomLevel).then((tileData) => {
if (tileData !== undefined)
this._missingTileData = tileData.data;
});
}
get tilingScheme() { return this.useGeographicTilingScheme ? this._geographicTilingScheme : this._mercatorTilingScheme; }
/** @deprecated in 5.0 - will not be removed until after 2026-06-13. Use [addAttributions] instead. */
addLogoCards(_cards, _viewport) { }
/**
* Add attribution logo cards for the data supplied by this provider to the [[Viewport]]'s logo div.
* @param _cards Logo cards HTML element that may contain custom data attributes.
* @param _viewport Viewport to add logo cards to.
* @beta
*/
async addAttributions(cards, vp) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
return Promise.resolve(this.addLogoCards(cards, vp));
}
/** @internal */
_missingTileData;
/** @internal */
get transparentBackgroundString() { return this._settings.transparentBackground ? "true" : "false"; }
/** @internal */
async _areChildrenAvailable(_tile) { return true; }
/** @internal */
getPotentialChildIds(quadId) {
const childLevel = quadId.level + 1;
return quadId.getChildIds(this.tilingScheme.getNumberOfXChildrenAtLevel(childLevel), this.tilingScheme.getNumberOfYChildrenAtLevel(childLevel));
}
/**
* Get child IDs of a quad and generate tiles based on these child IDs.
* See [[ImageryTileTree._loadChildren]] for the definition of `resolveChildren` where this function is commonly called.
* @param quadId quad to generate child IDs for.
* @param resolveChildren Function that creates tiles from child IDs.
* @beta
*/
_generateChildIds(quadId, resolveChildren) {
resolveChildren(this.getPotentialChildIds(quadId));
}
/** @internal */
generateChildIds(tile, resolveChildren) {
if (tile.depth >= this.maximumZoomLevel || (undefined !== this.cartoRange && this._filterByCartoRange && !this.cartoRange.intersectsRange(tile.rectangle))) {
tile.setLeaf();
return;
}
this._generateChildIds(tile.quadId, resolveChildren);
}
/**
* Get tooltip text for a specific quad and cartographic position.
* @param strings List of strings to contain tooltip text.
* @param quadId Quad ID to get tooltip for.
* @param _carto Cartographic that may be used to retrieve and/or format tooltip text.
* @param tree Tree associated with the quad to get the tooltip for.
* @internal
*/
async getToolTip(strings, quadId, _carto, tree) {
if (doDebugToolTips) {
const range = quadId.getLatLongRangeDegrees(tree.tilingScheme);
strings.push(`QuadId: ${quadId.debugString}, Lat: ${range.low.x} - ${range.high.x} Long: ${range.low.y} - ${range.high.y}`);
}
}
/** @internal */
async getFeatureInfo(featureInfos, _quadId, _carto, _tree, _hit, _options) {
// default implementation; simply return an empty feature info
featureInfos.push({ layerName: this._settings.name });
}
/** @internal */
decorate(_context) {
}
/** @internal */
async getImageFromTileResponse(tileResponse, zoomLevel) {
const arrayBuffer = await tileResponse.arrayBuffer();
const byteArray = new Uint8Array(arrayBuffer);
if (!byteArray || (byteArray.length === 0))
return undefined;
if (this.matchesMissingTile(byteArray) && zoomLevel > 8)
return undefined;
const contentType = tileResponse.headers.get("content-type")?.toLowerCase();
let imageFormat;
if (contentType) {
// Note: 'includes' is used here instead of exact comparison because we encountered
// some servers that would give content type such as 'image/png;charset=UTF-8'.
if (contentType.includes("image/jpeg"))
imageFormat = ImageSourceFormat.Jpeg;
else if (contentType.includes("image/png"))
imageFormat = ImageSourceFormat.Png;
}
if (imageFormat !== undefined)
return new ImageSource(byteArray, imageFormat);
assert(false, "Invalid tile content type");
return undefined;
}
/**
* Change the status of this provider.
* Sub-classes should override 'onStatusUpdated' instead of this method.
* @internal
*/
setStatus(status) {
if (this._status !== status) {
this.onStatusUpdated(status);
this._status = status;
this.onStatusChanged.raiseEvent(this);
}
}
/** Method called whenever the status changes, giving the opportunity to sub-classes to have a custom behavior.
* @internal
*/
onStatusUpdated(_newStatus) { }
/** @internal */
setRequestAuthorization(headers) {
if (this._settings.userName && this._settings.password) {
setBasicAuthorization(headers, this._settings.userName, this._settings.password);
}
}
/** @internal */
async makeTileRequest(url, timeoutMs, authorization) {
// We want to complete the first request before letting other requests go;
// this done to avoid flooding server with requests missing credentials
if (!this._firstRequestPromise)
this._firstRequestPromise = new Promise((resolve) => this.onFirstRequestCompleted.addOnce(() => resolve()));
else
await this._firstRequestPromise;
let response;
try {
response = await this.makeRequest(url, timeoutMs, authorization);
}
finally {
this.onFirstRequestCompleted.raiseEvent();
}
if (response === undefined)
throw new Error("fetch call failed");
return response;
}
/** @internal */
async makeRequest(url, timeoutMs, authorization) {
let response;
let headers;
let hasCreds = false;
if (authorization) {
headers = new Headers();
headers.set("Authorization", authorization);
}
else if (this._settings.userName && this._settings.password) {
hasCreds = true;
headers = new Headers();
this.setRequestAuthorization(headers);
}
const opts = {
method: "GET",
headers,
credentials: this._includeUserCredentials ? "include" : undefined,
};
if (timeoutMs !== undefined)
setRequestTimeout(opts, timeoutMs);
response = await fetch(url, opts);
if (response.status === 401
&& headersIncludeAuthMethod(response.headers, ["ntlm", "negotiate"])
&& !this._includeUserCredentials
&& !hasCreds) {
// Removed the previous headers and make sure "include" credentials is set
opts.headers = undefined;
opts.credentials = "include";
// We got a http 401 challenge, lets try again with SSO enabled (i.e. Windows Authentication)
response = await fetch(url, opts);
if (response.status === 200) {
this._includeUserCredentials = true; // avoid going through 401 challenges over and over
}
}
return response;
}
/** Returns a map layer tile at the specified settings. */
async loadTile(row, column, zoomLevel) {
try {
const tileUrl = await this.constructUrl(row, column, zoomLevel);
if (tileUrl.length === 0)
return undefined;
const tileResponse = await this.makeTileRequest(tileUrl);
if (!this._hasSuccessfullyFetchedTile) {
this._hasSuccessfullyFetchedTile = true;
}
return await this.getImageFromTileResponse(tileResponse, zoomLevel);
}
catch (error) {
if (error?.status === 401) {
this.setStatus(MapLayerImageryProviderStatus.RequireAuth);
// Only report error to end-user if we were previously able to fetch tiles
// and then encountered an error, otherwise I assume an error was already reported
// through the source validation process.
if (this._hasSuccessfullyFetchedTile) {
const msg = IModelApp.localization.getLocalizedString("iModelJs:MapLayers.Messages.LoadTileTokenError", { layerName: this._settings.name });
IModelApp.notifications.outputMessage(new NotifyMessageDetails(OutputMessagePriority.Warning, msg));
}
}
return undefined;
}
}
/** @internal */
async toolTipFromUrl(strings, url) {
const headers = new Headers();
this.setRequestAuthorization(headers);
try {
const response = await fetch(url, {
method: "GET",
headers,
credentials: this._includeUserCredentials ? "include" : undefined,
});
const text = await response.text();
if (undefined !== text) {
strings.push(text);
}
}
catch {
}
}
/** @internal */
matchesMissingTile(tileData) {
if (!this._missingTileData)
return false;
if (tileData.length !== this._missingTileData.length)
return false;
for (let i = 0; i < tileData.length; i += 10) {
if (this._missingTileData[i] !== tileData[i]) {
return false;
}
}
return true;
}
/**
* Calculates the projected x cartesian coordinate in EPSG:3857 from the longitude in EPSG:4326 (WGS84)
* @param longitude Longitude in EPSG:4326 (WGS84)
* @internal
*/
getEPSG3857X(longitude) {
return longitude * 20037508.34 / 180.0;
}
/**
* Calculates the projected y cartesian coordinate in EPSG:3857 from the latitude in EPSG:4326 (WGS84)
* @param latitude Latitude in EPSG:4326 (WGS84)
* @internal
*/
getEPSG3857Y(latitude) {
const y = Math.log(Math.tan((90.0 + latitude) * Math.PI / 360.0)) / (Math.PI / 180.0);
return y * 20037508.34 / 180.0;
}
/**
* Calculates the longitude in EPSG:4326 (WGS84) from the projected x cartesian coordinate in EPSG:3857
* @param x3857 Projected x cartesian coordinate in EPSG:3857
* @internal
*/
getEPSG4326Lon(x3857) {
return Angle.radiansToDegrees(x3857 / earthRadius);
}
/**
* Calculates the latitude in EPSG:4326 (WGS84) from the projected y cartesian coordinate in EPSG:3857
* @param y3857 Projected y cartesian coordinate in EPSG:3857
* @internal
*/
getEPSG4326Lat(y3857) {
const y = 2 * Math.atan(Math.exp(y3857 / earthRadius)) - (Math.PI / 2);
return Angle.radiansToDegrees(y);
}
/**
* Get the bounding box/extents of a tile in EPSG:4326 (WGS84) format.
* Map tile providers like Bing and Mapbox allow the URL to be constructed directly from the zoom level and tile coordinates.
* However, WMS-based servers take a bounding box instead. This method can help get that bounding box from a tile.
* @param row Row of the tile
* @param column Column of the tile
* @param zoomLevel Desired zoom level of the tile
* @internal
*/
getEPSG4326Extent(row, column, zoomLevel) {
// Shift left (this.tileSize << zoomLevel) overflow when using 512 pixels tile at higher resolution,
// so use Math.pow instead (I assume the performance lost to be minimal)
const mapSize = this.tileSize * Math.pow(2, zoomLevel);
const leftGrid = this.tileSize * column;
const topGrid = this.tileSize * row;
const longitudeLeft = 360 * ((leftGrid / mapSize) - 0.5);
const y0 = 0.5 - ((topGrid + this.tileSize) / mapSize);
const latitudeBottom = 90.0 - 360.0 * Math.atan(Math.exp(-y0 * 2 * Math.PI)) / Math.PI;
const longitudeRight = 360 * (((leftGrid + this.tileSize) / mapSize) - 0.5);
const y1 = 0.5 - (topGrid / mapSize);
const latitudeTop = 90.0 - 360.0 * Math.atan(Math.exp(-y1 * 2 * Math.PI)) / Math.PI;
return { longitudeLeft, longitudeRight, latitudeTop, latitudeBottom };
}
/**
* Get the bounding box/extents of a tile in EPSG:3857 format.
* @param row Row of the tile
* @param column Column of the tile
* @param zoomLevel Desired zoom level of the tile
* @internal
*/
getEPSG3857Extent(row, column, zoomLevel) {
const epsg4326Extent = this.getEPSG4326Extent(row, column, zoomLevel);
const left = this.getEPSG3857X(epsg4326Extent.longitudeLeft);
const right = this.getEPSG3857X(epsg4326Extent.longitudeRight);
const bottom = this.getEPSG3857Y(epsg4326Extent.latitudeBottom);
const top = this.getEPSG3857Y(epsg4326Extent.latitudeTop);
return { left, right, bottom, top };
}
/** @internal */
getEPSG3857ExtentString(row, column, zoomLevel) {
const tileExtent = this.getEPSG3857Extent(row, column, zoomLevel);
return `${tileExtent.left.toFixed(2)},${tileExtent.bottom.toFixed(2)},${tileExtent.right.toFixed(2)},${tileExtent.top.toFixed(2)}`;
}
/** @internal */
getEPSG4326TileExtentString(row, column, zoomLevel, latLongAxisOrdering) {
const tileExtent = this.getEPSG4326Extent(row, column, zoomLevel);
return this.getEPSG4326ExtentString(tileExtent, latLongAxisOrdering);
}
/** @internal */
getEPSG4326ExtentString(tileExtent, latLongAxisOrdering) {
if (latLongAxisOrdering) {
return `${tileExtent.latitudeBottom.toFixed(8)},${tileExtent.longitudeLeft.toFixed(8)},${tileExtent.latitudeTop.toFixed(8)},${tileExtent.longitudeRight.toFixed(8)}`;
}
else {
return `${tileExtent.longitudeLeft.toFixed(8)},${tileExtent.latitudeBottom.toFixed(8)},${tileExtent.longitudeRight.toFixed(8)},${tileExtent.latitudeTop.toFixed(8)}`;
}
}
/** Append custom parameters for settings to provided URL object.
* @internal
*/
appendCustomParams(url) {
if (!this._settings.savedQueryParams && !this._settings.unsavedQueryParams)
return url;
let tmpUrl = appendQueryParams(url, this._settings.savedQueryParams);
tmpUrl = appendQueryParams(tmpUrl, this._settings.unsavedQueryParams);
return tmpUrl;
}
}
//# sourceMappingURL=MapLayerImageryProvider.js.map