Skip to content

Commit e026e51

Browse files
committed
fix(plugins): make Earthdata GIS layers render, and add web maps
Many services looked broken because ArcGIS mosaics carry a MaxPS visibility limit: fitting a layer's extent asks for a coarser pixel size than the mosaic draws at, so the service returns a transparent PNG. The plugin now reads that limit, sets a matching minzoom, and zooms to where data exists. Raster layers were also typed xyz while carrying a {bbox-epsg-3857} template, so Raster Subset handed the unsubstituted placeholder to an XYZ fetcher. They are typed wms now, like every other web service plugin. Web Maps are the portal's most numerous item type and were filtered out, so search results did not match the portal's own gallery. They are now included and expand into their layers, grouped under the web map's name.
1 parent a97589c commit e026e51

5 files changed

Lines changed: 768 additions & 32 deletions

File tree

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,18 +294,28 @@ export function TopToolbar({
294294
kindImage: t("earthdataGis.kindImage"),
295295
kindMap: t("earthdataGis.kindMap"),
296296
kindFeature: t("earthdataGis.kindFeature"),
297+
kindWebMap: t("earthdataGis.kindWebMap"),
298+
filterWebMap: t("earthdataGis.filterWebMap"),
299+
webMapAdded: (added, total) =>
300+
added === total
301+
? t("earthdataGis.webMapAdded", { count: added })
302+
: t("earthdataGis.webMapAddedPartial", { added, total }),
303+
webMapEmpty: t("earthdataGis.webMapEmpty"),
297304
add: t("earthdataGis.add"),
298305
adding: t("earthdataGis.adding"),
299306
remove: t("earthdataGis.remove"),
300307
zoom: t("earthdataGis.zoom"),
301308
details: t("earthdataGis.details"),
309+
portal: t("earthdataGis.portal"),
310+
portalTitle: t("earthdataGis.portalTitle"),
302311
addTitle: t("earthdataGis.addTitle"),
303312
removeTitle: t("earthdataGis.removeTitle"),
304313
zoomTitle: t("earthdataGis.zoomTitle"),
305314
zoomUnavailableTitle: t("earthdataGis.zoomUnavailableTitle"),
306315
detailsTitle: t("earthdataGis.detailsTitle"),
307316
addError: (message) => t("earthdataGis.addError", { message }),
308317
addTimeout: t("earthdataGis.addTimeout"),
318+
zoomedToData: t("earthdataGis.zoomedToData"),
309319
detailsHeading: t("earthdataGis.detailsHeading"),
310320
close: t("earthdataGis.close"),
311321
metaTitle: t("earthdataGis.metaTitle"),

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2719,18 +2719,27 @@
27192719
"kindImage": "Image service",
27202720
"kindMap": "Map service",
27212721
"kindFeature": "Feature service",
2722+
"kindWebMap": "Web map",
2723+
"filterWebMap": "Web maps",
2724+
"webMapAdded_one": "Added {{count}} layer from this web map.",
2725+
"webMapAdded_other": "Added {{count}} layers from this web map.",
2726+
"webMapAddedPartial": "Added {{added}} of {{total}} layers from this web map; the rest could not be reached.",
2727+
"webMapEmpty": "this web map has no layers GeoLibre can render.",
27222728
"add": "Add",
27232729
"adding": "Adding…",
27242730
"remove": "Remove",
27252731
"zoom": "Zoom",
27262732
"details": "Details",
2733+
"portal": "Portal",
2734+
"portalTitle": "Open this item on the Earthdata GIS portal",
27272735
"addTitle": "Add this service to the map",
27282736
"removeTitle": "Remove this service from the map",
27292737
"zoomTitle": "Zoom to this service",
27302738
"zoomUnavailableTitle": "This service does not publish an extent",
27312739
"detailsTitle": "View this service's metadata",
27322740
"addError": "Could not add the service: {{message}}",
27332741
"addTimeout": "it did not respond within a minute. The layer will still appear if it finishes.",
2742+
"zoomedToData": "Zoomed in past this layer's full extent: the service only renders at higher zoom levels.",
27342743
"detailsHeading": "Service details",
27352744
"close": "Close",
27362745
"metaTitle": "Title",

packages/plugins/src/plugins/earthdata-gis-api.ts

Lines changed: 267 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
3939
export 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. */
4550
const 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. */
5258
export 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
*/
290297
export 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 = /^(.*\/MapServer)\/(\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

Comments
 (0)