diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1c05e298a..5cf0c328c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -67,6 +67,27 @@ "problemMatcher": [], "label": "Start Database & Redis", "detail": "docker-compose up -d" + }, + { + "type": "npm", + "script": "watch", + "path": "packages/mapbox-gl-esri-sources/", + "problemMatcher": [], + "label": "Rollup: mapbox-gl-esri-sources", + "runOptions": { + "runOn": "folderOpen" + } + }, + { + "type": "typescript", + "runOptions": { + "runOn": "folderOpen" + }, + "tsconfig": "packages/mapbox-gl-esri-sources/tsconfig.json", + "option": "watch", + "problemMatcher": ["$tsc-watch"], + "group": "build", + "label": "TypeScript: watch mapbox-gl-esri-sources" } ] } diff --git a/packages/client/package-lock.json b/packages/client/package-lock.json index a9d497c7c..b58ad00fc 100644 --- a/packages/client/package-lock.json +++ b/packages/client/package-lock.json @@ -154,6 +154,7 @@ "mapbox-gl-arcgis-featureserver": "file://Users/cburt/src/mapbox-gl-arcgis-featureserver", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "mapbox-gl-esri-sources": "git+https://git@github.com/underbluewaters/mapbox-gl-esri-sources.git", + "mapboxgl-legend": "^1.12.0", "md5": "^2.3.0", "mnemonist": "^0.39.2", "mustache": "^4.1.0", @@ -27294,6 +27295,11 @@ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, + "node_modules/mapboxgl-legend": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mapboxgl-legend/-/mapboxgl-legend-1.12.0.tgz", + "integrity": "sha512-hnSL6pg6UkAL3jAYhL7c0pK5HtLeInWQBcZBnhERb1HtqgkIKuNNj0wkU/7WbZ5rQVlarf+UpJ4H93NXBehvFA==" + }, "node_modules/markdown-escapes": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", @@ -63081,6 +63087,11 @@ "version": "git+https://git@github.com/underbluewaters/mapbox-gl-esri-sources.git#7cacc57fdd58a93a3cf1546c4ba11f953174a6bd", "from": "mapbox-gl-esri-sources@git+https://git@github.com/underbluewaters/mapbox-gl-esri-sources.git" }, + "mapboxgl-legend": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mapboxgl-legend/-/mapboxgl-legend-1.12.0.tgz", + "integrity": "sha512-hnSL6pg6UkAL3jAYhL7c0pK5HtLeInWQBcZBnhERb1HtqgkIKuNNj0wkU/7WbZ5rQVlarf+UpJ4H93NXBehvFA==" + }, "markdown-escapes": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", diff --git a/packages/client/package.json b/packages/client/package.json index e3740fe55..5b2b199d9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -148,6 +148,7 @@ "lodash.setwith": "^4.3.2", "lodash.sortby": "^4.7.0", "lru-cache": "^6.0.0", + "mapbox-expression": "^0.0.3", "mapbox-gl": "2.15", "mapbox-gl-arcgis-featureserver": "file://Users/cburt/src/mapbox-gl-arcgis-featureserver", "mapbox-gl-draw-rectangle-mode": "^1.0.4", diff --git a/packages/client/src/admin/data/DataSettings.tsx b/packages/client/src/admin/data/DataSettings.tsx index abd46390d..b70fc28be 100644 --- a/packages/client/src/admin/data/DataSettings.tsx +++ b/packages/client/src/admin/data/DataSettings.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Link, Route, @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import DataUploadDropzone from "../uploads/DataUploadDropzone"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; +import Legend, { LegendItem } from "../../dataLayers/Legend"; const LazyArcGISBrowser = React.lazy( () => @@ -35,6 +36,28 @@ export default function DataSettings() { }, }); + const [legendState, setLegendState] = useState<{ items: LegendItem[] }>({ + items: [], + }); + + useEffect(() => { + if (mapContext.legends) { + // TODO: this does't really handle WMS or dynamic map services + const visibleLegends: LegendItem[] = []; + for (const id in mapContext.layerStatesByTocStaticId) { + if (mapContext.layerStatesByTocStaticId[id].visible) { + const legend = mapContext.legends[id]; + if (legend) { + visibleLegends.push(legend); + } + } + } + setLegendState({ + items: visibleLegends, + }); + } + }, [mapContext.legends, mapContext.layerStatesByTocStaticId]); + return ( <> @@ -49,6 +72,15 @@ export default function DataSettings() {
+ {data?.projectBySlug && ( void; + map: Map; }) { function onChangeVisibility(id: string) { if (toggleLayer) { @@ -45,7 +57,7 @@ export default function ArcGISCartLegend({ }} className={`${className} shadow rounded bg-white bg-opacity-90 w-64 text-sm flex flex-col overflow-hidden`} > - + @@ -78,22 +90,78 @@ export default function ArcGISCartLegend({ ); } else { if (!item.legend) { - return ( -
  • - {item.label} -
  • - ); + if (item.glStyle) { + if (styleHasDataExpression(item.glStyle.layers)) { + return ( +
  • +
    + + {item.label} + + +
    +
      +
    • +
    +
  • + ); + } else { + return ( +
  • + + + {item.label} + + +
  • + ); + } + } else { + return ( +
  • + + {item.label} + + +
  • + ); + } } else if (item.legend && item.legend.length === 1) { const legendItem = item.legend[0]; return (
  • @@ -109,7 +177,7 @@ export default function ArcGISCartLegend({
  • @@ -162,6 +230,7 @@ function LegendImage({ return ( {item.label} ); } + +export function styleHasDataExpression(style: Layer[]) { + for (const layer of style) { + if ( + style.length > 1 && + layer.filter && + isExpression(layer.filter) && + hasGetExpression(layer.filter) + ) { + return true; + } else if (layer.paint) { + for (const key in layer.paint) { + if (isExpression((layer.paint as any)[key])) { + return hasGetExpression((layer.paint as any)[key]); + } + } + } else if (layer.layout) { + for (const key in layer.layout) { + if (isExpression((layer.layout as any)[key])) { + return hasGetExpression((layer.layout as any)[key]); + } + } + } + } + return false; +} + +type Expression = [string, Expression | string | number | boolean | null]; + +const SimpleLegendIconFromStyle = memo( + function _SimpleLegendIconFromStyle(props: { + style: { + layers: Layer[]; + imageList?: ImageList; + }; + map: Map; + }) { + let data: LegendForGLLayers | undefined; + try { + data = getLegendForGLStyleLayers(props.style.layers, "vector"); + } catch (e) { + // Do nothing + } + const simpleSymbol = data?.type === "SimpleGLLegend" ? data.symbol : null; + return ( +
    + {simpleSymbol ? ( + + ) : null} +
    + ); + } +); diff --git a/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx b/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx index 552a467df..09d7d4b5b 100644 --- a/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx +++ b/packages/client/src/admin/data/arcgis/ArcGISCartModal.tsx @@ -8,7 +8,7 @@ import { useCatalogItemDetails, useCatalogItems, } from "./arcgis"; -import { LngLatBounds, LngLatBoundsLike, Map } from "mapbox-gl"; +import { AnyLayer, LngLatBounds, LngLatBoundsLike, Map } from "mapbox-gl"; import { SearchIcon } from "@heroicons/react/outline"; import Skeleton from "../../../components/Skeleton"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; @@ -22,6 +22,7 @@ import { ArcGISTiledMapService, DataTableOfContentsItem, FolderTableOfContentsItem, + ArcGISFeatureLayerSource, } from "@seasketch/mapbox-gl-esri-sources"; import { Feature } from "geojson"; import bbox from "@turf/bbox"; @@ -82,6 +83,8 @@ export default function ArcGISCartModal({ center: [-74.5, 40], zoom: 9, }); + // const legend = new LegendControl(); + // m.addControl(legend, "bottom-left"); m.fitBounds(mapBounds as LngLatBounds, { padding: 10, animate: false }); m.on("load", () => { @@ -147,6 +150,7 @@ export default function ArcGISCartModal({ useEffect(() => { setSourceLoading(false); setTableOfContentsItems([]); + setVisibleLayers([]); if (catalogItemDetailsQuery.data && map && selection) { const { type } = catalogItemDetailsQuery.data; setSourceLoading(true); @@ -173,7 +177,7 @@ export default function ArcGISCartModal({ }); setCustomSources([tileSource]); tileSource.addToMap(map).then(() => { - tileSource.getGLStyleLayers().then((layers) => { + tileSource.getGLStyleLayers().then(({ layers }) => { for (const layer of layers) { map.addLayer(layer); } @@ -207,7 +211,7 @@ export default function ArcGISCartModal({ }); setCustomSources([dynamicSource]); dynamicSource.addToMap(map).then(() => { - dynamicSource.getGLStyleLayers().then((layers) => { + dynamicSource.getGLStyleLayers().then(({ layers }) => { for (const layer of layers) { map.addLayer(layer); } @@ -217,6 +221,61 @@ export default function ArcGISCartModal({ setCustomSources([]); dynamicSource.destroy(); }; + } else if (type === "FeatureServer") { + const sources: ArcGISFeatureLayerSource[] = []; + for (const layer of [ + ...catalogItemDetailsQuery.data.metadata?.layers, + ].reverse()) { + const featureLayerSource = new ArcGISFeatureLayerSource( + requestManager, + { + url: selection.url + "/" + layer.id, + fetchStrategy: "raw", + } + ); + sources.push(featureLayerSource); + featureLayerSource.getComputedMetadata().then((data) => { + const { bounds, tableOfContentsItems } = data; + setTableOfContentsItems((prev) => [ + ...prev, + ...tableOfContentsItems, + ]); + setVisibleLayers((prev) => { + return [ + ...prev, + ...tableOfContentsItems + .filter( + (item) => + item.type === "data" && item.defaultVisibility === true + ) + .map((item) => item.id), + ]; + }); + if (bounds && bounds[0]) { + map.fitBounds(bounds, { padding: 10 }); + } + }); + featureLayerSource.addToMap(map).then(() => { + featureLayerSource + .getGLStyleLayers() + .then(({ layers, imageList }) => { + if (imageList) { + imageList.addToMap(map); + } + for (const layer of layers) { + map.addLayer(layer as AnyLayer); + } + }); + }); + } + + setCustomSources(sources); + return () => { + setCustomSources([]); + for (const source of sources) { + source.destroy(); + } + }; } return; // } else if (selection.type === "FeatureServer") { @@ -300,6 +359,7 @@ export default function ArcGISCartModal({ {!location && (
    { setLocation(result.location); }} @@ -365,10 +425,12 @@ export default function ArcGISCartModal({
    - { - setVisibleLayers((prev) => { - if (prev.includes(id)) { - return prev.filter((i) => i !== id); - } else { - return [...prev, id]; - } - }); - }} - /> + {map && ( + { + setVisibleLayers((prev) => { + if (prev.includes(id)) { + return prev.filter((i) => i !== id); + } else { + return [...prev, id]; + } + }); + }} + /> + )}
  • diff --git a/packages/client/src/admin/data/arcgis/ArcGISSearchPage.tsx b/packages/client/src/admin/data/arcgis/ArcGISSearchPage.tsx index d3b5c8110..76daea54c 100644 --- a/packages/client/src/admin/data/arcgis/ArcGISSearchPage.tsx +++ b/packages/client/src/admin/data/arcgis/ArcGISSearchPage.tsx @@ -6,14 +6,17 @@ import { import useRecentDataServers from "./useRecentServers"; import { Trans } from "react-i18next"; import Spinner from "../../../components/Spinner"; +import { ArcGISRESTServiceRequestManager } from "@seasketch/mapbox-gl-esri-sources"; export default function ArcGISSearchPage({ onResult, + requestManager, }: { onResult?: (e: { location: NormalizedArcGISServerLocation; version: string; }) => void; + requestManager: ArcGISRESTServiceRequestManager; }) { const [inputUrl, setInputUrl] = useState(""); const [inputError, setInputError] = useState(null); @@ -38,16 +41,16 @@ export default function ArcGISSearchPage({ } else { try { setLoading(true); - const serviceResponse = await fetch( - location.servicesRoot + "?f=json" - ).then((r) => r.json()); + const serviceResponse = await requestManager.getCatalogItems( + location.servicesRoot + ); setLoading(false); if (serviceResponse.currentVersion) { addServer({ location: location.baseUrl, type: "arcgis" }); if (onResult) { onResult({ location, - version: serviceResponse.currentVersion, + version: serviceResponse.currentVersion.toString(), }); } // setVersion(serviceResponse.currentVersion); diff --git a/packages/client/src/admin/data/arcgis/arcgis.ts b/packages/client/src/admin/data/arcgis/arcgis.ts index fc397b402..550bbc22b 100644 --- a/packages/client/src/admin/data/arcgis/arcgis.ts +++ b/packages/client/src/admin/data/arcgis/arcgis.ts @@ -7,6 +7,7 @@ import { ImageList, styleForFeatureLayer, MapServiceMetadata, + FeatureServerMetadata, } from "@seasketch/mapbox-gl-esri-sources"; import { v4 as uuid } from "uuid"; import bboxPolygon from "@turf/bbox-polygon"; @@ -36,7 +37,6 @@ import { customAlphabet } from "nanoid"; import { default as axios } from "axios"; import { MapContext } from "../../../dataLayers/MapContextManager"; import { MutationFunctionOptions } from "@apollo/client"; -import { metadata } from "../../../editor/config"; // import { ArcGISVectorSourceCacheEvent } from "../../../dataLayers/ArcGISVectorSourceCache"; const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; @@ -362,7 +362,7 @@ export interface MapServerCatalogItemDetails { export interface FeatureServerCatalogItemDetails { type: "FeatureServer"; - metadata: any; + metadata: FeatureServerMetadata; } export function useCatalogItemDetails( @@ -402,6 +402,27 @@ export function useCatalogItemDetails( } }); return () => AC.abort(); + } else if (url && /FeatureServer/.test(url)) { + requestManager + .getFeatureServerMetadata(url, { signal: AC.signal }) + .then((data) => { + if (!AC.signal.aborted) { + setError(undefined); + setLoading(false); + setData({ + type: "FeatureServer", + metadata: data.serviceMetadata, + }); + } + }) + .catch((e) => { + if (e.cancelled) { + // do nothing + } else { + setError(e.message); + } + }); + return () => AC.abort(); } }, [url]); diff --git a/packages/client/src/dataLayers/Legend.tsx b/packages/client/src/dataLayers/Legend.tsx new file mode 100644 index 000000000..de1dcd103 --- /dev/null +++ b/packages/client/src/dataLayers/Legend.tsx @@ -0,0 +1,242 @@ +import { + DynamicRenderingSupportOptions, + LegendItem as LegendSymbolItem, +} from "@seasketch/mapbox-gl-esri-sources"; +import { LegendForGLLayers } from "./legends/glLegends"; +import * as Accordion from "@radix-ui/react-accordion"; +import { + CaretDownIcon, + EyeClosedIcon, + EyeOpenIcon, +} from "@radix-ui/react-icons"; +import { useTranslation } from "react-i18next"; +import Spinner from "../components/Spinner"; +import SimpleSymbol from "./legends/SimpleSymbol"; +import { Map } from "mapbox-gl"; +import LegendBubblePanel from "./legends/LegendBubblePanel"; +import LegendGradientPanel from "./legends/LegendGradientPanel"; +import { stopsToLinearGradient } from "./legends/utils"; +import LegendHeatmapPanel from "./legends/LegendHeatmapPanel"; +import LegendListPanel from "./legends/LegendListPanel"; +require("../admin/data/arcgis/Accordion.css"); + +interface SingleImageLegendItem { + type: "SingleImageLegendItem"; + label: string; + /** TableOfContentsItem ids */ + ids: string[]; + /** Image URL */ + url: string; + supportsDynamicRendering: DynamicRenderingSupportOptions; +} + +interface CustomGLSourceSymbolLegend { + label: string; + type: "CustomGLSourceSymbolLegend"; + supportsDynamicRendering: DynamicRenderingSupportOptions; + symbols: LegendSymbolItem[]; +} + +interface GLStyleLegendItem { + label: string; + type: "GLStyleLegendItem"; + /** Table of contents item id */ + id: string; + legend?: LegendForGLLayers; +} + +export type LegendItem = + | GLStyleLegendItem + | CustomGLSourceSymbolLegend + | SingleImageLegendItem; + +const PANEL_WIDTH = 180; + +export default function Legend({ + className, + items, + loading, + hiddenItems, + onHiddenItemsChange, + map, + maxHeight, +}: { + items: LegendItem[]; + zOrder: { [id: string]: number }; + opacity: { [id: string]: number }; + hiddenItems: string[]; + onZOrderChange?: (id: string, zOrder: number) => void; + onOpacityChange?: (id: string, opacity: number) => void; + onHiddenItemsChange?: (id: string, hidden: boolean) => void; + className?: string; + loading?: boolean; + map?: Map; + maxHeight?: number; +}) { + const { t } = useTranslation("homepage"); + maxHeight = maxHeight || undefined; + return ( +
    + + + + +

    + {t("Legend")} + {loading && } +

    + +
    +
    + +
      + {items.map((item, i) => { + if (item.type === "GLStyleLegendItem") { + const legend = item.legend; + if (!legend || legend.type === "SimpleGLLegend") { + const visible = + !hiddenItems || !hiddenItems.includes(item.id); + return ( +
    • +
      + {map && legend ? ( + + ) : null} +
      + + {item.label} + { + if (onHiddenItemsChange) { + onHiddenItemsChange( + item.id, + !hiddenItems.includes(item.id) + ); + } + }} + visible={visible} + /> +
    • + ); + } else if (legend.type === "MultipleSymbolGLLegend") { + const visible = + !hiddenItems || !hiddenItems.includes(item.id); + return ( +
    • +
      + {item.label} + { + if (onHiddenItemsChange) { + onHiddenItemsChange(item.id, !visible); + } + }} + visible={visible} + /> +
      +
        + {legend.panels.map((panel) => { + switch (panel.type) { + case "GLLegendHeatmapPanel": + return ; + case "GLLegendGradientPanel": + return ; + case "GLLegendBubblePanel": + return ( + + ); + case "GLLegendListPanel": + return ( + + ); + default: + // eslint-disable-next-line i18next/no-literal-string + return
        not implemented
        ; + } + })} +
      +
    • + ); + } else { + return null; + } + } else { + return null; + } + })} +
    +
    +
    +
    +
    + ); +} + +function LegendImage({ + item, + className, +}: { + item: LegendSymbolItem; + className?: string; +}) { + return ( + {item.label} 1 ? window.devicePixelRatio / 1.5 : 1) + } + height={ + (item.imageHeight || 20) / + (window.devicePixelRatio > 1 ? window.devicePixelRatio / 1.5 : 1) + } + /> + ); +} + +function Toggle({ + visible, + onChange, + className, +}: { + visible: boolean; + onChange?: () => void; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/client/src/dataLayers/MapContextManager.ts b/packages/client/src/dataLayers/MapContextManager.ts index ce44efdc1..0e3bdb730 100644 --- a/packages/client/src/dataLayers/MapContextManager.ts +++ b/packages/client/src/dataLayers/MapContextManager.ts @@ -48,6 +48,8 @@ import LRU from "lru-cache"; import debounce from "lodash.debounce"; import { currentSidebarState } from "../projects/ProjectAppSidebar"; import { ApolloClient, NormalizedCacheObject } from "@apollo/client"; +import { getLegendForGLStyleLayers } from "./legends/glLegends"; +import { LegendItem } from "./Legend"; const rejectAfter = (duration: number) => new Promise((resolve, reject) => { @@ -758,6 +760,7 @@ class MapContextManager { } this.debouncedUpdateLayerState(); this.debouncedUpdateStyle(); + this.updateLegends(); } /** @@ -770,6 +773,7 @@ class MapContextManager { ? this.geoprocessingReferenceIds[id] : id; delete this.visibleLayers[stableId]; + this.updateLegends(); } /** @@ -797,6 +801,7 @@ class MapContextManager { visible: true, }; } + this.updateLegends(); } /** @@ -1574,12 +1579,14 @@ class MapContextManager { highlightLayer(layerId: string) {} + private tocItemLabels: { [id: string]: string } = {}; + reset( sources: DataSourceDetailsFragment[], layers: DataLayerDetailsFragment[], tocItems: Pick< OverlayFragment, - "id" | "stableId" | "dataLayerId" | "geoprocessingReferenceId" + "id" | "stableId" | "dataLayerId" | "geoprocessingReferenceId" | "title" >[] ) { this.clientDataSources = {}; @@ -1592,6 +1599,7 @@ class MapContextManager { // this.layers[layer.id] = layer; } for (const item of tocItems) { + this.tocItemLabels[item.stableId] = item.title; if (item.dataLayerId) { const layer = layersById[item.dataLayerId]; if (layer) { @@ -1619,6 +1627,7 @@ class MapContextManager { } this.debouncedUpdateStyle(); this.updateInteractivitySettings(); + this.updateLegends(); return; } @@ -2185,6 +2194,93 @@ class MapContextManager { getMissingSketches() {} isBasemapMissing() {} + + private _updateLegends(clearCache = false) { + const newLegendState: { [layerId: string]: LegendItem | null } = {}; + let changes = false; + for (const id in this.visibleLayers) { + if (clearCache === true && id in this.internalState.legends) { + newLegendState[id] = this.internalState.legends[id]; + } else { + const layer = this.layers[id]; + const source = this.clientDataSources[layer?.dataSourceId]; + if (layer && source && layer.mapboxGlStyles) { + let sourceType: + | undefined + | "vector" + | "raster" + | "image" + | "video" + | "raster-dem" + | "geojson" = undefined; + switch (source.type) { + case DataSourceTypes.Geojson: + case DataSourceTypes.SeasketchVector: + sourceType = "geojson"; + break; + case DataSourceTypes.Raster: + case DataSourceTypes.SeasketchRaster: + sourceType = "raster"; + break; + case DataSourceTypes.Vector: + case DataSourceTypes.SeasketchMvt: + sourceType = "vector"; + break; + case DataSourceTypes.RasterDem: + sourceType = "raster-dem"; + break; + case DataSourceTypes.ArcgisDynamicMapserver: + case DataSourceTypes.Image: + sourceType = "image"; + break; + case DataSourceTypes.Video: + sourceType = "video"; + break; + } + + if (sourceType) { + try { + const legend = getLegendForGLStyleLayers( + layer.mapboxGlStyles, + sourceType + ); + + const itemLabel = layer.tocId; + if (legend) { + newLegendState[id] = { + id, + type: "GLStyleLegendItem", + legend, + label: this.tocItemLabels[layer.tocId] || "", + }; + changes = true; + } else { + newLegendState[id] = null; + changes = true; + } + } catch (e) { + console.error(e); + newLegendState[id] = { + id, + type: "GLStyleLegendItem", + label: this.tocItemLabels[layer.tocId] || "", + }; + changes = true; + } + } + } + } + } + if (changes) { + this.setState((prev) => ({ + ...prev, + legends: newLegendState, + })); + // console.log(newLegendState); + } + } + + updateLegends = debounce(this._updateLegends, 20); } export default MapContextManager; @@ -2227,6 +2323,9 @@ export interface MapContextInterface { supportsUndo: boolean; }; languageCode?: string; + legends: { + [layerId: string]: LegendItem | null; + }; } interface MapContextOptions { /** If provided, map state will be restored upon return to the map by storing state in localStorage */ @@ -2261,6 +2360,7 @@ export function useMapContext(options?: MapContextOptions) { basemapOptionalLayerStates: {}, styleHash: "", containerPortal: containerPortal || null, + legends: {}, }; const token = useAccessToken(); let initialCameraOptions: CameraOptions | undefined = camera; @@ -2347,6 +2447,7 @@ export const MapContext = createContext({ basemapOptionalLayerStates: {}, styleHash: "", containerPortal: null, + legends: {}, }, (state) => {} ), @@ -2356,6 +2457,7 @@ export const MapContext = createContext({ terrainEnabled: false, basemapOptionalLayerStates: {}, containerPortal: null, + legends: {}, }); async function loadImage( diff --git a/packages/client/src/dataLayers/legends/CircleSymbol.tsx b/packages/client/src/dataLayers/legends/CircleSymbol.tsx new file mode 100644 index 000000000..b4b9bb375 --- /dev/null +++ b/packages/client/src/dataLayers/legends/CircleSymbol.tsx @@ -0,0 +1,30 @@ +import { GLLegendCircleSymbol } from "./glLegends"; +import { colord, extend } from "colord"; +import namesPlugin from "colord/plugins/names"; +extend([namesPlugin]); + +export default function CircleSymbol({ data }: { data: GLLegendCircleSymbol }) { + const simpleSymbol = data; + const diameter = simpleSymbol.radius + ? Math.min(simpleSymbol.radius, 16) * 2 + : 10; + let bg = colord(simpleSymbol.color || "transparent"); + if (data.fillOpacity && data.fillOpacity < 1 && bg.alpha() === 1) { + bg = bg.alpha(data.fillOpacity); + } + return ( +
    + ); +} diff --git a/packages/client/src/dataLayers/legends/FillSymbol.tsx b/packages/client/src/dataLayers/legends/FillSymbol.tsx new file mode 100644 index 000000000..e5579a62e --- /dev/null +++ b/packages/client/src/dataLayers/legends/FillSymbol.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { GLLegendFillSymbol } from "./glLegends"; +import { LegendResolvedImage, getImage } from "./MarkerSymbol"; +import { Map } from "mapbox-gl"; +import { colord, extend } from "colord"; +import namesPlugin from "colord/plugins/names"; +extend([namesPlugin]); + +export default function FillSymbol({ + data, + map, +}: { + data: GLLegendFillSymbol; + map: Map; +}) { + const [imageData, setImageData] = useState(); + useEffect(() => { + if (!imageData && data.patternImageId) { + const resolvedImage = getImage(data.patternImageId, map); + if (resolvedImage) { + setImageData(resolvedImage); + } else { + const handler = () => { + const resolvedImage = getImage(data.patternImageId!, map); + if (resolvedImage) { + setImageData(resolvedImage); + map.off("styledata", handler); + } + }; + map.on("styledata", handler); + return () => { + map.off("styledata", handler); + }; + } + } + }, [setImageData, map, data.patternImageId, imageData]); + const simpleSymbol = data; + let bg = colord(simpleSymbol.color); + if ( + data.fillOpacity !== undefined && + data.fillOpacity < 1 && + bg.alpha() === 1 + ) { + bg = bg.alpha(data.fillOpacity); + } + let strokeColor = colord(simpleSymbol.strokeColor || "#000"); + if ( + data.strokeOpacity !== undefined && + data.strokeOpacity < 1 && + strokeColor.alpha() === 1 + ) { + strokeColor = strokeColor.alpha(data.strokeOpacity); + } + + return ( +
    + ); +} diff --git a/packages/client/src/dataLayers/legends/LegendBubblePanel.tsx b/packages/client/src/dataLayers/legends/LegendBubblePanel.tsx new file mode 100644 index 000000000..66a082266 --- /dev/null +++ b/packages/client/src/dataLayers/legends/LegendBubblePanel.tsx @@ -0,0 +1,62 @@ +import { GLLegendBubblePanel } from "./glLegends"; + +export default function LegendBubblePanel({ + panel, + panelWidth, +}: { + panel: GLLegendBubblePanel; + panelWidth: number; +}) { + const maxRadius = panel.stops[panel.stops.length - 1]?.radius || 5; + const scaling = panelWidth < maxRadius ? panelWidth / maxRadius : 1; + return ( + // eslint-disable-next-line i18next/no-literal-string +
  • + {panel.label && ( +

    {panel.label}

    + )} + {[...panel.stops].reverse().map((stop, i) => { + return ( +
    +
    0 + ? `${stop.strokeWidth}px solid ${stop.stroke}` + : undefined, + }} + >
    + + + {stop.value.toLocaleString()} + + +
    + ); + })} +
  • + ); +} diff --git a/packages/client/src/dataLayers/legends/LegendGradientPanel.tsx b/packages/client/src/dataLayers/legends/LegendGradientPanel.tsx new file mode 100644 index 000000000..31fca576a --- /dev/null +++ b/packages/client/src/dataLayers/legends/LegendGradientPanel.tsx @@ -0,0 +1,26 @@ +import { GLLegendGradientPanel } from "./glLegends"; +import { stopsToLinearGradient } from "./utils"; + +export default function LegendGradientPanel({ + panel, +}: { + panel: GLLegendGradientPanel; +}) { + const vals = panel.stops.map((stop) => stop.value); + const maxVal = Math.max(...vals); + return ( +
  • + {panel.label &&

    {panel.label}

    } +
    +
    + {vals[0].toLocaleString()} + {maxVal.toLocaleString()} +
    +
  • + ); +} diff --git a/packages/client/src/dataLayers/legends/LegendHeatmapPanel.tsx b/packages/client/src/dataLayers/legends/LegendHeatmapPanel.tsx new file mode 100644 index 000000000..72421b0f6 --- /dev/null +++ b/packages/client/src/dataLayers/legends/LegendHeatmapPanel.tsx @@ -0,0 +1,25 @@ +import { GLLegendHeatmapPanel } from "./glLegends"; +import { stopsToLinearGradient } from "./utils"; + +export default function LegendHeatmapPanel({ + panel, +}: { + panel: GLLegendHeatmapPanel; +}) { + return ( +
  • +
    +
    +
  • + ); +} diff --git a/packages/client/src/dataLayers/legends/LegendListPanel.tsx b/packages/client/src/dataLayers/legends/LegendListPanel.tsx new file mode 100644 index 000000000..991ab0187 --- /dev/null +++ b/packages/client/src/dataLayers/legends/LegendListPanel.tsx @@ -0,0 +1,37 @@ +import SimpleSymbol from "./SimpleSymbol"; +import { GLLegendListPanel } from "./glLegends"; +import { Map } from "mapbox-gl"; + +export default function LegendListPanel({ + panel, + map, +}: { + panel: GLLegendListPanel; + map?: Map; +}) { + return ( +
  • + {panel.label && ( +

    {panel.label}

    + )} +
      + {panel.items.map((item) => { + return ( +
    • +
      + {map && item.symbol ? ( + + ) : null} +
      + + {item.label} +
    • + ); + })} +
    +
  • + ); +} diff --git a/packages/client/src/dataLayers/legends/LineSymbol.tsx b/packages/client/src/dataLayers/legends/LineSymbol.tsx new file mode 100644 index 000000000..39d795b87 --- /dev/null +++ b/packages/client/src/dataLayers/legends/LineSymbol.tsx @@ -0,0 +1,25 @@ +import { GLLegendLineSymbol } from "./glLegends"; +import { colord, extend } from "colord"; +import namesPlugin from "colord/plugins/names"; +extend([namesPlugin]); + +// TODO: support line patterns +// but how?? +export default function LineSymbol(props: { data: GLLegendLineSymbol }) { + const simpleSymbol = props.data; + console.log("dashed?", simpleSymbol.dashed); + return ( +
    + ); +} diff --git a/packages/client/src/dataLayers/legends/MarkerSymbol.tsx b/packages/client/src/dataLayers/legends/MarkerSymbol.tsx new file mode 100644 index 000000000..7898e0d3d --- /dev/null +++ b/packages/client/src/dataLayers/legends/MarkerSymbol.tsx @@ -0,0 +1,109 @@ +import { Map } from "mapbox-gl"; +import { GLLegendMarkerSymbol } from "./glLegends"; +import { useEffect, useState } from "react"; +import { blankDataUri } from "@seasketch/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService"; +import { colord, extend } from "colord"; +import namesPlugin from "colord/plugins/names"; +extend([namesPlugin]); + +export default function MarkerSymbol({ + map, + data, +}: { + data: GLLegendMarkerSymbol; + map: Map; +}) { + const [imageData, setImageData] = useState(); + + useEffect(() => { + if (!imageData && data.imageId) { + const resolvedImage = getImage(data.imageId, map); + if (resolvedImage) { + setImageData(resolvedImage); + } else { + const handler = () => { + const resolvedImage = getImage(data.imageId, map); + if (resolvedImage) { + setImageData(resolvedImage); + map.off("styledata", handler); + } + }; + map.on("styledata", handler); + return () => { + map.off("styledata", handler); + }; + } + } + }, [setImageData, map, data.imageId, imageData]); + + if (imageData) { + return ; + } else { + // eslint-disable-next-line i18next/no-literal-string + return null; + } +} + +const toImageData = (d: { width: number; height: number; data: any }) => { + const { width, height, data } = d; + const size = Math.max(width, height); + const canvas = document.createElement("canvas"); + canvas.setAttribute("width", size.toString()); + canvas.setAttribute("height", size.toString()); + const ctx = canvas.getContext("2d"); + if (ctx) { + const imageData = new ImageData( + Uint8ClampedArray.from(data), + width, + height + ); + ctx.putImageData(imageData, (size - width) / 4, (size - height) / 4); + } + return canvas.toDataURL(); +}; + +export interface LegendResolvedImage { + url: string; + width: number; + height: number; + pixelRatio: number; +} + +export function getImage( + id: string, + map: Map +): LegendResolvedImage | undefined { + // @ts-ignore + const d = map.style.getImage(id); + if (d) { + if (d.sdf) { + // TODO: somehow support sdf? + return { + url: blankDataUri, + width: d.width, + height: d.height, + pixelRatio: d.pixelRatio, + }; + } else { + const url = toImageData(d.data); + if (url) { + return { + url, + width: d.data.width, + height: d.data.height, + pixelRatio: d.pixelRatio, + }; + } else { + // If it fails somehow, return a blank data uri + return { + url: blankDataUri, + width: d.width, + height: d.height, + pixelRatio: d.pixelRatio, + }; + } + } + } else { + return undefined; + } +} diff --git a/packages/client/src/dataLayers/legends/SimpleSymbol.tsx b/packages/client/src/dataLayers/legends/SimpleSymbol.tsx new file mode 100644 index 000000000..dfc567ac2 --- /dev/null +++ b/packages/client/src/dataLayers/legends/SimpleSymbol.tsx @@ -0,0 +1,62 @@ +import { VideoCameraIcon } from "@heroicons/react/solid"; +import { GLLegendSymbol } from "./glLegends"; +import FillSymbol from "./FillSymbol"; +import LineSymbol from "./LineSymbol"; +import MarkerSymbol from "./MarkerSymbol"; +import { Map } from "mapbox-gl"; +import CircleSymbol from "./CircleSymbol"; + +export default function SimpleSymbol(props: { + data: GLLegendSymbol; + map: Map; +}) { + switch (props.data.type) { + case "line": + return ; + case "fill": + return ; + case "marker": + return ; + case "text": + return ( + + + + ); + case "raster": + return ( + + + + ); + case "video": + return ; + case "circle": + return ; + default: + throw new Error(`Unknown symbol type: ${props.data}`); + } +} diff --git a/packages/client/src/dataLayers/legends/glLegends.ts b/packages/client/src/dataLayers/legends/glLegends.ts new file mode 100644 index 000000000..11f10f3bf --- /dev/null +++ b/packages/client/src/dataLayers/legends/glLegends.ts @@ -0,0 +1,1718 @@ +import { Feature } from "geojson"; +import { Expression as MapboxExpression, StyleFunction } from "mapbox-gl"; +import { expression } from "mapbox-gl/dist/style-spec/index.es.js"; +import { + Expression, + FillExtrusionLayer, + FillLayer, + HeatmapLayer, + Layer, + LineLayer, +} from "mapbox-gl"; +import styleSpec from "mapbox-gl/src/style-spec/reference/v8.json"; +import { Geostats } from "../../admin/data/GLStyleEditor/GeostatsModal"; + +export interface GLLegendFillSymbol { + type: "fill"; + color: string; + extruded?: boolean; + patternImageId?: string; + /** 0-1 */ + fillOpacity: number; + strokeWidth: number; + strokeOpacity?: number; + strokeColor?: string; + dashed?: boolean; +} + +export interface GLLegendLineSymbol { + type: "line"; + color: string; + strokeWidth: number; + patternImageId?: string; + dashed?: boolean; + opacity?: number; +} + +export interface GLLegendCircleSymbol { + type: "circle"; + color: string; + strokeWidth: number; + strokeColor?: string; + /** 0-1 */ + fillOpacity: number; + strokeOpacity: number; + radius: number; +} + +export interface GLLegendMarkerSymbol { + type: "marker"; + imageId: string; + haloColor?: string; + haloWidth?: number; + rotation?: number; + /** multiple of width & height to display */ + iconSize: number; +} + +export interface GLLegendRasterSymbol { + type: "raster"; +} + +export interface GLLegendVideoSymbol { + type: "video"; +} + +export interface GLLegendTextSymbol { + type: "text"; + color: string; + fontFamily: string; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + haloColor?: string; + haloWidth?: number; +} + +export type GLLegendSymbol = + | GLLegendFillSymbol + | GLLegendCircleSymbol + | GLLegendMarkerSymbol + | GLLegendTextSymbol + | GLLegendRasterSymbol + | GLLegendLineSymbol + | GLLegendVideoSymbol; + +export type GLLegendListPanel = { + id: string; + type: "GLLegendListPanel"; + label?: string; + items: { id: string; label: string; symbol: GLLegendSymbol }[]; +}; + +/** + * Display should be stacked if bubbles are big and can nest together, otherwise + * display as a list. + * + * Note that a BubblePanel may be paired with a ListPanel for a common case of + * a bubble chart with a categorical variable controlling the color of the bubbles. + */ +export type GLLegendBubblePanel = { + id: string; + type: "GLLegendBubblePanel"; + label?: string; + stops: { + value: number; + radius: number; + fill: string; + stroke: string; + strokeWidth: number; + }[]; +}; + +export type GLLegendHeatmapPanel = { + id: string; + type: "GLLegendHeatmapPanel"; + stops: { value: number; color: string }[]; +}; + +export type GLLegendGradientPanel = { + id: string; + type: "GLLegendGradientPanel"; + label?: string; + stops: { value: number; label: string; color: string }[]; +}; + +export type GLLegendPanel = + | GLLegendListPanel + | GLLegendBubblePanel + | GLLegendHeatmapPanel + | GLLegendGradientPanel; + +export type SimpleLegendForGLLayers = { + type: "SimpleGLLegend"; + symbol: GLLegendSymbol; +}; + +export type MultipleSymbolLegendForGLLayers = { + type: "MultipleSymbolGLLegend"; + panels: GLLegendPanel[]; +}; + +export type LegendForGLLayers = + | SimpleLegendForGLLayers + | MultipleSymbolLegendForGLLayers; + +// ordered by rank of importance +const SIGNIFICANT_PAINT_PROPS = [ + "fill-color", + "fill-extrusion-color", + "fill-pattern", + "fill-opacity", + "fill-outline-color", + "fill-extrusion-height", + "circle-color", + "line-color", + "line-pattern", + "text-color", + "circle-radius", + "icon-color", + "circle-stroke-color", + "line-width", + "circle-stroke-width", + "line-dasharray", + "line-opacity", + "text-halo-color", + "circle-opacity", +]; + +const SIGNIFICANT_LAYOUT_PROPS = ["icon-image", "icon-size", "text-size"]; + +/** + * While building the legend, it is important to keep track of what style props + * are already represented in symbols. A single dataset with complex styles + * may for example have one panel which represents circle color as a gradient of + * interpolated color, but then needs another panel to show how a categorical + * variable controls circle stroke color. In this case, the categorical panel + * should have an empty fill, since it is already represented in the gradient. + * + * Tracking this is difficult since we need to keep track of what props have + * been represented and then add to the list when new symbols are created. + * Instances of this class can be passed down to symbol creation functions so + * that they can add to the list of represented properties. + * + * The pending/commit system ensures that properties are only added to the + * list of represented properties after a full "panel" has been created. + */ +class RepresentedProperties { + private committed = new Set(); + private pending = new Set(); + + has(prop: string) { + return this.committed.has(prop); + } + + add(prop: string, commit?: boolean) { + this.pending.add(prop); + if (commit) { + this.commit(); + } + } + + commit() { + this.pending.forEach((p) => this.committed.add(p)); + this.pending.clear(); + } + + reset() { + this.pending.clear(); + this.committed.clear(); + } + + clearPending() { + this.pending.clear(); + } +} + +function createStyleValueOrExpressionForContextFn( + context: LegendContext, + layers: SeaSketchGlLayer[], + representedProperties: RepresentedProperties, + expression: SignificantExpression +) { + /** + * Returns the appropriate value for a paint or layout property based on + * the current legend evaluation context. By context, we mean the current + * significant expression being evaluated for the creation of a legend + * panel. This is useful for getting values for related properties. + * + * For example, if the current expression is a circle-radius expression + * and a bubble chart is being built, this function can be used to get the + * appropriate value for circle-stroke-color, circle-color, and + * circle-stroke-width. These values each individually may have different + * behaviors: + * + * 1. If they are literal values or those controlled by non-data + * expressions, the correct value should be returned. + * 2. If any of these are controlled by an expression that references a + * different data property, the value should be set to something + * "blank" or simple so that it can be represented in a separate panel + * later. + * 3. If any of these are controlled by the same data property as the + * context's focal expression, the expression itself is returned so + * that it can be evaluated for each stop or domain being rendered. + * + * If case #3 is encountered, this function will add the style prop + * to the list of "representedProperties" so that it is not duplicated in + * another panel. + * + * + * @param stypeProp name of the style property to get the value of + * @param blankValue value to return if the property is controlled by a + * different data property + * @param anyLayer use when styling fill layers to get the value from any + * related line layers which can be treated like "fill-stroke" + */ + const styleValueOrExpressionForContext = ( + styleProp: string, + blankValue: any, + anyLayer = false + ): Expression | string | number => { + const relatedExpression = context.significantExpressions.find( + (e) => + (anyLayer || e.layerIndex === expression.layerIndex) && + e.styleProp === styleProp + // not sure I need the following: + // && !representedProperties.includes(e.styleProp) + ); + if (!relatedExpression) { + // Case #1: no related expression + const isPaint = SIGNIFICANT_PAINT_PROPS.includes(styleProp); + // Usually the prop of interest will be on the layer indicated by the + // focal expression. For line-color, line-opacity, and line-width it + // could be that the focal expression is a fill layer and we need + // to look for a related line layer. + let layer = layers[expression.layerIndex]; + if ( + layer.type === "fill" && + ["line-color", "line-opacity", "line-width"].includes(styleProp) + ) { + // need to find a related line layer + layer = layers.find((l) => l.type === "line") as LineLayer; + } + if (isPaint) { + const paint = layer.paint || {}; + return getPaintProp(paint, styleProp, {}); + } else { + const layout = layer.layout || {}; + return getLayoutProp( + layout, + layer !== layers[expression.layerIndex] ? "fill" : layer.type, + styleProp, + {} + ); + } + } else if (relatedExpression.getProp === expression.getProp) { + // Case #3: related expression is controlled by the same data property + // related expression is controlled by the same data property + // get the style property value from the related layer + const isPaint = SIGNIFICANT_PAINT_PROPS.includes( + relatedExpression.styleProp + ); + const layer = layers[relatedExpression.layerIndex]; + representedProperties.add(relatedExpression.styleProp, true); + if (isPaint) { + const paint = layer.paint!; + if (relatedExpression.styleProp in paint) { + return (paint as any)[relatedExpression.styleProp] as Expression; + } else { + throw new Error( + `SignificantExpression returned which references a non-existent paint property. ${relatedExpression.styleProp} ${relatedExpression.usageType}` + ); + } + } else { + const layout = layer.layout!; + if (relatedExpression.styleProp in layout) { + return (layout as any)[relatedExpression.styleProp] as Expression; + } else { + throw new Error( + `SignificantExpression returned which references a non-existent layout property. ${relatedExpression.styleProp} ${relatedExpression.usageType}` + ); + } + } + } else { + // Case #2: related expression is controlled by a different data property + return blankValue; + } + }; + return styleValueOrExpressionForContext; +} + +export function getLegendForGLStyleLayers( + layers: SeaSketchGlLayer[], + sourceType: + | "vector" + | "raster" + | "geojson" + | "image" + | "video" + | "raster-dem", + geostats?: Geostats +): LegendForGLLayers { + if ( + sourceType === "raster" || + sourceType === "raster-dem" || + sourceType === "image" + ) { + return { + type: "SimpleGLLegend", + symbol: { + type: "raster", + }, + }; + } else if (sourceType === "video") { + return { + type: "SimpleGLLegend", + symbol: { + type: "video", + }, + }; + } + const context = extractLegendContext(layers); + console.log("context", context); + if (context.significantExpressions.length === 0 && !context.includesHeatmap) { + // is simple + return { + type: "SimpleGLLegend", + symbol: getSingleSymbolForVectorLayers(layers), + }; + } else { + const legend: MultipleSymbolLegendForGLLayers = { + type: "MultipleSymbolGLLegend", + panels: [], + }; + // Keep track of properties (by layer) that have already been represented in + // an existing panel so that they are not duplicated. When building symbols + // this should be passed down so that if, for example, you had one panel + // representing circle fills and then another breaking down circle stroke by + // a different data property, that second panel would not include fill. + const representedProperties = new RepresentedProperties(); + + if (context.includesHeatmap) { + const heatmapLayer = layers.find( + (l) => l.type === "heatmap" + ) as HeatmapLayer; + const paint = heatmapLayer.paint || {}; + legend.panels.push({ + id: "heatmap", + type: "GLLegendHeatmapPanel", + stops: interpolationExpressionToStops(paint["heatmap-color"]), + }); + } + + for (const expression of context.significantExpressions) { + const styleValueOrExpressionForContext = + createStyleValueOrExpressionForContextFn( + context, + layers, + representedProperties, + expression + ); + + // Skip expression if it has already been represented in another panel + if (representedProperties.has(expression.styleProp)) { + continue; + } + // Skip opacity expressions unless they are the only interesting thing + // about a set of layers + if ( + /opacity/.test(expression.styleProp) && + context.significantExpressions.length !== 1 + ) { + continue; + } + const layerType = layers[expression.layerIndex].type; + switch (expression.usageType) { + case "rampScaleOrCurve": + const propType = getPropType(expression.styleProp, layerType); + // # Detect Color Gradients + if (propType === "color" && /interpolate/.test(expression.fnName)) { + // gradient + legend.panels.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `${expression.layerIndex}-${expression.styleProp}-gradient`, + label: expression.getProp, + type: "GLLegendGradientPanel", + stops: interpolationExpressionToStops( + (layers[expression.layerIndex].paint as any)[ + expression.styleProp + ] + ), + }); + representedProperties.add(expression.styleProp, true); + // identify other colors set by the same stops and exclude them from + // rendering redundant panels + const otherColors = context.significantExpressions.filter((e) => { + return ( + e !== expression && + e.getProp === expression.getProp && + e.usageType === "rampScaleOrCurve" && + /interpolate/.test(e.fnName) && + hasMatchingStops(e.stops, expression.stops) + ); + }); + for (const otherColor of otherColors) { + representedProperties.add(otherColor.styleProp, true); + } + } else if ( + propType === "number" && + expression.fnName === "interpolate" + ) { + // Potential props here: + // - circle-radius + // - line-width + // - circle-stroke-width + // - fill-extrusion-height + // Opacity props, if they get here, are the only significant + // expression in this set of layers + // - line-opacity + // - fill-opacity + // - circle-opacity + // Don't just rely on this comment. Check SIGNIFICANT_PAINT_PROPS + // in case of any additions. + if ( + layerType === "circle" && + expression.styleProp === "circle-radius" + ) { + // # Bubble Chart + const stops = expression.stops; + if ( + stops.length === 2 && + expression.interpolationType === "linear" && + "input" in stops[0] && + "input" in stops[1] && + typeof stops[0].output === "number" && + typeof stops[1].output === "number" + ) { + // add intermediate + stops.splice(1, 0, { + input: (stops[0].input + stops[1].input) / 2, + output: (stops[0].output + stops[1].output) / 2, + }); + } + + const stroke = styleValueOrExpressionForContext( + "circle-stroke-color", + "rgba(0, 0, 0, 0.25)" + ); + + const circleColor = styleValueOrExpressionForContext( + "circle-color", + "rgba(175, 175, 175)" + ); + + const strokeWidth = styleValueOrExpressionForContext( + "circle-stroke-width", + 1 + ); + + legend.panels.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `${expression.layerIndex}-${expression.styleProp}-bubble`, + type: "GLLegendBubblePanel", + label: expression.getProp, + stops: expression.stops.map((s) => { + const value = "input" in s ? s.input : 0; + return { + value, + radius: s.output as number, + fill: evaluate(circleColor, { + [expression.getProp]: value, + }), + stroke: evaluate(stroke, { [expression.getProp]: value }), + strokeWidth: evaluate(strokeWidth, { + [expression.getProp]: value, + }), + }; + }), + }); + } else if (/opacity/.test(expression.styleProp)) { + // Only create a panel just for opacity if there are no other + // interesting things to represent. + // TODO: + } else { + // test for + } + } else if (expression.fnName === "step") { + // Create a step panel + } + break; + case "decision": + let layer = layers[expression.layerIndex]; + const fillLayer = layers.find((l) => l.type === "fill"); + if (layer.type === "line" && fillLayer) { + layer = fillLayer; + } + const domains = [...expression.domains]; + // Merge domains from other expressions which have the same getProp + for (const otherExpression of context.significantExpressions) { + if (otherExpression === expression) { + continue; + } + if ( + otherExpression.getProp === expression.getProp && + otherExpression.usageType === "decision" + ) { + for (const entry of otherExpression.domains) { + const key = entry.comparators.join(","); + const existing = domains.find( + (d) => d.comparators.join(",") === key + ); + if (!existing) { + domains.push(entry); + } + } + } + } + + // Create a List panel + legend.panels.push({ + // eslint-disable-next-line i18next/no-literal-string + id: `${expression.layerIndex}-${expression.styleProp}-list`, + type: "GLLegendListPanel", + label: expression.getProp, + items: domains.map((d) => { + let symbol: GLLegendSymbol; + + switch (layer.type) { + case "fill": + symbol = createFillSymbol( + layer as FillLayer, + layers.filter((l) => l !== layer), + { + [expression.getProp]: d.comparators[0], + }, + representedProperties + ); + break; + case "circle": + symbol = createCircleSymbol( + layers[expression.layerIndex], + [], + { + [expression.getProp]: d.comparators[0], + }, + representedProperties + ); + break; + case "line": + symbol = createLineSymbol( + layers[expression.layerIndex], + [], + { + [expression.getProp]: d.comparators[0], + }, + representedProperties + ); + break; + } + return { + id: d.operator + d.comparators.join(","), + label: d.isFallback + ? "Default" + : (((d.operator === "==" ? "" : d.operator) + + d.comparators.join(", ")) as string), + symbol: symbol!, + }; + }), + }); + representedProperties.commit(); + break; + case "literal": + { + console.log("literal"); + } + break; + case "filter": + { + console.log("filter"); + } + break; + } + } + + // TODO: if no panels have been added yet, try inserting a single symbol + // legend as a fallback + return legend; + } +} + +function evaluate( + expression: number | string | Expression, + featureProps: any, + geometryType?: "Point" | "Polygon" | "LineString" +) { + if (isExpression(expression)) { + geometryType = geometryType || "Point"; + const feature = { + type: "Feature", + properties: featureProps, + geometry: { + type: geometryType, + coordinates: [0, 0], + }, + } as Feature; + return ExpressionEvaluator.parse(expression).evaluate(feature); + } else { + return expression; + } +} + +function hasMatchingStops(a: Stop[], b: Stop[]) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + // @ts-ignore + if (a[i].output !== b[i].output || a[i].input !== b[i].input) { + return false; + } + } + return true; +} + +function getPropType(styleProp: string, layerType: string) { + const isPaintProp = SIGNIFICANT_PAINT_PROPS.includes(styleProp); + const propDetails = + // @ts-ignore + styleSpec[isPaintProp ? "paint_" + layerType : "layout_" + layerType][ + styleProp + ]; + if (propDetails) { + return propDetails.type as "enum" | "color" | "number" | "string"; + } else { + throw new Error("no prop details"); + } +} + +/** + * Converts a mapbox-gl-style interpolation expression to a list of stops that can be used to create a legend. + * @param expression Mapbox gl style interpolation expression + * @returns Array<{value: number, color: string}> + */ +function interpolationExpressionToStops(expression?: any) { + expression = + expression && isExpression(expression) + ? expression + : [ + "interpolate", + ["linear"], + ["heatmap-density"], + 0, + "rgba(0, 0, 255, 0)", + 0.1, + "royalblue", + 0.3, + "cyan", + 0.5, + "lime", + 0.7, + "yellow", + 1, + "red", + ]; + const stops: { value: number; color: string; label: string }[] = []; + if (expression[0] === "interpolate") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [interpolationType, property, ...args] = expression; + if (interpolationType === "interpolate") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [input, ...stopPairs] = args; + for (let i = 0; i < stopPairs.length; i += 2) { + stops.push({ + value: stopPairs[i], + color: stopPairs[i + 1], + label: stopPairs[i], + }); + } + } + } + return stops; +} + +function getPaintProp( + paint: any, + propName: string, + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties, + defaultIfAlreadyRepresented?: number | string | boolean +) { + if (representedProperties && representedProperties.has(propName)) { + return defaultIfAlreadyRepresented; + } + const type = propName.split("-")[0]; + if (propName in paint) { + const value = paint[propName]; + if (isExpression(value)) { + if (representedProperties && hasGetExpression(value)) { + representedProperties.add(propName); + } + // evaluate expression using featureData + return ExpressionEvaluator.parse(value).evaluate({ + type: "Feature", + properties: featureData || {}, + geometry: { + type: "Point", + coordinates: [1, 2], + }, + }); + } else { + return value; + } + } + // @ts-ignore + const defaultForProp = styleSpec["paint_" + type][propName]["default"]; + return defaultForProp || undefined; +} + +function getLayoutProp( + layout: any, + type: string, + propName: string, + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties, + defaultIfAlreadyRepresented?: number | string +) { + if (representedProperties && isRepresented(propName, representedProperties)) { + return defaultIfAlreadyRepresented; + } + if (propName in layout) { + const value = layout[propName]; + if (isExpression(value)) { + // evaluate expression using featureData + return ExpressionEvaluator.parse(value).evaluate({ + type: "Feature", + properties: featureData || {}, + geometry: { + type: "Point", + coordinates: [1, 2], + }, + }); + } else { + return value; + } + } + // @ts-ignore + const defaultForProp = styleSpec["layout_" + type][propName]["default"]; + return defaultForProp || undefined; +} + +interface SignificantLiteralExpression { + layerIndex: number; + styleProp: string; + getProp: string; + usageType: "literal"; + rank: number; +} + +type Operator = "<" | "<=" | ">" | ">=" | "==" | "!=" | "!"; + +interface SignificantExpressionDomain { + operator: Operator; + comparators: any[]; + isFallback?: boolean; +} + +interface SignificantDecisionExpression { + layerIndex: number; + styleProp: string; + getProp: string; + usageType: "decision"; + fnName: "case" | "match"; + domains: SignificantExpressionDomain[]; + rank: number; +} + +type Stop = + | { input: number; output: string | number } + | { output: string | number; isDefault: true }; + +interface SignificantRampScaleOrCurveExpression { + layerIndex: number; + styleProp: string; + getProp: string; + usageType: "rampScaleOrCurve"; + fnName: "interpolate" | "interpolate-hcl" | "interpolate-lab" | "step"; + stops: Stop[]; + rank: number; + interpolationType?: "linear" | "exponential" | "log"; +} + +interface SignificantFilterExpression { + layerIndex: number; + styleProp: string; + getProp: string; + usageType: "filter"; + domains: SignificantExpressionDomain[]; + rank: 0; +} + +type SignificantExpression = + | SignificantLiteralExpression + | SignificantDecisionExpression + | SignificantRampScaleOrCurveExpression + | SignificantFilterExpression; + +type SeaSketchGlLayer = Omit; + +interface LegendContext { + globalContext: { + zoomRanges: { layerIndex: number; zoomRange: [number, number] }[]; + }; + significantExpressions: SignificantExpression[]; + includesHeatmap: boolean; +} + +function extractLegendContext( + layers: SeaSketchGlLayer[], + geostats?: Geostats +): LegendContext { + const results: LegendContext = { + globalContext: { + zoomRanges: [], + }, + significantExpressions: [], + includesHeatmap: false, + }; + for (const layer of layers) { + if (layer.type === "heatmap") { + results.includesHeatmap = true; + } + // First, find all the filter expressions since they are easy + // TODO: implement + if (layer.filter && isExpression(layer.filter)) { + } + if (layer.paint) { + for (const prop of SIGNIFICANT_PAINT_PROPS) { + if (prop in layer.paint) { + const value = (layer.paint as any)[prop]; + if (isExpression(value)) { + const expressionDetails = extractExpressionDetails( + prop, + value, + layers.indexOf(layer) + ); + if (expressionDetails) { + results.significantExpressions.push(expressionDetails); + } + } + } + } + } + if (layer.layout) { + for (const prop of SIGNIFICANT_LAYOUT_PROPS) { + if (prop in layer.layout) { + const value = (layer.layout as any)[prop]; + if (isExpression(value)) { + const expressionDetails = extractExpressionDetails( + prop, + value, + layers.indexOf(layer) + ); + if (expressionDetails) { + results.significantExpressions.push(expressionDetails); + } + } + } + } + } + } + results.significantExpressions.sort((a, b) => a.rank - b.rank); + return results; +} + +interface ParentExpressionContext { + fnName: string; + expression: Expression; + parent?: ParentExpressionContext; +} + +function getUsageContextFromContext(parent?: ParentExpressionContext): + | { + usageType: "decision" | "rampScaleOrCurve" | "filter"; + parentExpression: Expression; + } + | { usageType: "literal" } { + if (!parent) { + return { usageType: "literal" }; + } else { + switch (parent.fnName) { + case "case": + case "match": + return { usageType: "decision", parentExpression: parent.expression }; + case "interpolate": + case "interpolate-hcl": + case "interpolate-lab": + case "step": + return { + usageType: "rampScaleOrCurve", + parentExpression: parent.expression, + }; + default: + return getUsageContextFromContext(parent.parent); + } + } +} + +function stopsFromInterpolation(expression: Expression) { + if (/interpolate/.test(expression[0])) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [fnName, interpolationType, input, ...inputOutputPairs] = expression; + const stops: Stop[] = []; + for (let i = 0; i < inputOutputPairs.length; i++) { + if (i % 2 === 0) { + stops.push({ + input: inputOutputPairs[i], + output: inputOutputPairs[i + 1], + }); + } + } + return stops; + } else { + throw new Error(`Expected interpolation expression. Got ${expression[0]}`); + } +} + +function stopsFromStepExpression(expression: Expression) { + if (expression[0] === "step") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [fnName, input, firstOutput, ...inputOutputPairs] = expression; + const stops: Stop[] = []; + stops.push({ output: firstOutput, isDefault: true }); + for (const pair of inputOutputPairs) { + stops.push({ input: pair[0], output: pair[1] }); + } + return stops; + } else { + throw new Error(`Expected step expression. Got ${expression[0]}`); + } +} + +function extractExpressionDetails( + styleProp: string, + propertyValue: any, + layerIndex: number, + parent?: ParentExpressionContext +): SignificantExpression | null { + if (!isExpression(propertyValue)) { + return null; + } else if (propertyValue[0] === "get") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_get, getProp, defaultFallbackValue] = propertyValue; + // found get expression! + // first find the usage type + const context = getUsageContextFromContext(parent); + const rank = + SIGNIFICANT_PAINT_PROPS.indexOf(styleProp) > -1 + ? SIGNIFICANT_PAINT_PROPS.indexOf(styleProp) + : SIGNIFICANT_LAYOUT_PROPS.indexOf(styleProp); + const knownProps = { styleProp, rank, layerIndex }; + switch (context.usageType) { + case "literal": + return { + ...knownProps, + getProp, + usageType: context.usageType, + }; + case "rampScaleOrCurve": + const { usageType, parentExpression } = context; + const [fnName, ...args] = parentExpression; + switch (fnName) { + case "interpolate": + case "interpolate-hcl": + case "interpolate-lab": { + return { + ...knownProps, + usageType, + getProp, + fnName, + stops: stopsFromInterpolation(parentExpression), + interpolationType: + args.length > 0 && args[0].length > 0 ? args[0][0] : undefined, + }; + } + case "step": { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [input, firstOutput, ...inputOutputPairs] = args; + return { + ...knownProps, + usageType, + getProp, + fnName, + stops: stopsFromStepExpression(parentExpression), + }; + } + default: + throw new Error( + `Unknown ramp, scale or curve expression "${fnName}"` + ); + } + case "decision": + if (context.parentExpression[0] === "match") { + // Simpler of the two, enumerates entire domain + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_match, input, ...inputOutputPairsAndFallback] = + context.parentExpression; + const inputOutputPairs = inputOutputPairsAndFallback.slice( + 0, + inputOutputPairsAndFallback.length - 1 + ); + const domains: SignificantExpressionDomain[] = []; + for (let i = 0; i < inputOutputPairs.length; i++) { + if (i % 2 === 0) { + domains.push({ + operator: "==", + comparators: [inputOutputPairs[i]], + }); + } + } + // Add default fallback value + const lastValueInDomain = domains[domains.length - 1].comparators[0]; + domains.push({ + // Add a default. Every match function has a default + operator: "==", + comparators: [ + typeof lastValueInDomain === "number" + ? lastValueInDomain + 99999 + : "__glLegendDefaultValue", + ], + isFallback: true, + }); + + return { + ...knownProps, + usageType: context.usageType, + getProp, + fnName: "match", + domains, + }; + } else if (context.parentExpression[0] === "case") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_case, ...args] = context.parentExpression; + const conditionsAndOutputs = args.slice(0, args.length - 1); + const domains: SignificantExpressionDomain[] = []; + for (let i = 0; i < conditionsAndOutputs.length; i++) { + if (i % 2 === 0) { + const condition = conditionsAndOutputs[i]; + if (isExpression(condition)) { + const { isSimple, comparatorIndex } = isSimpleCondition( + condition, + getProp + ); + const [conditionFnName, ...conditionArgs] = condition; + if (isSimple) { + domains.push({ + operator: conditionFnName as Operator, + comparators: + comparatorIndex !== undefined && comparatorIndex > -1 + ? [conditionArgs[comparatorIndex!]] + : [], + }); + } + } + } + } + // Add fallback + const lastValueInDomain = domains[domains.length - 1].comparators[0]; + + domains.push({ + operator: "==", + comparators: [ + typeof lastValueInDomain === "number" + ? lastValueInDomain + 99999 + : "__glLegendDefaultValue", + ], + isFallback: true, + }); + return { + ...knownProps, + usageType: context.usageType, + getProp, + fnName: "case", + domains, + }; + } + } + } else { + // it's an expression, but we don't know what kind. Drill down into args + // looking for a get expression + const currentContext: ParentExpressionContext = { + fnName: propertyValue[0], + expression: propertyValue, + parent, + }; + for (const arg of propertyValue.slice(1)) { + const details = extractExpressionDetails( + styleProp, + arg, + layerIndex, + currentContext + ); + if (details !== null) { + return details; + } + } + } + return null; +} + +/** + * Returns whether the given condition is a simple condition that directly + * compares the named feature property to a literal value. Simple type + * conversion like to-number can wrap property access but nothing else. + * @param condition + * @param featurePropertyName + */ +function isSimpleCondition(condition: Expression, featurePropertyName: string) { + const [fnName, ...args] = condition; + if (["==", "!=", "<", "<=", ">", ">=", "!"].includes(fnName)) { + const getArgIndex = args.findIndex((arg) => isSimpleGetArg(arg)); + if (getArgIndex === -1) { + return { isSimple: false }; + } else { + // Special case with no comparator + if (fnName === "!") { + return { isSimple: true, comparatorIndex: -1 }; + } + const comparatorIndex = getArgIndex === 0 ? 1 : 0; + if (isExpression(args[comparatorIndex])) { + // Unexpected situation. + return { isSimple: false }; + } else { + return { isSimple: true, comparatorIndex }; + } + } + } else { + return { isSimple: false }; + } +} + +function isSimpleGetArg(arg: any): boolean { + if (isExpression(arg)) { + const [fnName, ...args] = arg; + if (fnName === "get") { + return true; + } else if (/to-/.test(fnName)) { + return isSimpleGetArg(args[0]); + } + } + return false; +} + +export function isExpression(e: any): e is Expression { + return Array.isArray(e) && typeof e[0] === "string"; +} + +export function findGetExpression( + expression: any +): null | { type: "legacy" | "get"; property: string } { + if (!isExpression(expression)) { + return null; + } + if (expression[0] === "get") { + return { type: "get", property: expression[1] }; + } else { + for (const arg of expression.slice(1)) { + if (isExpression(arg)) { + const found = findGetExpression(arg); + if (found !== null) { + return found; + } + } + } + } + return null; +} + +export function hasGetExpression(expression: any): boolean { + const get = findGetExpression(expression); + return get ? true : false; +} + +function getSingleSymbolForVectorLayers( + layers: SeaSketchGlLayer[] +): GLLegendSymbol { + // Last layer in the array is the top-most layer, so it should be the primary + layers = [...layers].reverse(); + // determine primary symbol type + const fillLayer = layers.find( + (layer) => layer.type === "fill" || layer.type === "fill-extrusion" + ) as FillLayer | FillExtrusionLayer | undefined; + if (fillLayer && layerIsVisible(fillLayer)) { + return createFillSymbol( + fillLayer, + layers.filter((l) => l !== fillLayer), + {} + ); + } + const lineLayer = layers.find((layer) => layer.type === "line") as LineLayer; + if (lineLayer && layerIsVisible(lineLayer)) { + return createLineLayer( + lineLayer, + layers.filter((l) => l !== lineLayer), + {} + ); + } + const circleLayer = layers.find((layer) => layer.type === "circle"); + if (circleLayer && layerIsVisible(circleLayer)) { + return createCircleSymbol( + circleLayer, + layers.filter((l) => l !== circleLayer), + {} + ); + } + const symbolLayer = layers.find((layer) => layer.type === "symbol"); + if (symbolLayer && layerIsVisible(symbolLayer)) { + const layout = (symbolLayer.layout || {}) as any; + const iconImage = layout["icon-image"]; + if (iconImage) { + return createMarkerSymbol( + symbolLayer, + layers.filter((l) => l !== symbolLayer) + ); + } else if (layout["text-field"]) { + return createTextSymbol( + symbolLayer, + layers.filter((l) => l !== symbolLayer), + {} + ); + } + } + throw new Error("Not implemented"); +} + +function createMarkerSymbol( + symbolLayer: SeaSketchGlLayer, + otherLayers: SeaSketchGlLayer[] +): GLLegendMarkerSymbol { + const paint = (symbolLayer.paint || {}) as any; + const layout = (symbolLayer.layout || {}) as any; + const iconImage = layout["icon-image"]; + const iconSize = layout["icon-size"] || 1; + const rotation = layout["icon-rotate"]; + const haloColor = paint["text-halo-color"]; + const haloWidth = paint["text-halo-width"]; + return { + type: "marker", + imageId: iconImage, + haloColor, + haloWidth, + rotation, + iconSize, + }; +} + +function createTextSymbol( + symbolLayer: SeaSketchGlLayer, + otherLayers: SeaSketchGlLayer[], + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties +): GLLegendTextSymbol { + const paint = (symbolLayer.paint || {}) as any; + const layout = (symbolLayer.layout || {}) as any; + const color = getPaintProp( + paint, + "text-color", + featureData, + representedProperties, + "#000" + ); + const fontFamily = getLayoutProp( + layout, + "symbol", + "text-font", + featureData, + representedProperties, + "Open Sans Regular" + ); + const haloColor = getPaintProp( + paint, + "text-halo-color", + featureData, + representedProperties, + "transparent" + ); + const haloWidth = getPaintProp( + paint, + "text-halo-width", + featureData, + representedProperties, + 0 + ); + return { + type: "text", + color, + fontFamily: Array.isArray(fontFamily) ? fontFamily[0] : fontFamily, + fontWeight: /bold/.test(fontFamily) ? "bold" : "normal", + fontStyle: /italic/.test(fontFamily) ? "italic" : "normal", + haloColor, + haloWidth, + }; +} +function createLineSymbol( + lineLayer: SeaSketchGlLayer, + otherLayers: SeaSketchGlLayer[], + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties +): GLLegendLineSymbol { + const paint = (lineLayer.paint || {}) as any; + const color = getPaintProp( + paint, + "line-color", + featureData, + representedProperties, + "#000" + ); + const opacity = getPaintProp( + paint, + "line-opacity", + featureData, + representedProperties, + 1 + ); + const width = getPaintProp( + paint, + "line-width", + featureData, + representedProperties, + 1 + ); + const dasharray = getPaintProp( + paint, + "line-dasharray", + featureData, + representedProperties, + false + ); + return { + type: "line", + color, + opacity, + strokeWidth: width, + dashed: dasharray, + }; +} + +function createCircleSymbol( + circleLayer: SeaSketchGlLayer, + otherLayers: SeaSketchGlLayer[], + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties +): GLLegendCircleSymbol { + const paint = (circleLayer.paint || {}) as any; + const fillOpacity = getPaintProp( + paint, + "circle-opacity", + featureData, + representedProperties, + 1 + ); + const strokeWidth = getPaintProp( + paint, + "circle-stroke-width", + featureData, + representedProperties, + 1 + ); + const strokeColor = getPaintProp( + paint, + "circle-stroke-color", + featureData, + representedProperties, + "#000" + ); + const strokeOpacity = getPaintProp( + paint, + "circle-stroke-opacity", + featureData, + representedProperties, + 1 + ); + const color = getPaintProp( + paint, + "circle-color", + featureData, + representedProperties, + "transparent" + ); + + const radius = getPaintProp( + paint, + "circle-radius", + featureData, + representedProperties, + 5 + ); + return { + radius, + type: "circle", + color, + fillOpacity, + strokeWidth, + strokeColor, + strokeOpacity, + }; +} + +function isRepresented( + styleProp: string, + representedProperties?: RepresentedProperties +) { + if (!representedProperties) { + return false; + } else { + return representedProperties.has(styleProp); + } +} + +function createFillSymbol( + fillLayer: FillLayer | FillExtrusionLayer, + otherLayers: SeaSketchGlLayer[], + featureData: { [propName: string]: any }, + representedProperties?: RepresentedProperties +): GLLegendFillSymbol { + const paint = (fillLayer.paint || {}) as any; + const extruded = fillLayer.type === "fill-extrusion"; + const opacity = extruded + ? getPaintProp( + paint, + "fill-extrusion-opacity", + featureData, + representedProperties, + 1 + ) + : getPaintProp( + paint, + "fill-opacity", + featureData, + representedProperties, + 1 + ); + let strokeWidth = paint["fill-outline-color"] ? 1 : 0; + let strokeColor = getPaintProp( + paint, + "fill-outline-color", + featureData, + representedProperties, + "transparent" + ); + const lineLayer = otherLayers.find((layer) => layer.type === "line") as + | Layer + | undefined; + let dashedLine = false; + let strokeOpacity: number | undefined; + if (lineLayer) { + const linePaint = (lineLayer.paint || {}) as any; + strokeWidth = getPaintProp( + linePaint, + "line-width", + featureData, + representedProperties, + 1 + ); + strokeColor = getPaintProp( + linePaint, + "line-color", + featureData, + representedProperties, + "#000" + ); + dashedLine = getPaintProp( + linePaint, + "line-dasharray", + featureData, + representedProperties, + false + ); + strokeOpacity = getPaintProp( + linePaint, + "line-opacity", + featureData, + representedProperties, + 1 + ); + } + const color = extruded + ? getPaintProp( + paint, + "fill-extrusion-color", + featureData, + representedProperties, + "rgba(0,0,0,0.2)" + ) + : getPaintProp( + paint, + "fill-color", + featureData, + representedProperties, + "rgba(0,0,0,0.2)" + ); + return { + type: "fill", + color, + extruded: fillLayer.type === "fill-extrusion" ? true : false, + fillOpacity: opacity, + strokeWidth, + dashed: dashedLine, + strokeColor, + strokeOpacity, + patternImageId: extruded + ? getPaintProp( + paint, + "fill-extrusion-pattern", + featureData, + representedProperties, + undefined + ) + : getPaintProp( + paint, + "fill-pattern", + featureData, + representedProperties, + undefined + ), + }; +} + +function createLineLayer( + lineLayer: LineLayer, + otherLayers: SeaSketchGlLayer[], + featureData: { [propName: string]: any } +): GLLegendLineSymbol { + const paint = (lineLayer.paint || {}) as any; + const opacity = getPaintProp(paint, "line-opacity", featureData); + const strokeWidth = getPaintProp(paint, "line-width", featureData); + const strokeColor = getPaintProp(paint, "line-color", featureData); + const dashedLine = paint["line-dasharray"] || false; + + return { + type: "line", + color: strokeColor, + strokeWidth, + dashed: dashedLine, + opacity, + patternImageId: getPaintProp(paint, "line-pattern", featureData), + }; +} + +function layerIsVisible(layer: Pick) { + if ("layout" in layer && layer.layout?.visibility) { + return layer.layout.visibility !== "none"; + } else { + return true; + } +} + +// type can be "woodland", "scrub", or "desert" +// What's important to get right here? +// * habitat fill color by type +// * Ideally the stroke for all these as well, though that filter is a problem +// * The extra thick red stroke for the protected area is important. It would +// probably make sense as a seperate panel, but is that generalizable? How +// would you handle the stroke interpolation as well? +const FillStyledWithCaseExpression = [ + { + id: "fill-styled-with-case-expression", + type: "fill", + source: "land-parcels", + paint: { + "fill-color": [ + "match", + ["get", "habitat"], + "woodland", + "green", + "scrub", + "yellow", + "blue", + ], + "fill-opacity": 0.5, + }, + }, + { + id: "fill-styled-with-case-expression-stroke", + type: "line", + source: "land-parcels", + paint: { + "line-color": [ + "case", + ["==", ["get", "habitat"], "woodland"], + "darkgreen", + "black", + ], + "line-width": 1, + }, + filter: ["!=", ["get", "habitat"], "scrub"], + }, + { + id: "fill-styled-with-case-expression-thick-stroke", + type: "line", + source: "land-parcels", + paint: { + "line-color": ["case", ["get", "protected_area"], "red", "white"], + "line-width": [ + "interpolate", + ["linear"], + ["get", "size_sq_km"], + 1, + 3, + 5, + 5, + ], + }, + }, +]; + +const expressionGlobals = { + zoom: 14, +}; + +/** A color as returned by a Mapbox style expression. All values are in [0, 1] */ +export interface RGBA { + r: number; + g: number; + b: number; + a: number; +} + +interface TypeMap { + string: string; + number: number; + color: RGBA; + boolean: boolean; + [other: string]: any; +} + +// Copied from https://gist.github.com/danvk/4378b6936f9cd634fc8c9f69c4f18b81 +/** + * Class for working with Mapbox style expressions. + * + * See https://docs.mapbox.com/mapbox-gl-js/style-spec/#expressions + */ +export class ExpressionEvaluator { + /** + * Parse a Mapbox style expression. + * + * Pass an expected type to get tigher error checking and more precise types. + */ + static parse( + expr: + | number + | string + | Readonly + | Readonly + | undefined, + expectedType?: T + ): ExpressionEvaluator { + // For details on use of this private API and plans to publicize it, see + // https://github.com/mapbox/mapbox-gl-js/issues/7670 + let parseResult: expression.ParseResult; + if (expectedType) { + parseResult = expression.createExpression(expr, { type: expectedType }); + if (parseResult.result === "success") { + return new ExpressionEvaluator(parseResult.value); + } + } else { + parseResult = expression.createExpression(expr); + if (parseResult.result === "success") { + return new ExpressionEvaluator(parseResult.value); + } + } + + throw parseResult.value[0]; + } + + constructor(public parsedExpression: expression.StyleExpression) {} + + evaluate(feature: Feature): T { + return this.parsedExpression.evaluate(expressionGlobals, feature); + } +} diff --git a/packages/client/src/dataLayers/legends/style-spec.d.ts b/packages/client/src/dataLayers/legends/style-spec.d.ts new file mode 100644 index 000000000..41d50acff --- /dev/null +++ b/packages/client/src/dataLayers/legends/style-spec.d.ts @@ -0,0 +1,62 @@ +declare module "mapbox-gl/dist/style-spec/index.es.js" { + import { Feature } from "geojson"; + + export namespace expression { + export type FeatureState = { [key: string]: any }; + + export type GlobalProperties = Readonly<{ + zoom: number; + heatmapDensity?: number; + lineProgress?: number; + isSupportedScript?: (script: string) => boolean; + accumulated?: any; + }>; + + interface StyleExpression { + expression: any; + + evaluate( + globals: GlobalProperties, + feature?: Feature, + featureState?: FeatureState + ): any; + evaluateWithoutErrorHandling( + globals: GlobalProperties, + feature?: Feature, + featureState?: FeatureState + ): any; + } + + export interface ParseResultSuccess { + result: "success"; + value: StyleExpression; + } + export interface ParsingError extends Error { + key: string; + message: string; + } + export interface ParseResultError { + result: "error"; + value: ParsingError[]; + } + export type ParseResult = ParseResultSuccess | ParseResultError; + + export type StylePropertyType = + | "color" + | "string" + | "number" + | "enum" + | "boolean" + | "formatted" + | "image"; + + export interface StylePropertySpecification { + type: StylePropertyType; + } + + export function createExpression( + expr: any, + propertySpec?: StylePropertySpecification + ): ParseResult; + } +} diff --git a/packages/client/src/dataLayers/legends/utils.ts b/packages/client/src/dataLayers/legends/utils.ts new file mode 100644 index 000000000..676b13d7c --- /dev/null +++ b/packages/client/src/dataLayers/legends/utils.ts @@ -0,0 +1,19 @@ +/** + * Converts a set of stops to a css linear-gradient + * @param stops { color: string; value: number} + */ +export function stopsToLinearGradient( + stops: { color: string; value: number }[] +): string { + const sortedStops = [...stops].sort((a, b) => a.value - b.value); + const colors = sortedStops.map((stop) => stop.color); + const values = sortedStops.map((stop) => stop.value); + const max = Math.max(...values); + const min = Math.min(...values); + const percentages = values.map((val) => (val - min) / (max - min)); + const stopStrings = colors.map( + (color, i) => `${color} ${percentages[i] * 100}%` + ); + // eslint-disable-next-line i18next/no-literal-string + return `linear-gradient(90deg, ${stopStrings.join(", ")})`; +} diff --git a/packages/mapbox-gl-esri-sources/dist/bundle.js b/packages/mapbox-gl-esri-sources/dist/bundle.js index 270476f09..b8eb7c0d2 100644 --- a/packages/mapbox-gl-esri-sources/dist/bundle.js +++ b/packages/mapbox-gl-esri-sources/dist/bundle.js @@ -158,10 +158,8 @@ var MapBoxGLEsriSources = (function (exports) { 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(",") - : []; + 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: [ @@ -264,8 +262,7 @@ var MapBoxGLEsriSources = (function (exports) { : legendLayer.legend.length === 1 ? legendLayer.layerName : "", - imageUrl: (legend === null || legend === void 0 ? void 0 : legend.imageData) - ? `data:${legend.contentType};base64,${legend.imageData}` + imageUrl: (legend === null || legend === void 0 ? void 0 : legend.imageData) ? `data:${legend.contentType};base64,${legend.imageData}` : blankDataUri, imageWidth: 20, imageHeight: 20, @@ -656,16 +653,18 @@ var MapBoxGLEsriSources = (function (exports) { } } async getGLStyleLayers() { - return [ - { - id: v4(), - type: "raster", - source: this.sourceId, - paint: { - "raster-fade-duration": this.options.useTiles ? 300 : 0, + return { + layers: [ + { + id: v4(), + type: "raster", + source: this.sourceId, + paint: { + "raster-fade-duration": this.options.useTiles ? 300 : 0, + }, }, - }, - ]; + ], + }; } } function lat2meters(lat) { @@ -729,7 +728,7 @@ var MapBoxGLEsriSources = (function (exports) { isSourceLoaded: false, sourceDataType: "content", }); - fetchFeatureLayerData(this.baseUrl, this.outFields, onError, (_a = this.options) === null || _a === void 0 ? void 0 : _a.geometryPrecision, null, null, false, 1000, options === null || options === void 0 ? void 0 : options.bytesLimit) + fetchFeatureLayerData$1(this.baseUrl, this.outFields, onError, (_a = this.options) === null || _a === void 0 ? void 0 : _a.geometryPrecision, null, null, false, 1000, options === null || options === void 0 ? void 0 : options.bytesLimit) .then((fc) => { this._loading = false; if (!hadError) { @@ -748,7 +747,7 @@ var MapBoxGLEsriSources = (function (exports) { this.map.removeSource(this._id); } } - async function fetchFeatureLayerData(url, outFields, onError, geometryPrecision = 6, abortController = null, onPageReceived = null, disablePagination = false, pageSize = 1000, bytesLimit) { + async function fetchFeatureLayerData$1(url, outFields, onError, geometryPrecision = 6, abortController = null, onPageReceived = null, disablePagination = false, pageSize = 1000, bytesLimit) { const featureCollection = { type: "FeatureCollection", features: [], @@ -763,10 +762,10 @@ var MapBoxGLEsriSources = (function (exports) { returnIdsOnly: "false", f: "geojson", }); - await fetchData(url, params, featureCollection, onError, abortController || new AbortController(), onPageReceived, disablePagination, pageSize, bytesLimit); + await fetchData$1(url, params, featureCollection, onError, abortController || new AbortController(), onPageReceived, disablePagination, pageSize, bytesLimit); return featureCollection; } - async function fetchData(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination = false, pageSize = 1000, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount) { + async function fetchData$1(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination = false, pageSize = 1000, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount) { bytesReceived = bytesReceived || 0; new TextDecoder("utf-8"); params.set("returnIdsOnly", "false"); @@ -783,7 +782,7 @@ var MapBoxGLEsriSources = (function (exports) { signal: abortController.signal, }); const str = await response.text(); - bytesReceived += byteLength(str); + bytesReceived += byteLength$1(str); if (bytesLimit && bytesReceived >= bytesLimit) { const e = new Error(`Exceeded bytesLimit. ${bytesReceived} >= ${bytesLimit}`); return onError(e); @@ -814,12 +813,12 @@ var MapBoxGLEsriSources = (function (exports) { if (onPageReceived) { onPageReceived(bytesReceived, featureCollection.features.length, expectedFeatureCount); } - await fetchData(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination, pageSize, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount); + await fetchData$1(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination, pageSize, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount); } } return bytesReceived; } - function byteLength(str) { + function byteLength$1(str) { var s = str.length; for (var i = str.length - 1; i >= 0; i--) { var code = str.charCodeAt(i); @@ -865,6 +864,32 @@ var MapBoxGLEsriSources = (function (exports) { } return { serviceMetadata, layers }; } + async getFeatureServerMetadata(url, options) { + url = url.replace(/\/$/, ""); + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/FeatureServer/.test(url)) { + throw new Error("Invalid FeatureServer URL"); + } + if (/\d+$/.test(url)) { + throw new Error("Invalid FeatureServer URL"); + } + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (options === null || options === void 0 ? void 0 : options.credentials) { + const token = await this.getToken(url.replace(/rest\/services\/.*/, "/rest/services/"), options.credentials); + params.set("token", token); + } + const requestUrl = `${url}?${params.toString()}`; + const serviceMetadata = await this.fetch(requestUrl, options === null || options === void 0 ? void 0 : options.signal); + const layers = await this.fetch(`${url}/layers?${params.toString()}`); + if (layers.error) { + throw new Error(layers.error.message); + } + return { serviceMetadata, layers }; + } async getCatalogItems(url, options) { if (!/rest\/services/.test(url)) { throw new Error("Invalid ArcGIS REST Service URL"); @@ -913,8 +938,8 @@ var MapBoxGLEsriSources = (function (exports) { if (!/rest\/services/.test(url)) { throw new Error("Invalid ArcGIS REST Service URL"); } - if (!/MapServer/.test(url)) { - throw new Error("Invalid MapServer URL"); + if (!/MapServer/.test(url) && !/FeatureServer/.test(url)) { + throw new Error("Invalid MapServer or FeatureServer URL"); } url = url.replace(/\/$/, ""); url = url.replace(/\?.*$/, ""); @@ -1091,16 +1116,18 @@ var MapBoxGLEsriSources = (function (exports) { return this.sourceId; } async getGLStyleLayers() { - return [ - { - type: "raster", - source: this.sourceId, - id: v4(), - paint: { - "raster-fade-duration": 300, + return { + layers: [ + { + type: "raster", + source: this.sourceId, + id: v4(), + paint: { + "raster-fade-duration": 300, + }, }, - }, - ]; + ], + }; } removeFromMap(map) { if (map.getSource(this.sourceId)) { @@ -1122,6 +1149,291 @@ var MapBoxGLEsriSources = (function (exports) { updateLayers(layers) { } } + function fetchFeatureCollection(url, geometryPrecision = 6, outFields = "*", bytesLimit = 1000000 * 100) { + return new Promise((resolve, reject) => { + fetchFeatureLayerData(url, outFields, reject, geometryPrecision, null, null, undefined, undefined, bytesLimit) + .then((data) => resolve(data)) + .catch((e) => reject(e)); + }); + } + async function fetchFeatureLayerData(url, outFields, onError, geometryPrecision = 6, abortController = null, onPageReceived = null, disablePagination = false, pageSize = 1000, bytesLimit) { + const featureCollection = { + type: "FeatureCollection", + features: [], + }; + const params = new URLSearchParams({ + inSR: "4326", + outSR: "4326", + where: "1>0", + outFields, + returnGeometry: "true", + geometryPrecision: geometryPrecision.toString(), + returnIdsOnly: "false", + f: "geojson", + }); + await fetchData(url, params, featureCollection, onError, abortController, onPageReceived, disablePagination, pageSize, bytesLimit); + return featureCollection; + } + async function fetchData(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination = false, pageSize = 1000, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount) { + var _a; + bytesReceived = bytesReceived || 0; + new TextDecoder("utf-8"); + params.set("returnIdsOnly", "false"); + if (featureCollection.features.length > 0) { + params.delete("where"); + params.delete("resultOffset"); + params.delete("resultRecordCount"); + params.set("orderByFields", objectIdFieldName); + const lastFeature = featureCollection.features[featureCollection.features.length - 1]; + params.set("where", `${objectIdFieldName}>${lastFeature.id}`); + } + const response = await fetch(`${baseUrl}/query?${params.toString()}`, { + ...(abortController ? { signal: abortController.signal } : {}), + }); + const str = await response.text(); + bytesReceived += byteLength(str); + if (bytesLimit && bytesReceived > bytesLimit) { + const e = new Error(`Exceeded bytesLimit. ${bytesReceived} > ${bytesLimit}`); + return onError(e); + } + const fc = JSON.parse(str); + if (fc.error) { + return onError(new Error(fc.error.message)); + } + else { + featureCollection.features.push(...fc.features); + if (fc.exceededTransferLimit || ((_a = fc.properties) === null || _a === void 0 ? void 0 : _a.exceededTransferLimit)) { + if (!objectIdFieldName) { + params.set("returnIdsOnly", "true"); + try { + const r = await fetch(`${baseUrl}/query?${params.toString()}`, { + ...(abortController ? { signal: abortController.signal } : {}), + }); + const featureIds = featureCollection.features.map((f) => f.id); + let objectIdParameters = await r.json(); + if (objectIdParameters.properties) { + objectIdParameters = objectIdParameters.properties; + } + expectedFeatureCount = objectIdParameters.objectIds.length; + objectIdFieldName = objectIdParameters.objectIdFieldName; + } + catch (e) { + return onError(e); + } + } + if (onPageReceived) { + onPageReceived(bytesReceived, featureCollection.features.length, expectedFeatureCount); + } + await fetchData(baseUrl, params, featureCollection, onError, abortController, onPageReceived, disablePagination, pageSize, bytesLimit, bytesReceived, objectIdFieldName, expectedFeatureCount); + } + } + return bytesReceived; + } + function byteLength(str) { + var s = str.length; + for (var i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) + s++; + else if (code > 0x7ff && code <= 0xffff) + s += 2; + if (code >= 0xdc00 && code <= 0xdfff) + i--; + } + return s; + } + + class ArcGISFeatureLayerSource { + constructor(requestManager, options) { + var _a; + this._loading = true; + this.rawFeaturesHaveBeenFetched = false; + this.exceededBytesLimit = false; + this.sourceId = options.sourceId || v4(); + this.options = options; + this.requestManager = requestManager; + options.url = options.url.replace(/\/$/, ""); + if (!/rest\/services/.test(options.url) || + (!/MapServer/.test(options.url) && !/FeatureServer/.test(options.url))) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/\d+$/.test(options.url)) { + throw new Error("URL must end in /FeatureServer/{layerId} or /MapServer/{layerId}"); + } + this.layerId = parseInt(((_a = options.url.match(/\d+$/)) === null || _a === void 0 ? void 0 : _a[0]) || "0"); + } + async getComputedMetadata() { + const { serviceMetadata, layers } = await this.getMetadata(); + const { bounds, minzoom, maxzoom, attribution } = await this.getComputedProperties(); + const layer = layers.layers.find((l) => l.id === this.layerId); + const glStyle = await this.getGLStyleLayers(); + if (!layer) { + throw new Error("Layer not found"); + } + return { + bounds, + minzoom, + maxzoom, + attribution, + supportsDynamicRendering: { + layerOpacity: false, + layerVisibility: false, + layerOrder: false, + }, + tableOfContentsItems: [ + { + type: "data", + defaultVisibility: true, + id: this.sourceId, + label: layer.name, + metadata: generateMetadataForLayer(this.options.url.replace(/\/\d+$/, ""), serviceMetadata, layer), + glStyle: glStyle, + }, + ], + }; + } + async getComputedProperties() { + const { serviceMetadata, layers } = await this.getMetadata(); + const attribution = contentOrFalse(serviceMetadata.copyrightText) || undefined; + const layer = layers.layers.find((l) => l.id === this.layerId); + if (!layer) { + throw new Error(`Sublayer ${this.layerId} not found`); + } + return { + minzoom: 0, + maxzoom: 24, + bounds: (await extentToLatLngBounds((layer === null || layer === void 0 ? void 0 : layer.extent) || serviceMetadata.fullExtent)) || undefined, + attribution, + }; + } + fireError(e) { + var _a; + (_a = this.map) === null || _a === void 0 ? void 0 : _a.fire("error", { + sourceId: this.sourceId, + error: e.message, + }); + } + getMetadata() { + if (this.serviceMetadata && this.layerMetadata) { + return Promise.resolve({ + serviceMetadata: this.serviceMetadata, + layers: this.layerMetadata, + }); + } + else { + if (/FeatureServer/.test(this.options.url)) { + return this.requestManager + .getFeatureServerMetadata(this.options.url.replace(/\/\d+$/, ""), { + credentials: this.options.credentials, + }) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } + else { + return this.requestManager + .getMapServiceMetadata(this.options.url, { + credentials: this.options.credentials, + }) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } + } + } + get loading() { + return this._loading; + } + async getGLStyleLayers() { + if (this._glStylePromise) { + console.log("return promise"); + return this._glStylePromise; + } + else { + console.log("make promise"); + this._glStylePromise = new Promise(async (resolve, reject) => { + const { serviceMetadata, layers } = await this.getMetadata(); + const layer = layers.layers.find((l) => l.id === this.layerId); + if (!layer) { + throw new Error("Layer not found"); + } + resolve(styleForFeatureLayer(this.options.url.replace(/\/\d+$/, ""), this.layerId, this.sourceId, layer)); + }); + return this._glStylePromise; + } + } + async addToMap(map) { + this.map = map; + await this.getMetadata(); + const { attribution } = await this.getComputedProperties(); + map.addSource(this.sourceId, { + type: "geojson", + data: this.featureData || { + type: "FeatureCollection", + features: [], + }, + attribution: attribution ? attribution : "", + }); + this._loading = this.featureData ? false : true; + if (!this.rawFeaturesHaveBeenFetched) { + this.fetchFeatures(); + } + return this.sourceId; + } + async fetchFeatures() { + var _a; + if (this.exceededBytesLimit) { + return; + } + try { + const data = await fetchFeatureCollection(this.options.url, 6, "*", this.options.fetchStrategy === "raw" + ? 120000000 + : this.options.autoFetchByteLimit || 2000000); + this.featureData = data; + const source = (_a = this.map) === null || _a === void 0 ? void 0 : _a.getSource(this.sourceId); + if (source && source.type === "geojson") { + source.setData(data); + } + this._loading = false; + this.rawFeaturesHaveBeenFetched = true; + } + catch (e) { + if ("message" in e && /bytesLimit/.test(e.message)) { + this.exceededBytesLimit = true; + } + this.fireError(e); + console.error(e); + this._loading = false; + } + } + async updateLayers() { + } + async removeFromMap(map) { + if (this.map) { + const source = map.getSource(this.sourceId); + if (source) { + 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; + } + } + destroy() { + if (this.map) { + this.removeFromMap(this.map); + } + } + } + function generateId() { return v4(); } @@ -1873,6 +2185,7 @@ var MapBoxGLEsriSources = (function (exports) { }; exports.ArcGISDynamicMapService = ArcGISDynamicMapService; + exports.ArcGISFeatureLayerSource = ArcGISFeatureLayerSource; exports.ArcGISRESTServiceRequestManager = ArcGISRESTServiceRequestManager; exports.ArcGISTiledMapService = ArcGISTiledMapService; exports.ArcGISVectorSource = ArcGISVectorSource; diff --git a/packages/mapbox-gl-esri-sources/dist/index.d.ts b/packages/mapbox-gl-esri-sources/dist/index.d.ts index c85be8eed..adc7f70a7 100644 --- a/packages/mapbox-gl-esri-sources/dist/index.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/index.d.ts @@ -2,8 +2,9 @@ import { ArcGISDynamicMapService, ArcGISDynamicMapServiceOptions } from "./src/A import { ArcGISVectorSource, ArcGISVectorSourceOptions } from "./src/ArcGISVectorSource"; import { ArcGISRESTServiceRequestManager } from "./src/ArcGISRESTServiceRequestManager"; export { ArcGISTiledMapService, ArcGISTiledMapServiceOptions, } from "./src/ArcGISTiledMapService"; -export { MapServiceMetadata } from "./src/ServiceMetadata"; +export { MapServiceMetadata, FeatureServerMetadata, } from "./src/ServiceMetadata"; export { CustomGLSource, CustomGLSourceOptions, DynamicRenderingSupportOptions, LegendItem, SingleImageLegend, DataTableOfContentsItem, FolderTableOfContentsItem, } from "./src/CustomGLSource"; export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISDynamicMapServiceOptions, ArcGISVectorSourceOptions, ArcGISRESTServiceRequestManager, }; +export { ArcGISFeatureLayerSourceOptions, default as ArcGISFeatureLayerSource, } from "./src/ArcGISFeatureLayerSource"; 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 7c71311ce..483c2e047 100644 --- a/packages/mapbox-gl-esri-sources/dist/index.js +++ b/packages/mapbox-gl-esri-sources/dist/index.js @@ -3,5 +3,6 @@ import { ArcGISVectorSource, } from "./src/ArcGISVectorSource"; import { ArcGISRESTServiceRequestManager } from "./src/ArcGISRESTServiceRequestManager"; export { ArcGISTiledMapService, } from "./src/ArcGISTiledMapService"; export { ArcGISDynamicMapService, ArcGISVectorSource, ArcGISRESTServiceRequestManager, }; +export { default as ArcGISFeatureLayerSource, } from "./src/ArcGISFeatureLayerSource"; export { default as styleForFeatureLayer } from "./src/styleForFeatureLayer"; export { ImageList } from "./src/ImageList"; diff --git a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.d.ts b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.d.ts index 8183d9f4e..f363d9ff9 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.d.ts +++ b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.d.ts @@ -136,5 +136,7 @@ export declare class ArcGISDynamicMapService implements CustomGLSource; + getGLStyleLayers(): Promise<{ + layers: AnyLayer[]; + }>; } diff --git a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js index 0f7c9e893..aff1ba414 100644 --- a/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js +++ b/packages/mapbox-gl-esri-sources/dist/src/ArcGISDynamicMapService.js @@ -469,16 +469,18 @@ export class ArcGISDynamicMapService { } } async getGLStyleLayers() { - return [ - { - id: uuid(), - type: "raster", - source: this.sourceId, - paint: { - "raster-fade-duration": this.options.useTiles ? 300 : 0, + return { + layers: [ + { + id: uuid(), + type: "raster", + source: this.sourceId, + paint: { + "raster-fade-duration": this.options.useTiles ? 300 : 0, + }, }, - }, - ]; + ], + }; } } /** @hidden */ 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 f968426b2..1b36b31b6 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"; -/** @hidden */ declare const _default: (symbol: PictureFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; +/** @hidden */ 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 a7dca27e6..9bf2ec243 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"; -/** @hidden */ declare const _default: (symbol: PictureMarkerSymbol, sourceId: string, imageList: ImageList, serviceBaseUrl: string, sublayer: number, legendIndex: number) => Layer[]; +/** @hidden */ 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 9f2a6f472..1f805c6fd 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"; -/** @hidden */ declare const _default: (symbol: SimpleFillSymbol, sourceId: string, imageList: ImageList) => Layer[]; +/** @hidden */ 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 73c8859dc..ff18fa1b7 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"; -/** @hidden */ declare const _default: (symbol: SimpleLineSymbol, sourceId: string) => Layer[]; +/** @hidden */ 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 1a5cc2a4b..662a899da 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"; -/** @hidden */ declare const _default: (symbol: SimpleMarkerSymbol, sourceId: string, imageList: ImageList) => Layer[]; +/** @hidden */ 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 ead0b3895..2305e3916 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"; -/** @hidden */ declare const _default: (labelingInfo: any, geometryType: "line" | "point", fieldNames: string[]) => Layer; +/** @hidden */ 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 17bee920f..1ebb5b849 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 18725911e..2e7fa51fd 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 */ -type RGBA = [number, number, number, number]; +declare 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) => string; +export declare const rgba: (color?: RGBA | undefined) => string; /** @hidden */ -export declare const colorAndOpacity: (color?: RGBA) => { +export declare const colorAndOpacity: (color?: RGBA | undefined) => { color: string; opacity: number; }; diff --git a/packages/mapbox-gl-esri-sources/index.ts b/packages/mapbox-gl-esri-sources/index.ts index 4fef39f09..259386e81 100644 --- a/packages/mapbox-gl-esri-sources/index.ts +++ b/packages/mapbox-gl-esri-sources/index.ts @@ -12,7 +12,10 @@ export { ArcGISTiledMapService, ArcGISTiledMapServiceOptions, } from "./src/ArcGISTiledMapService"; -export { MapServiceMetadata } from "./src/ServiceMetadata"; +export { + MapServiceMetadata, + FeatureServerMetadata, +} from "./src/ServiceMetadata"; export { CustomGLSource, CustomGLSourceOptions, @@ -29,5 +32,9 @@ export { ArcGISVectorSourceOptions, ArcGISRESTServiceRequestManager, }; +export { + ArcGISFeatureLayerSourceOptions, + default as ArcGISFeatureLayerSource, +} from "./src/ArcGISFeatureLayerSource"; 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 57cf2bc37..cca6efe35 100644 --- a/packages/mapbox-gl-esri-sources/package.json +++ b/packages/mapbox-gl-esri-sources/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@terraformer/arcgis": "^2.0.7", + "bytes": "^3.1.2", "uuid": "^8.3.0" } } diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISDynamicMapService.ts b/packages/mapbox-gl-esri-sources/src/ArcGISDynamicMapService.ts index 19545f35c..943cd6e1b 100644 --- a/packages/mapbox-gl-esri-sources/src/ArcGISDynamicMapService.ts +++ b/packages/mapbox-gl-esri-sources/src/ArcGISDynamicMapService.ts @@ -16,6 +16,7 @@ import { generateMetadataForLayer, makeLegend, } from "./utils"; +import { ImageList } from "./ImageList"; /** @hidden */ export const blankDataUri = @@ -575,17 +576,19 @@ export class ArcGISDynamicMapService } } - async getGLStyleLayers(): Promise { - return [ - { - id: uuid(), - type: "raster", - source: this.sourceId, - paint: { - "raster-fade-duration": this.options.useTiles ? 300 : 0, + async getGLStyleLayers() { + return { + layers: [ + { + id: uuid(), + type: "raster", + source: this.sourceId, + paint: { + "raster-fade-duration": this.options.useTiles ? 300 : 0, + }, }, - }, - ] as AnyLayer[]; + ] as AnyLayer[], + }; } } diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISFeatureLayerSource.ts b/packages/mapbox-gl-esri-sources/src/ArcGISFeatureLayerSource.ts new file mode 100644 index 000000000..265fa14c4 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/ArcGISFeatureLayerSource.ts @@ -0,0 +1,313 @@ +import { + ArcGISRESTServiceRequestManager, + CustomGLSource, + ImageList, + styleForFeatureLayer, +} from "../index"; +import { + ComputedMetadata, + CustomGLSourceOptions, + LegendItem, +} from "./CustomGLSource"; +import { v4 as uuid } from "uuid"; +import { + AnyLayer, + GeoJSONSource, + GeoJSONSourceOptions, + Layer, + Map, +} from "mapbox-gl"; +import { + FeatureServerMetadata, + LayersMetadata, + MapServiceMetadata, +} from "./ServiceMetadata"; +import { + contentOrFalse, + extentToLatLngBounds, + generateMetadataForLayer, + makeLegend, +} from "./utils"; +import { FeatureCollection } from "geojson"; +import { fetchFeatureCollection } from "./fetchData"; +import { fetchFeatureLayerData } from "./ArcGISVectorSource"; + +export interface ArcGISFeatureLayerSourceOptions extends CustomGLSourceOptions { + /** + * URL for the service. Should end in /FeatureServer/{layerId} or /MapServer/{layerId}. + */ + url: string; + /** + * All query parameters will be added to each query request, overriding any + * settings made by this library. Useful for filtering or working with + * temporal data. + **/ + queryParameters?: { + [queryString: string]: string | number; + }; + credentials?: { username: string; password: string }; + /** + * Indicates how to fetch feature geometry from the service. + * - "raw": The entire dataset will be downloaded and rendered client-side. This + * may be slow for large datasets but is the most efficient option for smaller ones. + * - "quantized": Simplified vectors for only the current viewport will be + * fetched and updated when the map extent changes. + * - "auto": Will attempt the "raw" strategy up to a byte limit (default 2MB) and + * fall back to "quantized" if the limit is exceeded. + * @default "auto" + */ + fetchStrategy?: "auto" | "raw" | "quantized"; + /** + * If fetchStrategy is "auto", this is the byte limit before switching from + * "raw" to "quantized" mode. Default 2MB. + * @default 2_000_000 + */ + autoFetchByteLimit?: number; +} + +export default class ArcGISFeatureLayerSource + implements CustomGLSource +{ + /** Source id used in the map style */ + sourceId: string; + layerId: number; + options: ArcGISFeatureLayerSourceOptions; + private map?: Map; + private requestManager: ArcGISRESTServiceRequestManager; + private serviceMetadata?: FeatureServerMetadata | MapServiceMetadata; + private layerMetadata?: LayersMetadata; + private _loading = true; + private featureData?: FeatureCollection; + private rawFeaturesHaveBeenFetched = false; + private exceededBytesLimit = false; + + constructor( + requestManager: ArcGISRESTServiceRequestManager, + options: ArcGISFeatureLayerSourceOptions + ) { + this.sourceId = options.sourceId || uuid(); + this.options = options; + this.requestManager = requestManager; + // remove trailing slash if present + options.url = options.url.replace(/\/$/, ""); + if ( + !/rest\/services/.test(options.url) || + (!/MapServer/.test(options.url) && !/FeatureServer/.test(options.url)) + ) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/\d+$/.test(options.url)) { + throw new Error( + "URL must end in /FeatureServer/{layerId} or /MapServer/{layerId}" + ); + } + this.layerId = parseInt(options.url.match(/\d+$/)?.[0] || "0"); + } + + async getComputedMetadata(): Promise { + const { serviceMetadata, layers } = await this.getMetadata(); + const { bounds, minzoom, maxzoom, attribution } = + await this.getComputedProperties(); + const layer = layers.layers.find((l) => l.id === this.layerId); + const glStyle = await this.getGLStyleLayers(); + if (!layer) { + throw new Error("Layer not found"); + } + return { + bounds, + minzoom, + maxzoom, + attribution, + supportsDynamicRendering: { + layerOpacity: false, + layerVisibility: false, + layerOrder: false, + }, + tableOfContentsItems: [ + { + type: "data", + defaultVisibility: true, + id: this.sourceId, + label: layer.name!, + metadata: generateMetadataForLayer( + this.options.url.replace(/\/\d+$/, ""), + serviceMetadata, + layer + ), + glStyle: glStyle, + }, + ], + }; + } + + /** + * 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 attribution = + contentOrFalse(serviceMetadata.copyrightText) || undefined; + const layer = layers.layers.find((l) => l.id === this.layerId); + if (!layer) { + throw new Error(`Sublayer ${this.layerId} not found`); + } + return { + minzoom: 0, + maxzoom: 24, + bounds: + (await extentToLatLngBounds( + layer?.extent || serviceMetadata.fullExtent + )) || undefined, + attribution, + }; + } + + private fireError(e: Error) { + this.map?.fire("error", { + sourceId: this.sourceId, + error: e.message, + }); + } + + /** + * 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 { + if (/FeatureServer/.test(this.options.url)) { + return this.requestManager + .getFeatureServerMetadata(this.options.url.replace(/\/\d+$/, ""), { + credentials: this.options.credentials, + }) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } else { + return this.requestManager + .getMapServiceMetadata(this.options.url, { + credentials: this.options.credentials, + }) + .then(({ serviceMetadata, layers }) => { + this.serviceMetadata = serviceMetadata; + this.layerMetadata = layers; + return { serviceMetadata, layers }; + }); + } + } + } + + get loading() { + return this._loading; + } + + private _glStylePromise?: Promise<{ layers: Layer[]; imageList: ImageList }>; + async getGLStyleLayers() { + if (this._glStylePromise) { + console.log("return promise"); + return this._glStylePromise; + } else { + console.log("make promise"); + this._glStylePromise = new Promise(async (resolve, reject) => { + const { serviceMetadata, layers } = await this.getMetadata(); + const layer = layers.layers.find((l) => l.id === this.layerId); + if (!layer) { + throw new Error("Layer not found"); + } + resolve( + styleForFeatureLayer( + this.options.url.replace(/\/\d+$/, ""), + this.layerId, + this.sourceId, + layer + ) + ); + }); + return this._glStylePromise; + } + } + + async addToMap(map: Map) { + this.map = map; + const { serviceMetadata, layers } = await this.getMetadata(); + const { attribution } = await this.getComputedProperties(); + map.addSource(this.sourceId, { + type: "geojson", + data: this.featureData || { + type: "FeatureCollection", + features: [], + }, + attribution: attribution ? attribution : "", + }); + this._loading = this.featureData ? false : true; + if (!this.rawFeaturesHaveBeenFetched) { + this.fetchFeatures(); + } + return this.sourceId; + } + + private async fetchFeatures() { + if (this.exceededBytesLimit) { + return; + } + try { + const data = await fetchFeatureCollection( + this.options.url, + 6, + "*", + this.options.fetchStrategy === "raw" + ? 120_000_000 + : this.options.autoFetchByteLimit || 2_000_000 + ); + this.featureData = data; + const source = this.map?.getSource(this.sourceId); + if (source && source.type === "geojson") { + source.setData(data); + } + this._loading = false; + this.rawFeaturesHaveBeenFetched = true; + } catch (e) { + if ("message" in (e as any) && /bytesLimit/.test((e as any).message)) { + this.exceededBytesLimit = true; + } + this.fireError(e as Error); + console.error(e); + this._loading = false; + } + } + + async updateLayers() { + // throw new Error("Method not implemented."); + } + + async removeFromMap(map: Map) { + if (this.map) { + const source = map.getSource(this.sourceId); + if (source) { + 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; + } + } + + destroy() { + if (this.map) { + this.removeFromMap(this.map); + } + } +} diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts b/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts index 976891537..8e5c49b7c 100644 --- a/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts +++ b/packages/mapbox-gl-esri-sources/src/ArcGISRESTServiceRequestManager.ts @@ -1,4 +1,5 @@ import { + FeatureServerMetadata, LayerLegendData, LayersMetadata, MapServiceMetadata, @@ -70,6 +71,45 @@ export class ArcGISRESTServiceRequestManager { return { serviceMetadata, layers }; } + async getFeatureServerMetadata(url: string, options: FetchOptions) { + // remove trailing slash if present + url = url.replace(/\/$/, ""); + if (!/rest\/services/.test(url)) { + throw new Error("Invalid ArcGIS REST Service URL"); + } + if (!/FeatureServer/.test(url)) { + throw new Error("Invalid FeatureServer URL"); + } + // make sure the url does not include a feature layer id + if (/\d+$/.test(url)) { + throw new Error("Invalid FeatureServer URL"); + } + // remove url params if present + url = url.replace(/\?.*$/, ""); + const params = new URLSearchParams(); + params.set("f", "json"); + if (options?.credentials) { + const token = await this.getToken( + url.replace(/rest\/services\/.*/, "/rest/services/"), + options.credentials + ); + params.set("token", token); + } + + const requestUrl = `${url}?${params.toString()}`; + const serviceMetadata = await this.fetch( + requestUrl, + options?.signal + ); + 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 getCatalogItems(url: string, options?: FetchOptions) { if (!/rest\/services/.test(url)) { throw new Error("Invalid ArcGIS REST Service URL"); @@ -148,8 +188,8 @@ export class ArcGISRESTServiceRequestManager { if (!/rest\/services/.test(url)) { throw new Error("Invalid ArcGIS REST Service URL"); } - if (!/MapServer/.test(url)) { - throw new Error("Invalid MapServer URL"); + if (!/MapServer/.test(url) && !/FeatureServer/.test(url)) { + throw new Error("Invalid MapServer or FeatureServer URL"); } // remove trailing slash if present url = url.replace(/\/$/, ""); @@ -200,6 +240,7 @@ async function fetchWithTTL( Promise.reject("aborted"); } let cachedResponse = await cache.match(request); + if (cachedResponse && cachedResponseIsExpired(cachedResponse)) { cache.delete(request); cachedResponse = undefined; diff --git a/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts b/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts index a76108b20..333c4793b 100644 --- a/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts +++ b/packages/mapbox-gl-esri-sources/src/ArcGISTiledMapService.ts @@ -202,16 +202,18 @@ export class ArcGISTiledMapService * @returns RasterLayer[] */ async getGLStyleLayers() { - return [ - { - type: "raster", - source: this.sourceId, - id: uuid(), - paint: { - "raster-fade-duration": 300, + return { + layers: [ + { + type: "raster", + source: this.sourceId, + id: uuid(), + paint: { + "raster-fade-duration": 300, + }, }, - }, - ] as RasterLayer[]; + ] as RasterLayer[], + }; } /** diff --git a/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts b/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts index 54b8934ce..07b55faf2 100644 --- a/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts +++ b/packages/mapbox-gl-esri-sources/src/CustomGLSource.ts @@ -1,4 +1,5 @@ -import { AnyLayer, Map } from "mapbox-gl"; +import { AnyLayer, Layer, Map } from "mapbox-gl"; +import { ImageList } from "./ImageList"; export interface CustomGLSourceOptions { /** Optional. If not provided a uuid will be used. */ @@ -50,6 +51,7 @@ export interface DataTableOfContentsItem { content: ({ type: string } & any)[]; }; legend?: LegendItem[]; + glStyle?: { layers: Layer[]; imageList?: ImageList }; parentId?: string; } export interface ComputedMetadata { @@ -110,7 +112,7 @@ export interface CustomGLSource< * options are changed. * */ getLegend?(): Promise; - getGLStyleLayers(): Promise; + getGLStyleLayers(): Promise<{ layers: Layer[]; imageList?: ImageList }>; getComputedMetadata(): Promise; updateLayers(layers: OrderedLayerSettings): void; } diff --git a/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts b/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts index 9dd0b73c2..44193c898 100644 --- a/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts +++ b/packages/mapbox-gl-esri-sources/src/ServiceMetadata.d.ts @@ -68,6 +68,24 @@ export interface MapServiceMetadata { }; } +export interface FeatureServerMetadata { + /** ArcGIS Server REST API version */ + currentVersion: number; + serviceDescription: string; + maxRecordCount: number; + supportedQueryFormats: string; + supportedExportFormats: string; + capabilities: string; + description: string; + copyrightText: string; + spatialReference: SpatialReference; + initialExtent: Extent; + fullExtent: Extent; + size?: number; + layers: SimpleLayerInfo[]; + documentInfo: undefined; +} + export interface LayersMetadata { layers: DetailedLayerMetadata[]; } diff --git a/packages/mapbox-gl-esri-sources/src/fetchData.ts b/packages/mapbox-gl-esri-sources/src/fetchData.ts new file mode 100644 index 000000000..68b690ff8 --- /dev/null +++ b/packages/mapbox-gl-esri-sources/src/fetchData.ts @@ -0,0 +1,193 @@ +import { FeatureCollection } from "geojson"; + +export function fetchFeatureCollection( + url: string, + geometryPrecision = 6, + outFields = "*", + bytesLimit = 1000000 * 100 +) { + return new Promise((resolve, reject) => { + fetchFeatureLayerData( + url, + outFields, + reject, + geometryPrecision, + null, + null, + undefined, + undefined, + bytesLimit + ) + .then((data) => resolve(data)) + .catch((e) => reject(e)); + }); +} + +export async function fetchFeatureLayerData( + url: string, + outFields: string, + onError: (error: Error) => void, + geometryPrecision = 6, + abortController: AbortController | null = null, + onPageReceived: + | (( + bytes: number, + loadedFeatures: number, + estimatedFeatures: number + ) => void) + | null = null, + disablePagination = false, + pageSize = 1000, + bytesLimit?: number +) { + const featureCollection: FeatureCollection = { + type: "FeatureCollection", + features: [], + }; + const params = new URLSearchParams({ + inSR: "4326", + outSR: "4326", + where: "1>0", + outFields, + returnGeometry: "true", + geometryPrecision: geometryPrecision.toString(), + returnIdsOnly: "false", + f: "geojson", + }); + await fetchData( + url, + params, + featureCollection, + onError, + abortController, + onPageReceived, + disablePagination, + pageSize, + bytesLimit + ); + return featureCollection; +} + +async function fetchData( + baseUrl: string, + params: URLSearchParams, + featureCollection: FeatureCollection, + onError: (error: Error) => void, + abortController: AbortController | null, + onPageReceived: + | (( + bytes: number, + loadedFeatures: number, + estimatedFeatures: number + ) => void) + | null, + disablePagination = false, + pageSize = 1000, + bytesLimit?: number, + bytesReceived?: number, + objectIdFieldName?: string, + expectedFeatureCount?: number +) { + bytesReceived = bytesReceived || 0; + const decoder = new TextDecoder("utf-8"); + params.set("returnIdsOnly", "false"); + if (featureCollection.features.length > 0) { + // fetch next page using objectIds + let featureIds: number[]; + params.delete("where"); + params.delete("resultOffset"); + params.delete("resultRecordCount"); + params.set("orderByFields", objectIdFieldName!); + const lastFeature = + featureCollection.features[featureCollection.features.length - 1]; + params.set("where", `${objectIdFieldName}>${lastFeature.id}`); + } + const response = await fetch(`${baseUrl}/query?${params.toString()}`, { + // mode: "cors", + ...(abortController ? { signal: abortController.signal } : {}), + }); + const str = await response.text(); + bytesReceived += byteLength(str); + if (bytesLimit && bytesReceived > bytesLimit) { + const e = new Error( + `Exceeded bytesLimit. ${bytesReceived} > ${bytesLimit}` + ); + return onError(e); + } + + const fc = JSON.parse(str) as FeatureCollection & { + error?: { + code: number; + message: string; + }; + exceededTransferLimit?: boolean; + /* used in (arcgis-online only?) feature servers */ + properties?: { + exceededTransferLimit?: boolean; + }; + }; + if (fc.error) { + return onError(new Error(fc.error.message)); + } else { + featureCollection.features.push(...fc.features); + if (fc.exceededTransferLimit || fc.properties?.exceededTransferLimit) { + if (!objectIdFieldName) { + // Fetch objectIds to do manual paging + params.set("returnIdsOnly", "true"); + try { + const r = await fetch(`${baseUrl}/query?${params.toString()}`, { + // mode: "cors", + ...(abortController ? { signal: abortController.signal } : {}), + }); + const featureIds = featureCollection.features.map((f) => f.id); + let objectIdParameters = await r.json(); + // FeatureServers (at least on ArcGIS Online) behave differently + if (objectIdParameters.properties) { + objectIdParameters = objectIdParameters.properties; + } + expectedFeatureCount = objectIdParameters.objectIds.length; + objectIdFieldName = objectIdParameters.objectIdFieldName; + } catch (e) { + return onError(e as Error); + } + } + + if (onPageReceived) { + onPageReceived( + bytesReceived, + featureCollection.features.length, + expectedFeatureCount! + ); + } + + await fetchData( + baseUrl, + params, + featureCollection, + onError, + abortController, + onPageReceived, + disablePagination, + pageSize, + bytesLimit, + bytesReceived, + objectIdFieldName, + expectedFeatureCount + ); + } + } + return bytesReceived; +} + +// https://stackoverflow.com/a/23329386/299467 +function byteLength(str: string) { + // returns the byte length of an utf8 string + var s = str.length; + for (var i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s += 2; + if (code >= 0xdc00 && code <= 0xdfff) i--; //trail surrogate + } + return s; +} diff --git a/packages/mapbox-gl-esri-sources/src/styleForFeatureLayer.ts b/packages/mapbox-gl-esri-sources/src/styleForFeatureLayer.ts index 6622271da..b5776294b 100644 --- a/packages/mapbox-gl-esri-sources/src/styleForFeatureLayer.ts +++ b/packages/mapbox-gl-esri-sources/src/styleForFeatureLayer.ts @@ -209,7 +209,7 @@ async function styleForFeatureLayer( (b) => { const values = [b.classMinValue || minValue, b.classMaxValue] as [ number, - number, + number ]; minValue = values[1]; return values; diff --git a/packages/mapbox-gl-esri-sources/src/utils.ts b/packages/mapbox-gl-esri-sources/src/utils.ts index 9476474f3..289a1df37 100644 --- a/packages/mapbox-gl-esri-sources/src/utils.ts +++ b/packages/mapbox-gl-esri-sources/src/utils.ts @@ -1,7 +1,8 @@ -import { AnySourceData, Map } from "mapbox-gl"; +import { AnySourceData, Layer, Map } from "mapbox-gl"; import { DetailedLayerMetadata, Extent, + FeatureServerMetadata, MapServiceMetadata, } from "./ServiceMetadata"; import { SpatialReference } from "arcgis-rest-api"; @@ -134,7 +135,7 @@ export function contentOrFalse(str?: string) { } function pickDescription( - info: MapServiceMetadata, + info: MapServiceMetadata | FeatureServerMetadata, layer?: DetailedLayerMetadata ) { return ( @@ -155,7 +156,7 @@ function pickDescription( */ export function generateMetadataForLayer( url: string, - mapServerInfo: MapServiceMetadata, + mapServerInfo: MapServiceMetadata | FeatureServerMetadata, layer: DetailedLayerMetadata ) { const attribution = diff --git a/packages/spatial-uploads-handler/src/index.ts b/packages/spatial-uploads-handler/src/index.ts index a8c9d6eeb..0da60bddc 100644 --- a/packages/spatial-uploads-handler/src/index.ts +++ b/packages/spatial-uploads-handler/src/index.ts @@ -170,6 +170,10 @@ export default async function handleUpload( let workingFilePath = `${path.join(tmpobj.name, objectKey.split("/")[1])}`; let originalFilePath = workingFilePath; await updateProgress("fetching", 0.0); + console.log( + workingFilePath, + `s3://${path.join(process.env.BUCKET!, objectKey)}` + ); await getObject( workingFilePath, `s3://${path.join(process.env.BUCKET!, objectKey)}`,