diff --git a/.vscode/settings.json b/.vscode/settings.json index 57e762a9f..657bf3793 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,8 @@ "**/dist/**": true, "**/infra/lib/*.d.ts": true, "**/infra/lib/*Stack.js": true, - "node_modules": true + "node_modules": true, + "packages/client/src/lang/**/*.json": true }, "eslint.workingDirectories": ["packages/client"], "[git-commit]": { diff --git a/packages/api/migrations/current.sql b/packages/api/migrations/current.sql index 8da533983..3a9bdd0d4 100644 --- a/packages/api/migrations/current.sql +++ b/packages/api/migrations/current.sql @@ -1 +1,2 @@ -- Enter migration here + diff --git a/packages/client/package-lock.json b/packages/client/package-lock.json index 3cb7d7502..1c0350f13 100644 --- a/packages/client/package-lock.json +++ b/packages/client/package-lock.json @@ -32,7 +32,6 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-menubar": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.6", - "@seasketch/mapbox-gl-esri-sources": "file://Users/cburt/src/@seasketch/mapbox-gl-esri-sources", "@sentry/react": "^6.17.1", "@sentry/tracing": "^6.17.1", "@tailwindcss/aspect-ratio": "^0.2.0", @@ -307,6 +306,7 @@ }, "../../../mapbox-gl-esri-sources": { "version": "0.9.0", + "extraneous": true, "license": "BSD-3-Clause", "dependencies": { "@terraformer/arcgis": "^2.0.7", @@ -6744,10 +6744,6 @@ } } }, - "node_modules/@seasketch/mapbox-gl-esri-sources": { - "resolved": "../../../mapbox-gl-esri-sources", - "link": true - }, "node_modules/@sentry/browser": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.19.7.tgz", @@ -47350,25 +47346,6 @@ "any-observable": "^0.3.0" } }, - "@seasketch/mapbox-gl-esri-sources": { - "version": "file:../../../mapbox-gl-esri-sources", - "requires": { - "@rollup/plugin-commonjs": "^14.0.0", - "@rollup/plugin-node-resolve": "^8.4.0", - "@terraformer/arcgis": "^2.0.7", - "@types/arcgis-rest-api": "^10.4.4", - "@types/geojson": "^7946.0.7", - "@types/mapbox-gl": "^2.7.13", - "@types/uuid": "^8.0.1", - "http-server": "^0.12.3", - "jest": "^26.2.2", - "rollup": "^2.23.0", - "rollup-plugin-cleanup": "^3.1.1", - "typedoc": "^0.17.0-3", - "typescript": "^3.9.7", - "uuid": "^8.3.0" - } - }, "@sentry/browser": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.19.7.tgz", diff --git a/packages/client/package.json b/packages/client/package.json index aade6560d..de1ef9143 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -150,7 +150,7 @@ "mapbox-gl-draw-rectangle-mode": "^1.0.4", "mapbox-gl-esri-feature-layers": "^1.0.0", "mapbox-gl-esri-sources": "git+https://git@github.com/underbluewaters/mapbox-gl-esri-sources.git", - "@seasketch/mapbox-gl-esri-sources": "file://Users/cburt/src/@seasketch/mapbox-gl-esri-sources", + "@seasketch/mapbox-gl-esri-sources": "0.9.0", "md5": "^2.3.0", "mnemonist": "^0.39.2", "mustache": "^4.1.0", diff --git a/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx b/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx index ccb7b1df8..7932864e6 100644 --- a/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx +++ b/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import ArcGISSearchPage from "./ArcGISSearchPage"; import { @@ -15,17 +15,19 @@ import Skeleton from "../../../components/Skeleton"; import { ArrowLeftIcon, MinusIcon, PlusIcon } from "@radix-ui/react-icons"; import { FolderIcon } from "@heroicons/react/solid"; import Spinner from "../../../components/Spinner"; -import { TiledMapService } from "mapbox-gl-esri-sources"; import FeatureService from "mapbox-gl-arcgis-featureserver"; import Button from "../../../components/Button"; import { ArcGISDynamicMapService, - ArcGISVectorSource, - styleForFeatureLayer, + ArcGISRESTServiceRequestManager, + CustomGLSource, + ArcGISTiledMapService, } from "@seasketch/mapbox-gl-esri-sources"; import { Feature } from "geojson"; import bbox from "@turf/bbox"; +const requestManager = new ArcGISRESTServiceRequestManager(); + export default function ArcGISCartModal({ onRequestClose, region, @@ -121,6 +123,8 @@ export default function ArcGISCartModal({ const [mapIsLoadingData, setMapIsLoadingData] = useState(false); + const [customSources, setCustomSources] = useState[]>([]); + useEffect(() => { if (mapServerInfo.data && map && selection) { const bounds = extentToLatLngBounds( @@ -135,31 +139,30 @@ export default function ArcGISCartModal({ .map((l) => l.id.toString()); setSelectedLayerIds(layersToSelect); if (mapServerInfo.data.mapServerInfo.tileInfo?.rows) { - const levels = mapServerInfo.data.mapServerInfo.tileInfo.lods.map( - (lod) => lod.level - ); - const minzoom = Math.min(...levels); - const maxzoom = Math.max(...levels); - const sourceId = `${selection.name}-raster-source`; - map.addSource(sourceId, { - type: "raster", - tiles: [selection.url + "/tile/{z}/{y}/{x}"], - tileSize: - mapServerInfo.data.mapServerInfo.tileInfo.rows / - window.devicePixelRatio, - minzoom: Math.max(minzoom, 1), - maxzoom, - // ...(bounds ? { bounds } : {}), + const tileSource = new ArcGISTiledMapService(requestManager, { + url: selection.url, + supportHighDpiDisplays: true, }); - const layerId = `${selection.name}-tiled-layer`; - map.addLayer({ - id: layerId, - type: "raster", - source: sourceId, + // @ts-ignore + window.map = map; + tileSource.getLegend().then((legend) => { + console.log("legend", legend); + }); + tileSource.getComputedMetadata().then((metadata) => { + console.log("metadata", metadata); + }); + setCustomSources([tileSource]); + tileSource.addToMap(map).then(() => { + tileSource.getLayers().then((layers) => { + console.log("add layers", layers); + for (const layer of layers) { + map.addLayer(layer); + } + }); }); return () => { - map.removeLayer(layerId); - map.removeSource(sourceId); + setCustomSources([]); + tileSource.removeFromMap(map); }; } else { const useTiles = false; diff --git a/packages/client/src/admin/data/arcgis/arcgis.ts b/packages/client/src/admin/data/arcgis/arcgis.ts index eada6a16f..9810c35d6 100644 --- a/packages/client/src/admin/data/arcgis/arcgis.ts +++ b/packages/client/src/admin/data/arcgis/arcgis.ts @@ -10,10 +10,7 @@ import { v4 as uuid } from "uuid"; import bboxPolygon from "@turf/bbox-polygon"; import area from "@turf/area"; import bbox from "@turf/bbox"; -import geobuf from "geobuf"; -import Pbf from "pbf"; import { FeatureCollection } from "geojson"; -import Worker from "../../../workers/index"; import { useCreateTableOfContentsItemMutation, useCreateArcGisDynamicDataSourceMutation, @@ -30,7 +27,6 @@ import { AddImageToSpriteMutation, DataSourceTypes, useUpdateInteractivitySettingsMutation, - OverlayFragment, DataSourceDetailsFragment, } from "../../../generated/graphql"; // import nanoid from "nanoid"; diff --git a/packages/mapbox-gl-esri-sources/dist/bundle.js b/packages/mapbox-gl-esri-sources/dist/bundle.js index 2aa8dc3fd..d0f5d5fb8 100644 --- a/packages/mapbox-gl-esri-sources/dist/bundle.js +++ b/packages/mapbox-gl-esri-sources/dist/bundle.js @@ -394,6 +394,136 @@ var MapBoxGLEsriSources = (function (exports) { return s; } + class ArcGISRESTServiceRequestManager { + constructor(options) { + this.inFlightRequests = {}; + caches + .open((options === null || options === void 0 ? void 0 : options.cacheKey) || "seasketch-arcgis-rest-services") + .then((cache) => { + this.cache = cache; + }); + } + async getMapServiceMetadata(url, credentials) { + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/MapServer/.test(url)) { + throw new Error("Invalid MapServer URL"); + } + url = url.replace(/\/$/, ""); + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (credentials) { + const token = await this.getToken(url.replace(/rest\/services\/.*/, "/rest/services/"), credentials); + params.set("token", token); + } + const requestUrl = `${url}?${params.toString()}`; + const serviceMetadata = await this.fetch(requestUrl); + const layers = await this.fetch(`${url}/layers?${params.toString()}`); + if (layers.error) { + throw new Error(layers.error.message); + } + return { serviceMetadata, layers }; + } + async getToken(url, credentials) { + throw new Error("Not implemented"); + } + async fetch(url) { + if (url in this.inFlightRequests) { + return this.inFlightRequests[url].then((json) => json); + } + const cache = await this.cache; + if (!cache) { + throw new Error("Cache not initialized"); + } + this.inFlightRequests[url] = fetchWithTTL(url, 60 * 300, cache); + return new Promise((resolve, reject) => { + this.inFlightRequests[url] + .then((json) => { + if (json["error"]) { + reject(new Error(json["error"].message)); + } + else { + resolve(json); + } + }) + .catch(reject) + .finally(() => { + delete this.inFlightRequests[url]; + }); + }); + } + async getLegendMetadata(url, credentials) { + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/MapServer/.test(url)) { + throw new Error("Invalid MapServer URL"); + } + url = url.replace(/\/$/, ""); + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (credentials) { + const token = await this.getToken(url.replace(/rest\/services\/.*/, "/rest/services/"), credentials); + params.set("token", token); + } + const requestUrl = `${url}/legend?${params.toString()}`; + const response = await this.fetch(requestUrl); + return response; + } + } + function cachedResponseIsExpired(response) { + const cacheControlHeader = response.headers.get("Cache-Control"); + if (cacheControlHeader) { + const expires = /expires=(.*)/i.exec(cacheControlHeader); + if (expires) { + const expiration = new Date(expires[1]); + if (new Date().getTime() > expiration.getTime()) { + return true; + } + else { + return false; + } + } + } + return false; + } + async function fetchWithTTL(url, ttl, cache, options + ) { + var _a, _b, _c; + if (!((_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.aborted)) { + const request = new Request(url, options); + if ((_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.aborted) { + Promise.reject("aborted"); + } + let cachedResponse = await cache.match(request); + if (cachedResponse && cachedResponseIsExpired(cachedResponse)) { + cache.delete(request); + cachedResponse = undefined; + } + if (cachedResponse) { + return cachedResponse.json(); + } + else { + const response = await fetch(url, options); + if (!((_c = options === null || options === void 0 ? void 0 : options.signal) === null || _c === void 0 ? void 0 : _c.aborted)) { + const headers = new Headers(response.headers); + headers.set("Cache-Control", `Expires=${new Date(new Date().getTime() + 1000 * ttl).toUTCString()}`); + const copy = response.clone(); + const clone = new Response(copy.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + cache.put(url, clone); + } + return await response.json(); + } + } + } + var getRandomValues; var rnds8 = new Uint8Array(16); function rng() { @@ -440,6 +570,346 @@ var MapBoxGLEsriSources = (function (exports) { return stringify(rnds); } + function replaceSource(sourceId, map, sourceData) { + var _a; + const existingSource = map.getSource(sourceId); + if (!existingSource) { + throw new Error("Source does not exist"); + } + if (existingSource.type !== sourceData.type) { + throw new Error("Source type mismatch"); + } + const allLayers = map.getStyle().layers || []; + const relatedLayers = allLayers.filter((l) => { + return "source" in l && l.source === sourceId; + }); + relatedLayers.reverse(); + const idx = allLayers.indexOf(relatedLayers[0]); + let before = ((_a = allLayers[idx + 1]) === null || _a === void 0 ? void 0 : _a.id) || undefined; + for (const layer of relatedLayers) { + map.removeLayer(layer.id); + } + map.removeSource(sourceId); + map.addSource(sourceId, sourceData); + for (const layer of relatedLayers) { + map.addLayer(layer, before); + before = layer.id; + } + } + function metersToDegrees(x, y) { + var lon = (x * 180) / 20037508.34; + var lat = (Math.atan(Math.exp((y * Math.PI) / 20037508.34)) * 360) / Math.PI - 90; + return [lon, lat]; + } + async function extentToLatLngBounds(extent) { + if (extent) { + const wkid = normalizeSpatialReference(extent.spatialReference); + if (wkid === 4326) { + return [extent.xmin, extent.ymin, extent.xmax, extent.ymax]; + } + else if (wkid === 3857 || wkid === 102100) { + return [ + ...metersToDegrees(extent.xmin, extent.ymin), + ...metersToDegrees(extent.xmax, extent.ymax), + ]; + } + else { + try { + const projected = await projectExtent(extent); + console.log("projected", projected, extent); + return [projected.xmin, projected.ymin, projected.xmax, projected.ymax]; + } + catch (e) { + console.error(e); + return; + } + } + } + } + function normalizeSpatialReference(sr) { + const wkid = "latestWkid" in sr ? sr.latestWkid : "wkid" in sr ? sr.wkid : -1; + if (typeof wkid === "string") { + if (/WGS\s*84/.test(wkid)) { + return 4326; + } + else { + return -1; + } + } + else { + return wkid || -1; + } + } + async function projectExtent(extent) { + const endpoint = "https://tasks.arcgisonline.com/arcgis/rest/services/Geometry/GeometryServer/project"; + const params = new URLSearchParams({ + geometries: JSON.stringify({ + geometryType: "esriGeometryEnvelope", + geometries: [extent], + }), + inSR: `${extent.spatialReference.wkid}`, + outSR: "4326", + f: "json", + }); + const response = await fetch(`${endpoint}?${params.toString()}`); + const data = await response.json(); + const projected = data.geometries[0]; + if (projected) { + return projected; + } + else { + throw new Error("Failed to reproject"); + } + } + function contentOrFalse(str) { + if (str && str.length > 0) { + return str; + } + else { + return false; + } + } + function pickDescription(info, layer) { + var _a, _b; + return (contentOrFalse(layer === null || layer === void 0 ? void 0 : layer.description) || + contentOrFalse(info.description) || + contentOrFalse((_a = info.documentInfo) === null || _a === void 0 ? void 0 : _a.Subject) || + contentOrFalse((_b = info.documentInfo) === null || _b === void 0 ? void 0 : _b.Comments)); + } + function generateMetadataForLayer(url, mapServerInfo, layer) { + var _a, _b, _c, _d; + const attribution = contentOrFalse(layer.copyrightText) || + contentOrFalse(mapServerInfo.copyrightText) || + contentOrFalse((_a = mapServerInfo.documentInfo) === null || _a === void 0 ? void 0 : _a.Author); + const description = pickDescription(mapServerInfo, layer); + let keywords = ((_b = mapServerInfo.documentInfo) === null || _b === void 0 ? void 0 : _b.Keywords) && + ((_c = mapServerInfo.documentInfo) === null || _c === void 0 ? void 0 : _c.Keywords.length) + ? (_d = mapServerInfo.documentInfo) === null || _d === void 0 ? void 0 : _d.Keywords.split(",") + : []; + return { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: layer.name }], + }, + ...(description + ? [ + { + type: "paragraph", + content: [ + { + type: "text", + text: description, + }, + ], + }, + ] + : []), + ...(attribution + ? [ + { type: "paragraph" }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Attribution" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: attribution, + }, + ], + }, + ] + : []), + ...(keywords && keywords.length + ? [ + { type: "paragraph" }, + { + type: "heading", + attrs: { level: 3 }, + content: [ + { + type: "text", + text: "Keywords", + }, + ], + }, + { + type: "bullet_list", + marks: [], + attrs: {}, + content: keywords.map((word) => ({ + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: word }], + }, + ], + })), + }, + ] + : []), + { type: "paragraph" }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: url, + title: "ArcGIS Server", + }, + }, + ], + text: url, + }, + ], + }, + ], + }; + } + + class ArcGISTiledMapService { + constructor(requestManager, options) { + options.url = options.url.replace(/\/$/, ""); + if (!/rest\/services/.test(options.url) || !/MapServer/.test(options.url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + this.requestManager = requestManager; + this.sourceId = options.sourceId || v4(); + this.options = options; + } + getMetadata() { + if (this.serviceMetadata && this.layerMetadata) { + return Promise.resolve({ + serviceMetadata: this.serviceMetadata, + layers: this.layerMetadata, + }); + } + else { + return this.requestManager + .getMapServiceMetadata(this.options.url, this.options.credentials) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } + } + async getComputedMetadata() { + await this.getMetadata(); + const { bounds, minzoom, maxzoom, tileSize, attribution } = await this.getComputedProperties(); + return { + bounds: bounds || undefined, + minzoom, + maxzoom, + attribution, + metadata: generateMetadataForLayer(this.options.url, this.serviceMetadata, this.layerMetadata.layers[0]), + }; + } + get loading() { + var _a, _b; + return Boolean(((_a = this.map) === null || _a === void 0 ? void 0 : _a.getSource(this.sourceId)) && + ((_b = this.map) === null || _b === void 0 ? void 0 : _b.isSourceLoaded(this.sourceId)) === false); + } + async getLegend() { + const data = await this.requestManager.getLegendMetadata(this.options.url); + return data.layers[0].legend.map((l) => ({ + id: l.url, + label: l.label, + imageUrl: `data:${l.contentType};base64,${l.imageData}`, + })); + } + async getComputedProperties() { + var _a, _b, _c; + const { serviceMetadata, layers } = await this.getMetadata(); + const levels = ((_a = serviceMetadata.tileInfo) === null || _a === void 0 ? void 0 : _a.lods.map((l) => l.level)) || []; + const attribution = contentOrFalse(layers.layers[0].copyrightText) || + contentOrFalse(serviceMetadata.copyrightText) || + contentOrFalse((_b = serviceMetadata.documentInfo) === null || _b === void 0 ? void 0 : _b.Author) || + undefined; + const minzoom = Math.min(...levels); + const maxzoom = Math.max(...levels); + if (!((_c = serviceMetadata.tileInfo) === null || _c === void 0 ? void 0 : _c.rows)) { + throw new Error("Invalid tile info"); + } + return { + minzoom, + maxzoom, + bounds: await extentToLatLngBounds(serviceMetadata.fullExtent), + tileSize: serviceMetadata.tileInfo.rows, + attribution, + }; + } + async addToMap(map) { + this.map = map; + const { minzoom, maxzoom, bounds, tileSize, attribution } = await this.getComputedProperties(); + const sourceData = { + type: "raster", + tiles: [`${this.options.url}/tile/{z}/{y}/{x}`], + tileSize: this.options.supportHighDpiDisplays + ? tileSize / window.devicePixelRatio + : tileSize, + minzoom, + maxzoom, + attribution, + ...(bounds ? { bounds } : {}), + }; + console.log("add to map", sourceData); + if (this.map.getSource(this.sourceId)) { + replaceSource(this.sourceId, this.map, sourceData); + } + else { + this.map.addSource(this.sourceId, sourceData); + } + return this.sourceId; + } + async getLayers() { + return [ + { + type: "raster", + source: this.sourceId, + id: v4(), + paint: { + "raster-fade-duration": 300, + }, + }, + ]; + } + removeFromMap(map, removeLayers) { + if (map.getSource(this.sourceId)) { + const layers = map.getStyle().layers || []; + for (const layer of layers) { + if ("source" in layer && layer.source === this.sourceId) { + map.removeLayer(layer.id); + } + } + map.removeSource(this.sourceId); + this.map = undefined; + } + } + async getSupportsDynamicRendering() { + return { + layerOrder: false, + layerOpacity: false, + }; + } + destroy() { + if (this.map) { + this.removeFromMap(this.map); + } + } + } + function generateId() { return v4(); } @@ -1191,6 +1661,8 @@ var MapBoxGLEsriSources = (function (exports) { }; exports.ArcGISDynamicMapService = ArcGISDynamicMapService; + exports.ArcGISRESTServiceRequestManager = ArcGISRESTServiceRequestManager; + exports.ArcGISTiledMapService = ArcGISTiledMapService; exports.ArcGISVectorSource = ArcGISVectorSource; exports.ImageList = ImageList; exports.styleForFeatureLayer = styleForFeatureLayer; diff --git a/packages/mapbox-gl-esri-sources/dist/index.d.ts b/packages/mapbox-gl-esri-sources/dist/index.d.ts index b95f0fed2..1a927fb53 100644 --- a/packages/mapbox-gl-esri-sources/dist/index.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/index.d.ts @@ -1,5 +1,8 @@ import { ArcGISDynamicMapService, ArcGISDynamicMapServiceOptions } from "./src/ArcGISDynamicMapService"; import { ArcGISVectorSource, ArcGISVectorSourceOptions } from "./src/ArcGISVectorSource"; -export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISDynamicMapServiceOptions, ArcGISVectorSourceOptions, }; +import { ArcGISRESTServiceRequestManager } from "./src/ArcGISRESTServiceRequestManager"; +import { ArcGISTiledMapService, ArcGISTiledMapServiceOptions } from "./src/ArcGISTiledMapService"; +export { CustomGLSource, CustomGLSourceOptions, DynamicRenderingSupportOptions, LegendItem, SingleImageLegend, } from "./src/CustomGLSource"; +export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISDynamicMapServiceOptions, ArcGISVectorSourceOptions, ArcGISRESTServiceRequestManager, ArcGISTiledMapService, ArcGISTiledMapServiceOptions, }; export { default as styleForFeatureLayer } from "./src/styleForFeatureLayer"; export { ImageList } from "./src/ImageList"; diff --git a/packages/mapbox-gl-esri-sources/dist/index.js b/packages/mapbox-gl-esri-sources/dist/index.js index 94290b2e6..76e42493c 100644 --- a/packages/mapbox-gl-esri-sources/dist/index.js +++ b/packages/mapbox-gl-esri-sources/dist/index.js @@ -1,5 +1,7 @@ import { ArcGISDynamicMapService, } from "./src/ArcGISDynamicMapService"; import { ArcGISVectorSource, } from "./src/ArcGISVectorSource"; -export { ArcGISDynamicMapService, ArcGISVectorSource, }; +import { ArcGISRESTServiceRequestManager } from "./src/ArcGISRESTServiceRequestManager"; +import { ArcGISTiledMapService, } from "./src/ArcGISTiledMapService"; +export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISRESTServiceRequestManager, ArcGISTiledMapService, }; export { default as styleForFeatureLayer } from "./src/styleForFeatureLayer"; export { ImageList } from "./src/ImageList"; diff --git a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js index 1c10f24bf..cfc88d4de 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js +++ b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js @@ -50,6 +50,7 @@ const blankDataUri = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAAB * @class ArcGISDynamicMapService */ export class ArcGISDynamicMapService { + // TODO: fetch metadata and calculate minzoom, maxzoom, and bounds /** * @param {Map} map MapBox GL JS Map instance * @param {string} id ID to be used when adding refering to this source from layers diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPFS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPFS.d.ts index 1b36b31b6..f968426b2 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPFS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPFS.d.ts @@ -1,6 +1,6 @@ import { PictureFillSymbol } from "arcgis-rest-api"; import { Layer } from "mapbox-gl"; import { ImageList } from "../ImageList"; -declare const _default: (symbol: PictureFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; /** @hidden */ +declare const _default: (symbol: PictureFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPMS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPMS.d.ts index 9bf2ec243..a7dca27e6 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPMS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriPMS.d.ts @@ -1,6 +1,6 @@ import { PictureMarkerSymbol } from "arcgis-rest-api"; import { Layer } from "mapbox-gl"; import { ImageList } from "../ImageList"; -declare const _default: (symbol: PictureMarkerSymbol, sourceId: string, imageList: ImageList, serviceBaseUrl: string, sublayer: number, legendIndex: number) => Layer[]; /** @hidden */ +declare const _default: (symbol: PictureMarkerSymbol, sourceId: string, imageList: ImageList, serviceBaseUrl: string, sublayer: number, legendIndex: number) => Layer[]; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSFS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSFS.d.ts index 1f805c6fd..9f2a6f472 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSFS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSFS.d.ts @@ -1,6 +1,6 @@ import { SimpleFillSymbol } from "arcgis-rest-api"; import { Layer } from "mapbox-gl"; import { ImageList } from "../ImageList"; -declare const _default: (symbol: SimpleFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; /** @hidden */ +declare const _default: (symbol: SimpleFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSLS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSLS.d.ts index ff18fa1b7..73c8859dc 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSLS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSLS.d.ts @@ -1,5 +1,5 @@ import { SimpleLineSymbol } from "arcgis-rest-api"; import { Layer } from "mapbox-gl"; -declare const _default: (symbol: SimpleLineSymbol, sourceId: string) => Layer[]; /** @hidden */ +declare const _default: (symbol: SimpleLineSymbol, sourceId: string) => Layer[]; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSMS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSMS.d.ts index 662a899da..1a5cc2a4b 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSMS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriSMS.d.ts @@ -1,6 +1,6 @@ import { SimpleMarkerSymbol } from "arcgis-rest-api"; import { ImageList } from "../ImageList"; import { Layer } from "mapbox-gl"; -declare const _default: (symbol: SimpleMarkerSymbol, sourceId: string, imageList: ImageList) => Layer[]; /** @hidden */ +declare const _default: (symbol: SimpleMarkerSymbol, sourceId: string, imageList: ImageList) => Layer[]; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriTS.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriTS.d.ts index 2305e3916..ead0b3895 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/esriTS.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/esriTS.d.ts @@ -1,4 +1,4 @@ import { Layer } from "mapbox-gl"; -declare const _default: (labelingInfo: any, geometryType: "line" | "point", fieldNames: string[]) => Layer; /** @hidden */ +declare const _default: (labelingInfo: any, geometryType: "line" | "point", fieldNames: string[]) => Layer; export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/fillPatterns.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/fillPatterns.d.ts index 1ebb5b849..17bee920f 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/fillPatterns.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/fillPatterns.d.ts @@ -1,5 +1,5 @@ +/** @hidden */ declare const _default: { [key: string]: (strokeStyle: string) => CanvasPattern; }; -/** @hidden */ export default _default; diff --git a/packages/mapbox-gl-esri-sources/dist/src/symbols/utils.d.ts b/packages/mapbox-gl-esri-sources/dist/src/symbols/utils.d.ts index 2e7fa51fd..18725911e 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/symbols/utils.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/symbols/utils.d.ts @@ -1,13 +1,13 @@ /** @hidden */ -declare type RGBA = [number, number, number, number]; +type RGBA = [number, number, number, number]; /** @hidden */ export declare function generateId(): string; /** @hidden */ export declare function createCanvas(w: number, h: number): HTMLCanvasElement; /** @hidden */ -export declare const rgba: (color?: RGBA | undefined) => string; +export declare const rgba: (color?: RGBA) => string; /** @hidden */ -export declare const colorAndOpacity: (color?: RGBA | undefined) => { +export declare const colorAndOpacity: (color?: RGBA) => { color: string; opacity: number; }; diff --git a/packages/mapbox-gl-esri-sources/index.ts b/packages/mapbox-gl-esri-sources/index.ts index 085b1ced5..7d5769ac7 100644 --- a/packages/mapbox-gl-esri-sources/index.ts +++ b/packages/mapbox-gl-esri-sources/index.ts @@ -7,11 +7,26 @@ import { ArcGISVectorSourceOptions, fetchFeatureLayerData, } from "./src/ArcGISVectorSource"; +import { ArcGISRESTServiceRequestManager } from "./src/ArcGISRESTServiceRequestManager"; +import { + ArcGISTiledMapService, + ArcGISTiledMapServiceOptions, +} from "./src/ArcGISTiledMapService"; +export { + CustomGLSource, + CustomGLSourceOptions, + DynamicRenderingSupportOptions, + LegendItem, + SingleImageLegend, +} from "./src/CustomGLSource"; export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISDynamicMapServiceOptions, ArcGISVectorSourceOptions, + ArcGISRESTServiceRequestManager, + ArcGISTiledMapService, + ArcGISTiledMapServiceOptions, }; export { default as styleForFeatureLayer } from "./src/styleForFeatureLayer"; export { ImageList } from "./src/ImageList"; diff --git a/packages/mapbox-gl-esri-sources/package.json b/packages/mapbox-gl-esri-sources/package.json index 89fd66064..57cf2bc37 100644 --- a/packages/mapbox-gl-esri-sources/package.json +++ b/packages/mapbox-gl-esri-sources/package.json @@ -28,13 +28,13 @@ "homepage": "https://github.com/seasketch/mapbox-gl-dynamic-image-sources#readme", "devDependencies": { "@rollup/plugin-commonjs": "^14.0.0", - "@rollup/plugin-node-resolve": "^8.4.0", + "@rollup/plugin-node-resolve": "^15.2.1", "@types/arcgis-rest-api": "^10.4.4", "@types/geojson": "^7946.0.7", "@types/mapbox-gl": "^2.7.13", "@types/uuid": "^8.0.1", "http-server": "^0.12.3", - "rollup": "^2.23.0", + "rollup": "^2.79.1", "rollup-plugin-cleanup": "^3.1.1", "typedoc": "^0.17.0-3", "typescript": "^3.9.7" diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts b/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts new file mode 100644 index 000000000..d45d29df6 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts @@ -0,0 +1,180 @@ +import { + LayerLegendData, + LayersMetadata, + MapServiceMetadata, +} from "./ServiceMetadata"; + +export class ArcGISRESTServiceRequestManager { + private cache?: Cache; + + constructor(options?: { cacheKey?: string }) { + // TODO: evict excess items from cache on startup + // TODO: respect cache headers if they exist + const cache = caches + .open(options?.cacheKey || "seasketch-arcgis-rest-services") + .then((cache) => { + this.cache = cache; + }); + } + + async getMapServiceMetadata( + url: string, + credentials?: { username: string; password: string } + ) { + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/MapServer/.test(url)) { + throw new Error("Invalid MapServer URL"); + } + // remove trailing slash if present + url = url.replace(/\/$/, ""); + // remove url params if present + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (credentials) { + const token = await this.getToken( + url.replace(/rest\/services\/.*/, "/rest/services/"), + credentials + ); + params.set("token", token); + } + + const requestUrl = `${url}?${params.toString()}`; + const serviceMetadata = await this.fetch(requestUrl); + const layers = await this.fetch( + `${url}/layers?${params.toString()}` + ); + if ((layers as any).error) { + throw new Error((layers as any).error.message); + } + return { serviceMetadata, layers }; + } + + async getToken( + url: string, + credentials: { username: string; password: string } + ): Promise { + throw new Error("Not implemented"); + } + + private inFlightRequests: { [url: string]: Promise } = {}; + + private async fetch(url: string) { + if (url in this.inFlightRequests) { + return this.inFlightRequests[url].then((json) => json as T); + } + const cache = await this.cache; + if (!cache) { + throw new Error("Cache not initialized"); + } + this.inFlightRequests[url] = fetchWithTTL(url, 60 * 300, cache); + return new Promise((resolve, reject) => { + this.inFlightRequests[url] + .then((json) => { + if (json["error"]) { + reject(new Error(json["error"].message)); + } else { + resolve(json as T); + } + }) + .catch(reject) + .finally(() => { + delete this.inFlightRequests[url]; + }); + }); + } + + async getLegendMetadata( + url: string, + credentials?: { username: string; password: string } + ) { + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/MapServer/.test(url)) { + throw new Error("Invalid MapServer URL"); + } + // remove trailing slash if present + url = url.replace(/\/$/, ""); + // remove url params if present + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (credentials) { + const token = await this.getToken( + url.replace(/rest\/services\/.*/, "/rest/services/"), + credentials + ); + params.set("token", token); + } + + const requestUrl = `${url}/legend?${params.toString()}`; + const response = await this.fetch<{ + layers: { + layerId: number; + layerName: string; + layerType: "Feature Layer" | "Raster Layer"; + legend: LayerLegendData[]; + }[]; + }>(requestUrl); + return response; + } +} + +function cachedResponseIsExpired(response: Response) { + const cacheControlHeader = response.headers.get("Cache-Control"); + if (cacheControlHeader) { + const expires = /expires=(.*)/i.exec(cacheControlHeader); + if (expires) { + const expiration = new Date(expires[1]); + if (new Date().getTime() > expiration.getTime()) { + return true; + } else { + return false; + } + } + } + return false; +} + +async function fetchWithTTL( + url: string, + ttl: number, + cache: Cache, + options?: RequestInit + // @ts-ignore +): Promise { + if (!options?.signal?.aborted) { + const request = new Request(url, options); + if (options?.signal?.aborted) { + Promise.reject("aborted"); + } + let cachedResponse = await cache.match(request); + if (cachedResponse && cachedResponseIsExpired(cachedResponse)) { + cache.delete(request); + cachedResponse = undefined; + } + if (cachedResponse) { + return cachedResponse.json(); + } else { + const response = await fetch(url, options); + if (!options?.signal?.aborted) { + const headers = new Headers(response.headers); + headers.set( + "Cache-Control", + `Expires=${new Date(new Date().getTime() + 1000 * ttl).toUTCString()}` + ); + const copy = response.clone(); + const clone = new Response(copy.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + cache.put(url, clone); + } + return await response.json(); + } + } +} diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts b/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts new file mode 100644 index 000000000..01fa01522 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts @@ -0,0 +1,238 @@ +import { Map, RasterLayer, RasterSource } from "mapbox-gl"; +import { ArcGISRESTServiceRequestManager } from "./ArcGISRESTServiceRequestManager"; +import { + ComputedMetadata, + CustomGLSource, + CustomGLSourceOptions, + LegendItem, +} from "./CustomGLSource"; +import { v4 as uuid } from "uuid"; +import { LayersMetadata, MapServiceMetadata } from "./ServiceMetadata"; +import { + contentOrFalse, + extentToLatLngBounds, + generateMetadataForLayer, + replaceSource, +} from "./utils"; + +export interface ArcGISTiledMapServiceOptions extends CustomGLSourceOptions { + url: string; + supportHighDpiDisplays?: boolean; + credentials?: { username: string; password: string }; +} + +/** + * CustomGLSource used to add an ArcGIS Tile MapService. + */ +export class ArcGISTiledMapService + implements CustomGLSource +{ + sourceId: string; + options: ArcGISTiledMapServiceOptions; + private map?: Map; + private requestManager: ArcGISRESTServiceRequestManager; + private serviceMetadata?: MapServiceMetadata; + private layerMetadata?: LayersMetadata; + + /** + * + * @param requestManager ArcGISRESTServiceRequestManager instance + * @param options.url URL to ArcGIS REST MapServer (should end in /MapServer) + * @param options.supportHighDpiDisplays If true, will detect high-dpi displays and request more tiles at higher resolution + * @param options.credentials Optional. If provided, will use these credentials to request a token for the service. + * + */ + constructor( + requestManager: ArcGISRESTServiceRequestManager, + options: ArcGISTiledMapServiceOptions + ) { + // remove trailing slash if present + options.url = options.url.replace(/\/$/, ""); + if (!/rest\/services/.test(options.url) || !/MapServer/.test(options.url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + this.requestManager = requestManager; + this.sourceId = options.sourceId || uuid(); + this.options = options; + } + + /** + * Use ArcGISRESTServiceRequestManager to fetch metadata for the service, + * caching it on the instance for reuse. + */ + private getMetadata() { + if (this.serviceMetadata && this.layerMetadata) { + return Promise.resolve({ + serviceMetadata: this.serviceMetadata, + layers: this.layerMetadata, + }); + } else { + return this.requestManager + .getMapServiceMetadata(this.options.url, this.options.credentials) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } + } + + /** + * Returns computed metadata for the service, including bounds, minzoom, maxzoom, and attribution. + * @returns ComputedMetadata + * @throws Error if metadata is not available + * @throws Error if tileInfo is not available + * */ + async getComputedMetadata(): Promise { + const { serviceMetadata, layers } = await this.getMetadata(); + const { bounds, minzoom, maxzoom, tileSize, attribution } = + await this.getComputedProperties(); + + return { + bounds: bounds || undefined, + minzoom, + maxzoom, + attribution, + metadata: generateMetadataForLayer( + this.options.url, + this.serviceMetadata!, + this.layerMetadata!.layers[0] + ), + }; + } + + /** + * Returns true if the source is not yet loaded. Will return to false if tiles + * are loading when the map is moved. + */ + get loading() { + return Boolean( + this.map?.getSource(this.sourceId) && + this.map?.isSourceLoaded(this.sourceId) === false + ); + } + + async getLegend(): Promise { + const data = await this.requestManager.getLegendMetadata(this.options.url); + return data.layers[0].legend.map((l) => ({ + id: l.url, + label: l.label, + imageUrl: `data:${l.contentType};base64,${l.imageData}`, + })); + } + + /** + * Private method used as the basis for getComputedMetadata and also used + * when generating the source data for addToMap. + * @returns Computed properties for the service, including bounds, minzoom, maxzoom, and attribution. + */ + private async getComputedProperties() { + const { serviceMetadata, layers } = await this.getMetadata(); + const levels = serviceMetadata.tileInfo?.lods.map((l) => l.level) || []; + const attribution = + contentOrFalse(layers.layers[0].copyrightText) || + contentOrFalse(serviceMetadata.copyrightText) || + contentOrFalse(serviceMetadata.documentInfo?.Author) || + undefined; + const minzoom = Math.min(...levels); + const maxzoom = Math.max(...levels); + if (!serviceMetadata.tileInfo?.rows) { + throw new Error("Invalid tile info"); + } + return { + minzoom, + maxzoom, + bounds: await extentToLatLngBounds(serviceMetadata.fullExtent), + tileSize: serviceMetadata.tileInfo!.rows, + attribution, + }; + } + + /** + * Add source to map. Does not add any layers to the map. + * @param map Mapbox GL JS Map + * @returns sourceId + */ + async addToMap(map: mapboxgl.Map) { + this.map = map; + const { minzoom, maxzoom, bounds, tileSize, attribution } = + await this.getComputedProperties(); + const sourceData = { + type: "raster", + tiles: [`${this.options.url}/tile/{z}/{y}/{x}`], + tileSize: this.options.supportHighDpiDisplays + ? tileSize / window.devicePixelRatio + : tileSize, + minzoom, + maxzoom, + attribution, + ...(bounds ? { bounds } : {}), + } as RasterSource; + console.log("add to map", sourceData); + // It's possible that the map has changed since we started fetching metadata + if (this.map.getSource(this.sourceId)) { + replaceSource(this.sourceId, this.map, sourceData); + } else { + this.map.addSource(this.sourceId, sourceData); + } + return this.sourceId; + } + + /** + * Returns a raster layer for the source. + * @returns RasterLayer[] + */ + async getLayers() { + return [ + { + type: "raster", + source: this.sourceId, + id: uuid(), + paint: { + "raster-fade-duration": 300, + }, + }, + ] as RasterLayer[]; + } + + /** + * Remove source from map. If there are any layers on the map that use this + * source, they will also be removed. + * @param map Mapbox GL JS Map + */ + removeFromMap(map: Map, removeLayers?: boolean) { + if (map.getSource(this.sourceId)) { + const layers = map.getStyle().layers || []; + for (const layer of layers) { + if ("source" in layer && layer.source === this.sourceId) { + map.removeLayer(layer.id); + } + } + map.removeSource(this.sourceId); + this.map = undefined; + } + } + + /** + * Returns an object with booleans indicating whether the source supports + * dynamic rendering of layer order and opacity. Always returns false for + * this service type. + * @returns DynamicRenderingSupportOptions + * @throws Error if metadata is not available + */ + async getSupportsDynamicRendering() { + return { + layerOrder: false, + layerOpacity: false, + }; + } + + /** + * Removes the source from the map and removes any event listeners + */ + destroy(): void { + if (this.map) { + this.removeFromMap(this.map); + } + } +} diff --git a/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts b/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts new file mode 100644 index 000000000..1a1657b5f --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts @@ -0,0 +1,87 @@ +import { AnyLayer, AnySourceData, Map } from "mapbox-gl"; +import { ArcGISRESTServiceRequestManager } from "./ArcGISRESTServiceRequestManager"; + +export interface CustomGLSourceOptions { + /** Optional. If not provided a uuid will be used. */ + sourceId?: string; +} + +export interface LegendItem { + /** Use for jsx key, no more */ + id: string; + label: string; + imageUrl: string; +} + +export interface SingleImageLegend { + url: string; +} + +export interface DynamicRenderingSupportOptions { + layerOrder: boolean; + layerOpacity: boolean; +} + +export interface ComputedMetadata { + /** xmin, ymin, xmax, ymax */ + bounds?: [number, number, number, number]; + minzoom: number; + maxzoom: number; + attribution?: string; + /** Metadata as prosemirror document */ + metadata: { + type: string; + content: ({ type: string } & any)[]; + }; +} + +/** + * CustomGLSources are used to add custom data sources to a Mapbox GL JS map. + * Used to support ArcGIS, WMS, (and other?) sources using SeaSketch's + * MapContextManager. + */ +export interface CustomGLSource< + T extends CustomGLSourceOptions, + LegendType = LegendItem[] | SingleImageLegend +> { + sourceId: string; + /** + * CustomGLSources should trigger data and dataload events on the Map, but + * it won't be possible to call Map.isSourceLoaded(sourceId) on some custom + * types (such as those that rely on geojson layers). This property should + * be used instead. + */ + loading: boolean; + + // new ( + // requestManager: ArcGISRESTServiceRequestManager, + // options?: T + // ): CustomGLSource; + /** + * Add source to map. Does not add any layers to the map. + * @returns Source ID + * @param map Mapbox GL JS Map + */ + addToMap(map: Map): Promise; + /** + * Remove source from map, including any related layers. + * @param map Mapbox GL JS Map + * @throws If the source is not on the map + */ + removeFromMap(map: Map): void; + // /** + // * Get metadata for the source. Requests should be de-duped and cached. + // * @returns Metadata for the source + // */ + // getMetadata(): Promise; + /** + * Whether the source supports dynamic rendering. If true, clients can use + * the updateLayers method to update source data. + */ + getSupportsDynamicRendering(): Promise; + /** Removes the source from the map and removes any event listeners */ + destroy(): void; + getLegend(): Promise; + getLayers(): Promise; + getComputedMetadata(): Promise; +} diff --git a/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts b/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts new file mode 100644 index 000000000..ccf36cd21 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts @@ -0,0 +1,132 @@ +import { SpatialReference, esriGeometryType } from "arcgis-rest-api"; +export interface Extent { + xmin: number; + ymin: number; + xmax: number; + ymax: number; + spatialReference: SpatialReference; +} + +export interface SimpleLayerInfo { + id: number; + name: string; + parentLayerId: number; + defaultVisibility: boolean; + subLayerIds: number[] | null; + minScale: number; + maxScale: number; +} + +export interface MapServiceMetadata { + /** ArcGIS Server REST API version */ + currentVersion: number; + serviceDescription: string; + /** Typically just "Layers". Better to use url slug as a name */ + mapName: string; + description: string; + copyrightText: string; + supportsDynamicLayers: boolean; + spatialReference: SpatialReference; + singleFusedMapCache: boolean; + tileInfo?: { + rows: number; + cols: number; + dpi: number; + format: string; + compressionQuality: number; + origin: { + x: number; + y: number; + }; + spatialReference: SpatialReference; + lods: { + level: number; + resolution: number; + scale: number; + }[]; + }; + initialExtent: Extent; + fullExtent: Extent; + supportedImageFormatTypes: string[]; + /** + * comma separated list of supported capabilities - e.g. "Map,Query,Data" + */ + capabilities: string; + maxRecordCount: number; + maxImageHeight: number; + maxImageWidth: number; + minScale: number; + maxScale: number; + tileServers: string[]; + layers: SimpleLayerInfo[]; + documentInfo?: { + Title?: string; + Subject?: string; + Author?: string; + Comments?: string; + Keywords?: string; + }; +} + +export interface LayersMetadata { + layers: DetailedLayerMetadata[]; +} + +export interface DetailedLayerMetadata { + currentVersion: number; + id: number; + name: string; + type: "Feature Layer" | "Raster Layer"; + description: string; + geometryType: esriGeometryType; + copyrightText: string; + parentLayer: null | number; + sublayers: { id: number; name: string }[]; + minScale: number; + maxScale: number; + defaultVisibility: boolean; + extent: { + xmin: number; + ymin: number; + xmax: number; + ymax: number; + spatialReference: SpatialReference; + }; + hasAttachments: boolean; + htmlPopupType: + | "esriServerHTMLPopupTypeNone" + | "esriServerHTMLPopupTypeAsURL" + | "esriServerHTMLPopupTypeAsHTMLText"; + displayField: string; + typeIdField: string; + fields: { + name: string; + type: string; + alias: string; + domain: null | { + type: "codedValue"; + name: string; + codedValues: { + name: string; + code: string; + }[]; + }; + length: number; + editable?: boolean; + nullable?: boolean; + }[]; + maxRecordCount: number; + supportedQueryFormats: string; + advancedQueryCapabilities: { + supportsPagination: boolean; + }; +} + +export interface LayerLegendData { + label: string; + url: string; + imageData: string; + contentType: string; + height: number; + width: 20; +} diff --git a/packages/mapbox-gl-esri-sources/src/utils.ts b/packages/mapbox-gl-esri-sources/src/utils.ts new file mode 100644 index 000000000..e98a6f1c7 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/utils.ts @@ -0,0 +1,260 @@ +import { AnySourceData, Map } from "mapbox-gl"; +import { + DetailedLayerMetadata, + Extent, + MapServiceMetadata, +} from "./ServiceMetadata"; +import { SpatialReference } from "arcgis-rest-api"; + +/** + * Replaced an existing source, preserving layers and their order by temporarily + * removing them + * @param sourceId ID of the source to replace + * @param map Mapbox GL JS Map instance + * @param sourceData Replacement source options + */ +export function replaceSource( + sourceId: string, + map: Map, + sourceData: AnySourceData +) { + const existingSource = map.getSource(sourceId); + if (!existingSource) { + throw new Error("Source does not exist"); + } + if (existingSource.type !== sourceData.type) { + throw new Error("Source type mismatch"); + } + const allLayers = map.getStyle().layers || []; + const relatedLayers = allLayers.filter((l) => { + return "source" in l && l.source === sourceId; + }); + + relatedLayers.reverse(); + const idx = allLayers.indexOf(relatedLayers[0]); + let before = allLayers[idx + 1]?.id || undefined; + for (const layer of relatedLayers) { + map.removeLayer(layer.id); + } + map.removeSource(sourceId); + map.addSource(sourceId, sourceData); + for (const layer of relatedLayers) { + map.addLayer(layer, before); + before = layer.id; + } +} + +/** + * Convert meters to degrees in web mercator projection + * @param x + * @param y + * @returns [lon, lat] + */ +export function metersToDegrees(x: number, y: number): [number, number] { + var lon = (x * 180) / 20037508.34; + var lat = + (Math.atan(Math.exp((y * Math.PI) / 20037508.34)) * 360) / Math.PI - 90; + return [lon, lat]; +} + +/** + * Convert an ArcGIS REST Service extent to a Mapbox GL JS LatLngBounds + * compatible array + * @param extent + * @returns [xmin, ymin, xmax, ymax] + */ +export async function extentToLatLngBounds( + extent: Extent +): Promise<[number, number, number, number] | void> { + if (extent) { + const wkid = normalizeSpatialReference(extent.spatialReference); + if (wkid === 4326) { + return [extent.xmin, extent.ymin, extent.xmax, extent.ymax]; + } else if (wkid === 3857 || wkid === 102100) { + return [ + ...metersToDegrees(extent.xmin, extent.ymin), + ...metersToDegrees(extent.xmax, extent.ymax), + ]; + } else { + try { + const projected = await projectExtent(extent); + console.log("projected", projected, extent); + return [projected.xmin, projected.ymin, projected.xmax, projected.ymax]; + } catch (e) { + console.error(e); + return; + } + } + } +} + +export function normalizeSpatialReference(sr: SpatialReference) { + const wkid = "latestWkid" in sr ? sr.latestWkid : "wkid" in sr ? sr.wkid : -1; + if (typeof wkid === "string") { + if (/WGS\s*84/.test(wkid)) { + return 4326; + } else { + return -1; + } + } else { + return wkid || -1; + } +} + +export async function projectExtent(extent: Extent) { + const endpoint = + "https://tasks.arcgisonline.com/arcgis/rest/services/Geometry/GeometryServer/project"; + const params = new URLSearchParams({ + geometries: JSON.stringify({ + geometryType: "esriGeometryEnvelope", + geometries: [extent], + }), + // @ts-ignore + inSR: `${extent.spatialReference.wkid}`, + outSR: "4326", + f: "json", + }); + const response = await fetch(`${endpoint}?${params.toString()}`); + const data = await response.json(); + const projected = data.geometries[0]; + if (projected) { + return projected; + } else { + throw new Error("Failed to reproject"); + } +} + +export function contentOrFalse(str?: string) { + if (str && str.length > 0) { + return str; + } else { + return false; + } +} + +function pickDescription( + info: MapServiceMetadata, + layer?: DetailedLayerMetadata +) { + return ( + contentOrFalse(layer?.description) || + contentOrFalse(info.description) || + contentOrFalse(info.documentInfo?.Subject) || + contentOrFalse(info.documentInfo?.Comments) + ); +} + +/** + * Uses service metadata to create a markdown-like prosemirror document which + * represents layer metadata + * @param url + * @param mapServerInfo + * @param layer + * @returns + */ +export function generateMetadataForLayer( + url: string, + mapServerInfo: MapServiceMetadata, + layer: DetailedLayerMetadata +) { + const attribution = + contentOrFalse(layer.copyrightText) || + contentOrFalse(mapServerInfo.copyrightText) || + contentOrFalse(mapServerInfo.documentInfo?.Author); + const description = pickDescription(mapServerInfo, layer); + let keywords = + mapServerInfo.documentInfo?.Keywords && + mapServerInfo.documentInfo?.Keywords.length + ? mapServerInfo.documentInfo?.Keywords.split(",") + : []; + return { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: layer.name }], + }, + ...(description + ? [ + { + type: "paragraph", + content: [ + { + type: "text", + text: description, + }, + ], + }, + ] + : []), + ...(attribution + ? [ + { type: "paragraph" }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Attribution" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: attribution, + }, + ], + }, + ] + : []), + ...(keywords && keywords.length + ? [ + { type: "paragraph" }, + { + type: "heading", + attrs: { level: 3 }, + content: [ + { + type: "text", + text: "Keywords", + }, + ], + }, + { + type: "bullet_list", + marks: [], + attrs: {}, + content: keywords.map((word) => ({ + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: word }], + }, + ], + })), + }, + ] + : []), + { type: "paragraph" }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: url, + title: "ArcGIS Server", + }, + }, + ], + text: url, + }, + ], + }, + ], + }; +}