diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 2f9d43a88d..72443d4777 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -916,6 +916,13 @@ export function TopToolbar({ showingOfMatched: (count, matched) => t("stacPlugin.showingOfMatched", { count, matched }), loadMore: t("stacPlugin.loadMore"), renderOptions: t("stacPlugin.renderOptions"), + 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 31f72f3148..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,11 +935,8 @@ 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" }, + // 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/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/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/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index f66a71a8c1..b05b3bcb50 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,34 @@ 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"; +// "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(): StacCogEngine { + let saved: string | null = null; + try { + saved = + typeof localStorage === "undefined" ? null : localStorage.getItem(COG_ENGINE_STORAGE_KEY); + } catch { + return "auto"; + } + return COG_ENGINES.includes(saved as StacCogEngine) ? (saved as StacCogEngine) : "auto"; +} + +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 @@ -129,6 +162,13 @@ export interface StacLabels { searchFailed: string; loadMore: string; renderOptions: string; + renderingEngine: string; + engineAuto: string; + engineGpu: string; + engineWasm: string; + engineTitiler: string; + engineHint: string; + resizeResults: string; bands: string; bandsPlaceholder: string; colormap: string; @@ -206,6 +246,15 @@ let labels: StacLabels = { searchFailed: "STAC search failed", loadMore: "Load more", renderOptions: "Raster rendering options", + 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)", @@ -254,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;" + @@ -274,11 +330,13 @@ 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. - results: - "display:flex;flex:1 1 auto;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;", + // 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:${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);", 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 +719,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 +793,29 @@ 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 [ + ["auto", labels.engineAuto], + ["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", () => { + rememberCogEngine(engineSelect.value); + }); + 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"); @@ -761,6 +842,8 @@ 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, bandsField.wrap, @@ -768,12 +851,20 @@ function buildPanel(container: HTMLElement): () => void { rescaleRow, nodataField.wrap, renderHint, + engineWrap, ); const status = el("div", labels.initialStatus); 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", labels.resizeResults); + resultSplitter.setAttribute("aria-valuemin", String(RESULTS_MIN_HEIGHT)); + resultSplitter.tabIndex = 0; const loadMore = el("button", labels.loadMore); loadMore.type = "button"; loadMore.style.cssText = style.primary; @@ -781,7 +872,71 @@ 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); + + // 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 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(RESULTS_MIN_HEIGHT, 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 + // 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 = splitterBounds(); + const next = Math.min(maximum, Math.max(RESULTS_MIN_HEIGHT, height)); + results.style.flexBasis = `${next}px`; + announceResults(next, maximum); + }; + + resultSplitter.addEventListener("focus", () => { + announceResults(results.getBoundingClientRect().height, splitterBounds()); + }); + + 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 +969,9 @@ function buildPanel(container: HTMLElement): () => void { const rescaleMax = numeric(vmaxField.input); const nodata = numeric(nodataField.input); return { + // "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/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 28014a5356..ac1a67b2e9 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,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..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; +} + function absoluteHref(href: string, base: string): string { - return new URL(href, base).href; + return browserCatalogHref(new URL(href, base).href); } /** @@ -186,7 +217,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 +358,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 +617,78 @@ 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); + // 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 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); + 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..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 @@ -177,6 +180,14 @@ export interface GeoLibreOvertureQueryResult { * the GeoTIFF when they are omitted. */ export interface GeoLibreCogLayerOptions { + /** + * 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; /** 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 da9c9e560a..f8a99dfc77 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -161,6 +161,61 @@ 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("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")) { @@ -1363,4 +1418,120 @@ 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], + ); + 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], + ); + // 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, + ); + // 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], + ); });