Skip to content

Commit 8f32885

Browse files
authored
feat(plugins): add a STAC catalogs browser plugin (#1516)
* feat(plugins): add a STAC catalogs browser plugin Browsing SpatioTemporal Asset Catalogs previously meant leaving the app or hand-adding COG URLs. This plugin discovers catalogs from STAC Index, connects to both static catalogs and STAC APIs, and adds visualizable assets as layers. * Address CodeRabbit review feedback - Thread the panel's AbortController signal through visualizeAsset so an in-flight GeoJSON asset fetch is cancelled when the panel unmounts instead of leaving the Add button disabled indefinitely. - Normalize item.bbox through itemBbox() before the extent test in searchStaticStac; a 3D (6-element) bbox was read as 2D, comparing minZ against the query's minX and silently mis-filtering items with an elevation extent. - Cover the searchStacApi GET fallback and the 3D-bbox static filter case. * fix(stac): collapse per-asset buttons into a dropdown, resize collections An item with ten assets rendered twenty buttons, so a page of twenty results was a wall of them. Each card is now Zoom plus one asset dropdown with a single Add and Download, preselecting the first asset that can go on the map and disabling Add with an explanatory tooltip for the rest. The collection list is also drag-resizable, since catalogs can advertise well over a hundred collections into a three-row box. * fix(stac): register search footprints as a real layer The footprints were pushed straight onto the MapLibre style, so they never appeared in the Layers panel and there was no way to hide or remove them. They are a store GeoJSON layer now, with visibility, opacity, styling, and delete working like any other layer, and they persist when the panel closes. Repeat searches patch the existing layer instead of stacking duplicates, and a search after the user deletes it adds it back. * feat(stac): footprint selection and raster rendering options Clicking a footprint on the map now selects that item: the matching result card is outlined and scrolled into view, and the footprint is highlighted. Clicking a card selects it too, so the map and the list stay in step both ways. A fresh search clears the selection; Load more keeps it. Adds a collapsed "Raster rendering options" section feeding addCogLayer, so a GeoTIFF asset can be added with a band combination, named colormap, min/max rescale, and NoData override instead of only the renderer's inferred defaults. Result cards get a minimum height and the filter sections scroll as a group, so opening the new section cannot push the results off the panel. * Address CodeRabbit review feedback - Mark the footprints layer with metadata.sourceKind and look it up by that marker as well as by id. The layer is saved into the project while footprintLayerId only lives for the session, so a search after reopening a saved project added a second copy and deactivation could not remove the restored one. * fix(stac): expose raster symbology for COG assets * feat(stac): extend and clear catalog searches * fix(stac): render COG assets from direct HTTP URLs * fix(layers): scroll and copy metadata * i18n: translate the STAC Catalogs plugin name into the 15 non-English catalogs The plugin name added in this branch was the only key missing anywhere, so every catalog fell back to English in the Plugins menu. STAC stays Latin like the other acronym-named entries (NASA Earthdata, FEMA NFHL); only "Catalogs" is translated, reusing each catalog's existing word for it. * i18n: migrate the STAC panel strings to the label-push pattern The panel built its DOM with English literals, so it rendered English in every locale no matter what the catalogs said. It now takes its 64 user-facing strings from a StacLabels record that the host fills with t() and re-pushes on language change, matching maplibre-graticule and maplibre-timelapse. All 15 non-English catalogs are translated, so coverage stays at 100 percent. Count and asset strings follow the h3Plugin.cellCount precedent of a single form phrased neutrally rather than CLDR plural sets.
1 parent fe8466c commit 8f32885

27 files changed

Lines changed: 2966 additions & 42 deletions

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
setHuggingFaceLabels,
3030
setSourceCoopLabels,
3131
setReverseGeocodeLabels,
32+
setStacLabels,
3233
setTimelapseLabels,
3334
DECK_VIZ_PLUGIN_ID,
3435
DIRECTIONS_PLUGIN_ID,
@@ -572,6 +573,73 @@ export function TopToolbar({
572573
recordingUnsupported: t("timelapse.recordingUnsupported"),
573574
loadingTiles: t("timelapse.loadingTiles"),
574575
});
576+
setStacLabels({
577+
title: t("stacPlugin.title"),
578+
getTitle: () => i18n.t("stacPlugin.title"),
579+
footprintLayerName: t("stacPlugin.footprintLayerName"),
580+
catalogSearch: t("stacPlugin.catalogSearch"),
581+
catalogSearchPlaceholder: t("stacPlugin.catalogSearchPlaceholder"),
582+
indexLoading: t("stacPlugin.indexLoading"),
583+
indexUnavailable: t("stacPlugin.indexUnavailable"),
584+
indexLoadFailed: t("stacPlugin.indexLoadFailed"),
585+
urlLabel: t("stacPlugin.urlLabel"),
586+
connect: t("stacPlugin.connect"),
587+
connecting: t("stacPlugin.connecting"),
588+
connected: t("stacPlugin.connected"),
589+
connectFailed: t("stacPlugin.connectFailed"),
590+
selectCatalog: t("stacPlugin.selectCatalog"),
591+
noMatchingCatalogs: t("stacPlugin.noMatchingCatalogs"),
592+
catalogApiSuffix: t("stacPlugin.catalogApiSuffix"),
593+
catalogStaticSuffix: t("stacPlugin.catalogStaticSuffix"),
594+
kindApi: t("stacPlugin.kindApi"),
595+
kindStatic: t("stacPlugin.kindStatic"),
596+
collectionsHint: t("stacPlugin.collectionsHint"),
597+
limitToExtent: t("stacPlugin.limitToExtent"),
598+
bboxLabel: t("stacPlugin.bboxLabel"),
599+
bboxPlaceholder: t("stacPlugin.bboxPlaceholder"),
600+
bboxInvalid: t("stacPlugin.bboxInvalid"),
601+
drawBbox: t("stacPlugin.drawBbox"),
602+
cancelDrawing: t("stacPlugin.cancelDrawing"),
603+
clearDrawnBbox: t("stacPlugin.clearDrawnBbox"),
604+
drawHint: t("stacPlugin.drawHint"),
605+
drawnBbox: (bbox) => t("stacPlugin.drawnBbox", { bbox }),
606+
drawnBboxCleared: t("stacPlugin.drawnBboxCleared"),
607+
mapNotReady: t("stacPlugin.mapNotReady"),
608+
startDate: t("stacPlugin.startDate"),
609+
endDate: t("stacPlugin.endDate"),
610+
additionalParams: t("stacPlugin.additionalParams"),
611+
additionalInvalid: t("stacPlugin.additionalInvalid"),
612+
searchItems: t("stacPlugin.searchItems"),
613+
clearResults: t("stacPlugin.clearResults"),
614+
resultsCleared: t("stacPlugin.resultsCleared"),
615+
searching: t("stacPlugin.searching"),
616+
loadingMore: t("stacPlugin.loadingMore"),
617+
noResults: t("stacPlugin.noResults"),
618+
searchFailed: t("stacPlugin.searchFailed"),
619+
showing: (count) => t("stacPlugin.showing", { count }),
620+
showingOfMatched: (count, matched) => t("stacPlugin.showingOfMatched", { count, matched }),
621+
loadMore: t("stacPlugin.loadMore"),
622+
renderOptions: t("stacPlugin.renderOptions"),
623+
bands: t("stacPlugin.bands"),
624+
bandsPlaceholder: t("stacPlugin.bandsPlaceholder"),
625+
colormap: t("stacPlugin.colormap"),
626+
colormapDefault: t("stacPlugin.colormapDefault"),
627+
minValue: t("stacPlugin.minValue"),
628+
maxValue: t("stacPlugin.maxValue"),
629+
nodata: t("stacPlugin.nodata"),
630+
nodataPlaceholder: t("stacPlugin.nodataPlaceholder"),
631+
renderHint: t("stacPlugin.renderHint"),
632+
initialStatus: t("stacPlugin.initialStatus"),
633+
catalogInfo: (title, kind) => t("stacPlugin.catalogInfo", { title, kind }),
634+
zoom: t("stacPlugin.zoom"),
635+
add: t("stacPlugin.add"),
636+
download: t("stacPlugin.download"),
637+
adding: (asset) => t("stacPlugin.adding", { asset }),
638+
added: (asset) => t("stacPlugin.added", { asset }),
639+
addUnsupported: t("stacPlugin.addUnsupported"),
640+
addFailed: t("stacPlugin.addFailed"),
641+
cogUnsupported: t("stacPlugin.cogUnsupported"),
642+
});
575643
}, [t]);
576644

577645
const setProcessingOpen = useAppStore((s) => s.setProcessingOpen);

apps/geolibre-desktop/src/components/panels/LayerPanel.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ export function LayerPanel({
602602
const [editingName, setEditingName] = useState("");
603603
const [basemapPickerOpen, setBasemapPickerOpen] = useState(false);
604604
const [metadataLayer, setMetadataLayer] = useState<GeoLibreLayer | null>(null);
605+
const [metadataCopied, setMetadataCopied] = useState(false);
605606
// GeoTIFF header facts (CRS, pixel size, storage) for the raster whose
606607
// metadata dialog is open. The store layer does not carry them, so they are
607608
// read from the file on open (#1420): "loading" while the header is being
@@ -799,6 +800,23 @@ export function LayerPanel({
799800
// under the new layer's name.
800801
const metadataRasterInfo =
801802
rasterInfoState && rasterInfoState.layerId === metadataLayer?.id ? rasterInfoState : null;
803+
const metadataJson = metadataLayer
804+
? JSON.stringify(
805+
layerMetadataPayload(
806+
metadataLayer,
807+
metadataRasterInfo?.status === "ready" ? metadataRasterInfo.info : null,
808+
),
809+
null,
810+
2,
811+
)
812+
: "";
813+
const copyMetadata = useCallback(() => {
814+
if (!metadataJson) return;
815+
void navigator.clipboard
816+
?.writeText(metadataJson)
817+
.then(() => setMetadataCopied(true))
818+
.catch(() => setMetadataCopied(false));
819+
}, [metadataJson]);
802820
const refreshSettingsLayer = refreshSettingsLayerId
803821
? (layers.find((layer) => layer.id === refreshSettingsLayerId) ?? null)
804822
: null;
@@ -3555,7 +3573,10 @@ export function LayerPanel({
35553573
<Dialog
35563574
open={!!metadataLayer}
35573575
onOpenChange={(open: boolean) => {
3558-
if (!open) setMetadataLayer(null);
3576+
if (!open) {
3577+
setMetadataLayer(null);
3578+
setMetadataCopied(false);
3579+
}
35593580
}}
35603581
>
35613582
<DialogContent
@@ -3603,27 +3624,27 @@ export function LayerPanel({
36033624
</DialogTitle>
36043625
<DialogDescription>{t("layers.metadataDialogDescription")}</DialogDescription>
36053626
</DialogHeader>
3627+
<div className="flex justify-end">
3628+
<Button type="button" variant="outline" size="sm" onClick={copyMetadata}>
3629+
<Copy className="h-4 w-4" />
3630+
{metadataCopied ? t("attributeStats.copiedToClipboard") : t("attributeStats.copy")}
3631+
</Button>
3632+
</div>
36063633
{metadataRasterInfo && metadataRasterInfo.status !== "ready" && (
36073634
<p className="text-xs text-muted-foreground">
36083635
{metadataRasterInfo.status === "loading"
36093636
? t("layers.metadataRasterLoading")
36103637
: t("layers.metadataRasterError")}
36113638
</p>
36123639
)}
3613-
{/* Capped at a readable height until the user resizes the dialog,
3614-
after which the payload fills whatever height they chose. */}
3615-
<ScrollArea className={cn("min-h-0", metadataDialogSize ? "flex-1" : "max-h-80")}>
3616-
<pre className="whitespace-pre-wrap break-all text-xs">
3617-
{metadataLayer &&
3618-
JSON.stringify(
3619-
layerMetadataPayload(
3620-
metadataLayer,
3621-
metadataRasterInfo?.status === "ready" ? metadataRasterInfo.info : null,
3622-
),
3623-
null,
3624-
2,
3625-
)}
3626-
</pre>
3640+
{/* A definite initial height lets Radix measure overflow on first
3641+
layout; max-height alone left its viewport unconstrained until
3642+
the resize handle caused a second measurement. */}
3643+
<ScrollArea
3644+
type="auto"
3645+
className={cn("min-h-0", metadataDialogSize ? "flex-1" : "h-80 shrink-0")}
3646+
>
3647+
<pre className="whitespace-pre-wrap break-all text-xs">{metadataJson}</pre>
36273648
</ScrollArea>
36283649
</DialogContent>
36293650
</Dialog>

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
useAppStore,
55
} from "@geolibre/core";
66
import {
7-
addCogRasterLayer,
7+
addRasterToMap,
88
addZarrRasterLayer,
99
buildSelectorTimeBinding,
1010
registerTemporalLayer,
@@ -35,6 +35,7 @@ import {
3535
maplibreNasaEarthdataPlugin,
3636
maplibreNationalMapPlugin,
3737
maplibreOpenAerialMapPlugin,
38+
maplibreStacCatalogsPlugin,
3839
maplibreSourceCoopPlugin,
3940
maplibreNaturalEarthPlugin,
4041
maplibreHuggingFacePlugin,
@@ -168,6 +169,7 @@ manager.registerAll([
168169
maplibreNationalMapPlugin,
169170
maplibreEarthdataGisPlugin,
170171
maplibreOpenAerialMapPlugin,
172+
maplibreStacCatalogsPlugin,
171173
maplibreSourceCoopPlugin,
172174
maplibreNaturalEarthPlugin,
173175
maplibreHuggingFacePlugin,
@@ -799,7 +801,7 @@ export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapController
799801
export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) {
800802
const store = useAppStore.getState();
801803
// Captured so methods that delegate to plugin helpers taking the AppAPI
802-
// itself (e.g. addCogLayer -> addCogRasterLayer) can pass `api`. Only read
804+
// itself (e.g. addCogLayer -> addRasterToMap) can pass `api`. Only read
803805
// when those methods are called, which is always after assignment.
804806
const api = {
805807
setBasemap: (url: string) => store.setBasemapStyleUrl(url),
@@ -884,30 +886,36 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
884886
beforeLayerId ?? null,
885887
);
886888
},
887-
// Unlike the tile helpers above, a COG is read client-side by the maplibre
888-
// raster control (band/rescale/colormap/nodata), so it delegates to the
889-
// components plugin's addCogRasterLayer rather than building a store layer
890-
// here. It takes the AppAPI itself (to mount the control on demand), so we
891-
// hand it the captured `api`.
892-
addCogLayer: (name: string, url: string, options?: GeoLibreCogLayerOptions) =>
893-
addCogRasterLayer(api, {
894-
url,
889+
// Unlike the tile helpers above, a COG is read client-side by the shared
890+
// raster control. Besides keeping every COG path on one renderer, this is
891+
// what mirrors the layer as `maplibre-gl-raster`, making the full Raster
892+
// symbology section available in the Style panel.
893+
addCogLayer: (name: string, url: string, options?: GeoLibreCogLayerOptions) => {
894+
const bands = options?.bands
895+
?.split(",")
896+
.map((value) => Number(value.trim()))
897+
.filter((value) => Number.isInteger(value) && value > 0);
898+
const range =
899+
options?.rescaleMin !== undefined && options.rescaleMax !== undefined
900+
? ([options.rescaleMin, options.rescaleMax] as [number, number])
901+
: undefined;
902+
return addRasterToMap(api, url, {
895903
name,
896-
...(options?.bands !== undefined ? { bands: options.bands } : {}),
897-
// The public option is a loose `string` (so JS plugins need not import
898-
// the renderer's colormap union); the renderer validates the name and
899-
// falls back to its default for anything it doesn't recognize.
900-
...(options?.colormap !== undefined
901-
? {
902-
colormap: options.colormap as Parameters<typeof addCogRasterLayer>[1]["colormap"],
903-
}
904-
: {}),
905-
...(options?.rescaleMin !== undefined ? { rescaleMin: options.rescaleMin } : {}),
906-
...(options?.rescaleMax !== undefined ? { rescaleMax: options.rescaleMax } : {}),
907-
...(options?.nodata !== undefined ? { nodata: options.nodata } : {}),
908-
...(options?.opacity !== undefined ? { opacity: options.opacity } : {}),
909-
beforeLayerId: options?.beforeLayerId ?? null,
910-
}),
904+
// STAC assets are already COGs with an HTTP(S) range-readable URL.
905+
// Render them directly through the GPU COG engine; the WASM tiler is
906+
// intended for local files and can leave remote programmatic layers
907+
// registered without producing pixels.
908+
defaults: { engine: "maplibre-gl-raster" },
909+
state: {
910+
...(bands?.length ? { bands, mode: bands.length >= 3 ? "rgb" : "single" } : {}),
911+
...(options?.colormap !== undefined ? { colormap: options.colormap } : {}),
912+
...(range ? { rescale: [range] } : {}),
913+
...(options?.nodata !== undefined ? { nodata: options.nodata } : {}),
914+
...(options?.opacity !== undefined ? { opacity: options.opacity } : {}),
915+
},
916+
...(options?.beforeLayerId ? { beforeId: options.beforeLayerId } : {}),
917+
});
918+
},
911919
// Zarr goes through the components plugin's shared @carbonplan/zarr-layer
912920
// control for the same reason as addCogLayer: the host owns the renderer, so
913921
// a plugin does not bundle (and fail to activate) a second copy.

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2569,6 +2569,7 @@
25692569
"geolibre-timelapse": "التسلسل الزمني",
25702570
"maplibre-gl-esri-wayback": "الصور التاريخية",
25712571
"maplibre-gl-openaerialmap": "OpenAerialMap",
2572+
"geolibre-stac-catalogs": "كتالوجات STAC",
25722573
"maplibre-gl-source-coop": "Source Cooperative",
25732574
"maplibre-gl-natural-earth": "Natural Earth",
25742575
"maplibre-gl-swipe": "مقارنة الطبقات بالسحب",
@@ -4982,5 +4983,71 @@
49824983
"distributionAria": "توزيع {{field}}: {{total}} قيمة من {{min}} إلى {{max}}",
49834984
"noPopulated": "لا توجد قيم مُعبّأة.",
49844985
"moreValues": "+{{total}} قيمة أخرى"
4986+
},
4987+
"stacPlugin": {
4988+
"title": "كتالوجات STAC",
4989+
"footprintLayerName": "حدود نتائج بحث STAC",
4990+
"catalogSearch": "البحث عن كتالوج عام في STAC Index",
4991+
"catalogSearchPlaceholder": "البحث في أسماء الكتالوجات…",
4992+
"indexLoading": "جارٍ تحميل STAC Index…",
4993+
"indexUnavailable": "STAC Index غير متاح: أدخل عنوان URL",
4994+
"indexLoadFailed": "تعذّر تحميل STAC Index",
4995+
"urlLabel": "عنوان URL لكتالوج أو واجهة STAC",
4996+
"connect": "اتصال",
4997+
"connecting": "جارٍ الاتصال بـ STAC…",
4998+
"connected": "تم الاتصال. اختر عوامل التصفية ثم ابحث.",
4999+
"connectFailed": "تعذّر الاتصال بـ STAC",
5000+
"selectCatalog": "اختر كتالوجًا…",
5001+
"noMatchingCatalogs": "لا توجد كتالوجات مطابقة",
5002+
"catalogApiSuffix": " (API)",
5003+
"catalogStaticSuffix": " (ثابت)",
5004+
"kindApi": "واجهة STAC",
5005+
"kindStatic": "كتالوج ثابت",
5006+
"collectionsHint": "اضغط Ctrl/Cmd لتحديد عدة مجموعات؛ اسحب الحافة السفلية لتغيير الحجم",
5007+
"limitToExtent": "حصر البحث في نطاق الخريطة الحالي",
5008+
"bboxLabel": "الصندوق المحيط (غرب، جنوب، شرق، شمال)",
5009+
"bboxPlaceholder": "اختياري؛ يتجاوز نطاق الخريطة",
5010+
"bboxInvalid": "أدخل صندوقًا محيطًا صالحًا: غرب، جنوب، شرق، شمال.",
5011+
"drawBbox": "رسم صندوق على الخريطة",
5012+
"cancelDrawing": "إلغاء الرسم",
5013+
"clearDrawnBbox": "مسح الصندوق المرسوم",
5014+
"drawHint": "انقر واسحب على الخريطة لرسم صندوق البحث.",
5015+
"drawnBbox": "الصندوق المرسوم: {{bbox}}",
5016+
"drawnBboxCleared": "تم مسح الصندوق المرسوم.",
5017+
"mapNotReady": "الخريطة غير جاهزة للرسم.",
5018+
"startDate": "تاريخ البدء",
5019+
"endDate": "تاريخ الانتهاء",
5020+
"additionalParams": "معايير بحث إضافية (JSON، لواجهة STAC فقط)",
5021+
"additionalInvalid": "يجب أن تكون معايير البحث الإضافية كائن JSON.",
5022+
"searchItems": "البحث عن العناصر",
5023+
"clearResults": "مسح النتائج",
5024+
"resultsCleared": "تم مسح نتائج البحث.",
5025+
"searching": "جارٍ البحث عن عناصر STAC…",
5026+
"loadingMore": "جارٍ تحميل مزيد من العناصر…",
5027+
"noResults": "لا توجد عناصر STAC مطابقة لعوامل التصفية هذه.",
5028+
"searchFailed": "فشل بحث STAC",
5029+
"showing": "العناصر المعروضة: {{count}}.",
5030+
"showingOfMatched": "العناصر المعروضة: {{count}} من {{matched}}.",
5031+
"loadMore": "تحميل المزيد",
5032+
"renderOptions": "خيارات عرض الراستر",
5033+
"bands": "النطاقات",
5034+
"bandsPlaceholder": "مثال: 1 أو 1,2,3 (الافتراضي: تلقائي)",
5035+
"colormap": "خريطة الألوان (نطاق واحد فقط)",
5036+
"colormapDefault": "الإعداد الافتراضي للعارض",
5037+
"minValue": "القيمة الدنيا",
5038+
"maxValue": "القيمة القصوى",
5039+
"nodata": "قيمة NoData",
5040+
"nodataPlaceholder": "يتجاوز وسم NoData في الملف",
5041+
"renderHint": "اترك الحقل فارغًا ليستنتج العارض القيمة من ملف GeoTIFF. تنطبق الخيارات على الأصول المضافة بعد تغييرها.",
5042+
"initialStatus": "اختر كتالوجًا من STAC Index أو أدخل عنوان URL.",
5043+
"catalogInfo": "{{title}} · {{kind}}",
5044+
"zoom": "تكبير",
5045+
"add": "إضافة",
5046+
"download": "تنزيل",
5047+
"adding": "جارٍ إضافة {{asset}}…",
5048+
"added": "تمت إضافة {{asset}} إلى الخريطة.",
5049+
"addUnsupported": "يمكن إضافة أصول GeoTIFF/COG وGeoJSON فقط إلى الخريطة",
5050+
"addFailed": "تعذّرت إضافة الأصل",
5051+
"cogUnsupported": "لا يستطيع مضيف GeoLibre هذا عرض أصول GeoTIFF البعيدة"
49855052
}
49865053
}

0 commit comments

Comments
 (0)