@@ -38,21 +38,28 @@ export const EARTHDATA_GIS_TILE_SIZE = 256;
3838/** Default page size for a catalog search. The portal caps `num` at 100. */
3939export const EARTHDATA_GIS_PAGE_SIZE = 20 ;
4040
41- /** The servable ArcGIS service flavors this catalog exposes. */
42- export type EarthdataServiceKind = "image" | "map" | "feature" ;
41+ /**
42+ * The item flavors this catalog exposes. The first three are ArcGIS services
43+ * that render directly; `webmap` is an Esri Web Map, a saved composition that
44+ * carries no renderable URL of its own and is expanded into its constituent
45+ * layers on add (see {@link fetchWebMapLayers}).
46+ */
47+ export type EarthdataServiceKind = "image" | "map" | "feature" | "webmap" ;
4348
44- /** The portal `type` string for each servable kind. */
49+ /** The portal `type` string for each kind. */
4550const PORTAL_TYPE_BY_KIND : Record < EarthdataServiceKind , string > = {
4651 image : "Image Service" ,
4752 map : "Map Service" ,
4853 feature : "Feature Service" ,
54+ webmap : "Web Map" ,
4955} ;
5056
51- /** Every servable kind, in the order the panel offers them. */
57+ /** Every kind, in the order the panel offers them. */
5258export const EARTHDATA_SERVICE_KINDS : readonly EarthdataServiceKind [ ] = [
5359 "image" ,
5460 "map" ,
5561 "feature" ,
62+ "webmap" ,
5663] as const ;
5764
5865/** One Earthdata GIS catalog item, normalized from a portal search result. */
@@ -288,10 +295,15 @@ export function buildItemPageUrl(itemId: string): string {
288295 * @returns A raster tile template, or null for a non-raster item
289296 */
290297export function buildExportTileUrl ( item : EarthdataGisItem ) : string | null {
291- if ( item . kind === "feature" ) return null ;
298+ if ( item . kind === "feature" || item . kind === "webmap" ) return null ;
292299 if ( ! HTTP_URL_RE . test ( item . url ) ) return null ;
293300 const operation = item . kind === "image" ? "exportImage" : "export" ;
294301 const size = `${ EARTHDATA_GIS_TILE_SIZE } ,${ EARTHDATA_GIS_TILE_SIZE } ` ;
302+ // A web map can reference a single MapServer sublayer (`…/MapServer/3`).
303+ // `export` lives on the service, not the sublayer, so the index moves into a
304+ // `layers=show:` filter instead of being appended to the operation path.
305+ const sublayer = / ^ ( .* \/ M a p S e r v e r ) \/ ( \d + ) $ / . exec ( trimTrailingSlash ( item . url ) ) ;
306+ const base = sublayer ? sublayer [ 1 ] : trimTrailingSlash ( item . url ) ;
295307 const query = [
296308 "bbox={bbox-epsg-3857}" ,
297309 "bboxSR=3857" ,
@@ -300,9 +312,253 @@ export function buildExportTileUrl(item: EarthdataGisItem): string | null {
300312 "format=png32" ,
301313 "transparent=true" ,
302314 "dpi=96" ,
315+ ...( sublayer ? [ `layers=show:${ sublayer [ 2 ] } ` ] : [ ] ) ,
303316 "f=image" ,
304317 ] . join ( "&" ) ;
305- return `${ trimTrailingSlash ( item . url ) } /${ operation } ?${ query } ` ;
318+ return `${ base } /${ operation } ?${ query } ` ;
319+ }
320+
321+ /**
322+ * Builds the URL of a Web Map item's data document, which holds its
323+ * `operationalLayers`.
324+ *
325+ * @param itemId - Portal item id
326+ * @param endpoint - Sharing REST base URL
327+ * @returns The item `/data` URL
328+ */
329+ export function buildWebMapDataUrl (
330+ itemId : string ,
331+ endpoint : string = EARTHDATA_GIS_SHARING_URL ,
332+ ) : string {
333+ return `${ trimTrailingSlash ( endpoint ) } /content/items/${ encodeURIComponent ( itemId ) } /data?f=json` ;
334+ }
335+
336+ /** Esri `layerType` values this plugin knows how to render, mapped to a kind. */
337+ const WEB_MAP_LAYER_KINDS : Record < string , EarthdataServiceKind > = {
338+ ArcGISImageServiceLayer : "image" ,
339+ ArcGISMapServiceLayer : "map" ,
340+ ArcGISTiledMapServiceLayer : "map" ,
341+ ArcGISFeatureLayer : "feature" ,
342+ } ;
343+
344+ /** One renderable layer pulled out of a Web Map's composition. */
345+ export interface WebMapLayer {
346+ /** The layer's title within the web map. */
347+ title : string ;
348+ /** Absolute service URL. */
349+ url : string ;
350+ /** How the layer should be rendered. */
351+ kind : EarthdataServiceKind ;
352+ }
353+
354+ /**
355+ * Flattens a Web Map's `operationalLayers` into the layers this plugin can
356+ * render.
357+ *
358+ * Group layers nest arbitrarily deep and carry no URL of their own, so they are
359+ * walked rather than emitted. Layer types with no MapLibre equivalent (and any
360+ * entry missing an http(s) URL) are skipped, so a web map contributes only the
361+ * layers that will actually draw.
362+ *
363+ * @param body - Parsed JSON body from {@link buildWebMapDataUrl}
364+ * @returns The renderable layers, in the web map's own order
365+ */
366+ export function parseWebMapLayers ( body : unknown ) : WebMapLayer [ ] {
367+ const out : WebMapLayer [ ] = [ ] ;
368+ const seen = new Set < unknown > ( ) ;
369+
370+ const walk = ( entries : unknown , depth : number ) : void => {
371+ // Depth-guard a self-referencing group so a malformed document cannot spin.
372+ if ( ! Array . isArray ( entries ) || depth > 10 ) return ;
373+ for ( const entry of entries ) {
374+ if ( ! entry || typeof entry !== "object" || seen . has ( entry ) ) continue ;
375+ seen . add ( entry ) ;
376+ const layer = entry as Record < string , unknown > ;
377+ const layerType = asText ( layer . layerType ) ;
378+ if ( layerType === "GroupLayer" ) {
379+ walk ( layer . layers , depth + 1 ) ;
380+ continue ;
381+ }
382+ const kind = WEB_MAP_LAYER_KINDS [ layerType ] ;
383+ const url = asText ( layer . url ) . trim ( ) ;
384+ if ( ! kind || ! HTTP_URL_RE . test ( url ) ) continue ;
385+ out . push ( { title : asText ( layer . title ) . trim ( ) || url , url, kind } ) ;
386+ }
387+ } ;
388+
389+ const parsed = ( body ?? { } ) as { operationalLayers ?: unknown } ;
390+ walk ( parsed . operationalLayers , 0 ) ;
391+ return out ;
392+ }
393+
394+ /**
395+ * Reads the renderable layers out of a Web Map item.
396+ *
397+ * @param item - A `webmap` catalog item
398+ * @param fetchImpl - Fetch-like function (defaults to the global `fetch`)
399+ * @param signal - Aborts the request
400+ * @param endpoint - Sharing REST base URL
401+ * @returns The web map's renderable layers
402+ * @throws When the item's data document cannot be read
403+ */
404+ export async function fetchWebMapLayers (
405+ item : EarthdataGisItem ,
406+ fetchImpl : EarthdataGisFetch = defaultFetch ,
407+ signal ?: AbortSignal ,
408+ endpoint : string = EARTHDATA_GIS_SHARING_URL ,
409+ ) : Promise < WebMapLayer [ ] > {
410+ const response = await fetchImpl ( buildWebMapDataUrl ( item . id , endpoint ) , signal ) ;
411+ if ( ! response . ok ) {
412+ throw new Error ( `Earthdata GIS web map request failed (${ response . status } )` ) ;
413+ }
414+ return parseWebMapLayers ( await response . json ( ) ) ;
415+ }
416+
417+ /**
418+ * Projects one of a Web Map's layers into a standalone catalog item, so the
419+ * add path treats it exactly like a service found by search.
420+ *
421+ * The parent's extent is inherited because a web map layer carries none of its
422+ * own, and the parent id is folded into the child id to keep it unique.
423+ *
424+ * @param parent - The Web Map item the layer came from
425+ * @param layer - One renderable layer from {@link parseWebMapLayers}
426+ * @param index - The layer's position, used to build a stable id
427+ * @returns A catalog item for the layer
428+ */
429+ export function webMapLayerAsItem (
430+ parent : EarthdataGisItem ,
431+ layer : WebMapLayer ,
432+ index : number ,
433+ ) : EarthdataGisItem {
434+ return {
435+ ...parent ,
436+ id : `${ parent . id } :${ index } ` ,
437+ title : layer . title ,
438+ kind : layer . kind ,
439+ url : layer . url ,
440+ thumbnailUrl : null ,
441+ raw : layer ,
442+ } ;
443+ }
444+
445+ /**
446+ * Ground resolution in metres per pixel at the equator for zoom 0 with 256px
447+ * tiles — the constant behind every web-mercator zoom/resolution conversion.
448+ */
449+ const EQUATOR_METRES_PER_PIXEL_Z0 = 156543.03392804097 ;
450+
451+ /** Spatial-reference well-known ids whose units are metres. */
452+ const METRE_BASED_WKIDS = new Set ( [ 3857 , 102100 , 102113 ] ) ;
453+
454+ /**
455+ * Builds the catalog statistics query that reports the coarsest pixel size at
456+ * which an ImageServer's mosaic still draws.
457+ *
458+ * A mosaic dataset row carries `MaxPS` — the largest pixel size at which that
459+ * raster participates. Requesting an image coarser than every row's `MaxPS`
460+ * returns a fully transparent PNG rather than an error, which is why so many of
461+ * this portal's high-resolution disaster services look "broken" when first
462+ * added: the layer is fine, the view is simply too far out.
463+ *
464+ * @param serviceUrl - The `…/ImageServer` URL
465+ * @returns The `/query` URL returning `MAX(MaxPS)`
466+ */
467+ export function buildMaxPixelSizeUrl ( serviceUrl : string ) : string {
468+ const statistics = JSON . stringify ( [
469+ { statisticType : "max" , onStatisticField : "MaxPS" , outStatisticFieldName : "maxPixelSize" } ,
470+ ] ) ;
471+ const params = new URLSearchParams ( {
472+ f : "json" ,
473+ where : "1=1" ,
474+ outStatistics : statistics ,
475+ } ) ;
476+ return `${ trimTrailingSlash ( serviceUrl ) } /query?${ params . toString ( ) } ` ;
477+ }
478+
479+ /**
480+ * Reads `MAX(MaxPS)` out of a catalog statistics response.
481+ *
482+ * @param body - Parsed JSON body from {@link buildMaxPixelSizeUrl}
483+ * @returns The coarsest visible pixel size, or null when the service does not
484+ * report one (multidimensional CRF services have no such column)
485+ */
486+ export function parseMaxPixelSize ( body : unknown ) : number | null {
487+ const parsed = ( body ?? { } ) as { features ?: Array < { attributes ?: Record < string , unknown > } > } ;
488+ const attributes = parsed . features ?. [ 0 ] ?. attributes ;
489+ if ( ! attributes ) return null ;
490+ const value = attributes . maxPixelSize ?? attributes . MaxPixelSize ?? attributes . MAXPIXELSIZE ;
491+ return typeof value === "number" && Number . isFinite ( value ) && value > 0 ? value : null ;
492+ }
493+
494+ /**
495+ * Lowest web-mercator zoom whose ground resolution is fine enough for a mosaic
496+ * with this `MaxPS` to draw.
497+ *
498+ * @param maxPixelSize - Coarsest visible pixel size, in metres
499+ * @param latitude - Latitude the layer sits at (resolution is latitude-scaled)
500+ * @param tileSize - Raster source tile size in pixels
501+ * @returns The minimum zoom, clamped to [0, 24], or null when not computable
502+ */
503+ export function minZoomForPixelSize (
504+ maxPixelSize : number ,
505+ latitude : number ,
506+ tileSize : number = EARTHDATA_GIS_TILE_SIZE ,
507+ ) : number | null {
508+ if ( ! Number . isFinite ( maxPixelSize ) || maxPixelSize <= 0 ) return null ;
509+ if ( ! Number . isFinite ( latitude ) || Math . abs ( latitude ) > 85.05 ) return null ;
510+ if ( ! Number . isFinite ( tileSize ) || tileSize <= 0 ) return null ;
511+ const resolutionAtZoom0 =
512+ ( EQUATOR_METRES_PER_PIXEL_Z0 * Math . cos ( ( latitude * Math . PI ) / 180 ) * 256 ) / tileSize ;
513+ const zoom = Math . ceil ( Math . log2 ( resolutionAtZoom0 / maxPixelSize ) ) ;
514+ if ( ! Number . isFinite ( zoom ) ) return null ;
515+ return Math . min ( 24 , Math . max ( 0 , zoom ) ) ;
516+ }
517+
518+ /**
519+ * Best-effort lookup of the zoom below which an image service renders nothing.
520+ *
521+ * Returns null — meaning "impose no constraint" — whenever the answer would be
522+ * a guess: a non-image service, a service whose units are not metres (`MaxPS`
523+ * would then be in degrees and incomparable), a service that reports no
524+ * `MaxPS`, or any failed/slow request. Being wrong here would hide a layer that
525+ * actually draws, so every uncertain case falls back to the unconstrained
526+ * behavior.
527+ *
528+ * @param item - The catalog item being added
529+ * @param fetchImpl - Fetch-like function (defaults to the global `fetch`)
530+ * @param signal - Aborts the lookup
531+ * @returns The minimum zoom at which the service draws, or null
532+ */
533+ export async function fetchMinVisibleZoom (
534+ item : EarthdataGisItem ,
535+ fetchImpl : EarthdataGisFetch = defaultFetch ,
536+ signal ?: AbortSignal ,
537+ ) : Promise < number | null > {
538+ if ( item . kind !== "image" || ! item . bbox ) return null ;
539+ try {
540+ const metadataUrl = `${ trimTrailingSlash ( item . url ) } ?f=json` ;
541+ const metadataResponse = await fetchImpl ( metadataUrl , signal ) ;
542+ if ( ! metadataResponse . ok ) return null ;
543+ const metadata = ( await metadataResponse . json ( ) ) as {
544+ spatialReference ?: { latestWkid ?: number ; wkid ?: number } ;
545+ } ;
546+ const wkid = metadata . spatialReference ?. latestWkid ?? metadata . spatialReference ?. wkid ;
547+ // `MaxPS` is expressed in the mosaic's own units. Comparing a value in
548+ // degrees against a metres-per-pixel resolution would be meaningless, so
549+ // only metre-based services get a constraint.
550+ if ( wkid === undefined || ! METRE_BASED_WKIDS . has ( wkid ) ) return null ;
551+
552+ const statsResponse = await fetchImpl ( buildMaxPixelSizeUrl ( item . url ) , signal ) ;
553+ if ( ! statsResponse . ok ) return null ;
554+ const maxPixelSize = parseMaxPixelSize ( await statsResponse . json ( ) ) ;
555+ if ( maxPixelSize === null ) return null ;
556+
557+ const [ , south , , north ] = item . bbox ;
558+ return minZoomForPixelSize ( maxPixelSize , ( south + north ) / 2 ) ;
559+ } catch {
560+ return null ;
561+ }
306562}
307563
308564/**
@@ -354,9 +610,11 @@ export function normalizeItem(
354610 const id = asText ( record . id ) . trim ( ) ;
355611 const kind = kindFromPortalType ( record . type ) ;
356612 const url = asText ( record . url ) . trim ( ) ;
357- // Every servable item needs all three: an id (thumbnail/details), a known
358- // service kind, and an http(s) service URL to render or query.
359- if ( ! id || ! kind || ! HTTP_URL_RE . test ( url ) ) return null ;
613+ if ( ! id || ! kind ) return null ;
614+ // A service item is useless without an http(s) URL to render or query. A Web
615+ // Map legitimately has none (the portal stores its `url` as ""); its layers
616+ // are read from the item's data document by id instead.
617+ if ( kind !== "webmap" && ! HTTP_URL_RE . test ( url ) ) return null ;
360618
361619 return {
362620 id,
0 commit comments