@itwin/core-frontend
Version:
iTwin.js frontend components
357 lines • 18.8 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.ArcGisUtilities = exports.ArcGisErrorCode = void 0;
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
const core_geometry_1 = require("@itwin/core-geometry");
const internal_1 = require("../../../tile/internal");
const IModelApp_1 = require("../../../IModelApp");
const utils_1 = require("../../../request/utils");
/** @packageDocumentation
* @module Tiles
*/
const restServicesSubPath = "/rest/services/";
/**
* Class representing an ArcGIS error code.
* @internal
*/
var ArcGisErrorCode;
(function (ArcGisErrorCode) {
ArcGisErrorCode[ArcGisErrorCode["InvalidCredentials"] = 401] = "InvalidCredentials";
ArcGisErrorCode[ArcGisErrorCode["MissingPermissions"] = 403] = "MissingPermissions";
ArcGisErrorCode[ArcGisErrorCode["InvalidToken"] = 498] = "InvalidToken";
ArcGisErrorCode[ArcGisErrorCode["TokenRequired"] = 499] = "TokenRequired";
ArcGisErrorCode[ArcGisErrorCode["UnknownError"] = 1000] = "UnknownError";
ArcGisErrorCode[ArcGisErrorCode["NoTokenService"] = 1001] = "NoTokenService";
})(ArcGisErrorCode || (exports.ArcGisErrorCode = ArcGisErrorCode = {}));
/**
* Class containing utilities relating to ArcGIS services and coordinate systems.
* @internal
*/
class ArcGisUtilities {
static getBBoxString(range) {
if (!range)
range = internal_1.MapCartoRectangle.createMaximum();
return `${range.low.x * core_geometry_1.Angle.degreesPerRadian},${range.low.y * core_geometry_1.Angle.degreesPerRadian},${range.high.x * core_geometry_1.Angle.degreesPerRadian},${range.high.y * core_geometry_1.Angle.degreesPerRadian}`;
}
static async getNationalMapSources() {
const sources = new Array();
const response = await fetch("https://viewer.nationalmap.gov/tnmaccess/api/getMapServiceList", { method: "GET" });
const services = await response.json();
if (!Array.isArray(services))
return sources;
for (const service of services) {
if (service.wmsUrl.length === 0) // Exclude Wfs..
continue;
switch (service.serviceType) {
case "ArcGIS":
sources.push(internal_1.MapLayerSource.fromJSON({ name: service.displayName, url: service.serviceLink, formatId: "ArcGIS" }));
break;
default: {
const wmsIndex = service.wmsUrl.lastIndexOf("/wms");
if (wmsIndex > 0) {
const url = service.wmsUrl.slice(0, wmsIndex + 4);
sources.push(internal_1.MapLayerSource.fromJSON({ name: service.displayName, url, formatId: "WMS" }));
}
break;
}
}
}
return sources;
}
static async getServiceDirectorySources(url, baseUrl) {
if (undefined === baseUrl)
baseUrl = url;
let sources = new Array();
const response = await fetch(`${url}?f=json`, { method: "GET" });
const json = await response.json();
if (json !== undefined) {
if (Array.isArray(json.folders)) {
for (const folder of json.folders) {
sources = sources.concat(await ArcGisUtilities.getServiceDirectorySources(`${url}/${folder}`, url));
}
}
if (Array.isArray(json.services)) {
for (const service of json.services) {
let source;
if (service.type === "MapServer")
source = internal_1.MapLayerSource.fromJSON({ name: service.name, url: `${baseUrl}/${service.name}/MapServer`, formatId: "ArcGIS" });
else if (service.type === "ImageServer")
source = internal_1.MapLayerSource.fromJSON({ name: service.name, url: `${baseUrl}/${service.name}/ImageServer`, formatId: "ArcGIS" });
if (source)
sources.push(source);
}
}
}
return sources;
}
/**
* Get map layer sources from an ArcGIS query.
* @param range Range for the query.
* @param url URL for the query.
* @returns List of map layer sources.
*/
static async getSourcesFromQuery(range, url = "https://usgs.maps.arcgis.com/sharing/rest/search") {
const sources = new Array();
for (let start = 1; start > 0;) {
const response = await fetch(`${url}?f=json&q=(group:9d1199a521334e77a7d15abbc29f8144) AND (type:"Map Service")&bbox=${ArcGisUtilities.getBBoxString(range)}&sortOrder=desc&start=${start}&num=100`, { method: "GET" });
const json = await response.json();
if (!json)
break;
start = json.nextStart ? json.nextStart : -1;
if (json !== undefined && Array.isArray(json.results)) {
for (const result of json.results) {
const source = internal_1.MapLayerSource.fromJSON({ name: result.name ? result.name : result.title, url: result.url, formatId: "ArcGIS" });
if (source)
sources.push(source);
}
}
}
return sources;
}
/**
* Parse the URL to check if it represents a valid ArcGIS service
* @param url URL to validate.
* @param serviceType Service type to validate (i.e FeatureServer, MapServer)
* @return Validation Status.
*/
static validateUrl(url, serviceType) {
const urlObj = new URL(url.toLowerCase());
const restServicesPos = urlObj.pathname.search(restServicesSubPath);
if (restServicesPos !== -1) {
// This seem to be an ArcGIS URL, lets check the service type
if (urlObj.pathname.includes(serviceType.toLowerCase(), restServicesPos + restServicesSubPath.length)) {
return internal_1.MapLayerSourceStatus.Valid;
}
else {
return internal_1.MapLayerSourceStatus.IncompatibleFormat;
}
}
else {
return internal_1.MapLayerSourceStatus.InvalidUrl;
}
}
/**
* Attempt to access an ArcGIS service, and validate its service metadata.
* @param source Source to validate.
* @param opts Validation options
*/
static async validateSource(args) {
const { source, ignoreCache, capabilitiesFilter } = args;
const metadata = await this.getServiceJson({ url: source.url, formatId: source.formatId, userName: source.userName, password: source.password, queryParams: source.collectQueryParams(), ignoreCache });
const json = metadata?.content;
if (json === undefined) {
return { status: internal_1.MapLayerSourceStatus.InvalidUrl };
}
else if (json.error !== undefined) {
// If we got a 'Token Required' error, lets check what authentification methods this ESRI service offers
// and return information needed to initiate the authentification process... the end-user
// will have to provide his credentials before we can fully validate this source.
// Note: Some servers will throw a error 403 (You do not have permissions to access this resource or perform this operation),
// instead of 499 (TokenRequired)
if (json.error.code === ArcGisErrorCode.TokenRequired || json.error.code === ArcGisErrorCode.MissingPermissions) {
return (source.userName || source.password) ? { status: internal_1.MapLayerSourceStatus.InvalidCredentials } : { status: internal_1.MapLayerSourceStatus.RequireAuth };
}
else if (json.error.code === ArcGisErrorCode.InvalidCredentials)
return { status: internal_1.MapLayerSourceStatus.InvalidCredentials };
}
// Check this service support the expected queries
let hasCapabilities = false;
let capsArray = [];
if (json.capabilities && typeof json.capabilities === "string") {
const capabilities = json.capabilities;
capsArray = capabilities.split(",").map((entry) => entry.toLowerCase());
const filtered = capsArray.filter((element, _index, _array) => capabilitiesFilter.includes(element));
hasCapabilities = (filtered.length === capabilitiesFilter.length);
}
if (!hasCapabilities) {
return { status: internal_1.MapLayerSourceStatus.InvalidFormat };
}
// Only EPSG:3857 is supported with pre-rendered tiles.
if (json.tileInfo && capsArray.includes("tilesonly") && !ArcGisUtilities.isEpsg3857Compatible(json.tileInfo)) {
return { status: internal_1.MapLayerSourceStatus.InvalidCoordinateSystem };
}
let subLayers;
if (json.layers) {
subLayers = new Array();
for (const layer of json.layers) {
const parent = layer.parentLayerId < 0 ? undefined : layer.parentLayerId;
const children = Array.isArray(layer.subLayerIds) ? layer.subLayerIds : undefined;
subLayers.push({ name: layer.name, visible: layer.defaultVisibility !== false, id: layer.id, parent, children });
}
}
return { status: internal_1.MapLayerSourceStatus.Valid, subLayers };
}
/** Validate MapService tiling metadata and checks if the tile tree is 'Google Maps' compatible. */
static isEpsg3857Compatible(tileInfo) {
if (tileInfo.spatialReference?.latestWkid !== 3857 || !Array.isArray(tileInfo.lods))
return false;
const zeroLod = tileInfo.lods[0];
return zeroLod.level === 0 && Math.abs(zeroLod.resolution - 156543.03392800014) < .001;
}
static _serviceCache = new Map();
/**
* Fetches an ArcGIS service metadata, and returns its JSON representation.
* If an access client has been configured for the specified formatId,
* it will be used to apply required security token.
* By default, response for each URL are cached.
* @param url URL of the ArcGIS service
* @param formatId Format ID of the service
* @param userName Username to use for legacy token based security
* @param password Password to use for legacy token based security
* @param ignoreCache Flag to skip cache lookup (i.e. force a new server request)
* @param requireToken Flag to indicate if a token is required
*/
static async getServiceJson(args) {
const { url, formatId, userName, password, queryParams, ignoreCache, requireToken } = args;
if (!ignoreCache) {
const cached = ArcGisUtilities._serviceCache.get(url);
if (cached !== undefined)
return cached;
}
const appendParams = (urlObj, params) => {
if (params) {
Object.keys(params).forEach((paramKey) => {
if (!urlObj.searchParams.has(paramKey))
urlObj.searchParams.append(paramKey, params[paramKey]);
});
}
};
const createUrlObj = () => {
const tmpUrl = new URL(url);
tmpUrl.searchParams.append("f", "json");
appendParams(tmpUrl, queryParams);
return tmpUrl;
};
let accessTokenRequired = false;
try {
let tmpUrl = createUrlObj();
// In some cases, caller might already know token is required, so append it immediately
if (requireToken) {
const accessClient = IModelApp_1.IModelApp.mapLayerFormatRegistry.getAccessClient(formatId);
if (accessClient) {
accessTokenRequired = true;
await ArcGisUtilities.appendSecurityToken(tmpUrl, accessClient, { mapLayerUrl: new URL(url), userName, password });
}
}
let response = await fetch(tmpUrl, { method: "GET" });
if (response.status === 401 && !requireToken && (0, utils_1.headersIncludeAuthMethod)(response.headers, ["ntlm", "negotiate"])) {
// We got a http 401 challenge, lets try again with SSO enabled (i.e. Windows Authentication)
response = await fetch(tmpUrl, { method: "GET", credentials: "include" });
}
// Append security token when corresponding error code is returned by ArcGIS service
let errorCode = await ArcGisUtilities.checkForResponseErrorCode(response);
if (!accessTokenRequired
&& (errorCode === ArcGisErrorCode.TokenRequired || errorCode === ArcGisErrorCode.MissingPermissions)) {
accessTokenRequired = true;
// If token required
const accessClient = IModelApp_1.IModelApp.mapLayerFormatRegistry.getAccessClient(formatId);
if (accessClient) {
tmpUrl = createUrlObj();
await ArcGisUtilities.appendSecurityToken(tmpUrl, accessClient, { mapLayerUrl: new URL(url), userName, password });
response = await fetch(tmpUrl.toString(), { method: "GET" });
errorCode = await ArcGisUtilities.checkForResponseErrorCode(response);
}
}
const json = await response.json();
const info = { content: json, accessTokenRequired };
// Cache the response only if it doesn't contain any error.
ArcGisUtilities._serviceCache.set(url, (errorCode === undefined ? info : undefined));
return info; // Always return json, even though it contains an error code.
}
catch {
ArcGisUtilities._serviceCache.set(url, undefined);
return undefined;
}
}
/** Read a response from ArcGIS server and check for error code in the response. */
static async checkForResponseErrorCode(response) {
const tmpResponse = response;
if (response.headers && tmpResponse.headers.get("content-type")?.toLowerCase().includes("json")) {
try {
// Note:
// Since response stream can only be read once (i.e. calls to .json() method)
// we have to clone the response object in order to check for potential error code,
// but still keep the response stream as unread.
const clonedResponse = tmpResponse.clone();
const json = await clonedResponse.json();
if (json?.error?.code !== undefined)
return json?.error?.code;
}
catch { }
}
return undefined;
}
// return the appended access token if available.
static async appendSecurityToken(url, accessClient, accessTokenParams) {
// Append security token if available
let accessToken;
try {
accessToken = await accessClient.getAccessToken(accessTokenParams);
}
catch { }
if (accessToken?.token) {
url.searchParams.append("token", accessToken.token);
return accessToken;
}
return undefined;
}
/**
* Compute scale, resolution values for requested zoom levels (WSG 84)
* Use a scale of 96 dpi for Google Maps scales
* Based on this article: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale
* @param startZoom Zoom level where scales begins to be computed
* @param endZoom Zoom level where scales ends to be computed
* @param latitude Latitude in degrees to use to compute scales (i.e 0 for Equator)
* @param tileSize Size of a tile in pixels (i.e 256)
* @param screenDpi Monitor resolution in dots per inch (i.e. typically 96dpi is used by Google Maps)
* @returns An array containing resolution and scale values for each requested zoom level
*/
static computeZoomLevelsScales(startZoom = 0, endZoom = 20, latitude = 0, tileSize = 256, screenDpi = 96) {
// Note: There is probably a more direct way to compute this, but I prefer to go for a simple and well documented approach.
if (startZoom < 0 || endZoom < startZoom || tileSize < 0 || screenDpi < 1 || latitude < -90 || latitude > 90)
return [];
const inchPerMeter = 1 / 0.0254;
const results = [];
const equatorLength = core_geometry_1.Constant.earthRadiusWGS84.equator * 2 * Math.PI;
const zoom0Resolution = equatorLength / tileSize; // in meters per pixel
const cosLatitude = Math.cos(latitude);
for (let zoom = startZoom; zoom <= endZoom; zoom++) {
const resolution = zoom0Resolution * cosLatitude / Math.pow(2, zoom);
const scale = screenDpi * inchPerMeter * resolution;
results.push({ zoom, resolution, scale });
}
return results;
}
/**
* Match the provided minScale, maxScale values to corresponding wgs84 zoom levels
* @param defaultMaxLod Value of the last LOD (i.e 22)
* @param tileSize Size of a tile in pixels (i.e 256)
* @param minScale Minimum scale value that needs to be matched to a LOD level
* @param maxScale Maximum scale value that needs to be matched to a LOD level
* @returns minLod: LOD value matching minScale, maxLod: LOD value matching maxScale
*/
static getZoomLevelsScales(defaultMaxLod, tileSize, minScale, maxScale, tolerance = 0) {
let minLod, maxLod;
const zoomScales = ArcGisUtilities.computeZoomLevelsScales(0, defaultMaxLod, 0 /* latitude 0 = Equator*/, tileSize);
if (zoomScales.length > 0) {
if (minScale) {
minLod = 0;
// We are looking for the largest scale value with a scale value smaller than minScale
for (; minLod < zoomScales.length && (zoomScales[minLod].scale > minScale && Math.abs(zoomScales[minLod].scale - minScale) > tolerance); minLod++)
;
}
if (maxScale) {
maxLod = defaultMaxLod;
// We are looking for the smallest scale value with a value greater than maxScale
for (; maxLod >= 0 && zoomScales[maxLod].scale < maxScale && Math.abs(zoomScales[maxLod].scale - maxScale) > tolerance; maxLod--)
;
}
}
return { minLod, maxLod };
}
}
exports.ArcGisUtilities = ArcGisUtilities;
//# sourceMappingURL=ArcGisUtilities.js.map
;