From 3b63b1833f71dfe0a940851b1b396e809a75cc68 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 20:27:51 -0400 Subject: [PATCH 1/7] fix(stac): reach planetary catalogs and pick the COG engine STAC Index still advertises HTTP-only S3 website catalogs, which the web app blocks as mixed content, and the retired USGS Astrogeology catalog no longer holds its planetary buckets. Rewrite both to their supported HTTPS endpoints, derive the camera extent from item geometry when a record puts a projected bbox where lon/lat belongs, and let the panel choose its COG renderer so a globe view is not stuck with the Mercator-only GPU engine. --- .../src/components/layout/TopToolbar.tsx | 4 + apps/geolibre-desktop/src/hooks/usePlugins.ts | 8 +- packages/plugins/src/plugins/maplibre-stac.ts | 104 ++++++++++++++++-- packages/plugins/src/plugins/stac-api.ts | 90 ++++++++++++++- packages/plugins/src/types.ts | 4 + tests/stac-api.test.ts | 61 ++++++++++ 6 files changed, 255 insertions(+), 16 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 2f9d43a88d..a0a2478459 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -916,6 +916,10 @@ export function TopToolbar({ showingOfMatched: (count, matched) => t("stacPlugin.showingOfMatched", { count, matched }), loadMore: t("stacPlugin.loadMore"), renderOptions: t("stacPlugin.renderOptions"), + renderingEngine: t("huggingFace.engineHeading"), + engineGpu: t("huggingFace.engineGpu"), + engineWasm: t("huggingFace.engineWasm"), + engineTitiler: t("huggingFace.engineTitiler"), bands: t("stacPlugin.bands"), bandsPlaceholder: t("stacPlugin.bandsPlaceholder"), colormap: t("stacPlugin.colormap"), diff --git a/apps/geolibre-desktop/src/hooks/usePlugins.ts b/apps/geolibre-desktop/src/hooks/usePlugins.ts index 31f72f3148..324ecc7027 100644 --- a/apps/geolibre-desktop/src/hooks/usePlugins.ts +++ b/apps/geolibre-desktop/src/hooks/usePlugins.ts @@ -934,11 +934,9 @@ export function createAppAPI(mapControllerRef?: RefObject) : undefined; return addRasterToMap(api, url, { name, - // STAC assets are already COGs with an HTTP(S) range-readable URL. - // Render them directly through the GPU COG engine; the WASM tiler is - // intended for local files and can leave remote programmatic layers - // registered without producing pixels. - defaults: { engine: "maplibre-gl-raster" }, + // WASM is the globe-compatible default. Discovery plugins can opt into + // the GPU or TiTiler engine for a particular layer when appropriate. + defaults: { engine: options?.engine ?? "cog-tiler-wasm" }, state: { ...(bands?.length ? { bands, mode: bands.length >= 3 ? "rgb" : "single" } : {}), ...(options?.colormap !== undefined ? { colormap: options.colormap } : {}), diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index f66a71a8c1..942653fde9 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -2,7 +2,12 @@ import { DEFAULT_LAYER_STYLE, useAppStore } from "@geolibre/core"; import { fillLayerId, lineLayerId } from "@geolibre/map"; import type { FeatureCollection, Geometry } from "geojson"; import type { GeoJSONSource, MapMouseEvent, Map as MapLibreMap } from "maplibre-gl"; -import type { GeoLibreAppAPI, GeoLibreCogLayerOptions, GeoLibrePlugin } from "../types"; +import type { + GeoLibreAppAPI, + GeoLibreCogLayerOptions, + GeoLibreCogRenderEngine, + GeoLibrePlugin, +} from "../types"; import { addPMTilesAsset } from "./stac-layers"; import { assetDisplayFormat, @@ -43,6 +48,16 @@ const DRAW_LINE = "geolibre-stac-draw-bbox-line"; const SELECT_SOURCE = "geolibre-stac-selected"; const SELECT_FILL = "geolibre-stac-selected-fill"; const SELECT_LINE = "geolibre-stac-selected-line"; +const COG_ENGINE_STORAGE_KEY = "geolibre:stac-default-cog-engine"; +const COG_ENGINES = ["cog-tiler-wasm", "maplibre-gl-raster", "titiler"] as const; + +function savedCogEngine(): GeoLibreCogRenderEngine { + if (typeof localStorage === "undefined") return "cog-tiler-wasm"; + const saved = localStorage.getItem(COG_ENGINE_STORAGE_KEY); + return COG_ENGINES.includes(saved as (typeof COG_ENGINES)[number]) + ? (saved as GeoLibreCogRenderEngine) + : "cog-tiler-wasm"; +} /** * Colormaps the COG renderer knows by name (`ColormapName` in @@ -129,6 +144,10 @@ export interface StacLabels { searchFailed: string; loadMore: string; renderOptions: string; + renderingEngine: string; + engineGpu: string; + engineWasm: string; + engineTitiler: string; bands: string; bandsPlaceholder: string; colormap: string; @@ -206,6 +225,10 @@ let labels: StacLabels = { searchFailed: "STAC search failed", loadMore: "Load more", renderOptions: "Raster rendering options", + renderingEngine: "Default COG rendering engine", + engineGpu: "GPU (deck.gl; Mercator only)", + engineWasm: "WebAssembly tiler (globe compatible)", + engineTitiler: "TiTiler server", bands: "Bands", bandsPlaceholder: "e.g. 1 or 1,2,3 (default: auto)", colormap: "Colormap (single-band only)", @@ -274,11 +297,15 @@ const style = { "background:hsl(var(--primary));color:hsl(var(--primary-foreground));cursor:pointer;", status: "font-size:11px;line-height:1.4;color:hsl(var(--muted-foreground));", // The floor keeps a usable result list even with every filter section open; - // the controls above it scroll as a group rather than pushing it off-panel. + // its flex basis gives the search controls most of the panel initially. A + // splitter between the two lets the user choose a different balance. results: - "display:flex;flex:1 1 auto;min-height:150px;overflow:auto;flex-direction:column;gap:7px;", + "display:flex;flex:0 0 40%;min-height:150px;overflow:auto;flex-direction:column;gap:7px;", controls: - "display:flex;flex-direction:column;gap:10px;flex:0 1 auto;min-height:180px;overflow:auto;", + "display:flex;flex-direction:column;gap:10px;flex:1 1 60%;min-height:180px;overflow:auto;", + resultSplitter: + "height:8px;flex:0 0 8px;cursor:row-resize;border-radius:4px;touch-action:none;" + + "background:linear-gradient(transparent 3px,hsl(var(--border)) 3px,hsl(var(--border)) 5px,transparent 5px);", card: "display:flex;flex-direction:column;gap:5px;padding:8px;border:1px solid hsl(var(--border));" + "border-radius:7px;background:hsl(var(--muted));", @@ -661,9 +688,9 @@ function buildPanel(container: HTMLElement): () => void { catalogInfo.style.cssText = "font-weight:600;"; const collectionSelect = el("select"); collectionSelect.multiple = true; - collectionSelect.size = 3; + collectionSelect.size = 8; // Catalogs can advertise hundreds of collections, so let the list be dragged taller. - collectionSelect.style.cssText = `${style.input}resize:vertical;overflow:auto;min-height:58px;`; + collectionSelect.style.cssText = `${style.input}resize:vertical;overflow:auto;min-height:150px;`; collectionSelect.title = labels.collectionsHint; // An API answers with a flat list of collections; a static catalog is a tree read as it opens. const tree = buildCatalogTree({ @@ -735,6 +762,28 @@ function buildPanel(container: HTMLElement): () => void { renderSection.hidden = true; const renderSummary = el("summary", labels.renderOptions); renderSummary.style.cssText = "cursor:pointer;font-weight:600;"; + const engineWrap = el("label"); + engineWrap.style.cssText = "display:flex;flex-direction:column;gap:2px;"; + const engineCaption = el("span", labels.renderingEngine); + engineCaption.style.cssText = style.label; + const engineSelect = el("select"); + engineSelect.style.cssText = style.input; + for (const [value, title] of [ + ["cog-tiler-wasm", labels.engineWasm], + ["maplibre-gl-raster", labels.engineGpu], + ["titiler", labels.engineTitiler], + ] as const) { + const option = el("option", title); + option.value = value; + engineSelect.append(option); + } + engineSelect.value = savedCogEngine(); + engineSelect.addEventListener("change", () => { + if (typeof localStorage !== "undefined") { + localStorage.setItem(COG_ENGINE_STORAGE_KEY, engineSelect.value); + } + }); + engineWrap.append(engineCaption, engineSelect); const bandsField = field(labels.bands); bandsField.input.placeholder = labels.bandsPlaceholder; const colormapWrap = el("label"); @@ -763,6 +812,7 @@ function buildPanel(container: HTMLElement): () => void { renderHint.style.cssText = style.status; renderSection.append( renderSummary, + engineWrap, bandsField.wrap, colormapWrap, rescaleRow, @@ -774,6 +824,12 @@ function buildPanel(container: HTMLElement): () => void { status.style.cssText = style.status; const results = el("div"); results.style.cssText = style.results; + const resultSplitter = el("div"); + resultSplitter.style.cssText = style.resultSplitter; + resultSplitter.setAttribute("role", "separator"); + resultSplitter.setAttribute("aria-orientation", "horizontal"); + resultSplitter.setAttribute("aria-label", "Resize search results"); + resultSplitter.tabIndex = 0; const loadMore = el("button", labels.loadMore); loadMore.type = "button"; loadMore.style.cssText = style.primary; @@ -781,7 +837,40 @@ function buildPanel(container: HTMLElement): () => void { const controls = el("div"); controls.style.cssText = style.controls; controls.append(catalogSection, searchSection, renderSection); - container.append(controls, status, results, loadMore); + container.append(controls, status, resultSplitter, results, loadMore); + + const resizeResults = (height: number): void => { + const maximum = Math.max(150, container.getBoundingClientRect().height - 230); + const next = Math.min(maximum, Math.max(150, height)); + results.style.flexBasis = `${next}px`; + resultSplitter.setAttribute("aria-valuenow", String(Math.round(next))); + resultSplitter.setAttribute("aria-valuemin", "150"); + resultSplitter.setAttribute("aria-valuemax", String(Math.round(maximum))); + }; + + resultSplitter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + const startY = event.clientY; + const startHeight = results.getBoundingClientRect().height; + resultSplitter.setPointerCapture(event.pointerId); + const move = (moveEvent: PointerEvent): void => { + resizeResults(startHeight - (moveEvent.clientY - startY)); + }; + const stop = (): void => { + resultSplitter.removeEventListener("pointermove", move); + resultSplitter.removeEventListener("pointerup", stop); + resultSplitter.removeEventListener("pointercancel", stop); + }; + resultSplitter.addEventListener("pointermove", move); + resultSplitter.addEventListener("pointerup", stop); + resultSplitter.addEventListener("pointercancel", stop); + }); + resultSplitter.addEventListener("keydown", (event) => { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + const delta = event.key === "ArrowUp" ? 30 : -30; + resizeResults(results.getBoundingClientRect().height + delta); + }); let index: StacIndexCatalog[] = []; let filtered: StacIndexCatalog[] = []; @@ -814,6 +903,7 @@ function buildPanel(container: HTMLElement): () => void { const rescaleMax = numeric(vmaxField.input); const nodata = numeric(nodataField.input); return { + engine: engineSelect.value as GeoLibreCogRenderEngine, ...(bands ? { bands } : {}), ...(colormap ? { colormap } : {}), ...(rescaleMin !== undefined ? { rescaleMin } : {}), diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 28014a5356..745d3d6eb9 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -1,6 +1,7 @@ import type { BBox, Feature, Geometry } from "geojson"; export const STAC_INDEX_CATALOGS_URL = "https://stacindex.org/api/catalogs"; +const USGS_ASTROGEOLOGY_API_URL = "https://stac.astrogeology.usgs.gov/api"; // No item-search endpoint to ask, so a page is however much of the tree the walk covers. const STATIC_SEARCH_READS_PER_PAGE = 300; const STATIC_SEARCH_CONCURRENCY = 12; @@ -137,8 +138,30 @@ function httpUrl(value: unknown): value is string { } } +/** + * S3 website endpoints only support HTTP. Catalog indexes and older STAC documents still + * publish those URLs, which makes them mixed content in the web app. The equivalent virtual + * hosted-style S3 endpoint supports HTTPS and serves the same public object. + */ +function browserCatalogHref(href: string): string { + const url = new URL(href); + // STAC Index still advertises this 2022 static catalog. Its planetary child buckets have + // since been removed, while USGS publishes the same data through its supported STAC API. + if ( + url.hostname.toLowerCase() === "asc-stacbrowser.s3-website-us-west-2.amazonaws.com" && + url.pathname === "/catalog.json" + ) { + return USGS_ASTROGEOLOGY_API_URL; + } + const website = url.hostname.match(/^(.+)\.s3-website[.-]([a-z0-9-]+)\.amazonaws\.com$/i); + if (!website) return url.href; + url.protocol = "https:"; + url.hostname = `${website[1]}.s3.${website[2]}.amazonaws.com`; + return url.href; +} + function absoluteHref(href: string, base: string): string { - return new URL(href, base).href; + return browserCatalogHref(new URL(href, base).href); } /** @@ -186,7 +209,7 @@ export function isAzureBlobHref(href: string): boolean { } async function fetchJson(url: string, init: RequestInit, fetcher: FetchLike): Promise { - const response = await fetcher(url, { + const response = await fetcher(browserCatalogHref(url), { ...init, headers: { Accept: "application/geo+json, application/json", ...init.headers }, }); @@ -327,7 +350,7 @@ export async function connectStac( signal?: AbortSignal, ): Promise { if (!httpUrl(inputUrl)) throw new Error("Enter a valid HTTP or HTTPS STAC URL"); - const url = new URL(inputUrl).href; + const url = browserCatalogHref(inputUrl); const root = await fetchJson>(url, { signal }, fetcher); if (typeof root !== "object" || root === null) throw new Error("The URL did not return a STAC document"); @@ -586,7 +609,66 @@ export async function searchStaticStac( } export function itemBbox(item: StacItem): [number, number, number, number] | undefined { - return horizontalBbox(item.bbox); + const advertised = horizontalBbox(item.bbox); + if ( + advertised && + advertised[0] >= -180 && + advertised[0] <= 180 && + advertised[2] >= -180 && + advertised[2] <= 180 && + advertised[1] >= -90 && + advertised[1] <= 90 && + advertised[3] >= -90 && + advertised[3] <= 90 && + advertised[1] <= advertised[3] + ) { + return advertised; + } + + // Some planetary records (notably USGS Mars THEMIS mosaics) incorrectly put + // their projected metre extent in the STAC bbox while their required GeoJSON + // geometry is correctly expressed as lon/lat. Derive the camera extent from + // that geometry instead of handing MapLibre impossible million-degree values. + const positions: Array<[number, number]> = []; + const collect = (value: unknown): void => { + if (!Array.isArray(value)) return; + if ( + value.length >= 2 && + typeof value[0] === "number" && + Number.isFinite(value[0]) && + typeof value[1] === "number" && + Number.isFinite(value[1]) + ) { + positions.push([value[0], value[1]]); + return; + } + for (const child of value) collect(child); + }; + if (item.geometry?.type === "GeometryCollection") { + for (const geometry of item.geometry.geometries) + collect("coordinates" in geometry ? geometry.coordinates : []); + } else if (item.geometry && "coordinates" in item.geometry) { + collect(item.geometry.coordinates); + } + if (!positions.length) return advertised; + + const latitudes = positions.map(([, latitude]) => latitude); + if (latitudes.some((latitude) => latitude < -90 || latitude > 90)) return advertised; + const longitudes = positions + .map(([longitude]) => ((longitude % 360) + 360) % 360) + .sort((a, b) => a - b); + let gapIndex = longitudes.length - 1; + let largestGap = longitudes[0] + 360 - longitudes.at(-1)!; + for (let index = 0; index < longitudes.length - 1; index += 1) { + const gap = longitudes[index + 1] - longitudes[index]; + if (gap > largestGap) { + largestGap = gap; + gapIndex = index; + } + } + const start = longitudes[(gapIndex + 1) % longitudes.length]; + const west = start >= 180 ? start - 360 : start; + return [west, Math.min(...latitudes), west + (360 - largestGap), Math.max(...latitudes)]; } /** A format {@link assetFormat} recognizes, and {@link visualizeAsset} knows how to add. */ diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index 571044a0f0..d6c4792577 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -176,7 +176,11 @@ export interface GeoLibreOvertureQueryResult { * panel. All fields are optional; the renderer infers sensible defaults from * the GeoTIFF when they are omitted. */ +export type GeoLibreCogRenderEngine = "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler"; + export interface GeoLibreCogLayerOptions { + /** Renderer used for this COG. WASM is globe-compatible; the GPU renderer requires Mercator. */ + engine?: GeoLibreCogRenderEngine; /** Band selection, e.g. `"1"` (single band) or `"1,2,3"` (RGB). */ bands?: string; /** diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index da9c9e560a..cd2f103ff1 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -161,6 +161,44 @@ test("connectStac reads only the root of a static catalog", async () => { ); }); +test("connectStac redirects the retired USGS static catalog to its supported API", async () => { + const fetched: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + fetched.push(String(input)); + return jsonResponse({ + type: "Catalog", + id: "usgs_astrogeology_api", + links: [ + { + rel: "search", + href: "https://stac.astrogeology.usgs.gov/api/search", + }, + ], + }); + }) as typeof fetch; + + const connection = await connectStac( + "http://asc-stacbrowser.s3-website-us-west-2.amazonaws.com/catalog.json", + fetcher, + ); + + assert.equal(connection.url, "https://stac.astrogeology.usgs.gov/api"); + assert.deepEqual(fetched, ["https://stac.astrogeology.usgs.gov/api"]); + assert.equal(connection.isApi, true); +}); + +test("connectStac upgrades HTTP-only S3 website catalogs to their HTTPS endpoint", async () => { + const fetched: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + fetched.push(String(input)); + return jsonResponse({ type: "Catalog", id: "archive", links: [] }); + }) as typeof fetch; + + await connectStac("http://example.s3-website-us-west-2.amazonaws.com/catalog.json", fetcher); + + assert.deepEqual(fetched, ["https://example.s3.us-west-2.amazonaws.com/catalog.json"]); +}); + test("openCatalogNode reports what a node turned out to be and what is inside it", async () => { const fetcher = (async (input: RequestInfo | URL) => { if (String(input).endsWith("collection.json")) { @@ -1363,4 +1401,27 @@ test("asset and bbox helpers recognize common STAC data", () => { }), [1, 2, 3, 4], ); + assert.deepEqual( + itemBbox({ + type: "Feature", + id: "mars-themis", + // THEMIS publishes proj:bbox here even though STAC bbox must be lon/lat. + bbox: [7_112_945, -1_778_200, 10_669_445, -3_852_900], + geometry: { + type: "Polygon", + coordinates: [ + [ + [120, -30], + [180, -30], + [180, -65], + [120, -65], + [120, -30], + ], + ], + }, + properties: {}, + assets: {}, + }), + [120, -65, 180, -30], + ); }); From 7999ad440001cff8a0f3e9a4129d1dc9fce2046a Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 20:35:49 -0400 Subject: [PATCH 2/7] Address CodeRabbit review feedback - Wrap both localStorage reads and writes of the STAC COG engine choice in try/catch, since blocked Web Storage throws rather than returning null. - Send an S3 bucket whose name holds a dot through the path-style HTTPS endpoint: the virtual hosted-style certificate wildcard covers one label, so a dotted bucket would fail TLS validation. - Walk nested GeometryCollections when deriving an item extent from geometry, rather than reading only one level and falling back to a projected bbox. --- packages/plugins/src/plugins/maplibre-stac.ts | 24 +++++++-- packages/plugins/src/plugins/stac-api.ts | 30 +++++++---- tests/stac-api.test.ts | 50 +++++++++++++++++++ 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 942653fde9..b1d8b0d799 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -51,14 +51,30 @@ const SELECT_LINE = "geolibre-stac-selected-line"; const COG_ENGINE_STORAGE_KEY = "geolibre:stac-default-cog-engine"; const COG_ENGINES = ["cog-tiler-wasm", "maplibre-gl-raster", "titiler"] as const; +// Web Storage throws rather than returning null when the browser blocks it +// (private mode, a third-party-storage policy), so neither read nor write may +// be the only thing standing between the user and a working panel. function savedCogEngine(): GeoLibreCogRenderEngine { - if (typeof localStorage === "undefined") return "cog-tiler-wasm"; - const saved = localStorage.getItem(COG_ENGINE_STORAGE_KEY); + let saved: string | null = null; + try { + saved = + typeof localStorage === "undefined" ? null : localStorage.getItem(COG_ENGINE_STORAGE_KEY); + } catch { + return "cog-tiler-wasm"; + } return COG_ENGINES.includes(saved as (typeof COG_ENGINES)[number]) ? (saved as GeoLibreCogRenderEngine) : "cog-tiler-wasm"; } +function rememberCogEngine(engine: string): void { + try { + if (typeof localStorage !== "undefined") localStorage.setItem(COG_ENGINE_STORAGE_KEY, engine); + } catch { + // A blocked store just means the choice does not outlive the session. + } +} + /** * Colormaps the COG renderer knows by name (`ColormapName` in * `maplibre-gl-components`). This is a convenience list for the dropdown, not a @@ -779,9 +795,7 @@ function buildPanel(container: HTMLElement): () => void { } engineSelect.value = savedCogEngine(); engineSelect.addEventListener("change", () => { - if (typeof localStorage !== "undefined") { - localStorage.setItem(COG_ENGINE_STORAGE_KEY, engineSelect.value); - } + rememberCogEngine(engineSelect.value); }); engineWrap.append(engineCaption, engineSelect); const bandsField = field(labels.bands); diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 745d3d6eb9..fc1c8d6c71 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -140,8 +140,10 @@ function httpUrl(value: unknown): value is string { /** * S3 website endpoints only support HTTP. Catalog indexes and older STAC documents still - * publish those URLs, which makes them mixed content in the web app. The equivalent virtual - * hosted-style S3 endpoint supports HTTPS and serves the same public object. + * publish those URLs, which makes them mixed content in the web app. The equivalent REST + * S3 endpoint supports HTTPS and serves the same public object. A bucket whose name holds + * a dot has to go through the path-style endpoint: the wildcard on the virtual hosted-style + * certificate covers one label, so `a.b.s3..amazonaws.com` fails TLS validation. */ function browserCatalogHref(href: string): string { const url = new URL(href); @@ -155,8 +157,14 @@ function browserCatalogHref(href: string): string { } const website = url.hostname.match(/^(.+)\.s3-website[.-]([a-z0-9-]+)\.amazonaws\.com$/i); if (!website) return url.href; + const [, bucket, region] = website; url.protocol = "https:"; - url.hostname = `${website[1]}.s3.${website[2]}.amazonaws.com`; + if (bucket.includes(".")) { + url.hostname = `s3.${region}.amazonaws.com`; + url.pathname = `/${bucket}${url.pathname}`; + } else { + url.hostname = `${bucket}.s3.${region}.amazonaws.com`; + } return url.href; } @@ -644,12 +652,16 @@ export function itemBbox(item: StacItem): [number, number, number, number] | und } for (const child of value) collect(child); }; - if (item.geometry?.type === "GeometryCollection") { - for (const geometry of item.geometry.geometries) - collect("coordinates" in geometry ? geometry.coordinates : []); - } else if (item.geometry && "coordinates" in item.geometry) { - collect(item.geometry.coordinates); - } + // A GeometryCollection may hold another one, so walk rather than reading one level. + const collectGeometry = (geometry: Geometry | null | undefined): void => { + if (!geometry) return; + if (geometry.type === "GeometryCollection") { + for (const child of geometry.geometries) collectGeometry(child); + return; + } + collect(geometry.coordinates); + }; + collectGeometry(item.geometry); if (!positions.length) return advertised; const latitudes = positions.map(([, latitude]) => latitude); diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index cd2f103ff1..0b0dcc5dcb 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -199,6 +199,23 @@ test("connectStac upgrades HTTP-only S3 website catalogs to their HTTPS endpoint assert.deepEqual(fetched, ["https://example.s3.us-west-2.amazonaws.com/catalog.json"]); }); +test("connectStac reads a dotted S3 bucket through the path-style HTTPS endpoint", async () => { + const fetched: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + fetched.push(String(input)); + return jsonResponse({ type: "Catalog", id: "archive", links: [] }); + }) as typeof fetch; + + // The virtual hosted-style certificate wildcard covers a single label, so a + // bucket holding a dot has to go through the path-style endpoint instead. + await connectStac( + "http://example.catalog.s3-website-us-west-2.amazonaws.com/catalog.json", + fetcher, + ); + + assert.deepEqual(fetched, ["https://s3.us-west-2.amazonaws.com/example.catalog/catalog.json"]); +}); + test("openCatalogNode reports what a node turned out to be and what is inside it", async () => { const fetcher = (async (input: RequestInfo | URL) => { if (String(input).endsWith("collection.json")) { @@ -1424,4 +1441,37 @@ test("asset and bbox helpers recognize common STAC data", () => { }), [120, -65, 180, -30], ); + assert.deepEqual( + itemBbox({ + type: "Feature", + id: "nested-collection", + bbox: [7_112_945, -1_778_200, 10_669_445, -3_852_900], + geometry: { + type: "GeometryCollection", + geometries: [ + { + type: "GeometryCollection", + geometries: [ + { + type: "Polygon", + coordinates: [ + [ + [120, -30], + [150, -30], + [150, -65], + [120, -65], + [120, -30], + ], + ], + }, + ], + }, + { type: "Point", coordinates: [180, -50] }, + ], + }, + properties: {}, + assets: {}, + }), + [120, -65, 180, -30], + ); }); From c0c05f780eac661262c9f448f8c93a3bb60274d9 Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 20:51:19 -0400 Subject: [PATCH 3/7] Address Claude review feedback - The raster engine is a control-wide setting, so the STAC picker gains a "Leave unchanged" default and passes it through as "auto", and addCogLayer only calls setEngine when a caller opts in. Callers that say nothing keep the long-standing maplibre-gl-raster default, so Source Cooperative and external plugins are unaffected. - Give the picker its own stacPlugin.engine* keys across all 19 locales instead of borrowing huggingFace.*, which had dropped the Mercator/globe caveat that motivates the control, and show the control-wide hint. - Route the splitter's aria-label through labels/t(), and sync its aria-value* on focus so a screen reader is not left with a valueless separator before the first drag. --- .../src/components/layout/TopToolbar.tsx | 11 ++-- apps/geolibre-desktop/src/hooks/usePlugins.ts | 11 +++- .../geolibre-desktop/src/i18n/locales/ar.json | 7 +++ .../geolibre-desktop/src/i18n/locales/de.json | 7 +++ .../geolibre-desktop/src/i18n/locales/en.json | 7 +++ .../geolibre-desktop/src/i18n/locales/es.json | 7 +++ .../geolibre-desktop/src/i18n/locales/fa.json | 7 +++ .../geolibre-desktop/src/i18n/locales/fr.json | 7 +++ .../geolibre-desktop/src/i18n/locales/hi.json | 7 +++ .../geolibre-desktop/src/i18n/locales/id.json | 7 +++ .../geolibre-desktop/src/i18n/locales/it.json | 7 +++ .../geolibre-desktop/src/i18n/locales/ja.json | 7 +++ .../geolibre-desktop/src/i18n/locales/ka.json | 7 +++ .../geolibre-desktop/src/i18n/locales/ko.json | 7 +++ .../geolibre-desktop/src/i18n/locales/nl.json | 7 +++ .../geolibre-desktop/src/i18n/locales/pt.json | 7 +++ .../geolibre-desktop/src/i18n/locales/ru.json | 7 +++ .../geolibre-desktop/src/i18n/locales/th.json | 7 +++ .../geolibre-desktop/src/i18n/locales/tr.json | 7 +++ .../geolibre-desktop/src/i18n/locales/vi.json | 7 +++ .../geolibre-desktop/src/i18n/locales/zh.json | 7 +++ packages/plugins/src/plugins/maplibre-stac.ts | 59 ++++++++++++++----- packages/plugins/src/types.ts | 10 +++- 23 files changed, 200 insertions(+), 24 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index a0a2478459..72443d4777 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -916,10 +916,13 @@ export function TopToolbar({ showingOfMatched: (count, matched) => t("stacPlugin.showingOfMatched", { count, matched }), loadMore: t("stacPlugin.loadMore"), renderOptions: t("stacPlugin.renderOptions"), - renderingEngine: t("huggingFace.engineHeading"), - engineGpu: t("huggingFace.engineGpu"), - engineWasm: t("huggingFace.engineWasm"), - engineTitiler: t("huggingFace.engineTitiler"), + renderingEngine: t("stacPlugin.renderingEngine"), + engineAuto: t("stacPlugin.engineAuto"), + engineGpu: t("stacPlugin.engineGpu"), + engineWasm: t("stacPlugin.engineWasm"), + engineTitiler: t("stacPlugin.engineTitiler"), + engineHint: t("stacPlugin.engineHint"), + resizeResults: t("stacPlugin.resizeResults"), bands: t("stacPlugin.bands"), bandsPlaceholder: t("stacPlugin.bandsPlaceholder"), colormap: t("stacPlugin.colormap"), diff --git a/apps/geolibre-desktop/src/hooks/usePlugins.ts b/apps/geolibre-desktop/src/hooks/usePlugins.ts index 324ecc7027..79c737f05c 100644 --- a/apps/geolibre-desktop/src/hooks/usePlugins.ts +++ b/apps/geolibre-desktop/src/hooks/usePlugins.ts @@ -934,9 +934,14 @@ export function createAppAPI(mapControllerRef?: RefObject) : undefined; return addRasterToMap(api, url, { name, - // WASM is the globe-compatible default. Discovery plugins can opt into - // the GPU or TiTiler engine for a particular layer when appropriate. - defaults: { engine: options?.engine ?? "cog-tiler-wasm" }, + // The engine is a control-wide setting, not a per-layer one, so naming + // one here re-renders every raster already on the map. Callers that say + // nothing keep the long-standing GPU default: the WASM tiler is aimed at + // local files and can leave a remote programmatic layer registered + // without producing pixels. A caller that knows better (the STAC panel, + // where the user picks) opts in, and "auto" leaves the control alone. + defaults: + options?.engine === "auto" ? {} : { engine: options?.engine ?? "maplibre-gl-raster" }, state: { ...(bands?.length ? { bands, mode: bands.length >= 3 ? "rgb" : "single" } : {}), ...(options?.colormap !== undefined ? { colormap: options.colormap } : {}), diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 4c7aa8db57..addd48bde1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -3626,6 +3626,13 @@ "showingOfMatched": "العناصر المعروضة: {{count}} من {{matched}}.", "loadMore": "تحميل المزيد", "renderOptions": "خيارات عرض الراستر", + "renderingEngine": "محرك عرض COG", + "engineAuto": "الإبقاء دون تغيير", + "engineWasm": "مُبلِّط WebAssembly (متوافق مع الكرة الأرضية)", + "engineGpu": "وحدة معالجة الرسوميات (deck.gl, مركاتور فقط)", + "engineTitiler": "خادم TiTiler", + "engineHint": "أي مُصيِّر يفك ترميز الصور. وبخلاف الإعدادات أعلاه، لا ينطبق هذا لكل طبقة على حدة: بل على كل بيانات الراستر على الخريطة، بما فيها المضافة سابقًا.", + "resizeResults": "تغيير حجم نتائج البحث", "bands": "النطاقات", "bandsPlaceholder": "مثال: 1 أو 1,2,3 (الافتراضي: تلقائي)", "colormap": "خريطة الألوان (نطاق واحد فقط)", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 6f2b40dedc..123fd50ee8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "{{count}} von {{matched}} Objekten werden angezeigt.", "loadMore": "Weitere laden", "renderOptions": "Raster-Darstellungsoptionen", + "renderingEngine": "COG-Rendering-Engine", + "engineAuto": "Unverändert lassen", + "engineWasm": "WebAssembly-Tiler (globetauglich)", + "engineGpu": "GPU (deck.gl, nur Mercator)", + "engineTitiler": "TiTiler-Server", + "engineHint": "Welcher Renderer die Bilddaten dekodiert. Anders als die Einstellungen darüber gilt dies nicht pro Ebene: Es betrifft jedes Raster auf der Karte, auch bereits hinzugefügte.", + "resizeResults": "Suchergebnisse in der Größe ändern", "bands": "Kanäle", "bandsPlaceholder": "z. B. 1 oder 1,2,3 (Standard: automatisch)", "colormap": "Farbschema (nur einkanalig)", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index a999158113..71fb60d112 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -3413,6 +3413,13 @@ "showingOfMatched": "Showing {{count}} of {{matched}} items.", "loadMore": "Load more", "renderOptions": "Raster rendering options", + "renderingEngine": "COG rendering engine", + "engineAuto": "Leave unchanged", + "engineWasm": "WebAssembly tiler (globe compatible)", + "engineGpu": "GPU (deck.gl, Mercator only)", + "engineTitiler": "TiTiler server", + "engineHint": "Which renderer decodes the imagery. Unlike the settings above this is not per layer: it applies to every raster on the map, including ones already added.", + "resizeResults": "Resize search results", "bands": "Bands", "bandsPlaceholder": "e.g. 1 or 1,2,3 (default: auto)", "colormap": "Colormap (single-band only)", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 0461b2b4c4..a5c3e087ef 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "Mostrando {{count}} de {{matched}} elementos.", "loadMore": "Cargar más", "renderOptions": "Opciones de representación ráster", + "renderingEngine": "Motor de renderizado COG", + "engineAuto": "Dejar sin cambios", + "engineWasm": "Teselador WebAssembly (compatible con el globo)", + "engineGpu": "GPU (deck.gl, solo Mercator)", + "engineTitiler": "Servidor TiTiler", + "engineHint": "Qué renderizador descodifica las imágenes. A diferencia de los ajustes anteriores, este no es por capa: se aplica a todos los ráster del mapa, incluidos los ya añadidos.", + "resizeResults": "Redimensionar los resultados de búsqueda", "bands": "Bandas", "bandsPlaceholder": "p. ej., 1 o 1,2,3 (predeterminado: automático)", "colormap": "Mapa de colores (solo una banda)", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 832c184fd4..8e0eb6ac99 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "نمایش {{count}} از {{matched}} آیتم.", "loadMore": "بارگیری بیشتر", "renderOptions": "گزینه‌های ترسیم رستری", + "renderingEngine": "موتور ترسیم COG", + "engineAuto": "بدون تغییر بماند", + "engineWasm": "کاشی‌ساز WebAssembly (سازگار با کرهٔ زمین)", + "engineGpu": "پردازندهٔ گرافیکی (deck.gl, فقط مرکاتور)", + "engineTitiler": "سرور TiTiler", + "engineHint": "کدام ترسیم‌گر تصاویر را رمزگشایی کند. برخلاف تنظیمات بالا، این تنظیم برای هر لایه جدا نیست: بر همهٔ رسترهای روی نقشه، از جمله آن‌هایی که از پیش افزوده شده‌اند، اعمال می‌شود.", + "resizeResults": "تغییر اندازهٔ نتایج جست‌وجو", "bands": "باندها", "bandsPlaceholder": "مثال: 1 یا 1,2,3 (پیش‌فرض: خودکار)", "colormap": "نقشهٔ رنگ (تنها برای تک‌باند)", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 00eb6caa1a..6147c9a705 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "Affichage de {{count}} éléments sur {{matched}}.", "loadMore": "Charger plus", "renderOptions": "Options de rendu raster", + "renderingEngine": "Moteur de rendu COG", + "engineAuto": "Laisser inchangé", + "engineWasm": "Générateur de tuiles WebAssembly (compatible globe)", + "engineGpu": "GPU (deck.gl, Mercator uniquement)", + "engineTitiler": "Serveur TiTiler", + "engineHint": "Quel moteur décode l'imagerie. Contrairement aux réglages ci-dessus, celui-ci ne s'applique pas par couche : il concerne tous les rasters de la carte, y compris ceux déjà ajoutés.", + "resizeResults": "Redimensionner les résultats de recherche", "bands": "Bandes", "bandsPlaceholder": "p. ex. 1 ou 1,2,3 (par défaut : automatique)", "colormap": "Palette de couleurs (bande unique uniquement)", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index df113306e3..b8312f7933 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "{{matched}} में से {{count}} आइटम दिखाए जा रहे हैं।", "loadMore": "और लोड करें", "renderOptions": "रैस्टर रेंडरिंग विकल्प", + "renderingEngine": "COG रेंडरिंग इंजन", + "engineAuto": "अपरिवर्तित रहने दें", + "engineWasm": "WebAssembly टाइलर (ग्लोब संगत)", + "engineGpu": "GPU (deck.gl, केवल मर्केटर)", + "engineTitiler": "TiTiler सर्वर", + "engineHint": "कौन-सा रेंडरर इमेजरी को डिकोड करता है। ऊपर की सेटिंग्स के विपरीत यह प्रति लेयर नहीं है: यह मानचित्र के हर रास्टर पर लागू होती है, पहले से जोड़े गए रास्टर पर भी।", + "resizeResults": "खोज परिणामों का आकार बदलें", "bands": "बैंड", "bandsPlaceholder": "जैसे 1 या 1,2,3 (डिफ़ॉल्ट: स्वतः)", "colormap": "कलरमैप (केवल एकल बैंड)", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index da029c87ec..d8ad9df7cf 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -3347,6 +3347,13 @@ "showingOfMatched": "Menampilkan {{count}} dari {{matched}} item.", "loadMore": "Muat lebih banyak", "renderOptions": "Opsi perenderan raster", + "renderingEngine": "Mesin rendering COG", + "engineAuto": "Biarkan tidak berubah", + "engineWasm": "Tiler WebAssembly (kompatibel globe)", + "engineGpu": "GPU (deck.gl, hanya Mercator)", + "engineTitiler": "Server TiTiler", + "engineHint": "Perender mana yang mendekode citra. Berbeda dengan pengaturan di atas, yang satu ini tidak per layer: berlaku untuk setiap raster di peta, termasuk yang sudah ditambahkan.", + "resizeResults": "Ubah ukuran hasil pencarian", "bands": "Band", "bandsPlaceholder": "mis. 1 atau 1,2,3 (bawaan: otomatis)", "colormap": "Peta warna (hanya band tunggal)", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 73588a3d2e..ab236c2c12 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "Visualizzazione di {{count}} di {{matched}} elementi.", "loadMore": "Carica altri", "renderOptions": "Opzioni di rendering raster", + "renderingEngine": "Motore di rendering COG", + "engineAuto": "Lascia invariato", + "engineWasm": "Tiler WebAssembly (compatibile con il globo)", + "engineGpu": "GPU (deck.gl, solo Mercatore)", + "engineTitiler": "Server TiTiler", + "engineHint": "Quale renderer decodifica le immagini. A differenza delle impostazioni sopra, questa non è per livello: si applica a ogni raster sulla mappa, compresi quelli già aggiunti.", + "resizeResults": "Ridimensiona i risultati della ricerca", "bands": "Bande", "bandsPlaceholder": "ad es. 1 o 1,2,3 (predefinito: automatico)", "colormap": "Mappa di colori (solo banda singola)", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index baf4137b0f..23f8535c0a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -3347,6 +3347,13 @@ "showingOfMatched": "{{matched}} 件中 {{count}} 件のアイテムを表示しています。", "loadMore": "さらに読み込む", "renderOptions": "ラスター描画オプション", + "renderingEngine": "COG レンダリングエンジン", + "engineAuto": "変更しない", + "engineWasm": "WebAssembly タイラー (地球儀対応)", + "engineGpu": "GPU (deck.gl, メルカトルのみ)", + "engineTitiler": "TiTiler サーバー", + "engineHint": "どのレンダラーが画像をデコードするか。上の設定と違い、これはレイヤーごとではありません。すでに追加済みのものも含め、地図上のすべてのラスターに適用されます。", + "resizeResults": "検索結果のサイズを変更", "bands": "バンド", "bandsPlaceholder": "例: 1 または 1,2,3(既定: 自動)", "colormap": "カラーマップ(単バンドのみ)", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 81df9b878a..fd335cce13 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "ნაჩვენებია {{count}} ერთეული {{matched}}-დან.", "loadMore": "მეტის ჩატვირთვა", "renderOptions": "რასტრის ასახვის პარამეტრები", + "renderingEngine": "COG-ის რენდერინგის ძრავი", + "engineAuto": "უცვლელად დატოვება", + "engineWasm": "WebAssembly tiler (გლობუსთან თავსებადი)", + "engineGpu": "GPU (deck.gl, მხოლოდ მერკატორი)", + "engineTitiler": "TiTiler სერვერი", + "engineHint": "რომელი რენდერერი დეკოდირებს სურათებს. ზემოთ პარამეტრებისგან განსხვავებით ეს არ არის per-layer: ის ვრცელდება რუკის ყოველ რასტრზე, უკვე დამატებულების ჩათვლით.", + "resizeResults": "ძიების შედეგების ზომის შეცვლა", "bands": "არხები", "bandsPlaceholder": "მაგ. 1 ან 1,2,3 (ნაგულისხმევი: ავტომატური)", "colormap": "ფერთა რუკა (მხოლოდ ერთარხიანი)", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index fd261c2798..775ca7ed83 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -3347,6 +3347,13 @@ "showingOfMatched": "{{matched}}개 중 {{count}}개 항목을 표시하고 있습니다.", "loadMore": "더 불러오기", "renderOptions": "래스터 렌더링 옵션", + "renderingEngine": "COG 렌더링 엔진", + "engineAuto": "변경하지 않음", + "engineWasm": "WebAssembly 타일러 (지구본 호환)", + "engineGpu": "GPU (deck.gl, 메르카토르 전용)", + "engineTitiler": "TiTiler 서버", + "engineHint": "어떤 렌더러가 영상을 디코딩할지 정합니다. 위 설정과 달리 레이어별이 아니며, 이미 추가된 것을 포함해 지도의 모든 래스터에 적용됩니다.", + "resizeResults": "검색 결과 크기 조절", "bands": "밴드", "bandsPlaceholder": "예: 1 또는 1,2,3(기본값: 자동)", "colormap": "컬러맵(단일 밴드 전용)", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 546928f424..bb93f88d3a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "{{count}} van {{matched}} items worden weergegeven.", "loadMore": "Meer laden", "renderOptions": "Rasterweergaveopties", + "renderingEngine": "COG-rendering-engine", + "engineAuto": "Ongewijzigd laten", + "engineWasm": "WebAssembly-tiler (globe-compatibel)", + "engineGpu": "GPU (deck.gl, alleen Mercator)", + "engineTitiler": "TiTiler-server", + "engineHint": "Welke renderer de beelden decodeert. Anders dan de instellingen hierboven geldt dit niet per laag: het geldt voor elk raster op de kaart, ook voor de al toegevoegde.", + "resizeResults": "Zoekresultaten van grootte veranderen", "bands": "Banden", "bandsPlaceholder": "bijv. 1 of 1,2,3 (standaard: automatisch)", "colormap": "Kleurenkaart (alleen enkele band)", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index 28919fc241..b16d37ae49 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "Mostrando {{count}} de {{matched}} itens.", "loadMore": "Carregar mais", "renderOptions": "Opções de renderização raster", + "renderingEngine": "Mecanismo de renderização COG", + "engineAuto": "Deixar sem alteração", + "engineWasm": "Gerador de tiles WebAssembly (compatível com o globo)", + "engineGpu": "GPU (deck.gl, apenas Mercator)", + "engineTitiler": "Servidor TiTiler", + "engineHint": "Qual renderizador decodifica as imagens. Diferente das configurações acima, esta não é por camada: vale para todos os rasters do mapa, inclusive os já adicionados.", + "resizeResults": "Redimensionar os resultados da pesquisa", "bands": "Bandas", "bandsPlaceholder": "ex.: 1 ou 1,2,3 (padrão: automático)", "colormap": "Mapa de cores (apenas banda única)", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 4566bb3226..dbf2f22050 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -3515,6 +3515,13 @@ "showingOfMatched": "Показано объектов: {{count}} из {{matched}}.", "loadMore": "Загрузить ещё", "renderOptions": "Параметры отображения растра", + "renderingEngine": "Движок отрисовки COG", + "engineAuto": "Оставить без изменений", + "engineWasm": "Тайлер на WebAssembly (совместим с глобусом)", + "engineGpu": "GPU (deck.gl, только Меркатор)", + "engineTitiler": "Сервер TiTiler", + "engineHint": "Какой обработчик декодирует изображения. В отличие от настроек выше, эта настройка задаётся не для каждого слоя: она применяется ко всем растрам на карте, включая уже добавленные.", + "resizeResults": "Изменить размер результатов поиска", "bands": "Каналы", "bandsPlaceholder": "например, 1 или 1,2,3 (по умолчанию: автоматически)", "colormap": "Цветовая карта (только одноканальные)", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index ad895e7a50..60672dcf4a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -3347,6 +3347,13 @@ "showingOfMatched": "กำลังแสดง {{count}} จาก {{matched}} รายการ", "loadMore": "โหลดเพิ่มเติม", "renderOptions": "ตัวเลือกการแสดงผลแรสเตอร์", + "renderingEngine": "เอนจินการแสดงผล COG", + "engineAuto": "ไม่เปลี่ยนแปลง", + "engineWasm": "ตัวสร้างไทล์แบบ WebAssembly (รองรับลูกโลก)", + "engineGpu": "GPU (deck.gl, เมอร์เคเตอร์เท่านั้น)", + "engineTitiler": "เซิร์ฟเวอร์ TiTiler", + "engineHint": "กำหนดว่าตัวแสดงผลใดจะถอดรหัสภาพถ่าย ต่างจากการตั้งค่าด้านบน ค่านี้ไม่ได้แยกตามเลเยอร์ แต่มีผลกับแรสเตอร์ทุกตัวบนแผนที่ รวมถึงที่เพิ่มไปแล้ว", + "resizeResults": "ปรับขนาดผลการค้นหา", "bands": "แบนด์", "bandsPlaceholder": "เช่น 1 หรือ 1,2,3 (ค่าเริ่มต้น: อัตโนมัติ)", "colormap": "แผนผังสี (เฉพาะแบนด์เดียว)", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index c5f5553a10..1ecdfe0a59 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -3403,6 +3403,13 @@ "showingOfMatched": "{{matched}} öğeden {{count}} tanesi gösteriliyor.", "loadMore": "Daha fazla yükle", "renderOptions": "Raster görüntüleme seçenekleri", + "renderingEngine": "COG görüntüleme motoru", + "engineAuto": "Değiştirmeden bırak", + "engineWasm": "WebAssembly döşeyici (küre uyumlu)", + "engineGpu": "GPU (deck.gl, yalnızca Mercator)", + "engineTitiler": "TiTiler sunucusu", + "engineHint": "Görüntüyü hangi işleyicinin çözeceği. Yukarıdaki ayarlardan farklı olarak bu katman bazında değildir: haritadaki, daha önce eklenmiş olanlar dahil her raster için geçerlidir.", + "resizeResults": "Arama sonuçlarını yeniden boyutlandır", "bands": "Bantlar", "bandsPlaceholder": "örn. 1 veya 1,2,3 (varsayılan: otomatik)", "colormap": "Renk haritası (yalnızca tek bant)", diff --git a/apps/geolibre-desktop/src/i18n/locales/vi.json b/apps/geolibre-desktop/src/i18n/locales/vi.json index 777a87c7e3..c90bd94553 100644 --- a/apps/geolibre-desktop/src/i18n/locales/vi.json +++ b/apps/geolibre-desktop/src/i18n/locales/vi.json @@ -3334,6 +3334,13 @@ "showingOfMatched": "Đang hiển thị {{count}} trong số {{matched}} mục.", "loadMore": "Tải thêm", "renderOptions": "Tùy chọn hiển thị raster", + "renderingEngine": "Công cụ kết xuất COG", + "engineAuto": "Để nguyên không thay đổi", + "engineWasm": "Trình xếp WebAssembly (tương thích quả địa cầu)", + "engineGpu": "GPU (deck.gl, chỉ Mercator)", + "engineTitiler": "máy chủ TiTiler", + "engineHint": "Trình kết xuất nào giải mã hình ảnh. Không giống như các cài đặt ở trên, cài đặt này không áp dụng cho mỗi lớp: nó áp dụng cho mọi raster trên bản đồ, bao gồm cả những raster đã được thêm vào.", + "resizeResults": "Thay đổi kích thước kết quả tìm kiếm", "bands": "Ban nhạc", "bandsPlaceholder": "ví dụ. 1 hoặc 1,2,3 (mặc định: tự động)", "colormap": "Bản đồ màu (chỉ một băng tần)", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index ce0dbffcb5..852c54d8d6 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -3347,6 +3347,13 @@ "showingOfMatched": "正在显示 {{matched}} 个项目中的 {{count}} 个。", "loadMore": "加载更多", "renderOptions": "栅格渲染选项", + "renderingEngine": "COG 渲染引擎", + "engineAuto": "保持不变", + "engineWasm": "WebAssembly 切片器 (支持球面)", + "engineGpu": "GPU (deck.gl, 仅墨卡托)", + "engineTitiler": "TiTiler 服务器", + "engineHint": "由哪个渲染器解码影像。与上面的设置不同,此项不是按图层生效:它作用于地图上的所有栅格,包括已经添加的栅格。", + "resizeResults": "调整搜索结果大小", "bands": "波段", "bandsPlaceholder": "例如 1 或 1,2,3(默认:自动)", "colormap": "色彩映射(仅限单波段)", diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index b1d8b0d799..26162f8ef9 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -49,22 +49,24 @@ const SELECT_SOURCE = "geolibre-stac-selected"; const SELECT_FILL = "geolibre-stac-selected-fill"; const SELECT_LINE = "geolibre-stac-selected-line"; const COG_ENGINE_STORAGE_KEY = "geolibre:stac-default-cog-engine"; -const COG_ENGINES = ["cog-tiler-wasm", "maplibre-gl-raster", "titiler"] as const; +// "auto" leaves the raster control on whatever engine it already holds. It is +// the default because the engine is control-wide: naming one re-renders every +// raster on the map, including layers another panel put there. +const COG_ENGINES = ["auto", "cog-tiler-wasm", "maplibre-gl-raster", "titiler"] as const; +type StacCogEngine = (typeof COG_ENGINES)[number]; // Web Storage throws rather than returning null when the browser blocks it // (private mode, a third-party-storage policy), so neither read nor write may // be the only thing standing between the user and a working panel. -function savedCogEngine(): GeoLibreCogRenderEngine { +function savedCogEngine(): StacCogEngine { let saved: string | null = null; try { saved = typeof localStorage === "undefined" ? null : localStorage.getItem(COG_ENGINE_STORAGE_KEY); } catch { - return "cog-tiler-wasm"; + return "auto"; } - return COG_ENGINES.includes(saved as (typeof COG_ENGINES)[number]) - ? (saved as GeoLibreCogRenderEngine) - : "cog-tiler-wasm"; + return COG_ENGINES.includes(saved as StacCogEngine) ? (saved as StacCogEngine) : "auto"; } function rememberCogEngine(engine: string): void { @@ -161,9 +163,12 @@ export interface StacLabels { loadMore: string; renderOptions: string; renderingEngine: string; + engineAuto: string; engineGpu: string; engineWasm: string; engineTitiler: string; + engineHint: string; + resizeResults: string; bands: string; bandsPlaceholder: string; colormap: string; @@ -241,10 +246,15 @@ let labels: StacLabels = { searchFailed: "STAC search failed", loadMore: "Load more", renderOptions: "Raster rendering options", - renderingEngine: "Default COG rendering engine", - engineGpu: "GPU (deck.gl; Mercator only)", + renderingEngine: "COG rendering engine", + engineAuto: "Leave unchanged", + engineGpu: "GPU (deck.gl, Mercator only)", engineWasm: "WebAssembly tiler (globe compatible)", engineTitiler: "TiTiler server", + engineHint: + "Which renderer decodes the imagery. Unlike the settings above this is not per layer: " + + "it applies to every raster on the map, including ones already added.", + resizeResults: "Resize search results", bands: "Bands", bandsPlaceholder: "e.g. 1 or 1,2,3 (default: auto)", colormap: "Colormap (single-band only)", @@ -785,6 +795,7 @@ function buildPanel(container: HTMLElement): () => void { const engineSelect = el("select"); engineSelect.style.cssText = style.input; for (const [value, title] of [ + ["auto", labels.engineAuto], ["cog-tiler-wasm", labels.engineWasm], ["maplibre-gl-raster", labels.engineGpu], ["titiler", labels.engineTitiler], @@ -797,7 +808,9 @@ function buildPanel(container: HTMLElement): () => void { engineSelect.addEventListener("change", () => { rememberCogEngine(engineSelect.value); }); - engineWrap.append(engineCaption, engineSelect); + const engineHint = el("span", labels.engineHint); + engineHint.style.cssText = style.status; + engineWrap.append(engineCaption, engineSelect, engineHint); const bandsField = field(labels.bands); bandsField.input.placeholder = labels.bandsPlaceholder; const colormapWrap = el("label"); @@ -842,7 +855,8 @@ function buildPanel(container: HTMLElement): () => void { resultSplitter.style.cssText = style.resultSplitter; resultSplitter.setAttribute("role", "separator"); resultSplitter.setAttribute("aria-orientation", "horizontal"); - resultSplitter.setAttribute("aria-label", "Resize search results"); + resultSplitter.setAttribute("aria-label", labels.resizeResults); + resultSplitter.setAttribute("aria-valuemin", "150"); resultSplitter.tabIndex = 0; const loadMore = el("button", labels.loadMore); loadMore.type = "button"; @@ -853,15 +867,28 @@ function buildPanel(container: HTMLElement): () => void { controls.append(catalogSection, searchSection, renderSection); container.append(controls, status, resultSplitter, results, loadMore); + const splitterBounds = (): number => + Math.max(150, container.getBoundingClientRect().height - 230); + + // Announcing the size only after the first drag would leave a screen reader + // with a valueless separator, so the values are also synced on focus -- which + // must not pin the percentage flex basis into pixels the way a resize does. + const announceResults = (height: number, maximum: number): void => { + resultSplitter.setAttribute("aria-valuenow", String(Math.round(height))); + resultSplitter.setAttribute("aria-valuemax", String(Math.round(maximum))); + }; + const resizeResults = (height: number): void => { - const maximum = Math.max(150, container.getBoundingClientRect().height - 230); + const maximum = splitterBounds(); const next = Math.min(maximum, Math.max(150, height)); results.style.flexBasis = `${next}px`; - resultSplitter.setAttribute("aria-valuenow", String(Math.round(next))); - resultSplitter.setAttribute("aria-valuemin", "150"); - resultSplitter.setAttribute("aria-valuemax", String(Math.round(maximum))); + announceResults(next, maximum); }; + resultSplitter.addEventListener("focus", () => { + announceResults(results.getBoundingClientRect().height, splitterBounds()); + }); + resultSplitter.addEventListener("pointerdown", (event) => { event.preventDefault(); const startY = event.clientY; @@ -917,7 +944,9 @@ function buildPanel(container: HTMLElement): () => void { const rescaleMax = numeric(vmaxField.input); const nodata = numeric(nodataField.input); return { - engine: engineSelect.value as GeoLibreCogRenderEngine, + // "auto" is sent through as-is so the host leaves the control-wide engine + // alone rather than falling back to its own default. + engine: engineSelect.value as GeoLibreCogRenderEngine | "auto", ...(bands ? { bands } : {}), ...(colormap ? { colormap } : {}), ...(rescaleMin !== undefined ? { rescaleMin } : {}), diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index d6c4792577..e9ed2fce59 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -179,8 +179,14 @@ export interface GeoLibreOvertureQueryResult { export type GeoLibreCogRenderEngine = "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler"; export interface GeoLibreCogLayerOptions { - /** Renderer used for this COG. WASM is globe-compatible; the GPU renderer requires Mercator. */ - engine?: GeoLibreCogRenderEngine; + /** + * Renderer that decodes this COG. WASM is globe-compatible; the GPU renderer + * requires Mercator. Unlike the other options here this is **not** per layer: + * the raster control holds one engine for every raster it manages, so naming + * one re-renders the rasters already on the map too. Pass `"auto"` to leave + * whatever the control is already on alone. + */ + engine?: GeoLibreCogRenderEngine | "auto"; /** Band selection, e.g. `"1"` (single band) or `"1,2,3"` (RGB). */ bands?: string; /** From b52b221570f57265a7c92ac8c83293529905e5db Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 21:05:09 -0400 Subject: [PATCH 4/7] Address review feedback - Move the engine picker below the per-layer options so its control-wide hint ("unlike the settings above") reads true, matching the Hugging Face panel. - Derive the splitter maximum from the measured siblings and container padding instead of a fixed 230px reserve, which let a drag to the reported maximum clip the tail of the results and the Load more button. - Return undefined from itemBbox when no usable geometry is found: anything reaching that fallback already failed the lon/lat check, so handing back the advertised bbox reintroduced the projected values this branch replaces. - Move GeoLibreCogRenderEngine above the JSDoc block it had displaced, so the docs attach to GeoLibreCogLayerOptions again. --- packages/plugins/src/plugins/maplibre-stac.ts | 27 ++++++++++++++++--- packages/plugins/src/plugins/stac-api.ts | 7 +++-- packages/plugins/src/types.ts | 5 ++-- tests/stac-api.test.ts | 13 +++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 26162f8ef9..9d1c32385d 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -837,14 +837,16 @@ function buildPanel(container: HTMLElement): () => void { nodataField.input.placeholder = labels.nodataPlaceholder; const renderHint = el("div", labels.renderHint); renderHint.style.cssText = style.status; + // The engine picker sits last, after the per-layer options: it is the one + // control-wide setting here, and its hint reads "unlike the settings above". renderSection.append( renderSummary, - engineWrap, bandsField.wrap, colormapWrap, rescaleRow, nodataField.wrap, renderHint, + engineWrap, ); const status = el("div", labels.initialStatus); @@ -867,8 +869,27 @@ function buildPanel(container: HTMLElement): () => void { controls.append(catalogSection, searchSection, renderSection); container.append(controls, status, resultSplitter, results, loadMore); - const splitterBounds = (): number => - Math.max(150, container.getBoundingClientRect().height - 230); + // The results pane neither grows nor shrinks once its basis is set, so the + // ceiling has to be whatever the container has left after the siblings that + // keep their own size. Guessing a fixed reserve here let a drag to the + // reported maximum clip the tail of the list and the Load more button. + const CONTROLS_MIN_HEIGHT = 180; + const splitterBounds = (): number => { + const reserved = [status, resultSplitter, loadMore].reduce( + (total, element) => total + (element.hidden ? 0 : element.getBoundingClientRect().height), + CONTROLS_MIN_HEIGHT, + ); + const box = container.getBoundingClientRect(); + const padding = window.getComputedStyle(container); + const gap = Number.parseFloat(padding.rowGap) || 0; + const inner = + box.height - + (Number.parseFloat(padding.paddingTop) || 0) - + (Number.parseFloat(padding.paddingBottom) || 0) - + // One gap per sibling boundary: controls, status, splitter, results, loadMore. + gap * 4; + return Math.max(150, inner - reserved); + }; // Announcing the size only after the first drag would leave a screen reader // with a valueless separator, so the values are also synced on focus -- which diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index fc1c8d6c71..88c1fe1e29 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -662,10 +662,13 @@ export function itemBbox(item: StacItem): [number, number, number, number] | und collect(geometry.coordinates); }; collectGeometry(item.geometry); - if (!positions.length) return advertised; + // Anything still here failed the lon/lat check above, so falling back to it + // would hand out the impossible values this whole branch exists to replace. + // Without a usable geometry the honest answer is that there is no extent. + if (!positions.length) return undefined; const latitudes = positions.map(([, latitude]) => latitude); - if (latitudes.some((latitude) => latitude < -90 || latitude > 90)) return advertised; + if (latitudes.some((latitude) => latitude < -90 || latitude > 90)) return undefined; const longitudes = positions .map(([longitude]) => ((longitude % 360) + 360) % 360) .sort((a, b) => a - b); diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index e9ed2fce59..e9fecdf082 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -169,6 +169,9 @@ export interface GeoLibreOvertureQueryResult { truncated: boolean; } +/** Renderers the raster control can decode a COG with. */ +export type GeoLibreCogRenderEngine = "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler"; + /** * Options for {@link GeoLibreAppAPI.addCogLayer}: a native Cloud-Optimized * GeoTIFF layer read directly from a URL and rendered client-side, with band @@ -176,8 +179,6 @@ export interface GeoLibreOvertureQueryResult { * panel. All fields are optional; the renderer infers sensible defaults from * the GeoTIFF when they are omitted. */ -export type GeoLibreCogRenderEngine = "maplibre-gl-raster" | "cog-tiler-wasm" | "titiler"; - export interface GeoLibreCogLayerOptions { /** * Renderer that decodes this COG. WASM is globe-compatible; the GPU renderer diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 0b0dcc5dcb..6246ca9b96 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -1474,4 +1474,17 @@ test("asset and bbox helpers recognize common STAC data", () => { }), [120, -65, 180, -30], ); + // A projected bbox with no geometry to fall back on has no usable extent, so + // the caller must be told that rather than handed the projected numbers. + assert.equal( + itemBbox({ + type: "Feature", + id: "projected-bbox-no-geometry", + bbox: [7_112_945, -1_778_200, 10_669_445, -3_852_900], + geometry: null, + properties: {}, + assets: {}, + }), + undefined, + ); }); From 54ff3fca707503263893760b602e80310dbe686e Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 21:20:09 -0400 Subject: [PATCH 5/7] Address Claude review feedback - Declare the results and controls minimum heights once and interpolate them into the styles, so splitterBounds() cannot drift from the CSS floors it reserves against. --- extensions/geolibre-chrome/STORE_LISTING.md | 61 ++++++++++--------- packages/plugins/src/plugins/maplibre-stac.ts | 20 +++--- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/extensions/geolibre-chrome/STORE_LISTING.md b/extensions/geolibre-chrome/STORE_LISTING.md index 9c2ca9eaab..9818ff6f09 100644 --- a/extensions/geolibre-chrome/STORE_LISTING.md +++ b/extensions/geolibre-chrome/STORE_LISTING.md @@ -13,31 +13,17 @@ Find geospatial datasets and map services on a webpage and open them in GeoLibre ## Detailed description -Open data in GeoLibre turns dataset catalogs, documentation pages, and project -websites into launch points for an interactive map. +Open data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map. -Click the extension icon to find supported data links on the current page, -filter them by vector or raster type, choose the files you need, and open them -together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, -Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON. +Click the extension icon to find supported data links on the current page, filter them by vector or raster type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON. -The extension also reads schema.org download metadata, understands existing -GeoLibre links, pairs matching GeoLibre style files, and discovers the complete -file inventory on virtualized Source Cooperative repository pages. +The extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages. -Interactive maps are supported too. The extension recognizes completed WMS, -WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile -requests made by the current tab. +Interactive maps are supported too. The extension recognizes completed WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests made by the current tab. -Detected service URLs stay in temporary browser session storage only until the -tab closes. The extension runs no analytics and sends no browsing activity to -GeoLibre unless you explicitly select an item and open it. +Detected service URLs stay in temporary browser session storage only until the tab closes. The extension runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it. -Dataset servers must allow browser access through CORS. Complete HTTP(S) URLs, -including signed query parameters, are forwarded to GeoLibre. Cookies and other -browser-session credentials are not forwarded, so cookie-bound or -session-authenticated links may fail. Temporary `blob:` links cannot be -transferred. +Dataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred. ## Category @@ -49,13 +35,30 @@ English ## Permission justification -- `activeTab`: grants temporary access to the page only after the user invokes - the extension, so its dataset links can be inspected. -- `scripting`: injects the local, packaged dataset scanner into that active tab. -- `webRequest` and HTTP(S) host access: observes completed requests locally to - identify geospatial services used by interactive maps. -- `storage`: holds detected service URLs in session-only storage until their tab - closes so the popup can display them. +Each block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. -The extension does not request browsing history, downloads, cookies, or remote -code. +### activeTab + +activeTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time. + +### scripting + +scripting injects the dataset scanner into the active tab when the user opens the popup. The scanner is packaged in the extension and is the code that reads the page's links and metadata; nothing is fetched or evaluated from a remote source. It runs once per invocation and returns the candidate dataset list to the popup. + +### storage + +storage provides chrome.storage.session, an in-memory area, where the extension keeps the list of map-service URLs detected in each tab so the popup can show them when the user opens it. Entries are keyed by tab id and are deleted when the tab navigates or is closed. No data is written to disk: chrome.storage.local and chrome.storage.sync are never used, and nothing is retained after the browsing session. + +### webRequest + +webRequest is used in observe-only mode. onBeforeRequest and onCompleted listeners read the URL of the page's own HTTP(S) requests to recognize the geospatial services an interactive map is loading: XYZ/TMS tiles, WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, and vector tiles with their style JSON. A map requests these from JavaScript, so they never appear as links in the document and cannot be found by scanning the DOM. The extension does not use the blocking API, and does not read, modify, redirect, or cancel any request, header, or response body. Only the URL, resource type, and tab id are inspected, entirely locally in the service worker. + +### Host permissions + +Geospatial data and map services are published across the whole web, so the extension cannot know in advance which hosts to match. http://*/* and https://*/* serve as the URL filter for the observe-only webRequest listeners described above, and cover whichever page the user has open when they invoke the extension. No content script is declared, so nothing runs automatically on any site: the dataset scanner is injected only into the active tab, only after the user clicks the toolbar icon. The extension itself contacts no host. URLs leave the browser only when the user explicitly picks datasets and clicks Open in GeoLibre, which opens them in a new https://web.geolibre.app/ tab. + +Broad host permissions put the extension into Chrome's in-depth review, which delays publishing. They cannot be narrowed: webRequest only observes hosts it holds permission for, and the pages that embed map services are not a knowable list. + +### Not requested + +The extension does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 9d1c32385d..b05b3bcb50 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -303,6 +303,13 @@ let unregisterPanel: (() => void) | null = null; let disposePanel: (() => void) | null = null; let panelContainer: HTMLElement | null = null; +// The results pane and the controls above it each keep a floor so neither can +// be dragged away entirely. splitterBounds() reserves the controls floor and +// clamps to the results one, so both are declared here and interpolated into +// the styles rather than written twice. +const RESULTS_MIN_HEIGHT = 150; +const CONTROLS_MIN_HEIGHT = 180; + const style = { panel: "display:flex;flex-direction:column;gap:10px;height:100%;padding:10px;box-sizing:border-box;" + @@ -325,10 +332,8 @@ const style = { // The floor keeps a usable result list even with every filter section open; // its flex basis gives the search controls most of the panel initially. A // splitter between the two lets the user choose a different balance. - results: - "display:flex;flex:0 0 40%;min-height:150px;overflow:auto;flex-direction:column;gap:7px;", - controls: - "display:flex;flex-direction:column;gap:10px;flex:1 1 60%;min-height:180px;overflow:auto;", + results: `display:flex;flex:0 0 40%;min-height:${RESULTS_MIN_HEIGHT}px;overflow:auto;flex-direction:column;gap:7px;`, + controls: `display:flex;flex-direction:column;gap:10px;flex:1 1 60%;min-height:${CONTROLS_MIN_HEIGHT}px;overflow:auto;`, resultSplitter: "height:8px;flex:0 0 8px;cursor:row-resize;border-radius:4px;touch-action:none;" + "background:linear-gradient(transparent 3px,hsl(var(--border)) 3px,hsl(var(--border)) 5px,transparent 5px);", @@ -858,7 +863,7 @@ function buildPanel(container: HTMLElement): () => void { resultSplitter.setAttribute("role", "separator"); resultSplitter.setAttribute("aria-orientation", "horizontal"); resultSplitter.setAttribute("aria-label", labels.resizeResults); - resultSplitter.setAttribute("aria-valuemin", "150"); + resultSplitter.setAttribute("aria-valuemin", String(RESULTS_MIN_HEIGHT)); resultSplitter.tabIndex = 0; const loadMore = el("button", labels.loadMore); loadMore.type = "button"; @@ -873,7 +878,6 @@ function buildPanel(container: HTMLElement): () => void { // ceiling has to be whatever the container has left after the siblings that // keep their own size. Guessing a fixed reserve here let a drag to the // reported maximum clip the tail of the list and the Load more button. - const CONTROLS_MIN_HEIGHT = 180; const splitterBounds = (): number => { const reserved = [status, resultSplitter, loadMore].reduce( (total, element) => total + (element.hidden ? 0 : element.getBoundingClientRect().height), @@ -888,7 +892,7 @@ function buildPanel(container: HTMLElement): () => void { (Number.parseFloat(padding.paddingBottom) || 0) - // One gap per sibling boundary: controls, status, splitter, results, loadMore. gap * 4; - return Math.max(150, inner - reserved); + return Math.max(RESULTS_MIN_HEIGHT, inner - reserved); }; // Announcing the size only after the first drag would leave a screen reader @@ -901,7 +905,7 @@ function buildPanel(container: HTMLElement): () => void { const resizeResults = (height: number): void => { const maximum = splitterBounds(); - const next = Math.min(maximum, Math.max(150, height)); + const next = Math.min(maximum, Math.max(RESULTS_MIN_HEIGHT, height)); results.style.flexBasis = `${next}px`; announceResults(next, maximum); }; From b36aada87fc2cccccfd26e48937477f5959594df Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 21:31:21 -0400 Subject: [PATCH 6/7] Drop an unrelated STORE_LISTING.md reflow from this branch A formatting hook rewrapped the Chrome extension's store listing while this branch was being built, and it was committed by accident. It has nothing to do with the STAC changes, so restore it to main's version. --- extensions/geolibre-chrome/STORE_LISTING.md | 61 ++++++++++----------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/extensions/geolibre-chrome/STORE_LISTING.md b/extensions/geolibre-chrome/STORE_LISTING.md index 9818ff6f09..9c2ca9eaab 100644 --- a/extensions/geolibre-chrome/STORE_LISTING.md +++ b/extensions/geolibre-chrome/STORE_LISTING.md @@ -13,17 +13,31 @@ Find geospatial datasets and map services on a webpage and open them in GeoLibre ## Detailed description -Open data in GeoLibre turns dataset catalogs, documentation pages, and project websites into launch points for an interactive map. +Open data in GeoLibre turns dataset catalogs, documentation pages, and project +websites into launch points for an interactive map. -Click the extension icon to find supported data links on the current page, filter them by vector or raster type, choose the files you need, and open them together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON. +Click the extension icon to find supported data links on the current page, +filter them by vector or raster type, choose the files you need, and open them +together in GeoLibre. Supported links include GeoJSON, GeoParquet, PMTiles, +Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON. -The extension also reads schema.org download metadata, understands existing GeoLibre links, pairs matching GeoLibre style files, and discovers the complete file inventory on virtualized Source Cooperative repository pages. +The extension also reads schema.org download metadata, understands existing +GeoLibre links, pairs matching GeoLibre style files, and discovers the complete +file inventory on virtualized Source Cooperative repository pages. -Interactive maps are supported too. The extension recognizes completed WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile requests made by the current tab. +Interactive maps are supported too. The extension recognizes completed WMS, +WMTS, WFS, OGC API Features, ArcGIS Feature Service, XYZ/TMS, and vector-tile +requests made by the current tab. -Detected service URLs stay in temporary browser session storage only until the tab closes. The extension runs no analytics and sends no browsing activity to GeoLibre unless you explicitly select an item and open it. +Detected service URLs stay in temporary browser session storage only until the +tab closes. The extension runs no analytics and sends no browsing activity to +GeoLibre unless you explicitly select an item and open it. -Dataset servers must allow browser access through CORS. Complete HTTP(S) URLs, including signed query parameters, are forwarded to GeoLibre. Cookies and other browser-session credentials are not forwarded, so cookie-bound or session-authenticated links may fail. Temporary `blob:` links cannot be transferred. +Dataset servers must allow browser access through CORS. Complete HTTP(S) URLs, +including signed query parameters, are forwarded to GeoLibre. Cookies and other +browser-session credentials are not forwarded, so cookie-bound or +session-authenticated links may fail. Temporary `blob:` links cannot be +transferred. ## Category @@ -35,30 +49,13 @@ English ## Permission justification -Each block below is self-contained and is pasted verbatim into the matching field of the Chrome Web Store dashboard's Privacy tab. Keep them in sync with `manifest.json`: a permission added there needs a justification here and in the dashboard, or the version is rejected. +- `activeTab`: grants temporary access to the page only after the user invokes + the extension, so its dataset links can be inspected. +- `scripting`: injects the local, packaged dataset scanner into that active tab. +- `webRequest` and HTTP(S) host access: observes completed requests locally to + identify geospatial services used by interactive maps. +- `storage`: holds detected service URLs in session-only storage until their tab + closes so the popup can display them. -### activeTab - -activeTab grants temporary access to the current page only after the user clicks the extension's toolbar icon. The extension uses that access to read the page's links and structured metadata and pick out geospatial datasets, such as GeoJSON, GeoParquet, PMTiles, Cloud-Optimized GeoTIFF, and ZIP archives containing GeoJSON, which it then lists in the popup for the user to choose from. Access ends when the user leaves or reloads the page, and no page content is read at any other time. - -### scripting - -scripting injects the dataset scanner into the active tab when the user opens the popup. The scanner is packaged in the extension and is the code that reads the page's links and metadata; nothing is fetched or evaluated from a remote source. It runs once per invocation and returns the candidate dataset list to the popup. - -### storage - -storage provides chrome.storage.session, an in-memory area, where the extension keeps the list of map-service URLs detected in each tab so the popup can show them when the user opens it. Entries are keyed by tab id and are deleted when the tab navigates or is closed. No data is written to disk: chrome.storage.local and chrome.storage.sync are never used, and nothing is retained after the browsing session. - -### webRequest - -webRequest is used in observe-only mode. onBeforeRequest and onCompleted listeners read the URL of the page's own HTTP(S) requests to recognize the geospatial services an interactive map is loading: XYZ/TMS tiles, WMS, WMTS, WFS, OGC API Features, ArcGIS Feature Services, and vector tiles with their style JSON. A map requests these from JavaScript, so they never appear as links in the document and cannot be found by scanning the DOM. The extension does not use the blocking API, and does not read, modify, redirect, or cancel any request, header, or response body. Only the URL, resource type, and tab id are inspected, entirely locally in the service worker. - -### Host permissions - -Geospatial data and map services are published across the whole web, so the extension cannot know in advance which hosts to match. http://*/* and https://*/* serve as the URL filter for the observe-only webRequest listeners described above, and cover whichever page the user has open when they invoke the extension. No content script is declared, so nothing runs automatically on any site: the dataset scanner is injected only into the active tab, only after the user clicks the toolbar icon. The extension itself contacts no host. URLs leave the browser only when the user explicitly picks datasets and clicks Open in GeoLibre, which opens them in a new https://web.geolibre.app/ tab. - -Broad host permissions put the extension into Chrome's in-depth review, which delays publishing. They cannot be narrowed: webRequest only observes hosts it holds permission for, and the pages that embed map services are not a knowable list. - -### Not requested - -The extension does not request browsing history, downloads, cookies, tabs beyond the active one, or remote code. +The extension does not request browsing history, downloads, cookies, or remote +code. From afcb2c4d945a4a58c25f682419846bfc94d3ad0f Mon Sep 17 00:00:00 2001 From: giswqs Date: Tue, 18 Aug 2026 21:34:27 -0400 Subject: [PATCH 7/7] Address Claude review feedback - Reject a geometry whose longitudes are out of range before wrapping them, mirroring the latitude guard: the modulo would otherwise fold projected metres into a plausible-looking angle. The bound is 360, not 180, because planetary catalogs legitimately write 0-360 east longitude. - Extract the addCogLayer engine resolution into lib/cog-render-engine.ts and cover its three cases. Testing it through usePlugins.ts would have pulled the whole built-in plugin registry into the coverage denominator, which is the trap CLAUDE.md and plugin-query-api.test.ts both warn about. - Document options.engine in docs/plugin-api.md, including that it is a control-wide setting and that the GPU renderer requires Mercator. --- apps/geolibre-desktop/src/hooks/usePlugins.ts | 11 ++--- .../src/lib/cog-render-engine.ts | 28 +++++++++++ docs/plugin-api.md | 2 + packages/plugins/src/plugins/stac-api.ts | 5 ++ tests/cog-render-engine.test.ts | 28 +++++++++++ tests/stac-api.test.ts | 47 +++++++++++++++++++ 6 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/cog-render-engine.ts create mode 100644 tests/cog-render-engine.test.ts diff --git a/apps/geolibre-desktop/src/hooks/usePlugins.ts b/apps/geolibre-desktop/src/hooks/usePlugins.ts index 79c737f05c..485a424b03 100644 --- a/apps/geolibre-desktop/src/hooks/usePlugins.ts +++ b/apps/geolibre-desktop/src/hooks/usePlugins.ts @@ -100,6 +100,7 @@ import type { GeoLibreZarrQueryOptions, GeoLibreZarrQuerySelector, } from "@geolibre/plugins"; +import { cogEngineDefaults } from "../lib/cog-render-engine"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { readDir, readFile } from "@tauri-apps/plugin-fs"; @@ -934,14 +935,8 @@ export function createAppAPI(mapControllerRef?: RefObject) : undefined; return addRasterToMap(api, url, { name, - // The engine is a control-wide setting, not a per-layer one, so naming - // one here re-renders every raster already on the map. Callers that say - // nothing keep the long-standing GPU default: the WASM tiler is aimed at - // local files and can leave a remote programmatic layer registered - // without producing pixels. A caller that knows better (the STAC panel, - // where the user picks) opts in, and "auto" leaves the control alone. - defaults: - options?.engine === "auto" ? {} : { engine: options?.engine ?? "maplibre-gl-raster" }, + // Control-wide, not per layer: see cogEngineDefaults. + defaults: cogEngineDefaults(options?.engine), state: { ...(bands?.length ? { bands, mode: bands.length >= 3 ? "rgb" : "single" } : {}), ...(options?.colormap !== undefined ? { colormap: options.colormap } : {}), diff --git a/apps/geolibre-desktop/src/lib/cog-render-engine.ts b/apps/geolibre-desktop/src/lib/cog-render-engine.ts new file mode 100644 index 0000000000..836e135842 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/cog-render-engine.ts @@ -0,0 +1,28 @@ +import type { GeoLibreCogLayerOptions, GeoLibreCogRenderEngine } from "@geolibre/plugins"; + +/** + * The renderer the raster control decodes a COG with when a caller of + * `addCogLayer` says nothing. The WASM tiler is aimed at local files and can + * leave a remote programmatic layer registered without producing pixels, so a + * caller that has not thought about it keeps the GPU renderer. + */ +export const LEGACY_COG_ENGINE: GeoLibreCogRenderEngine = "maplibre-gl-raster"; + +/** + * Resolve the `defaults` fragment `addCogLayer` hands the raster control. + * + * The engine is a **control-wide** setting: naming one re-renders every raster + * already on the map, not just the layer being added. So `"auto"` has to yield + * no `engine` key at all rather than a default, which is what lets the STAC + * panel's "Leave unchanged" option add a layer without clobbering rasters that + * another panel put there. + * + * Lives here rather than inline in `usePlugins.ts` so it can be tested without + * importing the whole built-in plugin registry (see `plugin-query-api.test.ts`). + */ +export function cogEngineDefaults(engine: GeoLibreCogLayerOptions["engine"]): { + engine?: GeoLibreCogRenderEngine; +} { + if (engine === "auto") return {}; + return { engine: engine ?? LEGACY_COG_ENGINE }; +} diff --git a/docs/plugin-api.md b/docs/plugin-api.md index df126cb53c..9062c97eaf 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -542,6 +542,8 @@ const cogId = await app.addCogLayer?.( ); ``` +`options.engine` picks the renderer (`"maplibre-gl-raster"` for the GPU/deck.gl path, `"cog-tiler-wasm"` for the WebAssembly tiler, `"titiler"` for a TiTiler server). Unlike the other options it is **not per layer**: the raster control holds one engine for every raster it manages, so naming one re-renders the rasters already on the map. Pass `"auto"` to leave whatever the control is on alone; omit it and the GPU renderer is used. The GPU renderer requires a Mercator projection, so a plugin that expects to work on the globe should ask for `"cog-tiler-wasm"`. + `addTileLayer`/`addWmtsLayer`/`addWmsLayer` expect **pre-rendered tiles** (e.g. a COG already served through a tiler such as titiler as an XYZ endpoint). `addCogLayer` is different: it loads the **GeoTIFF itself** and renders it client-side, exposing band selection, rescale, colormap, and nodata in the raster panel. It is async (it fetches the file's header), so it returns a `Promise` and rejects if the COG cannot be read. The helpers are typed optional for forward-compatibility with host variants, so call them with optional chaining (`app.addTileLayer?.(...)`). diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 88c1fe1e29..ac1a67b2e9 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -669,6 +669,11 @@ export function itemBbox(item: StacItem): [number, number, number, number] | und const latitudes = positions.map(([, latitude]) => latitude); if (latitudes.some((latitude) => latitude < -90 || latitude > 90)) return undefined; + // The wrap below would fold any magnitude into a plausible-looking angle, so + // a geometry carrying the same projected-metre bug as the bbox has to be + // rejected the way an impossible latitude is. The bound is 360 rather than + // 180 because planetary catalogs legitimately write 0-360 east longitude. + if (positions.some(([longitude]) => longitude < -360 || longitude > 360)) return undefined; const longitudes = positions .map(([longitude]) => ((longitude % 360) + 360) % 360) .sort((a, b) => a - b); diff --git a/tests/cog-render-engine.test.ts b/tests/cog-render-engine.test.ts new file mode 100644 index 0000000000..2d5d72d404 --- /dev/null +++ b/tests/cog-render-engine.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + LEGACY_COG_ENGINE, + cogEngineDefaults, +} from "../apps/geolibre-desktop/src/lib/cog-render-engine"; + +// The raster control holds one engine for every raster it manages, so the +// difference between "no engine key" and "engine: " decides whether +// adding a COG silently re-renders layers another panel put on the map. That +// only shows up as a UI side effect, hence these assertions. +describe("cogEngineDefaults", () => { + it('omits the engine entirely for "auto" so the control is left alone', () => { + const defaults = cogEngineDefaults("auto"); + assert.deepEqual(defaults, {}); + assert.equal("engine" in defaults, false); + }); + + it("passes an explicit engine through so a caller can opt in", () => { + assert.deepEqual(cogEngineDefaults("titiler"), { engine: "titiler" }); + assert.deepEqual(cogEngineDefaults("cog-tiler-wasm"), { engine: "cog-tiler-wasm" }); + }); + + it("keeps the legacy GPU default for a caller that says nothing", () => { + assert.deepEqual(cogEngineDefaults(undefined), { engine: LEGACY_COG_ENGINE }); + assert.equal(LEGACY_COG_ENGINE, "maplibre-gl-raster"); + }); +}); diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 6246ca9b96..f8a99dfc77 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -1487,4 +1487,51 @@ test("asset and bbox helpers recognize common STAC data", () => { }), undefined, ); + // A geometry carrying the same projected-metre bug as the bbox must not be + // wrapped into a plausible-looking angle. + assert.equal( + itemBbox({ + type: "Feature", + id: "projected-geometry", + bbox: [7_112_945, -1_778_200, 10_669_445, -3_852_900], + geometry: { + type: "Polygon", + coordinates: [ + [ + [7_112_945, -30], + [10_669_445, -30], + [10_669_445, -65], + [7_112_945, -65], + [7_112_945, -30], + ], + ], + }, + properties: {}, + assets: {}, + }), + undefined, + ); + // 0-360 east longitude is a real planetary convention, not a broken CRS. + assert.deepEqual( + itemBbox({ + type: "Feature", + id: "east-longitude", + bbox: [7_112_945, -1_778_200, 10_669_445, -3_852_900], + geometry: { + type: "Polygon", + coordinates: [ + [ + [200, -30], + [240, -30], + [240, -65], + [200, -65], + [200, -30], + ], + ], + }, + properties: {}, + assets: {}, + }), + [-160, -65, -120, -30], + ); });