diff --git a/apps/geolibre-desktop/package.json b/apps/geolibre-desktop/package.json index f07bae073..cb1899675 100644 --- a/apps/geolibre-desktop/package.json +++ b/apps/geolibre-desktop/package.json @@ -86,7 +86,7 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.7", + "maplibre-gl-vector": "^0.10.8", "openai": "^7.3.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", diff --git a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx index 17893944d..3548ee022 100644 --- a/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx +++ b/apps/geolibre-desktop/src/components/layout/DesktopShell.tsx @@ -84,6 +84,7 @@ import { import { buildKmlModelLayer } from "../../lib/kml-model-layer"; import { isPhotoDropFileName, type GeotaggedPhotoResult } from "../../lib/geotagged-photos"; import type { LargeVectorDataset } from "../../lib/duckdb-vector-guard"; +import { detectNonGeographicCoordinates } from "@geolibre/core"; import { PANEL_RESIZE_END_EVENT, PANEL_RESIZE_START_EVENT } from "../../lib/panel-resize"; import i18n from "../../i18n"; import { @@ -806,6 +807,11 @@ export function DesktopShell({ const [mapReadyGeneration, setMapReadyGeneration] = useState(0); const [dropMessage, setDropMessage] = useState(null); const [dropError, setDropError] = useState(null); + // Kept out of `dropError` because the drop handler sets its own success + // message after `addImportedVectorLayers` returns, which would clobber this + // one. A mislabelled-CRS layer loads *successfully* and still renders + // nowhere, so both messages are true at once and need separate slots. + const [crsWarning, setCrsWarning] = useState(null); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const diagnostics = useDiagnosticsSnapshot(); const externalPluginsReady = useExternalPluginsReady(mapControllerRef); @@ -1251,6 +1257,9 @@ export function DesktopShell({ const addImportedVectorLayers = useCallback( (importedLayers: ImportedVectorLayer[]) => { let lastLayerId: string | null = null; + // Layers whose coordinates cannot be WGS84; surfaced together after the + // loop so a multi-file drop reports once rather than per file. + const nonGeographic: string[] = []; // Frame ids for each time-animated overlay sequence (keyed by the loader's // group marker), so they can be gathered into one layer group afterward. const frameGroups = new Map(); @@ -1301,13 +1310,30 @@ export function DesktopShell({ } // `||` (not `??`) so an empty-string name falls back to the path, and // matches the name shown in the drop confirmation toast. - lastLayerId = addGeoJsonLayer( - layer.name || layerNameFromPath(layer.path), - layer.data, - layer.path, - ); + const layerName = layer.name || layerNameFromPath(layer.path); + // A file that declares WGS84 but holds projected coordinates loads + // cleanly, lists in the Layers panel, and renders nowhere — the map + // simply never moves. Warn rather than fail: the data is readable and + // only the user knows its true CRS. + const offRange = detectNonGeographicCoordinates(layer.data); + if (offRange) { + nonGeographic.push(layerName); + console.warn( + `[GeoLibre] "${layerName}" declares geographic coordinates but its values are out of range ` + + `(max |x| ${Math.round(offRange.maxAbsX).toLocaleString()}, max |y| ${Math.round(offRange.maxAbsY).toLocaleString()} ` + + `over ${offRange.sampled.toLocaleString()} sampled coordinates). The file's CRS is almost certainly ` + + `mislabelled — reproject it, or correct its .prj/crs, and load it again.`, + ); + } + lastLayerId = addGeoJsonLayer(layerName, layer.data, layer.path); } + setCrsWarning( + nonGeographic.length > 0 + ? t("addData.nonGeographicCoordinates", { names: nonGeographic.join(", ") }) + : null, + ); + // Gather each time-animated overlay's frames into one collapsible group so // the sequence reads as a single timeline entry, not N stacked layers. const sequences = [...frameGroups.values()].filter((ids) => ids.length > 1); @@ -1363,6 +1389,10 @@ export function DesktopShell({ useEffect(() => { setKmlFileImportHandler(async (imports) => { setDropError(null); + // Matches the drop handlers: the catch below sets `dropError` without + // reaching `addImportedVectorLayers`, so without this a previous file's + // banner would sit beside the new error. + setCrsWarning(null); try { const paths = imports .map(({ sourcePath }) => sourcePath) @@ -1372,12 +1402,12 @@ export function DesktopShell({ // pyramid, which a path-less browser File cannot support. const layers = paths.length === imports.length - ? await loadDroppedVectorPaths(paths, { - onLargeDataset: confirmLargeVectorDataset, - }) + ? await loadDroppedVectorPaths(paths, { onLargeDataset: confirmLargeVectorDataset }) : await loadDroppedVectorFiles( imports.map(({ file }) => file), - { onLargeDataset: confirmLargeVectorDataset }, + { + onLargeDataset: confirmLargeVectorDataset, + }, ); addImportedVectorLayers(layers); } catch (error) { @@ -1385,7 +1415,7 @@ export function DesktopShell({ } }); return () => setKmlFileImportHandler(null); - }, [addImportedVectorLayers, confirmLargeVectorDataset, t]); + }, [addImportedVectorLayers, t]); const addDroppedPhotos = useCallback( (result: GeotaggedPhotoResult | null): number => { @@ -1539,6 +1569,9 @@ export function DesktopShell({ setIsDraggingFiles(false); setDropError(null); + // Matches the browser drop handler: a warning about a previous file + // must not linger over an unrelated drop. + setCrsWarning(null); setDropMessage("Importing data..."); try { @@ -1708,6 +1741,10 @@ export function DesktopShell({ dragDepthRef.current = 0; setIsDraggingFiles(false); setDropError(null); + // Not auto-dismissed on the status timeout (it has its own Close button), + // so it is cleared here instead: a warning about a previous file must not + // linger over an unrelated drop. + setCrsWarning(null); setDropMessage("Importing data..."); try { @@ -2642,6 +2679,23 @@ export function DesktopShell({ {projectUrlLoadState.error} ) : null} + {crsWarning ? ( +
+ {crsWarning} + +
+ ) : null} {dropMessage || dropError ? (
{ timerRef.current = null; - const content = serializeProject(buildProjectSnapshot(mapControllerRef)); + // `serializeProject` runs synchronously, so its failure cannot be caught + // by the promise chain below. A project embedding a large vector layer + // serializes to more than V8's 536,870,888-byte string cap and throws + // `RangeError: Invalid string length`, which previously escaped as an + // unhandled error on every autosave tick. Autosave is best-effort — a + // project too large to snapshot must degrade to "no crash recovery", + // never to a crash. + // Built and serialized in separate steps so the two failures are not + // conflated: a snapshot that cannot be constructed is a genuine error, + // while one too large to stringify is an expected limit. + let snapshot: ReturnType; + try { + snapshot = buildProjectSnapshot(mapControllerRef); + } catch (error) { + console.error("Could not autosave the project.", error); + return; + } + let content: string; + try { + content = serializeProject(snapshot); + } catch (error) { + // Only the string-length cap means "too large"; anything else is a + // real serialization bug and must not be filed under a size problem, + // or that class of failure becomes invisible in the wild. + if (error instanceof RangeError) { + console.warn("Project autosave skipped: the project is too large to serialize.", error); + } else { + console.error("Could not autosave the project.", error); + } + return; + } void addProjectSnapshot(content, currentProjectKey()).catch((error) => console.error("Could not autosave the project.", error), ); diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 59d27a09c..17fea6707 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -823,7 +823,8 @@ "errorMaxFeatures": "أدخل قيمة للحد الأقصى للمعالم تساوي 1 أو أكثر.", "errorBbox": "أدخل الصندوق المحيط بالصيغة «غرب,جنوب,شرق,شمال».", "errorNoFeatures": "لم تُرجع هذه المجموعة أي معالم." - } + }, + "nonGeographicCoordinates": "الإحداثيات في {{names}} ليست خط طول/عرض، لذا لن يُرسم أي شيء على الخريطة. نظام الإحداثيات مذكور بشكل خاطئ — أعد إسقاط البيانات (أو صحّح ملف .prj) ثم حمّلها مرة أخرى." }, "offline": { "title": "تنزيل منطقة دون اتصال", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index 91b78b75b..af64368ef 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Geben Sie für die maximale Objektanzahl einen Wert von 1 oder mehr ein.", "errorBbox": "Geben Sie den Begrenzungsrahmen als „West,Süd,Ost,Nord“ an.", "errorNoFeatures": "Diese Collection hat keine Objekte zurückgegeben." - } + }, + "nonGeographicCoordinates": "Die Koordinaten in {{names}} sind keine geografische Länge/Breite, daher wird nichts auf der Karte gezeichnet. Das CRS ist falsch angegeben — projizieren Sie die Daten um (oder korrigieren Sie die .prj) und laden Sie sie erneut." }, "offline": { "title": "Offline-Bereich herunterladen", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index bfaaaf42b..a08771d08 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -754,7 +754,8 @@ "lineWidth": "Line width", "extrusion": "3D extrusion", "modelUrlPlaceholder": "https://example.com/model.glb" - } + }, + "nonGeographicCoordinates": "Coordinates in {{names}} are not longitude/latitude, so nothing will be drawn on the map. The CRS is mislabelled — reproject the data (or fix its .prj) and load it again." }, "offline": { "title": "Download Offline Area", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 08832516b..d505719f9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Introduzca un máximo de entidades de 1 o más.", "errorBbox": "Introduzca el cuadro delimitador como «oeste,sur,este,norte».", "errorNoFeatures": "Esta colección no devolvió ninguna entidad." - } + }, + "nonGeographicCoordinates": "Las coordenadas de {{names}} no son de longitud/latitud, por lo que no se dibujará nada en el mapa. El CRS está mal declarado: reproyecte los datos (o corrija su .prj) y vuelva a cargarlos." }, "offline": { "title": "Descargar área sin conexión", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index 0d44e9aad..1002be9f2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Saisissez un nombre maximal d'entités égal ou supérieur à 1.", "errorBbox": "Saisissez la boîte englobante sous la forme « ouest,sud,est,nord ».", "errorNoFeatures": "Cette collection n'a renvoyé aucune entité." - } + }, + "nonGeographicCoordinates": "Les coordonnées de {{names}} ne sont pas des longitudes/latitudes : rien ne sera dessiné sur la carte. Le SCR est mal déclaré — reprojetez les données (ou corrigez le .prj) puis rechargez-les." }, "offline": { "title": "Télécharger une zone hors ligne", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index ec789bfe3..502761166 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "अधिकतम फ़ीचर के लिए 1 या उससे अधिक मान दर्ज करें।", "errorBbox": "बाउंडिंग बॉक्स को «पश्चिम,दक्षिण,पूर्व,उत्तर» के रूप में दर्ज करें।", "errorNoFeatures": "इस कलेक्शन ने कोई फ़ीचर नहीं लौटाया।" - } + }, + "nonGeographicCoordinates": "{{names}} के निर्देशांक देशांतर/अक्षांश नहीं हैं, इसलिए मानचित्र पर कुछ भी नहीं बनेगा। CRS ग़लत दर्ज है — डेटा को पुनःप्रक्षेपित करें (या .prj ठीक करें) और दोबारा लोड करें।" }, "offline": { "title": "ऑफ़लाइन क्षेत्र डाउनलोड करें", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index c3588d319..64e875d31 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -723,7 +723,8 @@ "errorMaxFeatures": "Masukkan nilai jumlah fitur maksimum 1 atau lebih.", "errorBbox": "Masukkan kotak pembatas sebagai \"barat,selatan,timur,utara\".", "errorNoFeatures": "Koleksi ini tidak mengembalikan fitur apa pun." - } + }, + "nonGeographicCoordinates": "Koordinat pada {{names}} bukan bujur/lintang, sehingga tidak ada yang digambar di peta. CRS salah — proyeksikan ulang data (atau perbaiki .prj-nya) lalu muat kembali." }, "offline": { "title": "Unduh Area Offline", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index 487371769..398f02aca 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Inserisci un numero massimo di elementi pari a 1 o superiore.", "errorBbox": "Inserisci il rettangolo di delimitazione come «ovest,sud,est,nord».", "errorNoFeatures": "Questa collection non ha restituito alcun elemento." - } + }, + "nonGeographicCoordinates": "Le coordinate in {{names}} non sono longitudine/latitudine, quindi non verrà disegnato nulla sulla mappa. Il CRS è errato: riproietta i dati (o correggi il .prj) e ricaricali." }, "offline": { "title": "Scarica area offline", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 6a97f39cd..9682f193d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -723,7 +723,8 @@ "errorMaxFeatures": "最大フィーチャ数には1以上の値を入力してください。", "errorBbox": "バウンディングボックスを「西,南,東,北」の形式で入力してください。", "errorNoFeatures": "このコレクションはフィーチャを返しませんでした。" - } + }, + "nonGeographicCoordinates": "{{names}} の座標は経緯度ではないため、地図には何も描画されません。CRS が誤って記録されています — データを再投影する(または .prj を修正する)と読み込み直してください。" }, "offline": { "title": "オフラインエリアをダウンロード", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index ba00f87ad..8208ff3b7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -743,7 +743,8 @@ "lineWidth": "ხაზის სიგანე", "extrusion": "3D ამოწევა", "modelUrlPlaceholder": "https://example.com/model.glb" - } + }, + "nonGeographicCoordinates": "{{names}}-ის კოორდინატები არ არის გრძედი/განედი, ამიტომ რუკაზე არაფერი დაიხატება. CRS არასწორადაა მითითებული — გადააპროექტეთ მონაცემები (ან შეასწორეთ .prj) და ხელახლა ჩატვირთეთ." }, "offline": { "title": "ოფლაინ ტერიტორიის ჩამოტვირთვა", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index bad3789e0..f0ff5489d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -723,7 +723,8 @@ "errorMaxFeatures": "최대 피처 수를 1 이상으로 입력하세요.", "errorBbox": "경계 상자를 “서,남,동,북” 형식으로 입력하세요.", "errorNoFeatures": "이 컬렉션은 피처를 반환하지 않았습니다." - } + }, + "nonGeographicCoordinates": "{{names}}의 좌표가 경위도가 아니므로 지도에 아무것도 그려지지 않습니다. CRS가 잘못 지정되어 있습니다 — 데이터를 재투영하거나 .prj를 수정한 뒤 다시 불러오세요." }, "offline": { "title": "오프라인 지역 다운로드", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 05e9531f8..bcbce5fe8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Voer voor het maximale aantal features een waarde van 1 of hoger in.", "errorBbox": "Voer het begrenzingskader in als ‘west,zuid,oost,noord’.", "errorNoFeatures": "Deze collectie leverde geen features op." - } + }, + "nonGeographicCoordinates": "De coördinaten in {{names}} zijn geen lengte-/breedtegraad, dus er wordt niets op de kaart getekend. Het CRS klopt niet — herprojecteer de gegevens (of corrigeer de .prj) en laad ze opnieuw." }, "offline": { "title": "Offline gebied downloaden", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index 074d5d3e8..70dfabcc2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Informe um valor de máximo de feições igual ou maior que 1.", "errorBbox": "Informe a caixa delimitadora como “oeste,sul,leste,norte”.", "errorNoFeatures": "Esta coleção não retornou nenhuma feição." - } + }, + "nonGeographicCoordinates": "As coordenadas em {{names}} não são de longitude/latitude, portanto nada será desenhado no mapa. O CRS está incorreto — reprojete os dados (ou corrija o .prj) e carregue novamente." }, "offline": { "title": "Baixar área offline", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 70adaf5c7..35af33c72 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -783,7 +783,8 @@ "errorMaxFeatures": "Введите максимальное количество объектов, равное 1 или больше.", "errorBbox": "Введите ограничивающий прямоугольник в виде «запад,юг,восток,север».", "errorNoFeatures": "Эта коллекция не вернула ни одного объекта." - } + }, + "nonGeographicCoordinates": "Координаты в {{names}} не являются долготой/широтой, поэтому на карте ничего не отобразится. СК указана неверно — перепроецируйте данные (или исправьте .prj) и загрузите снова." }, "offline": { "title": "Скачать офлайн-область", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 554468e2e..411ea019a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -723,7 +723,8 @@ "lineWidth": "ความหนาเส้น", "extrusion": "การยกตัวแบบ 3 มิติ", "modelUrlPlaceholder": "https://example.com/model.glb" - } + }, + "nonGeographicCoordinates": "พิกัดใน {{names}} ไม่ใช่ลองจิจูด/ละติจูด จึงจะไม่มีสิ่งใดถูกวาดบนแผนที่ ระบบพิกัดระบุไว้ผิด — โปรดฉายพิกัดข้อมูลใหม่ (หรือแก้ไฟล์ .prj) แล้วโหลดอีกครั้ง" }, "offline": { "title": "ดาวน์โหลดพื้นที่แบบออฟไลน์", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 25ce3e231..7b2a3d723 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -743,7 +743,8 @@ "errorMaxFeatures": "Maksimum öğe değeri olarak 1 veya daha büyük bir sayı girin.", "errorBbox": "Sınırlayıcı kutuyu «batı,güney,doğu,kuzey» biçiminde girin.", "errorNoFeatures": "Bu koleksiyon hiç öğe döndürmedi." - } + }, + "nonGeographicCoordinates": "{{names}} içindeki koordinatlar boylam/enlem değil, bu yüzden haritaya hiçbir şey çizilmeyecek. CRS bilgisi yanlış — verileri yeniden projelendirin (veya .prj dosyasını düzeltin) ve tekrar yükleyin." }, "offline": { "title": "Çevrimdışı Alan İndir", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index c2bd84a3d..bde9ec6a1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -723,7 +723,8 @@ "errorMaxFeatures": "请输入不小于 1 的最大要素数。", "errorBbox": "请按“西,南,东,北”的格式输入边界框。", "errorNoFeatures": "此集合未返回任何要素。" - } + }, + "nonGeographicCoordinates": "{{names}} 中的坐标不是经纬度,因此地图上不会绘制任何内容。坐标参考系标注有误 — 请重新投影数据(或修正 .prj)后再次加载。" }, "offline": { "title": "下载离线区域", diff --git a/apps/geolibre-desktop/src/lib/duckdb-vector-guard.ts b/apps/geolibre-desktop/src/lib/duckdb-vector-guard.ts index ef720332d..f2eb7f1be 100644 --- a/apps/geolibre-desktop/src/lib/duckdb-vector-guard.ts +++ b/apps/geolibre-desktop/src/lib/duckdb-vector-guard.ts @@ -7,16 +7,12 @@ * `node --test` without pulling the WASM engine into the bundle/test. */ -/** - * Sources whose feature (row) count reaches this threshold prompt a - * confirmation before {@link loadDuckDbVectorFile} materializes every row as a - * GeoJSON Feature in memory — each row is JSON-parsed and turned into its own - * object, so a multi-million-row file can exhaust browser memory or wedge the - * tab. This is the DuckDB ingestion counterpart to `OSM_PBF_SIZE_WARN_BYTES` - * (`osm-pbf-loader.ts`); unlike a raw byte size it is accurate for compressed - * formats like GeoParquet, where a small file can hold millions of rows. - */ -export const DUCKDB_VECTOR_FEATURE_WARN_COUNT = 500_000; +import { DUCKDB_VECTOR_FEATURE_WARN_COUNT, DUCKDB_VECTOR_ROUTE_BYTES } from "@geolibre/core"; + +// Both thresholds live in `@geolibre/core` so the desktop loaders here and the +// Add Vector Layer panel (`@geolibre/plugins`) switch strategy at the same +// numbers; re-exported so callers keep importing them from the guard module. +export { DUCKDB_VECTOR_FEATURE_WARN_COUNT, DUCKDB_VECTOR_ROUTE_BYTES }; /** Details passed to {@link DuckDbVectorLoadOptions.onLargeDataset}. */ export interface LargeVectorDataset { @@ -96,3 +92,15 @@ export async function confirmLargeDataset( const proceed = await onLargeDataset(dataset); if (!proceed) throw new VectorLoadCancelledError(); } + +/** + * Whether a file of this size should skip the in-memory JavaScript readers and + * stream through DuckDB instead. An unknown size (undefined) reads as "small", + * so a failed `stat` leaves the existing behaviour untouched rather than + * diverting every file to DuckDB on a metadata hiccup. + * + * @see DUCKDB_VECTOR_ROUTE_BYTES + */ +export function shouldRouteToDuckDb(sizeBytes: number | undefined): boolean { + return sizeBytes !== undefined && sizeBytes >= DUCKDB_VECTOR_ROUTE_BYTES; +} diff --git a/apps/geolibre-desktop/src/lib/tauri-io.ts b/apps/geolibre-desktop/src/lib/tauri-io.ts index b77025acd..eca5aace3 100644 --- a/apps/geolibre-desktop/src/lib/tauri-io.ts +++ b/apps/geolibre-desktop/src/lib/tauri-io.ts @@ -11,6 +11,7 @@ import { readFile, readTextFile, readTextFileLines, + stat, writeFile, writeTextFile, } from "@tauri-apps/plugin-fs"; @@ -30,6 +31,7 @@ import { IS_MAS_BUILD } from "./build-flags"; import type { DuckDbVectorFile } from "./duckdb-vector-loader"; import { confirmLargeDataset, + shouldRouteToDuckDb, type DuckDbVectorLoadOptions, type LargeVectorDataset, } from "./duckdb-vector-guard"; @@ -545,6 +547,63 @@ async function readLocalFileText(path: string): Promise { } } +/** + * A local file's size in bytes, read from filesystem metadata so the size is + * known *before* the file is read into memory. Returns undefined outside Tauri + * (the browser has no path-based `stat`; those callers use `File.size`) or when + * the `stat` fails — an unreadable path surfaces its own error at read time, so + * a metadata failure must not block the load. + */ +async function localFileSizeBytes(path: string): Promise { + if (!isTauri()) return undefined; + try { + return (await stat(path)).size; + } catch (error) { + console.debug(`[GeoLibre] Could not stat "${path}" for the large-file guard.`, error); + return undefined; + } +} + +/** + * Extensions whose in-memory reader is bypassed by the size route. + * + * Containers (`zip`, `kmz`) unpack first and decide from their contents. + * Delimited text and GPX always use the JS parser: `loadDuckDbVector` passes no + * `layer` argument, so `ST_Read` would read only a GPX's first OGR layer + * (usually `waypoints`) and silently discard its tracks and routes, and it + * cannot build points from a CSV's lon/lat columns. + */ +const ROUTABLE_TEXT_EXTENSIONS = new Set(["geojson", "json", "kml"]); + +/** + * Read a dropped file as text, yielding "" when it cannot be read. + * + * KML overlay/model extraction needs the whole document as a string, and there + * is no way around that: `TextDecoder.decode()` over the full buffer builds one + * JS string exactly as `File.text()` does, so both hit the same + * `RangeError: Invalid string length` past the engine's cap. Rather than + * pretend to avoid it, the failure is caught here — a file too large to read as + * text contributes no overlays instead of aborting the whole drop batch. + */ +async function readVectorFileTextOrEmpty(file: File): Promise { + try { + return await file.text(); + } catch (error) { + console.warn(`[GeoLibre] Could not read "${file.name}" as text; skipping its overlays.`, error); + return ""; + } +} + +/** Path counterpart to {@link readVectorFileTextOrEmpty}. */ +async function readLocalFileTextOrEmpty(path: string): Promise { + try { + return await readLocalFileText(path); + } catch (error) { + console.warn(`[GeoLibre] Could not read "${path}" as text; skipping its overlays.`, error); + return ""; + } +} + function parseGpxText(text: string): FeatureCollection { const result = parseGpxLayer(text); return mergeFeatureCollections([result.waypoints, result.tracks, result.routes]); @@ -718,6 +777,13 @@ function parseShapefileComponents({ file, sidecar }: UnzippedShapefile): Feature * already-extracted buffers, retrying through DuckDB if shpjs cannot read it. A * corrupt archive or one without a `.shp` throws, since GeoLibre reads only * shapefile `.zip`s. + * + * A `.shp` at or above {@link DUCKDB_VECTOR_ROUTE_BYTES} skips shpjs and streams + * through DuckDB: shpjs would otherwise freeze the main thread reprojecting + * every coordinate synchronously, with no progress, no cancel, and no + * feature-count guard. The threshold is measured on the *uncompressed* `.shp`, + * which is the number that governs the parse cost — shapefiles compress heavily, + * so the zip's own size says little about it. */ async function loadShapefileZip( data: ArrayBuffer | Uint8Array, @@ -730,6 +796,12 @@ async function loadShapefileZip( if (unzipped.isMultiPatch) { return loadDuckDbVector(unzipped.file, options); } + if (shouldRouteToDuckDb(unzipped.file.data.byteLength)) { + console.info( + `[GeoLibre] "${unzipped.file.name}" is ${Math.round(unzipped.file.data.byteLength / (1024 * 1024))} MB uncompressed; reading it with DuckDB instead of shpjs to keep the parse off the main thread.`, + ); + return loadDuckDbVector(unzipped.file, options); + } try { return parseShapefileComponents(unzipped); } catch { @@ -1772,7 +1844,19 @@ async function loadBrowserVectorFile( options?: DuckDbVectorLoadOptions, ): Promise { const extension = fileExtension(file.name); - if (extension === "geojson" || extension === "json") { + // Browser counterpart to the metadata preflight in `loadTauriVectorFile`; + // `File.size` is known without reading the blob, so the same rule applies. + const streamViaDuckDb = shouldRouteToDuckDb(file.size); + // `zip`/`kmz` ignore this flag (the archive is unpacked first and + // `loadShapefileZip` decides from the *uncompressed* `.shp`), so announcing a + // route here would be misleading for a container near the threshold. + if (streamViaDuckDb && ROUTABLE_TEXT_EXTENSIONS.has(extension)) { + console.info( + `[GeoLibre] "${file.name}" is ${Math.round(file.size / (1024 * 1024))} MB; streaming it through DuckDB instead of the in-memory reader.`, + ); + } + + if (!streamViaDuckDb && (extension === "geojson" || extension === "json")) { try { return { data: await parseGeoJsonText(await file.text()), @@ -1798,7 +1882,7 @@ async function loadBrowserVectorFile( }; } - if (extension === "kml") { + if (!streamViaDuckDb && extension === "kml") { try { return { data: parseKmlText(await file.text()), @@ -1809,6 +1893,8 @@ async function loadBrowserVectorFile( } } + // Not gated on `streamViaDuckDb`: see ROUTABLE_TEXT_EXTENSIONS — the DuckDB + // reader would return only this GPX's first OGR layer. if (extension === "gpx") { return { data: parseGpxText(await file.text()), @@ -1816,6 +1902,12 @@ async function loadBrowserVectorFile( }; } + // Deliberately NOT gated on `streamViaDuckDb`: `loadDuckDbVectorFile` has no + // longitude/latitude column detection (that lives only in the GeoParquet + // conversion path), so routing a plain lon/lat CSV to DuckDB fails with + // "DuckDB did not find a geometry column in this file." Delimited text is + // line-oriented and cheap to parse, so size is not the concern it is for + // GeoJSON or shapefiles. if (isDelimitedTextFileName(file.name)) { const points = parseDelimitedTextFile(await file.text(), file.name); // No lon/lat columns: fall through to DuckDB so spatial CSV variants @@ -2031,7 +2123,18 @@ async function loadTauriVectorFile( path: string; }> { const extension = fileExtension(path); - if (extension === "geojson" || extension === "json") { + // Decided from filesystem metadata, before the first byte is read, so an + // oversized file never starts a text parse that would freeze the UI. + const sizeBytes = await localFileSizeBytes(path); + const streamViaDuckDb = shouldRouteToDuckDb(sizeBytes); + // See `loadBrowserVectorFile`: containers decide their own routing later. + if (streamViaDuckDb && ROUTABLE_TEXT_EXTENSIONS.has(extension)) { + console.info( + `[GeoLibre] "${browserSafeFileName(path)}" is ${Math.round((sizeBytes ?? 0) / (1024 * 1024))} MB; streaming it through DuckDB instead of the in-memory reader.`, + ); + } + + if (!streamViaDuckDb && (extension === "geojson" || extension === "json")) { try { return { data: await parseGeoJsonText(await readLocalFileText(path)), @@ -2063,7 +2166,7 @@ async function loadTauriVectorFile( } } - if (extension === "kml") { + if (!streamViaDuckDb && extension === "kml") { try { return { data: parseKmlText(await readLocalFileText(path)), @@ -2074,6 +2177,7 @@ async function loadTauriVectorFile( } } + // Not gated on `streamViaDuckDb`; see the browser counterpart. if (extension === "gpx") { try { return { @@ -2086,6 +2190,8 @@ async function loadTauriVectorFile( } } + // Not gated on `streamViaDuckDb` — see the note in `loadBrowserVectorFile`: + // the DuckDB reader cannot build points from lon/lat columns. if (isDelimitedTextFileName(path)) { const points = parseDelimitedTextFile(await readLocalFileText(path), path); // No lon/lat columns: fall through to DuckDB so spatial CSV variants @@ -2758,7 +2864,10 @@ export async function loadDroppedVectorFiles( // Load the vector placemarks and the ground overlays independently so an // overlay-only KML still adds its overlays even when it has no readable // placemarks (which makes the vector load throw). - const text = await file.text(); + // Overlay/model extraction needs the whole document as text. A file too + // large for that yields no overlays rather than aborting the batch; the + // guarded vector load below still runs and routes it to DuckDB. + const text = await readVectorFileTextOrEmpty(file); const overlays = groundOverlaysFromKml(text, file.name); const models = options?.skipModels ? [] : await modelsFromKml(text, file.name); // Overlays go under the placemarks (added first), matching the KMZ path. @@ -3036,7 +3145,9 @@ export async function loadDroppedVectorPaths( if (extension === "kml") { // Load placemarks and ground overlays independently so an overlay-only // KML still contributes its overlays when the vector load throws. - const kmlText = await readLocalFileText(path); + // See the browser counterpart: too large to read as text means no + // overlays, not a failed drop. + const kmlText = await readLocalFileTextOrEmpty(path); const overlays = groundOverlaysFromKml(kmlText, path); const models = options?.skipModels ? [] : await modelsFromKml(kmlText, path); // Overlays go under the placemarks (added first), matching the KMZ path. diff --git a/docs/user-guide/adding-data.md b/docs/user-guide/adding-data.md index b3f8cb54e..7e1de39dd 100644 --- a/docs/user-guide/adding-data.md +++ b/docs/user-guide/adding-data.md @@ -16,6 +16,28 @@ The **Add Data** menu is the main way to bring layers into GeoLibre. It groups s Vector files are reprojected to EPSG:4326 on load. In the browser, vector import relies on DuckDB-WASM Spatial, with direct handling for GeoJSON, zipped Shapefiles, and KMZ archives. +!!! warning "Large vector files" + There is no fixed size limit. Files **under 100 MB** are read by the + in-memory JavaScript readers, which are fastest for everyday data. At + **100 MB or larger**, GeoLibre streams the file through DuckDB instead — + off the main thread, so the interface keeps responding. For a zipped + Shapefile the threshold applies to the *uncompressed* `.shp`, since + shapefiles compress heavily and the archive's size says little about the + parse cost. This happens automatically; nothing is asked of you. + + A separate check counts features once the source is open. Past + **100,000 features**, GeoLibre asks before converting every one to GeoJSON + in memory, because that is where memory rather than file size becomes the + limit — a small GeoParquet can hold millions of rows. + + For very large data, converting first still pays: **Processing → Conversion + → Vector to PMTiles** writes a tiled format the map loads one tile at a + time instead of reading the whole file. Converting to **GeoParquet** + instead gives a compact columnar format that reads far faster than text, + though it is not tiled. GeoJSON is the most expensive option at any size — + it expands several-fold in memory — so prefer a Shapefile, GeoParquet, or + FlatGeobuf source when you have the choice. + !!! tip "KML and KMZ" KML is read by an in-house parser that keeps the file's own symbology, so styled KML renders the way it does in Google Earth. A file that parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. diff --git a/package-lock.json b/package-lock.json index 5e5019724..c9024b43f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.7", + "maplibre-gl-vector": "^0.10.8", "openai": "^7.3.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", @@ -17454,9 +17454,9 @@ } }, "node_modules/maplibre-gl-vector": { - "version": "0.10.7", - "resolved": "https://registry.npmjs.org/maplibre-gl-vector/-/maplibre-gl-vector-0.10.7.tgz", - "integrity": "sha512-5qBVgpk9MBW5TOkgxZbCdiZwEB0oHbe9EnHSSi6BNKZ3Q999T1Jib0NnobOrvpGzRtv7JwwiUlQhAIzuIJdHdA==", + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maplibre-gl-vector/-/maplibre-gl-vector-0.10.8.tgz", + "integrity": "sha512-1ZIPI9RyUYpHzkqvgS/CEFckvp2xdn+p9f981LD6PA30tFKJEYU/9XrK70X7HD/rYNQxF/AK8iCGKUUe9xSbAg==", "license": "MIT", "dependencies": { "fflate": "^0.8.2" @@ -22467,7 +22467,7 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.5", + "maplibre-gl-vector": "^0.10.8", "netcdfjs": "^4.0.0", "ngeohash": "^0.6.4", "open-location-code-typescript": "^1.5.0", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66fb5125a..e27083d83 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1820,3 +1820,104 @@ export interface RecentProjectEntry { name: string; openedAt: string; } + +/** + * Size at or above which a local vector file is read through DuckDB instead of + * the in-memory JavaScript readers, and the feature count above which the user + * is asked before every feature is materialized as GeoJSON. + * + * These live in core so the desktop loaders (`duckdb-vector-guard.ts`) and the + * Add Vector Layer panel (`@geolibre/plugins`, which configures the + * `maplibre-gl-vector` control's `autoThreshold`) switch strategy at the *same* + * numbers. Before they were unified the two entry points disagreed — the panel + * tiled at 25 MB / 50k while drag-and-drop stayed in memory to 100 MB — so the + * same file behaved differently depending on how it was added, and no single + * number could be documented. + */ +export const DUCKDB_VECTOR_ROUTE_BYTES = 100 * 1024 * 1024; // 100 MB +export const DUCKDB_VECTOR_FEATURE_WARN_COUNT = 100_000; + +/** A collection whose coordinates cannot be WGS84 longitude/latitude. */ +export interface NonGeographicCoordinates { + /** How many coordinates were inspected. */ + sampled: number; + /** The largest |x| seen — a longitude may not exceed 180. */ + maxAbsX: number; + /** The largest |y| seen — a latitude may not exceed 90. */ + maxAbsY: number; +} + +const MAX_WGS84_LON = 180; +const MAX_WGS84_LAT = 90; + +/** + * Detect a collection that declares (or is assumed to be) WGS84 but carries + * projected coordinates — the failure mode where a layer loads cleanly, appears + * in the Layers panel, and renders nowhere because its "longitude" is a easting + * in metres or feet. + * + * GeoLibre honours whatever CRS a file declares, so a file that declares + * `CRS84`/`GCS_WGS_1984` while holding State Plane or Albers coordinates is + * passed through untouched and lands off the map with no error. Callers use + * this to warn instead of failing silently; it does not guess the true CRS, + * which only the user knows. + * + * Sampling stops at `sampleLimit` coordinates: out-of-range values are a + * property of the whole file, so a prefix is enough and a 3-million-coordinate + * collection is not walked twice. + * + * @returns Details when a coordinate is out of geographic range, else null. + */ +export function detectNonGeographicCoordinates( + geojson: GeoJSON.FeatureCollection | undefined, + sampleLimit = 1000, +): NonGeographicCoordinates | null { + if (!geojson?.features?.length) return null; + let sampled = 0; + let maxAbsX = 0; + let maxAbsY = 0; + let offending = false; + + const visit = (coords: unknown): void => { + if (sampled >= sampleLimit || !Array.isArray(coords)) return; + if (typeof coords[0] === "number" && typeof coords[1] === "number") { + const x = Math.abs(coords[0]); + const y = Math.abs(coords[1]); + sampled += 1; + // Gated on finiteness for the same reason as `offending` below: an + // Infinity would otherwise be reported as the offending magnitude in the + // warning, hiding the real out-of-range value. + if (Number.isFinite(x) && x > maxAbsX) maxAbsX = x; + if (Number.isFinite(y) && y > maxAbsY) maxAbsY = y; + // NaN/Infinity are a different defect (a broken file, not a CRS mismatch), + // so only finite out-of-range values count. + if (Number.isFinite(x) && Number.isFinite(y) && (x > MAX_WGS84_LON || y > MAX_WGS84_LAT)) { + offending = true; + } + return; + } + for (const part of coords) { + if (sampled >= sampleLimit) return; + visit(part); + } + }; + + for (const feature of geojson.features) { + if (sampled >= sampleLimit) break; + const geometry = feature?.geometry as { + coordinates?: unknown; + geometries?: { coordinates?: unknown }[]; + } | null; + // A GeometryCollection holds its coordinates one level down, under + // `geometries[]`, so it has no `coordinates` of its own to visit. + if (geometry?.coordinates !== undefined) visit(geometry.coordinates); + else if (Array.isArray(geometry?.geometries)) { + for (const member of geometry.geometries) { + if (sampled >= sampleLimit) break; + if (member?.coordinates !== undefined) visit(member.coordinates); + } + } + } + + return offending ? { sampled, maxAbsX, maxAbsY } : null; +} diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 947ea7d57..c14f98875 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -63,7 +63,7 @@ "maplibre-gl-swipe": "^0.11.1", "maplibre-gl-time-slider": "^1.8.4", "maplibre-gl-usgs-lidar": "^0.11.1", - "maplibre-gl-vector": "^0.10.5", + "maplibre-gl-vector": "^0.10.8", "netcdfjs": "^4.0.0", "ngeohash": "^0.6.4", "open-location-code-typescript": "^1.5.0", diff --git a/packages/plugins/src/plugins/maplibre-vector.ts b/packages/plugins/src/plugins/maplibre-vector.ts index bfb613789..725141a16 100644 --- a/packages/plugins/src/plugins/maplibre-vector.ts +++ b/packages/plugins/src/plugins/maplibre-vector.ts @@ -1,4 +1,10 @@ -import { getSpatialExtensionPath, hasPathTraversal, useAppStore } from "@geolibre/core"; +import { + DUCKDB_VECTOR_FEATURE_WARN_COUNT, + DUCKDB_VECTOR_ROUTE_BYTES, + getSpatialExtensionPath, + hasPathTraversal, + useAppStore, +} from "@geolibre/core"; import type { GeoLibreLayer } from "@geolibre/core"; // Imported from the `/errors` subpath, a standalone entry point holding just // these helpers. The package root re-exports VectorControl, so importing from @@ -578,6 +584,15 @@ function createVectorControl( // Skip the remote spatial-extension install in offline/sandboxed // environments when a local extension path is configured. spatialExtensionPath: getSpatialExtensionPath(), + // Switch to the tiled path at the same numbers the drag-and-drop loaders + // use to switch to DuckDB (`duckdb-vector-guard.ts`). The control's own + // defaults are 25 MB / 50k, so without this the same file behaves + // differently depending on whether it was dropped on the map or added + // through this panel, and no single limit could be documented. + autoThreshold: { + featureCount: DUCKDB_VECTOR_FEATURE_WARN_COUNT, + byteSize: DUCKDB_VECTOR_ROUTE_BYTES, + }, }); for (const event of ["layeradded", "layerremoved", "layerupdated"] as const) { diff --git a/tests/duckdb-vector-guard.test.ts b/tests/duckdb-vector-guard.test.ts index 6efbabaef..cb70f6852 100644 --- a/tests/duckdb-vector-guard.test.ts +++ b/tests/duckdb-vector-guard.test.ts @@ -3,8 +3,11 @@ import { describe, it } from "node:test"; import { confirmLargeDataset, DUCKDB_VECTOR_FEATURE_WARN_COUNT, + DUCKDB_VECTOR_ROUTE_BYTES, + shouldRouteToDuckDb, VectorLoadCancelledError, } from "../apps/geolibre-desktop/src/lib/duckdb-vector-guard"; +import { detectNonGeographicCoordinates } from "../packages/core/src/types"; describe("confirmLargeDataset", () => { it("does nothing when no callback is supplied", async () => { @@ -59,3 +62,146 @@ describe("confirmLargeDataset", () => { ); }); }); + +describe("shouldRouteToDuckDb", () => { + it("treats an unknown size as small", () => { + // A failed `stat` must not divert every file to DuckDB. + assert.equal(shouldRouteToDuckDb(undefined), false); + }); + + it("routes at the threshold and keeps one byte under it in-memory", () => { + assert.equal(shouldRouteToDuckDb(DUCKDB_VECTOR_ROUTE_BYTES), true); + assert.equal(shouldRouteToDuckDb(DUCKDB_VECTOR_ROUTE_BYTES - 1), false); + }); + + it("routes the reported 148 MB shapefile and 539 MB GeoJSON", () => { + assert.equal(shouldRouteToDuckDb(148 * 1024 * 1024), true); + assert.equal(shouldRouteToDuckDb(539 * 1000 * 1000), true); + }); + + it("stays below V8's maximum string length", () => { + // Text files at or above 2**29 - 24 bytes cannot be read into a string at + // all; routing far below that is what makes the RangeError unreachable. + assert.ok(DUCKDB_VECTOR_ROUTE_BYTES < 2 ** 29 - 24); + }); +}); + +describe("configured defaults", () => { + it("routes at 100 MB and warns at 100k features", () => { + assert.equal(DUCKDB_VECTOR_ROUTE_BYTES, 100 * 1024 * 1024); + assert.equal(DUCKDB_VECTOR_FEATURE_WARN_COUNT, 100_000); + }); +}); + +describe("detectNonGeographicCoordinates", () => { + const fc = (coordinates: unknown, type = "Point") => ({ + type: "FeatureCollection" as const, + features: [{ type: "Feature" as const, properties: {}, geometry: { type, coordinates } }], + }); + + it("passes ordinary WGS84 coordinates", () => { + assert.equal(detectNonGeographicCoordinates(fc([-72.6, 41.9]) as never), null); + }); + + it("accepts the exact WGS84 corners", () => { + assert.equal(detectNonGeographicCoordinates(fc([180, 90]) as never), null); + assert.equal(detectNonGeographicCoordinates(fc([-180, -90]) as never), null); + }); + + it("flags projected coordinates hiding behind a geographic CRS", () => { + // The CT_Wetlands case: EPSG:5070 Albers metres declared as CRS84. + const found = detectNonGeographicCoordinates(fc([1905935.66, 2337159.4]) as never); + assert.ok(found); + assert.equal(found.sampled, 1); + assert.equal(Math.round(found.maxAbsX), 1905936); + }); + + it("walks nested MultiPolygon rings", () => { + const nested = fc( + [ + [ + [ + [1820601, 2201748], + [1993899, 2377614], + ], + ], + ], + "MultiPolygon", + ); + assert.ok(detectNonGeographicCoordinates(nested as never)); + }); + + it("ignores a latitude just inside range but a longitude just outside", () => { + assert.equal(detectNonGeographicCoordinates(fc([180.5, 12]) as never)?.sampled, 1); + assert.equal(detectNonGeographicCoordinates(fc([12, 90.5]) as never)?.sampled, 1); + }); + + it("treats non-finite coordinates as a different defect, not a CRS mismatch", () => { + assert.equal(detectNonGeographicCoordinates(fc([Number.NaN, Number.NaN]) as never), null); + }); + + it("returns null for an empty or missing collection", () => { + assert.equal(detectNonGeographicCoordinates(undefined), null); + assert.equal(detectNonGeographicCoordinates({ type: "FeatureCollection", features: [] }), null); + }); + + it("stops at the sample limit instead of walking the whole collection", () => { + // The out-of-range feature sits past the limit, so a null result proves the + // walk actually stopped — an all-valid collection would pass even if + // `sampleLimit` were ignored entirely. + const many = { + type: "FeatureCollection" as const, + features: Array.from({ length: 5000 }, (_unused, index) => ({ + type: "Feature" as const, + properties: {}, + geometry: { + type: "Point", + coordinates: index === 40 ? [1905935.66, 2337159.4] : [-72, 41], + }, + })), + }; + assert.equal(detectNonGeographicCoordinates(many as never, 25), null); + // ...and it is still found when the limit reaches it. + assert.ok(detectNonGeographicCoordinates(many as never, 100)); + }); +}); + +describe("detectNonGeographicCoordinates with GeometryCollection", () => { + it("visits coordinates nested under geometries[]", () => { + // A GeometryCollection has no `coordinates` of its own, so it was skipped + // entirely before and a wholly-GeometryCollection file passed silently. + const gc = { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + properties: {}, + geometry: { + type: "GeometryCollection", + geometries: [{ type: "Point", coordinates: [1905935.66, 2337159.4] }], + }, + }, + ], + }; + const found = detectNonGeographicCoordinates(gc as never); + assert.ok(found); + assert.equal(Math.round(found.maxAbsX), 1905936); + }); + + it("still passes a geographic GeometryCollection", () => { + const gc = { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + properties: {}, + geometry: { + type: "GeometryCollection", + geometries: [{ type: "Point", coordinates: [-72.6, 41.9] }], + }, + }, + ], + }; + assert.equal(detectNonGeographicCoordinates(gc as never), null); + }); +});