Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
giswqs marked this conversation as resolved.
Outdated
bands: t("stacPlugin.bands"),
bandsPlaceholder: t("stacPlugin.bandsPlaceholder"),
colormap: t("stacPlugin.colormap"),
Expand Down
8 changes: 3 additions & 5 deletions apps/geolibre-desktop/src/hooks/usePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,11 +934,9 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
: 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" },
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated
state: {
...(bands?.length ? { bands, mode: bands.length >= 3 ? "rgb" : "single" } : {}),
...(options?.colormap !== undefined ? { colormap: options.colormap } : {}),
Expand Down
118 changes: 111 additions & 7 deletions packages/plugins/src/plugins/maplibre-stac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,6 +48,32 @@ 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;

// 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 {
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
Expand Down Expand Up @@ -129,6 +160,10 @@ export interface StacLabels {
searchFailed: string;
loadMore: string;
renderOptions: string;
renderingEngine: string;
engineGpu: string;
engineWasm: string;
engineTitiler: string;
bands: string;
bandsPlaceholder: string;
colormap: string;
Expand Down Expand Up @@ -206,6 +241,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)",
Expand Down Expand Up @@ -274,11 +313,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));",
Expand Down Expand Up @@ -661,9 +704,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({
Expand Down Expand Up @@ -735,6 +778,26 @@ 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();
Comment thread
giswqs marked this conversation as resolved.
engineSelect.addEventListener("change", () => {
rememberCogEngine(engineSelect.value);
});
engineWrap.append(engineCaption, engineSelect);
const bandsField = field(labels.bands);
bandsField.input.placeholder = labels.bandsPlaceholder;
const colormapWrap = el("label");
Expand Down Expand Up @@ -763,6 +826,7 @@ function buildPanel(container: HTMLElement): () => void {
renderHint.style.cssText = style.status;
renderSection.append(
renderSummary,
engineWrap,
bandsField.wrap,
colormapWrap,
rescaleRow,
Expand All @@ -774,14 +838,53 @@ 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");
Comment thread
giswqs marked this conversation as resolved.
resultSplitter.setAttribute("aria-orientation", "horizontal");
resultSplitter.setAttribute("aria-label", "Resize search results");
Comment thread
giswqs marked this conversation as resolved.
Outdated
resultSplitter.tabIndex = 0;
const loadMore = el("button", labels.loadMore);
loadMore.type = "button";
loadMore.style.cssText = style.primary;
loadMore.hidden = true;
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[] = [];
Expand Down Expand Up @@ -814,6 +917,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 } : {}),
Expand Down
102 changes: 98 additions & 4 deletions packages/plugins/src/plugins/stac-api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -137,8 +138,38 @@ 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 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.<region>.amazonaws.com` fails TLS validation.
*/
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;
const [, bucket, region] = website;
url.protocol = "https:";
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function absoluteHref(href: string, base: string): string {
return new URL(href, base).href;
return browserCatalogHref(new URL(href, base).href);
}

/**
Expand Down Expand Up @@ -186,7 +217,7 @@ export function isAzureBlobHref(href: string): boolean {
}

async function fetchJson<T>(url: string, init: RequestInit, fetcher: FetchLike): Promise<T> {
const response = await fetcher(url, {
const response = await fetcher(browserCatalogHref(url), {
...init,
headers: { Accept: "application/geo+json, application/json", ...init.headers },
});
Expand Down Expand Up @@ -327,7 +358,7 @@ export async function connectStac(
signal?: AbortSignal,
): Promise<StacConnection> {
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<Record<string, unknown>>(url, { signal }, fetcher);
if (typeof root !== "object" || root === null)
throw new Error("The URL did not return a STAC document");
Expand Down Expand Up @@ -586,7 +617,70 @@ 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);
};
// 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);
if (latitudes.some((latitude) => latitude < -90 || latitude > 90)) return advertised;
Comment thread
giswqs marked this conversation as resolved.
Outdated
const longitudes = positions
.map(([longitude]) => ((longitude % 360) + 360) % 360)
Comment thread
giswqs marked this conversation as resolved.
.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)];
Comment thread
giswqs marked this conversation as resolved.
}

/** A format {@link assetFormat} recognizes, and {@link visualizeAsset} knows how to add. */
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
giswqs marked this conversation as resolved.
Outdated

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;
/**
Expand Down
Loading
Loading