From 976fa6d6ce669a3dfb29ab8d62ce251b3d7048cf Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 12:12:15 +0000 Subject: [PATCH] Add Data Library: live-fetched dataset, tile and pack layers --- .env.example | 5 + .github/workflows/ci.yml | 4 + CONTRIBUTING.md | 3 +- DATA_SOURCES.md | 117 +++ SECURITY.md | 1 + THIRD_PARTY_NOTICES.md | 19 +- docs/CURRENT-STATE.md | 200 +++++ docs/DATA-LIBRARY.md | 183 ++++ index.html | 2 +- package-lock.json | 14 + package.json | 7 + scripts/library.mjs | 321 +++++++ scripts/package-boundaries.json | 160 +++- scripts/qa-data-library.mjs | 636 ++++++++++++++ server/providers/library.js | 363 ++++++++ server/providers/library.test.mjs | 332 ++++++++ server/providers/local.js | 2 + src/app/layerPresentation.js | 1 + src/app/tools.js | 39 + src/data/libraryRegistration.test.mjs | 54 ++ src/data/lifecycle.js | 42 + src/data/localGeojsonCore.js | 53 +- src/i18n/locales/es.json | 106 ++- src/i18n/locales/ru.json | 112 ++- src/i18n/same-as-english.json | 7 +- src/library/camera.js | 74 ++ src/library/catalog.js | 248 ++++++ src/library/catalog.test.mjs | 47 + src/library/controller.js | 394 +++++++++ src/library/controller.test.mjs | 365 ++++++++ src/library/entries/ak-statewide-imagery.json | 47 + src/library/entries/ar-statewide-imagery.json | 47 + src/library/entries/az-statewide-imagery.json | 48 ++ src/library/entries/bangor-me.json | 48 ++ src/library/entries/basemap-at-orthofoto.json | 48 ++ src/library/entries/bayern-dop40.json | 48 ++ src/library/entries/bismarck-mandan-nd.json | 48 ++ src/library/entries/bkg-topplus-open.json | 34 + src/library/entries/cctv-florida-fl511.json | 24 + src/library/entries/cctv-lithuania.json | 24 + src/library/entries/cctv-nyc-dot.json | 24 + src/library/entries/ct-statewide-imagery.json | 48 ++ src/library/entries/dakota-county-mn.json | 48 ++ src/library/entries/dc-statewide-imagery.json | 47 + src/library/entries/de-statewide-imagery.json | 48 ++ src/library/entries/ee-maa-amet-ortofoto.json | 47 + src/library/entries/eia-power-plants.json | 68 ++ src/library/entries/emodnet-bathymetry.json | 34 + src/library/entries/epa-superfund-sites.json | 40 + .../entries/epa-toxic-release-facilities.json | 45 + src/library/entries/epc-england-wales.json | 66 ++ src/library/entries/fema-usa-structures.json | 65 ++ .../entries/gbif-occurrence-density.json | 60 ++ .../entries/gdacs-disaster-alerts.json | 56 ++ src/library/entries/geneva-sitg-ortho.json | 48 ++ .../entries/gpw-population-density.json | 60 ++ src/library/entries/grand-forks-nd.json | 48 ++ src/library/entries/gsi-japan-map.json | 35 + src/library/entries/gsi-japan-photo.json | 48 ++ .../entries/gvp-holocene-volcanoes.json | 47 + src/library/entries/ia-statewide-imagery.json | 48 ++ src/library/entries/icgc-orto-catalunya.json | 47 + src/library/entries/id-statewide-imagery.json | 47 + .../entries/ign-orthophotos-france.json | 48 ++ src/library/entries/il-statewide-imagery.json | 48 ++ .../entries/imf-portwatch-chokepoints.json | 39 + .../entries/imf-portwatch-disruptions.json | 57 ++ src/library/entries/in-statewide-imagery.json | 47 + src/library/entries/jrc-surface-water.json | 48 ++ .../entries/kartverket-admin-units.json | 46 + src/library/entries/kartverket-cadastre.json | 36 + src/library/entries/kartverket-depth.json | 102 +++ src/library/entries/kartverket-hillshade.json | 36 + .../entries/kartverket-nautical-charts.json | 36 + .../entries/kartverket-terrain-relief.json | 36 + src/library/entries/kartverket-topo-grey.json | 36 + src/library/entries/kartverket-topo.json | 36 + .../entries/kartverket-toporaster.json | 36 + src/library/entries/ks-statewide-imagery.json | 48 ++ src/library/entries/ky-statewide-imagery.json | 47 + src/library/entries/lake-county-in.json | 48 ++ src/library/entries/lawrence-ks.json | 48 ++ src/library/entries/lee-county-fl.json | 48 ++ src/library/entries/loudoun-county-va.json | 48 ++ src/library/entries/louisville-lojic-ky.json | 49 ++ src/library/entries/lu-ortho-latest.json | 48 ++ src/library/entries/ma-statewide-imagery.json | 48 ++ src/library/entries/macrostrat-geology.json | 88 ++ src/library/entries/mapterhorn-terrain.json | 32 + src/library/entries/maris-ms.json | 48 ++ src/library/entries/md-statewide-imagery.json | 48 ++ src/library/entries/me-statewide-imagery.json | 48 ++ src/library/entries/mi-statewide-imagery.json | 48 ++ src/library/entries/mn-statewide-imagery.json | 48 ++ src/library/entries/mountain-village-co.json | 48 ++ src/library/entries/ms-statewide-imagery.json | 48 ++ src/library/entries/mt-statewide-imagery.json | 47 + src/library/entries/naip.json | 47 + src/library/entries/nasa-black-marble.json | 30 + src/library/entries/nasa-eonet-events.json | 58 ++ src/library/entries/nc-statewide-imagery.json | 47 + src/library/entries/nctcog-tx.json | 48 ++ src/library/entries/nd-statewide-imagery.json | 48 ++ src/library/entries/new-orleans-rpc-la.json | 48 ++ src/library/entries/nh-statewide-imagery.json | 48 ++ src/library/entries/nj-statewide-imagery.json | 48 ++ src/library/entries/noaa-tide-stations.json | 37 + src/library/entries/nrw-dop.json | 48 ++ src/library/entries/nsw-imagery.json | 47 + .../entries/ntad-national-bridges.json | 57 ++ .../entries/nwa-benton-washington-ar.json | 48 ++ src/library/entries/nws-active-alerts.json | 56 ++ src/library/entries/ny-statewide-imagery.json | 47 + src/library/entries/oakland-county-mi.json | 48 ++ src/library/entries/oh-statewide-imagery.json | 47 + src/library/entries/openseamap-seamarks.json | 30 + src/library/entries/or-statewide-imagery.json | 47 + src/library/entries/pa-statewide-imagery.json | 48 ++ .../entries/pdok-aerial-netherlands.json | 48 ++ src/library/entries/pl-ortofotomapa.json | 48 ++ src/library/entries/pnoa-ma.json | 48 ++ src/library/entries/portland-metro-or.json | 48 ++ src/library/entries/pt-dgt-ortos.json | 48 ++ src/library/entries/ri-statewide-imagery.json | 48 ++ src/library/entries/swisstopo-swissimage.json | 49 ++ src/library/entries/tx-statewide-imagery.json | 47 + src/library/entries/union-county-oh.json | 49 ++ src/library/entries/usgs-hydrography.json | 35 + src/library/entries/usgs-topo.json | 35 + src/library/entries/va-statewide-imagery.json | 48 ++ src/library/entries/vlaanderen-omw.json | 48 ++ src/library/entries/vt-statewide-imagery.json | 47 + src/library/entries/wallonie-ortho.json | 48 ++ src/library/entries/wi-statewide-imagery.json | 48 ++ .../entries/wri-global-power-plants.json | 65 ++ src/library/entries/wv-statewide-imagery.json | 49 ++ src/library/extent.js | 104 +++ src/library/extent.test.mjs | 75 ++ src/library/formats.js | 453 ++++++++++ src/library/formats.test.mjs | 282 ++++++ src/library/layers.js | 800 ++++++++++++++++++ src/library/ledger.json | 105 +++ src/library/manifest.js | 771 +++++++++++++++++ src/library/manifest.test.mjs | 242 ++++++ src/library/outlines.js | 117 +++ src/library/outlines.test.mjs | 66 ++ src/library/parquet.js | 246 ++++++ src/library/parquet.test.mjs | 121 +++ src/library/relief.js | 109 +++ src/library/relief.test.mjs | 95 +++ src/library/selection.js | 134 +++ src/library/selection.test.mjs | 96 +++ src/library/source.js | 341 ++++++++ src/library/tileLayer.test.mjs | 135 +++ src/library/viewLayer.test.mjs | 192 +++++ src/navigationPolicy.js | 1 + src/sharelink.js | 17 + src/tooling/previewServing.test.mjs | 3 + src/ui/chipGroup.js | 29 +- src/ui/chipGroup.test.mjs | 30 + src/ui/layerPanel.js | 40 +- src/ui/libraryBrowser.js | 604 +++++++++++++ src/ui/libraryBrowser.test.mjs | 146 ++++ src/ui/railTestFixture.mjs | 4 + src/ui/styles/library.css | 368 ++++++++ src/ui/styles/radio.css | 24 + src/ui/styles/scenes.css | 1 + src/ui/templates/layer-panels.html | 5 + style.css | 1 + 169 files changed, 14637 insertions(+), 31 deletions(-) create mode 100644 docs/DATA-LIBRARY.md create mode 100644 scripts/library.mjs create mode 100644 scripts/qa-data-library.mjs create mode 100644 server/providers/library.js create mode 100644 server/providers/library.test.mjs create mode 100644 src/data/libraryRegistration.test.mjs create mode 100644 src/library/camera.js create mode 100644 src/library/catalog.js create mode 100644 src/library/catalog.test.mjs create mode 100644 src/library/controller.js create mode 100644 src/library/controller.test.mjs create mode 100644 src/library/entries/ak-statewide-imagery.json create mode 100644 src/library/entries/ar-statewide-imagery.json create mode 100644 src/library/entries/az-statewide-imagery.json create mode 100644 src/library/entries/bangor-me.json create mode 100644 src/library/entries/basemap-at-orthofoto.json create mode 100644 src/library/entries/bayern-dop40.json create mode 100644 src/library/entries/bismarck-mandan-nd.json create mode 100644 src/library/entries/bkg-topplus-open.json create mode 100644 src/library/entries/cctv-florida-fl511.json create mode 100644 src/library/entries/cctv-lithuania.json create mode 100644 src/library/entries/cctv-nyc-dot.json create mode 100644 src/library/entries/ct-statewide-imagery.json create mode 100644 src/library/entries/dakota-county-mn.json create mode 100644 src/library/entries/dc-statewide-imagery.json create mode 100644 src/library/entries/de-statewide-imagery.json create mode 100644 src/library/entries/ee-maa-amet-ortofoto.json create mode 100644 src/library/entries/eia-power-plants.json create mode 100644 src/library/entries/emodnet-bathymetry.json create mode 100644 src/library/entries/epa-superfund-sites.json create mode 100644 src/library/entries/epa-toxic-release-facilities.json create mode 100644 src/library/entries/epc-england-wales.json create mode 100644 src/library/entries/fema-usa-structures.json create mode 100644 src/library/entries/gbif-occurrence-density.json create mode 100644 src/library/entries/gdacs-disaster-alerts.json create mode 100644 src/library/entries/geneva-sitg-ortho.json create mode 100644 src/library/entries/gpw-population-density.json create mode 100644 src/library/entries/grand-forks-nd.json create mode 100644 src/library/entries/gsi-japan-map.json create mode 100644 src/library/entries/gsi-japan-photo.json create mode 100644 src/library/entries/gvp-holocene-volcanoes.json create mode 100644 src/library/entries/ia-statewide-imagery.json create mode 100644 src/library/entries/icgc-orto-catalunya.json create mode 100644 src/library/entries/id-statewide-imagery.json create mode 100644 src/library/entries/ign-orthophotos-france.json create mode 100644 src/library/entries/il-statewide-imagery.json create mode 100644 src/library/entries/imf-portwatch-chokepoints.json create mode 100644 src/library/entries/imf-portwatch-disruptions.json create mode 100644 src/library/entries/in-statewide-imagery.json create mode 100644 src/library/entries/jrc-surface-water.json create mode 100644 src/library/entries/kartverket-admin-units.json create mode 100644 src/library/entries/kartverket-cadastre.json create mode 100644 src/library/entries/kartverket-depth.json create mode 100644 src/library/entries/kartverket-hillshade.json create mode 100644 src/library/entries/kartverket-nautical-charts.json create mode 100644 src/library/entries/kartverket-terrain-relief.json create mode 100644 src/library/entries/kartverket-topo-grey.json create mode 100644 src/library/entries/kartverket-topo.json create mode 100644 src/library/entries/kartverket-toporaster.json create mode 100644 src/library/entries/ks-statewide-imagery.json create mode 100644 src/library/entries/ky-statewide-imagery.json create mode 100644 src/library/entries/lake-county-in.json create mode 100644 src/library/entries/lawrence-ks.json create mode 100644 src/library/entries/lee-county-fl.json create mode 100644 src/library/entries/loudoun-county-va.json create mode 100644 src/library/entries/louisville-lojic-ky.json create mode 100644 src/library/entries/lu-ortho-latest.json create mode 100644 src/library/entries/ma-statewide-imagery.json create mode 100644 src/library/entries/macrostrat-geology.json create mode 100644 src/library/entries/mapterhorn-terrain.json create mode 100644 src/library/entries/maris-ms.json create mode 100644 src/library/entries/md-statewide-imagery.json create mode 100644 src/library/entries/me-statewide-imagery.json create mode 100644 src/library/entries/mi-statewide-imagery.json create mode 100644 src/library/entries/mn-statewide-imagery.json create mode 100644 src/library/entries/mountain-village-co.json create mode 100644 src/library/entries/ms-statewide-imagery.json create mode 100644 src/library/entries/mt-statewide-imagery.json create mode 100644 src/library/entries/naip.json create mode 100644 src/library/entries/nasa-black-marble.json create mode 100644 src/library/entries/nasa-eonet-events.json create mode 100644 src/library/entries/nc-statewide-imagery.json create mode 100644 src/library/entries/nctcog-tx.json create mode 100644 src/library/entries/nd-statewide-imagery.json create mode 100644 src/library/entries/new-orleans-rpc-la.json create mode 100644 src/library/entries/nh-statewide-imagery.json create mode 100644 src/library/entries/nj-statewide-imagery.json create mode 100644 src/library/entries/noaa-tide-stations.json create mode 100644 src/library/entries/nrw-dop.json create mode 100644 src/library/entries/nsw-imagery.json create mode 100644 src/library/entries/ntad-national-bridges.json create mode 100644 src/library/entries/nwa-benton-washington-ar.json create mode 100644 src/library/entries/nws-active-alerts.json create mode 100644 src/library/entries/ny-statewide-imagery.json create mode 100644 src/library/entries/oakland-county-mi.json create mode 100644 src/library/entries/oh-statewide-imagery.json create mode 100644 src/library/entries/openseamap-seamarks.json create mode 100644 src/library/entries/or-statewide-imagery.json create mode 100644 src/library/entries/pa-statewide-imagery.json create mode 100644 src/library/entries/pdok-aerial-netherlands.json create mode 100644 src/library/entries/pl-ortofotomapa.json create mode 100644 src/library/entries/pnoa-ma.json create mode 100644 src/library/entries/portland-metro-or.json create mode 100644 src/library/entries/pt-dgt-ortos.json create mode 100644 src/library/entries/ri-statewide-imagery.json create mode 100644 src/library/entries/swisstopo-swissimage.json create mode 100644 src/library/entries/tx-statewide-imagery.json create mode 100644 src/library/entries/union-county-oh.json create mode 100644 src/library/entries/usgs-hydrography.json create mode 100644 src/library/entries/usgs-topo.json create mode 100644 src/library/entries/va-statewide-imagery.json create mode 100644 src/library/entries/vlaanderen-omw.json create mode 100644 src/library/entries/vt-statewide-imagery.json create mode 100644 src/library/entries/wallonie-ortho.json create mode 100644 src/library/entries/wi-statewide-imagery.json create mode 100644 src/library/entries/wri-global-power-plants.json create mode 100644 src/library/entries/wv-statewide-imagery.json create mode 100644 src/library/extent.js create mode 100644 src/library/extent.test.mjs create mode 100644 src/library/formats.js create mode 100644 src/library/formats.test.mjs create mode 100644 src/library/layers.js create mode 100644 src/library/ledger.json create mode 100644 src/library/manifest.js create mode 100644 src/library/manifest.test.mjs create mode 100644 src/library/outlines.js create mode 100644 src/library/outlines.test.mjs create mode 100644 src/library/parquet.js create mode 100644 src/library/parquet.test.mjs create mode 100644 src/library/relief.js create mode 100644 src/library/relief.test.mjs create mode 100644 src/library/selection.js create mode 100644 src/library/selection.test.mjs create mode 100644 src/library/source.js create mode 100644 src/library/tileLayer.test.mjs create mode 100644 src/library/viewLayer.test.mjs create mode 100644 src/ui/libraryBrowser.js create mode 100644 src/ui/libraryBrowser.test.mjs create mode 100644 src/ui/styles/library.css diff --git a/.env.example b/.env.example index 55cb367a0..0e2b68bc2 100644 --- a/.env.example +++ b/.env.example @@ -257,6 +257,11 @@ AISSTREAM_API_KEY= # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured +# Optional: Data Library (DATA LAYERS ▸ Browse data library). Every entry is +# fetched live from its provider; nothing is hosted here. See docs/DATA-LIBRARY.md. +# GEV_LIBRARY_ENABLE= # comma-separated ids of operator opt-in entries to serve (packs use their host's own switch, e.g. CCTV_NYC_ENABLED) +# GEV_LIBRARY_DISABLE= # comma-separated ids to remove from this instance entirely + # Optional: comma-separated Overpass URLs; use only instances you run or pay for. OVERPASS_UPSTREAMS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3563685..ae7addd23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: if: github.event_name == 'pull_request' run: npm run layer-token:check -- --base-ref origin/main + - name: Check Data Library manifests and share tokens + if: github.event_name == 'pull_request' + run: npm run library:check -- --base-ref origin/main + - name: Run unit tests run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442b9c1bf..c64c12dd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,6 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. +- **📚 Add a Data Library entry.** A regional or niche source that is not a core layer belongs in the Data Library: one JSON manifest pointing at a live API, provider-hosted file or tile endpoint, with its licence and attribution, and no JavaScript. See [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). Library entries use their own share-token ledger, not the layer tokens below. - **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. @@ -151,7 +152,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. -4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. +4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution (for a Data Library entry, `npm run library:docs` writes its row). **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). ## Maintainers diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 61b971b71..387de3239 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -189,6 +189,123 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei --- + + +## Data Library entries (fetched at runtime — not stored in this repo) + +Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`. + +| Entry | How it is served | Licence / terms | Attribution | +| --- | --- | --- | --- | +| **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" | +| **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" | +| **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" | +| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" | +| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" | +| **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" | +| **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" | +| **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" | +| **Florida cameras (FL511)** (`cctv-florida-fl511`, `fl511.com`) | CCTV pack; operator sets `CCTV_FLORIDA_ENABLED=1` | [FL511 content: individual personal use only](https://fl511.com/privacy#disclaimer) | "Florida DOT — FL511.com" | +| **Lithuania road cameras (Via Lietuva)** (`cctv-lithuania`, `eismoinfo.lt`) | CCTV pack; operator sets `CCTV_LITHUANIA_ENABLED=1` | [Via Lietuva open-data statement; camera photo coverage unconfirmed](https://eismoinfo.lt/eismoinfo-backend/content?id=44) | "Via Lietuva / eismoinfo.lt" | +| **New York City cameras (NYC DOT TMC)** (`cctv-nyc-dot`, `webcams.nyctmc.org`) | CCTV pack; operator sets `CCTV_NYC_ENABLED=1` | [NYC DOT camera feed: data-sharing agreement required](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) | "NYC DOT Traffic Management Center" | +| **Connecticut Statewide Imagery (2019)** (`ct-statewide-imagery`, `cteco.uconn.edu`) | tiles, bbox, browser-direct; operator adds `ct-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/) | "CT ECO — UConn CLEAR / CT DEEP" | +| **Dakota County, Minnesota Air Photo** (`dakota-county-mn`, `gisimg.co.dakota.mn.us`) | tiles, bbox, browser-direct; operator adds `dakota-county-mn` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis.co.dakota.mn.us/) | "Dakota County GIS" | +| **District of Columbia Statewide Imagery (2025)** (`dc-statewide-imagery`, `imagery.dcgis.dc.gov`) | tiles, bbox, browser-direct | [Open data — DC public domain terms](https://opendata.dc.gov/pages/terms-of-use) | "Office of the Chief Technology Officer" | +| **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." | +| **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" | +| **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" | +| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" | +| **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" | +| **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" | +| **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" | +| **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" | +| **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" | +| **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" | +| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" | +| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" | +| **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" | +| **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | +| **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | +| **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" | +| **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" | +| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" | +| **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" | +| **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" | +| **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" | +| **Maritime chokepoints (IMF PortWatch)** (`imf-portwatch-chokepoints`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-chokepoints` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org)" | +| **Port disruptions (IMF PortWatch)** (`imf-portwatch-disruptions`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-disruptions` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org), with GDACS event data" | +| **Indiana Statewide Imagery (2016)** (`in-statewide-imagery`, `di-ingov.img.arcgis.com`) | tiles, bbox, browser-direct | [CC0 1.0 Universal (public domain dedication)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | "Indiana Geographic Information Office (CC0)" | +| **Surface water occurrence (JRC)** (`jrc-surface-water`, `storage.googleapis.com`) | tiles, xyz, browser-direct | [Copernicus programme: free, without restriction of use](https://global-surface-water.appspot.com/download) | "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water" | +| **Norway counties and municipalities (Kartverket)** (`kartverket-admin-units`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway cadastral map (Kartverket)** (`kartverket-cadastre`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway sea depths (Kartverket)** (`kartverket-depth`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway hillshade (Kartverket)** (`kartverket-hillshade`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway nautical charts (Kartverket)** (`kartverket-nautical-charts`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway terrain relief (Kartverket DTM)** (`kartverket-terrain-relief`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic map (Kartverket)** (`kartverket-topo`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic map, greyscale (Kartverket)** (`kartverket-topo-grey`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic raster map (Kartverket)** (`kartverket-toporaster`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Kansas Statewide Imagery (2021)** (`ks-statewide-imagery`, `services.kgs.ku.edu`) | tiles, bbox, browser-direct; operator adds `ks-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.kansasgis.org/) | "FSA, USDA," | +| **Kentucky Statewide Imagery** (`ky-statewide-imagery`, `kyraster.ky.gov`) | tiles, bbox, browser-direct | [Public domain with attribution](https://kygeonet.ky.gov/) | "KyFromAbove" | +| **Lake County, Indiana Aerial Imagery** (`lake-county-in`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lake-county-in` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lakecountyin.org/) | "Lake County Surveyor's Office" | +| **Lawrence, Kansas Aerial Photos** (`lawrence-ks`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lawrence-ks` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis-lawrenceks.opendata.arcgis.com/) | "City of Lawrence GIS" | +| **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" | +| **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" | +| **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" | +| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" | +| **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" | +| **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" | +| **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" | +| **MARIS Imagery, Mississippi** (`maris-ms`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `maris-ms` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.ms.gov/) | "MARIS" | +| **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" | +| **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" | +| **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" | +| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" | +| **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" | +| **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" | +| **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" | +| **NAIP — National Agriculture Imagery Program** (`naip`, `imagery.nationalmap.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://naip-usdaonline.hub.arcgis.com/) | "USDA NAIP" | +| **Earth at night (NASA Black Marble 2016)** (`nasa-black-marble`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.earthdata.nasa.gov/engage/open-data-services-software-policies) | "NASA Earth Observatory / GIBS — VIIRS Black Marble" | +| **NASA EONET open natural events** (`nasa-eonet-events`, `eonet.gsfc.nasa.gov`) | dataset, geojson, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.nasa.gov/nasa-brand-center/images-and-media/) | "NASA Earth Observatory Natural Event Tracker (EONET)" | +| **North Carolina Statewide Imagery (2023)** (`nc-statewide-imagery`, `services.nconemap.gov`) | tiles, bbox, browser-direct | [Public domain — NC OneMap terms of use](https://www.nconemap.gov/pages/terms) | "NC911, NCCGIA" | +| **North Central Texas (NCTCOG) Ortho** (`nctcog-tx`, `mapitimage.tarrantcounty.com`) | tiles, bbox, browser-direct; operator adds `nctcog-tx` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nctcog.org/) | "North Central Texas Council of Governments" | +| **North Dakota Statewide Imagery (2013)** (`nd-statewide-imagery`, `ndgishub.nd.gov`) | tiles, xyz, browser-direct; operator adds `nd-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.nd.gov/) | "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub" | +| **New Orleans Regional Planning Commission Imagery** (`new-orleans-rpc-la`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `new-orleans-rpc-la` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.norpc.org/) | "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)" | +| **New Hampshire Statewide Imagery (2022)** (`nh-statewide-imagery`, `nhgeodata.unh.edu`) | tiles, bbox, browser-direct; operator adds `nh-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — NH GRANIT terms](https://www.granit.unh.edu/) | "NH GRANIT, University of New Hampshire" | +| **New Jersey Statewide Imagery (2015)** (`nj-statewide-imagery`, `maps.nj.gov`) | tiles, xyz, browser-direct; operator adds `nj-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — State of New Jersey provides data 'as is'](https://njgin.nj.gov/) | "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by" | +| **US tide stations (NOAA CO-OPS)** (`noaa-tide-stations`, `api.tidesandcurrents.noaa.gov`) | dataset, json, browser-direct | [NOAA data: U.S. public domain](https://tidesandcurrents.noaa.gov/disclaimers.html) | "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)" | +| **Digitale Orthophotos NRW (DOP 10 cm)** (`nrw-dop`, `www.wms.nrw.de`) | tiles, bbox, browser-direct | [Datenlizenz Deutschland – Zero (public-domain-like)](https://www.govdata.de/dl-de/zero-2-0) | "Geobasis NRW (dl-de/zero-2-0)" | +| **NSW Imagery (Six Maps)** (`nsw-imagery`, `maps.six.nsw.gov.au`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.spatial.nsw.gov.au/products_and_services/web_services) | "© Spatial Services NSW" | +| **US bridges and their condition (NBI)** (`ntad-national-bridges`, `services.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. government work: unrestricted public use](https://www.bts.gov/ntad) | "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory" | +| **Northwest Arkansas (Benton & Washington County) Imagery** (`nwa-benton-washington-ar`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `nwa-benton-washington-ar` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nwarpc.org/) | "NWARPC; Washington County; Benton County" | +| **US weather alerts with drawn areas (NWS)** (`nws-active-alerts`, `api.weather.gov`) | dataset, geojson, browser-direct | [NWS data: U.S. public domain](https://www.weather.gov/disclaimer) | "NOAA National Weather Service" | +| **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" | +| **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" | +| **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" | +| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" | +| **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" | +| **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" | +| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" | +| **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" | +| **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" | +| **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" | +| **Ortofotos de Portugal Continental (DGT)** (`pt-dgt-ortos`, `ortos.dgterritorio.gov.pt`) | tiles, bbox, browser-direct | [CC BY 4.0](https://www.dgterritorio.gov.pt/) | "© Direção-Geral do Território" | +| **Rhode Island Statewide Imagery (2019)** (`ri-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ri-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — RIGIS terms](https://www.rigis.org/) | "URI EDC, RIGIS" | +| **Switzerland aerial photos (SWISSIMAGE 10 cm)** (`swisstopo-swissimage`, `wmts.geo.admin.ch`) | tiles, xyz, browser-direct | [swisstopo free geodata (OGD): free use, including commercial, with source credit](https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices) | "©swisstopo — SWISSIMAGE 10 cm" | +| **Texas Statewide Imagery (2022)** (`tx-statewide-imagery`, `imagery.geographic.texas.gov`) | tiles, bbox, browser-direct | [Public domain — TxGIO open data](https://geographic.texas.gov/) | "Texas Geographic Information Office (TxGIO)" | +| **Union County, Ohio Aerial Imagery** (`union-county-oh`, `www7.co.union.oh.us`) | tiles, bbox, browser-direct; operator adds `union-county-oh` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://union-county-auditor-unionco.hub.arcgis.com/) | "Union County / City of Marysville GIS" | +| **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" | +| **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" | +| **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" | +| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" | +| **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" | +| **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" | +| **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" | +| **Global power plants (WRI)** (`wri-global-power-plants`, `raw.githubusercontent.com`) | dataset, csv, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "World Resources Institute, Global Power Plant Database (2021)" | +| **West Virginia Statewide Imagery** (`wv-statewide-imagery`, `services.wvgis.wvu.edu`) | tiles, bbox, browser-direct; operator adds `wv-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WV GIS Technical Center terms](https://wvgis.wvu.edu/) | "WV GIS Technical Center, West Virginia University" | + + + ## Bundled snapshots Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README. diff --git a/SECURITY.md b/SECURITY.md index 30203ed94..3c10623c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,6 +67,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary. - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. +- **The Data Library proxy fetches manifest upstreams only.** `/api/library/data/`, `/api/library/tile////` and `/api/library/index/` (a partitioned GeoParquet entry's file index, built from an S3 ListObjectsV2 listing and one ranged footer read per file, cached in memory for the entry's `cacheSeconds`) resolve the id against the validated manifests in `src/library/entries/`; the browser never supplies a URL, and every derived request must stay on the manifest's https host. Redirects are refused, dataset bodies are capped while streaming at the manifest's `maxBytes` (at most 32 MiB) and tiles at 2 MiB of `image/png`, `image/jpeg` or `image/webp`, responses are cached in memory only (at most 512 tiles), requests are rate-limited per client, and upstream error text is never returned. A keyed entry must be proxied; its key is read from this server's environment through the key registry and its routes refuse cross-site and forwarded requests like the other cost-bearing routes. Browser-direct entries are fetched by the browser with no cookies or referrer. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Response-size caps and timeouts** on proxied responses. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ac186c25b..4ed2ea418 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,8 +1,8 @@ # Third-party software notices The project depends on third-party npm packages whose own licenses apply. This -file records the packages added for the browser-local SDR feature and the -Street Level viewer; the complete resolved dependency inventory remains in +file records the packages added for the browser-local SDR feature, the +Street Level viewer and the Data Library's GeoParquet reader; the complete resolved dependency inventory remains in `package-lock.json`. ## Web RTL-SDR @@ -45,3 +45,18 @@ Street Level viewer; the complete resolved dependency inventory remains in - Source: - License: MIT - License text: + +## hyparquet + +- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library + GeoParquet entry is switched on) +- Author: Hyperparam +- Source: +- License: MIT + +## fzstd (hyparquet ZSTD decompressor) + +- Package: `fzstd` 0.1.1 +- Author: Copyright (c) 2020 Arjun Barrett +- Source: +- License: MIT diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b3930264c..a4b0460c2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -68,6 +68,206 @@ provider registration rejects compositions that cannot fit rather than truncatin identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. +## Data Library — October 21, 2026 + +DATA LAYERS keeps its curated core layers; everything else lives in the Data +Library, opened from **Browse data library** at the foot of the panel. An entry +is a manifest in `src/library/entries/` that points at a live upstream its +provider hosts — a query API, a provider-hosted static file or a tile endpoint. +Nothing an entry describes is bundled, so a self-hosted instance serves every +entry without hosting data. Manifests are validated strictly when +`src/library/catalog.js` loads (unknown fields are errors), so an invalid entry +fails the unit suite. Authoring guide: [DATA-LIBRARY.md](DATA-LIBRARY.md). + +Three kinds ship. A `dataset` (GeoJSON, GeoJSON Lines, CSV with lat/lon +columns, a JSON record list read from `source.records` with named or guessed +lat/lon fields, or a paged ArcGIS FeatureServer query) renders through the same +GeoJSON layer as Datacenters and Dams, with the manifest's label, up to two +card fields and an optional `rank` field that orders a capped set and feeds the +layer's level-of-detail priority (0–600, below the named/unnamed gap). A capped +dataset reads PARTIAL. A dataset with `source.view` is view-bounded, +for layers too large to load whole (FEMA USA Structures: ~135 million +footprints): below `view.maxAltitudeM` it queries ArcGIS for what intersects a +window around the point the camera looks at (half the camera height each way, +330 m – 5.5 km, clipped to the visible rectangle), one page per view after the +camera settles, replacing the previous set only when the new one is ready and +aborting a superseded request; above it the row reads "zoom in to load this +layer" and nothing is fetched. Footprints are ground-clamped polygons (draped on +Google 3D tiles too), without outlines. Zoom to layer lands straight above the +manifest's `view.sample` area at 60 % of the altitude limit. View-bounded +entries are ArcGIS or GeoParquet, and browser-direct, in this build. + +`geoparquet` datasets are read in the browser with `hyparquet` (with `fzstd` +for ZSTD), imported lazily on first use; only the columns an entry uses are +decoded, and WKB geometry arrives as GeoJSON. A partitioned set +(`source.partitions`: an S3 ListObjectsV2 `listUrl` and a `keyBase`, both on the +source host) must be view-bounded. Its index — each file's GeoParquet footer +bbox, read with one range request per file — comes from `/api/library/index/` +(built once per `cacheSeconds` and shared), or is built in the browser and kept +in `gev:library:index:` on a static build. Footer bboxes can be stretched +by stray geocodes, so candidates are ranked by how much of each file's bbox +the view covers, read in parallel batches of four (up to eight per view) +until a batch has features in view, and each file read once teaches its real +extent (the box holding 98 % of its points, `gev:library:extent:`), so +later views skip files that do not cover them. The last four decoded files +stay in memory. `display.colorBy` colours each feature by a field's value (a +dotted path such as `categories.0.title` is flattened into the feature's +`__gevColorKey`): view-bounded layers draw 6 px dots or filled polygons, other +datasets keep the GeoJSON layer's stems, cards and level of detail with a +per-feature colour (`featureColor` in `localGeojsonCore.js`). Values sharing a +`colorBy.labels` label form one legend group (coal, gas and oil are "Fossil"); +values the manifest does not colour draw in `display.color` and count as +Other. The row legend lists each group with its count (in view, or in the +loaded set), one item per line as Fire Perimeters' does. Status-like fields +(GDACS and PortWatch alert levels, NWS severity, bridge condition) use the +fixed status palette (good #0ca30c, warning #fab219, serious #ec835a, +critical #d03b3b); categorical ones (fuel, EONET category, building +occupancy) use at most four hues that pass all-pairs colour-vision checks on +the dark globe, plus a neutral Other. Tile layers carry a static +`display.legend` copied from the provider's own scale (GBIF classic.poly, +JRC occurrence, GIBS GPW, Macrostrat's ICS period colours); EMODnet publishes +no machine-readable scale and has none. Aerial photo layers (PDOK, IGN, GSI +photos, swisstopo SWISSIMAGE) set `display.outline`: their covered countries' +borders draw as a thin white ground-clamped line, so the imagery's edge is +plain, with a "Coverage outline" legend item. Borders come from Natural +Earth's 1:50m admin-0 countries (public domain, fetched live once per page and +credited); `countries` draws every part (Japan's islands, Liechtenstein beside +Switzerland), `coverage` only the parts inside `coverage.bbox` (the European +Netherlands, metropolitan France). A failed border fetch costs only the +outline. The same layers set `display.aerial`: they drape above every other +imagery layer and raise themselves again whenever another drape is added, so a +basemap or weather overlay switched on later goes underneath. Each is +`source.bounded` (tiles requested inside `coverage.bbox` only) and names its +provider's no-imagery fill in `display.noDataColor`, drawn transparent through +Cesium's colour-to-alpha (max channel distance 0.02): measured fills are PDOK +and swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey +(#343434). Without both, PDOK's white fill covered the whole globe. A 4xx tile +answer counts as "no imagery here", never as a tile error. IGN's overseas +departments are outside its single bbox and are not shown. The aerial set now holds 68 +entries: 64 imported from the Open Imagery Register (national programmes in +Austria, Belgium, Germany, Estonia, Spain, Luxembourg, Poland, Portugal, +Geneva, Australia's NSW, and 51 US services: NAIP plus state, county and +city programmes) plus PDOK, IGN, GSI and SWISSIMAGE, each with an `imagery` +block (type, published resolution, true ortho, years flown, refresh) that +Browse shows as a spec list. Tiles take `xyz` (with `{reverseY}` for TMS) or +`bbox` templates (`{bbox-epsg-3857}`: WMS GetMap and ArcGIS export, filled +by Cesium in the browser and by the proxy from z/x/y). Sub-national entries +draw a `bbox` rectangle outline without fetching the country borders. 35 +entries whose licence the register gives only as "see publisher terms" are +operator opt-in. Norway comes from Kartverket's open +services (CC BY 4.0, "© Kartverket"; its zoom 12–20 Geovekst data may be shown +in live services but not copied): the topographic, greyscale, raster-topo and +nautical-chart caches (`xyz`), and WMS hillshade, DTM colour relief, sea depths +(from zoom 11, Kartverket's 16-class depth legend), county and municipality +boundaries, and the cadastre (from zoom 14). Norge i bilder needs +authentication and is not included. `mapterhorn-terrain` is the first +`source.encoding: terrarium` entry: Mapterhorn's global 512 px terrain-RGB +tiles are decoded per tile in the browser (`src/library/relief.js`) into +shaded relief with a six-class elevation legend in metres. A `tiles` entry (`{z}/{x}/{y}` template) drapes on the +active imagery host — the globe, or the Google 3D tileset — and follows map +stack switches; with no host it shows the existing "choose a globe map" +guidance, and its row reads DRAPED rather than a count. A `pack` describes an +existing host-layer source pack (today the opt-in CCTV packs): it is +informational in Browse, names the host's own switch (`CCTV_NYC_ENABLED=1` and +so on) and offers no Add. The `provider` kind from the RFC is refused by the +validator until it ships. + +Entries register their layer only when a viewer adds them, after the core +catalog is sealed: `LayerLifecycle.registerLibraryLayer` accepts only ids in +the `lib-` namespace and only after `finalizeRegistrations`, and +`unregisterLibraryLayer` destroys them. Library layers never enter +`LAYER_STATE_REGISTRY` or the core token ledger. The panel groups them under a +**Library** heading after every core group and rebuilds its rows on a +`registrations` activity. Add switches the layer on; Remove destroys it. +A library layer's row carries two icon chips, on or off (the panel shows +row controls for an off layer only for Data Library rows, whose chips are +marked `whileOff`): **Zoom to layer** +(`zoom_in_map`) flies to the extent of the loaded features, or a tiles entry's +declared coverage, taking the shorter way round the antimeridian and showing the +whole globe for world-scale sets; **Remove from Data Layers** (`delete`) +removes the entry. Zoom on a layer that is off switches it on first (a +dataset only knows its extent once loaded). Zoom runs as an immediate +navigation (`layer`), so Cockpit +refuses it with its own message and the current follow owner is released first; +reduced motion flies instantly. Icon chips (`chipGroup.js` `icon`/`action`) use +their label as accessible name and tooltip and carry no `aria-pressed`. + +This browser's selection persists in `gev:library:v1` (`{ added, on }`), written +only for explicit (user, voice, tool) changes, like core layer state, so a +Context session's temporary changes are not remembered. Share links carry the +switched-on entries as `lib=.` with the permanent two-character tokens in +`src/library/ledger.json` (append-only; packs take none). A link's entries join +the viewer's library and, as for core layers, the link alone decides what is +on. Unknown tokens are skipped. The live hash follows the existing +initial-restore suppression. + +Browser-direct entries are fetched without cookies or a referrer, redirects +refused, bodies capped while streaming. Proxied entries go through +`/api/library` (`server/providers/library.js`): `/catalog` reports each entry's +availability and reason (`operator-opt-in`, `operator-disabled`, `key-missing`) +without secrets; `/data/` and `/tile////` reach only the +manifest's fixed upstream (the browser names an id, never a URL), refuse +redirects and off-manifest hosts, cap dataset bodies at the manifest's +`maxBytes` (≤ 32 MiB) and tiles at 2 MiB of `image/png|jpeg|webp`, cache per the +manifest's `cacheSeconds` (60 s – 24 h; at most 512 tiles), serve the last good +dataset as stale on failure, rate-limit per client, and never return upstream +text. A keyed entry must be proxied; its key comes from the server environment +through the key registry and its routes use the strict cross-site gate, while +keyless routes use the keyless gate so they work behind a reverse proxy. +Operators opt entries in with `GEV_LIBRARY_ENABLE` and remove them with +`GEV_LIBRARY_DISABLE`. A static build with no server still serves every +browser-direct, non-opt-in entry; proxied ones say they need the app's server. + +The Browse dialog is a `dialog[data-panel-surface]`: modal, labelled, search +focused on open, Escape and backdrop dismiss and return focus to Browse, and it +closes when clean view, recording or Cockpit hides the panels. Its surface is +frosted glass (a lighter `--glass-bg` than the rails, a 22 px backdrop blur +clipped to the theme's frame, a barely dimmed backdrop), so the globe shows +through without the HUD text behind it competing. An added entry that is on +offers ZOOM, which closes the dialog and flies to it. Above the intro, four +stat tiles summarise the library: Datasets (every entry, broken down by kind), +Themes (how many distinct themes the entries cover), Added (and how many are on +the map), and Map features (features loaded by dataset layers that are on; +tiles have no count). +They refresh with the dialog and once a second while it is open, so counts +land as datasets finish loading. The line beside **REMOVE ALL** answers the +filter ("Showing 2 of 8"). **REMOVE ALL** removes every added entry; its first +press arms a confirm for four seconds. It lists +addable entries first, then entries this instance would need to switch on, then +packs, with kind, status, opt-in or key badges, provider, coverage and a licence +link, and filters by text, theme and "only what this instance serves". +Archived entries are hidden unless added. An entry's attribution and licence are +registered in the Data attribution popover while it is in use. + +Seeds: NASA EONET open events (GeoJSON, browser-direct), EIA US power plants +≥ 100 MW (ArcGIS, browser-direct, ranked by capacity), Smithsonian GVP Holocene +volcanoes (WFS GeoJSON, proxied — the upstream sends no CORS headers), NASA +Black Marble 2016 night lights and OpenSeaMap seamarks (tiles), and the NYC, +Lithuania and FL511 CCTV packs. Added from the Ask dataset registry (its live, +mappable sources that are not core layers): the WRI Global Power Plant Database +(provider-hosted CSV, the 10,000 largest of ~35,000 by capacity, so it reads +PARTIAL), NWS active alerts that carry their own polygon (zone-wide alerts have +no geometry and are dropped), NOAA CO-OPS tide stations (JSON records), and IMF +PortWatch chokepoints and port disruptions (ArcGIS, operator opt-in until the +reuse terms are confirmed, as Ask holds them in hosted mode). FEMA/ORNL USA +Structures (CC BY 4.0) is the first view-bounded entry; its service refuses a +query page smaller than its own 2,000 records, so the probe asks for a full +page. Addresscloud's EPC ratings for England and Wales (Source Cooperative, +read from `s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop`, 344 +local-authority GeoParquet files, OGL v3.0) are the first partitioned +GeoParquet entry, coloured by band A–G with the official EPC colours. From an +open/freemium registry review (commercial sources excluded): GDACS disaster +alerts; the National Bridge Inventory (view-bounded, coloured Good/Fair/Poor); +EPA Superfund sites and Toxics Release Inventory facilities (view-bounded); and +tile overlays for Macrostrat geology, GBIF biodiversity density, USGS +topographic and hydrography maps, PDOK (Netherlands) and IGN (France) aerial +photos, BKG TopPlusOpen, GSI Japan map and photos, EMODnet bathymetry, SEDAC +GPW 2020 population density (legend from the GIBS colour map) and JRC surface +water occurrence. `npm run library:check` validates manifests, +the ledger (append-only with `--base-ref`, run in CI on pull requests) and the +generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream +once; `npm run qa:data-library` is the browser gate (fixture-backed). + ## God's Eye View in conversations — October 2, 2026 Tool answers that can be shown in God's Eye View include a view: camera, layers, diff --git a/docs/DATA-LIBRARY.md b/docs/DATA-LIBRARY.md new file mode 100644 index 000000000..c549e7b4d --- /dev/null +++ b/docs/DATA-LIBRARY.md @@ -0,0 +1,183 @@ +# Data Library + +DATA LAYERS is the curated set of core layers. The Data Library holds +everything else: regional sources, niche themes and community contributions. +A viewer opens it from **DATA LAYERS ▸ Browse data library**, adds what they +want, and the entry appears in the panel under **Library**. Nothing in the +library is on until someone adds it. + +Runtime behaviour is described in [CURRENT-STATE.md](CURRENT-STATE.md#data-library--october-21-2026). +This page is the authoring guide. + +## The one rule: live sources only + +A library entry points at data its provider hosts. That can be a query API, a +static file at a stable URL the provider controls, or a tile endpoint. The +data is never copied into this repository. If the upstream goes away, the +entry goes stale; this project never becomes the host of record. That is what +lets every self-hosted instance serve the whole library with no data of its +own. + +It also keeps the existing rule in [CONTRIBUTING.md](../CONTRIBUTING.md): do +not add data you have no right to redistribute. A live source still needs a +licence that allows this use, and the manifest must say what it is. + +## Kinds + +| Kind | What it is | Renders through | +| --- | --- | --- | +| `dataset` | Vector features: `geojson`, `geojsonl`, `csv` (lat/lon columns), `json` (a record list with lat/lon fields), `arcgis` (a FeatureServer or MapServer layer, paged) or `geoparquet` (one file, or a partitioned set on S3) | The GeoJSON layer Datacenters and Dams use; view-bounded and GeoParquet sets draw as ground-clamped dots and polygons | +| `tiles` | Raster tiles: `xyz` with a `{z}/{x}/{y}` template (`{reverseY}` for TMS rows), or `bbox` with a `{bbox-epsg-3857}` template (WMS GetMap, ArcGIS `export`/`exportImage`) | The active map's imagery host (globe or Google 3D tiles) | +| `pack` | A source pack an existing host layer already loads (today: CCTV) | The host layer, switched on by its own environment variable | + +A `dataset` or `tiles` entry needs no JavaScript. A `pack` documents a host +layer's opt-in source so viewers can see it and operators know the switch; the +host layer owns its code. Provider entries (code implementing a family +contract, such as a Street Level provider) are not supported yet and are +refused by the validator. + +## Add an entry + +1. Create `src/library/entries/.json`. The id is 3–48 lowercase letters, + digits or dashes and is permanent. +2. Add one import line for it in `src/library/catalog.js` (keep the list in + file-name order). +3. For a `dataset` or `tiles` entry, run `npm run library:next -- ` and + append `["", ""]` to `src/library/ledger.json`. Tokens are + permanent: never change or reuse a row, even when an entry is removed. + Packs take no token. +4. Run `npm run library:docs` to regenerate the DATA_SOURCES.md table. +5. Run `npm run library:check -- --base-ref origin/main`, + `npm run library:probe`, `npm test` and `npm run qa:data-library`. +6. Open a pull request with a `changelog.d/` fragment. + +Start new entries at `"status": "community"`. Maintainers move an entry to +`verified` after reviewing its licence and seeing it stay healthy; `stale` and +`archived` mark entries whose upstream has stopped answering. Archived entries +are hidden from Browse unless a viewer already added them. + +## Manifest reference + +```json +{ + "id": "eia-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "US power plants, 100 MW and up (EIA)", + "provider": "EIA", + "description": "Operating US power plants with at least 100 MW of capacity.", + "icon": "ϟ", + "maintainer": "github:your-handle", + "coverage": { "countries": ["US"], "bbox": [-180, 13, -64, 72] }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/…/Power_Plants_in_the_US/FeatureServer/0", + "query": { "where": "Total_MW >= 100", "outFields": "Plant_Name,PrimSource,Total_MW" }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 4000, + "maxPages": 3 + }, + "display": { + "color": "#ffc23d", + "label": "Plant_Name", + "fields": ["PrimSource", "Total_MW"], + "rank": { "field": "Total_MW", "order": "desc" } + }, + "licence": { "name": "EIA data: public domain", "url": "https://www.eia.gov/about/copyrights_reuse.php" }, + "attribution": { "text": "U.S. Energy Information Administration", "url": "https://atlas.eia.gov/" }, + "status": "community", + "reviewed": "2026-10-21" +} +``` + +Unknown fields are errors, so a typo cannot silently drop a cap or a licence. + +| Field | Required | Notes | +| --- | --- | --- | +| `id`, `kind`, `family`, `title` (≤ 60), `provider` (≤ 32), `description` (≤ 240), `maintainer`, `status` | yes | `family` is one of movement, cameras, infrastructure, events, weather, environment, imagery. `provider` is the short name the panel row shows. | +| `coverage` | yes | `global: true`, ISO 3166-1 alpha-2 `countries`, and/or a `bbox` `[west, south, east, north]`. | +| `source.url` | yes | https, a real hostname, no credentials or fragment. | +| `source.format` | yes | Datasets: `geojson`, `geojsonl`, `csv`, `json`, `arcgis`, `geoparquet`. Tiles: `xyz` (`{z}`, `{x}` and `{y}` or `{reverseY}`) or `bbox` (one `{bbox-epsg-3857}`, filled with the tile's Web Mercator bounds in metres, as MapLibre does). Packs: `host`. | +| `source.records` | no | JSON only: the property path to the record list, such as `stations` for `{ "stations": [ … ] }`. Omit when the response is the list itself. | +| `source.fetch` | datasets, tiles | `browser-direct` when the upstream sends CORS headers and needs no key; otherwise `proxied`. | +| `source.query` | no | Query parameters added to the URL. For `arcgis`, `where` and `outFields`; geometry is always WGS84 GeoJSON. | +| `source.columns` | no | CSV or JSON: `{ "lat": "…", "lon": "…" }` when the fields are not lat/latitude/y and lon/lng/longitude/x. | +| `source.cacheSeconds` | no | 60–86,400, default 3,600. How long the proxy keeps a response. | +| `source.maxBytes` | no | Body cap, default 16 MiB, at most 32 MiB. | +| `source.maxFeatures` | no | Feature cap, default 10,000, at most 20,000. A capped dataset reads PARTIAL. | +| `source.maxPages` | no | ArcGIS pages to follow, default 5, at most 10. | +| `source.view` | no | `{ "maxAltitudeM": 300–50000, "sample": [w, s, e, n] }`. Makes a huge ArcGIS layer view-bounded: below that camera height it loads what is in view, one page at a time; above it the row asks the viewer to zoom in. Needs `arcgis` or `geoparquet`, and `browser-direct`. `sample` is where Zoom to layer lands and what `library:probe` queries. | +| `source.partitions` | no | GeoParquet only: `{ "listUrl": "", "keyBase": "" }`, both on the source host. Needs `source.view`. The library indexes each file by its footer bbox and reads only the files in view. | +| `source.minZoom`, `source.maxZoom` | no | Tiles only, 0–22. | +| `source.encoding` | no | Tiles (`xyz`): `terrarium` when the pixels are elevation as terrain-RGB (height = R × 256 + G + B / 256 − 32768 m). The browser draws them as shaded relief: one orange ramp in six elevation classes (legend in metres), lit from the north-west; sea (exactly 0 m) is transparent. | +| `source.tileSize` | no | Tiles: 256 (default) or 512 px. | +| `source.bounded` | no | Tiles with `coverage.bbox`: `true` requests tiles inside the bbox only. Required for aerial imagery, whose servers paint a fill (white, a flat ocean, a coarse world mosaic) everywhere they have no photos. A 4xx tile answer is treated as "no imagery here", never retried or counted as an error. | +| `source.key` | no | `{ "id": "", "in": "query" \| "header", "name": "…" }`. The id must exist in `src/keySetupCore.mjs`. A keyed entry must be `proxied`, so the key never reaches the browser. | +| `display.color`, `display.label`, `display.fields` (≤ 4, two shown) | no | Datasets only, except `opacity`. | +| `display.rank` | no | `{ "field": "…", "order": "desc" \| "asc" }`. Keeps the most important features when capped and lets the map prefer them at global zoom. Numbers or ISO dates. Without it, a zoomed-out view shows whichever features come first. | +| `display.colorBy` | no | View-bounded datasets: `{ "field": "…", "colors": { "": "#rrggbb" }, "labels": { "": "…" } }`, up to 12 values. Colours each feature by value; the row legend lists each label with the count in view. | +| `display.legend` | no | `[{ "label": "…", "color": "#rrggbb" }]`, up to 12: a static legend, such as a tile layer's colour scale. Take the colours from the provider's own legend or colour map. | +| `display.outline` | no | Tiles: `countries` outlines every part of each covered country, `coverage` only the parts inside `coverage.bbox`, `bbox` the bbox rectangle itself (a state, county or city inside a country). Use it for aerial imagery so its edge shows. | +| `display.aerial` | no | Tiles: `true` for aerial or satellite photography. It drapes above every other imagery layer, including core overlays, and stays there when another layer is switched on. Pair it with `source.bounded`. | +| `display.noDataColor` | no | Tiles: `#rrggbb` of the provider's no-imagery fill, drawn transparent (within a small tolerance for JPEG noise along its edge). Measure it from a tile outside the coverage; do not guess. Globe maps only: Google 3D tiles ignore it. | +| `display.opacity` | no | Tiles: (0, 1]. | +| `imagery` | no | Tiles: what a photo layer is. `type` (`ortho`, `satellite-mosaic`, `oblique`, `historic`), `resolution` as the provider publishes it ("10 cm (plains) / 25 cm (Alps)"), `resolutionM` (the finest, in metres), `trueOrtho`, and optional `vintage` ("2021–present"), `updateCycle`, `orthoQuality` and `registry` (an https link to where the metadata came from). Browse shows it as a spec list on the card. | +| `pack` | packs | `{ "host": "cctv", "env": "CCTV_EXAMPLE_ENABLED" }`. Packs are always `licence.operatorOptIn: true`. | +| `licence` | yes | `name`, an https `url`, and `operatorOptIn: true` when the terms need the operator's own agreement or consent. | +| `attribution` | yes | `text` and an optional https `url`; shown in the Data attribution popover while the entry is in use. | +| `regionsServed` | no | ISO codes, when the upstream only answers from some countries. | +| `reviewed` | no | `YYYY-MM-DD` of the last licence and endpoint review. | + +## Aerial imagery from the Open Imagery Register + +The aerial photo entries came from the [Open Imagery Register](https://imagery-registry.netlify.app/) +on 2026-10-23: every register source with a map preview, less the ones that +fail the library's rules. Each entry keeps the register's resolution, product +type, true-ortho flag, years flown and refresh cycle in `imagery`. Before an +entry was written, a tile inside its coverage was fetched in a browser from +the app's origin: it had to be a real photo (not a blank fill), and the +provider had to send CORS headers (or the entry would be `proxied`). The +fill a provider paints at its coverage corners became `display.noDataColor`. + +Left out, with the reason: + +| Register source | Why | +| --- | --- | +| NASA GIBS true colour | Already in core (Recent imagery). | +| Sentinel-2 cloudless (EOX) | CC BY-NC-SA: non-commercial. | +| SWISSIMAGE Bern and Marion County PMTiles | Extracts hosted by the register, not the provider. | +| Cabarrus County NC, Hawaii, Nashville, Sioux Falls | Credit a commercial imagery vendor (EagleView, Maxar WorldView, Pictometry, Nearmap). | +| DVRPC, Missouri, Washington | The preview serves a blank white tile across the coverage centre. | +| Sarasota County FL | The service now requires a token. | + +Entries whose register licence reads "see publisher terms" or "verify portal +terms" are `licence.operatorOptIn: true`: an operator reads the publisher's +terms and lists the id in `GEV_LIBRARY_ENABLE`. Re-check an entry's terms and +drop the opt-in when its publisher states an open licence. + +## Choosing a fetch mode + +Use `browser-direct` when the upstream answers a browser on another origin +(`Access-Control-Allow-Origin`) and needs no key. Tile servers must send CORS +headers for WebGL to use them; without them, use `proxied`. + +Use `proxied` otherwise. The proxy is generic: there is no per-entry server +code. It reaches only the manifest's host, refuses redirects, caps bodies, +caches per `cacheSeconds` and never returns upstream error text. The browser +names an entry id, never a URL, so the proxy cannot be pointed anywhere else. + +## Operator switches + +| Variable | Effect | +| --- | --- | +| `GEV_LIBRARY_ENABLE` | Comma-separated ids of `operatorOptIn` datasets and tiles to serve. | +| `GEV_LIBRARY_DISABLE` | Comma-separated ids to remove from this instance. | +| A pack's own variable | For example `CCTV_NYC_ENABLED=1`; Browse then shows the pack as switched on. | + +## When an entry belongs in core instead + +A library entry can be proposed for DATA LAYERS when it has global or +near-global coverage, is keyless or uses a key the registry already has, has +stayed `verified` with no stale period for about six months, and a core +maintainer will own it. Promotion and demotion are maintainer decisions. diff --git a/index.html b/index.html index 8a01415b6..e774ce33e 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/package-lock.json b/package-lock.json index 7b6d3476f..156c8aeb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "fzstd": "0.1.1", "hls.js": "^1.7.3", + "hyparquet": "1.31.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", @@ -2043,6 +2045,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fzstd": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", + "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", + "license": "MIT" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2116,6 +2124,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/hyparquet": { + "version": "1.31.2", + "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz", + "integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 241c59e59..3221a3975 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "i18n": "node scripts/i18n.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "library:check": "node scripts/library.mjs check", + "library:next": "node scripts/library.mjs next", + "library:docs": "node scripts/library.mjs docs", + "library:probe": "node scripts/library.mjs probe", "changelog": "node scripts/assemble-changelog.mjs --write", "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", @@ -53,6 +57,7 @@ "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "qa:transit": "node scripts/qa-transit.mjs", + "qa:data-library": "node scripts/qa-data-library.mjs", "mcp": "node server/mcp/stdio.js" }, "dependencies": { @@ -62,7 +67,9 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "fzstd": "0.1.1", "hls.js": "^1.7.3", + "hyparquet": "1.31.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", diff --git a/scripts/library.mjs b/scripts/library.mjs new file mode 100644 index 000000000..62366302e --- /dev/null +++ b/scripts/library.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node +/** + * Data Library maintenance. See docs/DATA-LIBRARY.md. + * + * npm run library:check [-- --base-ref origin/main] + * Validate every manifest, the share-token ledger and the generated + * DATA_SOURCES.md section. With --base-ref, also prove the ledger only + * appended rows since that ref (published tokens never change). + * npm run library:next -- + * Print the next free share token for a new switchable entry. + * npm run library:docs + * Rewrite the generated Data Library section of DATA_SOURCES.md. + * npm run library:probe [-- --json] [-- --strict] + * Hit every dataset and tile upstream once (bounded) and report + * status, latency and feature count. --strict exits 1 on a failure. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js'; +import { libraryTileUrl } from '../src/library/manifest.js'; +import { + arcgisPageUrl, + datasetRequestUrl, + parseLibraryBody, +} from '../src/library/formats.js'; +import { + nextLibraryToken, + readLibraryLedger, +} from '../src/library/selection.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md'); +const START = ''; +const END = ''; +const LEDGER_PATH = 'src/library/ledger.json'; + +const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' '); + +/** The generated DATA_SOURCES.md section, one row per entry. */ +export function libraryDocsSection(entries = LIBRARY_ENTRIES) { + const rows = [...entries] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((entry) => { + const host = (() => { + try { + return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host; + } catch { + return ''; + } + })(); + const how = + entry.kind === 'pack' + ? `CCTV pack; operator sets \`${entry.pack.env}=1\`` + : `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` + + (entry.licence.operatorOptIn + ? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\`` + : '') + + (entry.source.key + ? `; needs the \`${entry.source.key.id}\` key` + : ''); + return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`; + }); + return [ + START, + '', + '## Data Library entries (fetched at runtime — not stored in this repo)', + '', + 'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.', + '', + '| Entry | How it is served | Licence / terms | Attribution |', + '| --- | --- | --- | --- |', + ...rows, + '', + END, + ].join('\n'); +} + +function replaceSection(text, section) { + const start = text.indexOf(START); + const end = text.indexOf(END); + if (start < 0 || end < start) { + const anchor = text.indexOf('\n## Bundled snapshots'); + if (anchor < 0) + throw new Error('DATA_SOURCES.md has no Bundled snapshots heading'); + return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`; + } + return text.slice(0, start) + section + text.slice(end + END.length); +} + +function check(baseRef) { + const errors = []; + const ledger = readLibraryLedger(LIBRARY_LEDGER); + errors.push(...ledger.errors.map((error) => `ledger ${error}`)); + for (const entry of LIBRARY_ENTRIES) { + const switchable = entry.kind !== 'pack'; + if (switchable && !ledger.tokenById.has(entry.id)) + errors.push( + `${entry.id}: no share token (npm run library:next -- ${entry.id})`, + ); + if (!switchable && ledger.tokenById.has(entry.id)) + errors.push( + `${entry.id}: packs are switched by their host and take no token`, + ); + } + if (baseRef) { + let published = []; + try { + published = JSON.parse( + execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }), + ); + } catch { + published = []; + } + published.forEach(([id, token], index) => { + const row = LIBRARY_LEDGER[index]; + if (!row || row[0] !== id || row[1] !== token) + errors.push( + `ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`, + ); + }); + LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => { + const expected = nextLibraryToken( + LIBRARY_LEDGER.slice(0, published.length + offset), + ); + if (token !== expected) + errors.push( + `ledger row for ${id} should take ${expected}, not ${token}`, + ); + }); + } + const text = readFileSync(DATA_SOURCES, 'utf8'); + if (!text.includes(libraryDocsSection())) + errors.push( + 'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)', + ); + if (errors.length) { + for (const error of errors) console.error(`✗ ${error}`); + process.exit(1); + } + console.log( + `Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`, + ); +} + +/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */ +const PROBE_POINTS = [ + [0.5, 0.5], + [0.4, 0.45], + [0.6, 0.55], + [0.25, 0.25], + [0.75, 0.75], +]; + +/** + * A Web Mercator tile to probe. Regional entries try points inside their + * coverage bbox (a regional server answers 404 or a blank fill outside its + * photos, and a bbox centre can be sea); global entries probe a mid-world + * tile. + */ +function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) { + const n = 2 ** z; + const bbox = entry.coverage?.bbox; + if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) }; + const lon = bbox[0] + (bbox[2] - bbox[0]) * fx; + const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy)); + const rad = (lat * Math.PI) / 180; + const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value))); + return { + x: clamp(((lon + 180) / 360) * n), + y: clamp( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n, + ), + }; +} + +async function probeEntry(entry) { + const started = Date.now(); + const signal = AbortSignal.timeout(30_000); + try { + if (entry.kind === 'tiles') { + // Regional imagery is probed at z13, the Open Imagery Register's + // validation zoom (some caches start there); global layers at their + // coarsest level. + const minZoom = entry.source.minZoom ?? 0; + const z = entry.coverage.bbox + ? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13)) + : minZoom; + const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]]; + let status = null; + let lastError = null; + for (const point of points) { + const { x, y } = probeTile(entry, z, point); + const url = libraryTileUrl(entry.source.url, z, x, y); + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(20_000), + redirect: 'error', + }); + await response.arrayBuffer(); + status = response.status; + if ( + response.ok && + /^image\//.test(response.headers.get('content-type') || '') + ) + return { id: entry.id, ok: true, status, ms: Date.now() - started }; + } catch (error) { + lastError = error; + } + } + if (status === null && lastError) throw lastError; + return { id: entry.id, ok: false, status, ms: Date.now() - started }; + } + if (entry.source.format === 'geoparquet') { + // List the partitions and read one footer by range request: proof the + // bucket answers and the files are GeoParquet, without reading data. + const { listPartitions, rangeBuffer, geoParquetInfo } = + await import('../src/library/parquet.js'); + const { parquetMetadataAsync } = await import('hyparquet'); + const fetchImpl = (target, options = {}) => + fetch(target, { ...options, signal }); + const objects = entry.source.partitions + ? await listPartitions(entry, { fetchImpl, signal }) + : [{ key: entry.source.url, size: null }]; + const first = objects[0]; + const url = entry.source.partitions + ? new URL(first.key, entry.source.partitions.keyBase).href + : entry.source.url; + const size = + first.size ?? + Number( + (await fetch(url, { method: 'HEAD', signal })).headers.get( + 'content-length', + ), + ); + const metadata = await parquetMetadataAsync( + rangeBuffer(url, size, { fetchImpl, signal }), + { initialFetchSize: 16 * 1024 }, + ); + return { + id: entry.id, + ok: Boolean(geoParquetInfo(metadata).column), + status: 200, + ms: Date.now() - started, + features: Number(metadata.num_rows), + partitions: objects.length, + }; + } + const url = + entry.source.format === 'arcgis' + ? arcgisPageUrl( + entry.source.url, + entry.source.query, + 0, + // Some huge view services refuse a page smaller than their own. + entry.source.view ? 2000 : 100, + // A view-bounded layer is probed over its sample area. + entry.source.view?.sample ?? null, + ) + : datasetRequestUrl(entry.source); + const response = await fetch(url, { signal, redirect: 'error' }); + const text = await response.text(); + const parsed = response.ok + ? parseLibraryBody(entry.source.format, text, entry.source) + : null; + return { + id: entry.id, + ok: Boolean(parsed?.candidates.length), + status: response.status, + ms: Date.now() - started, + features: parsed?.candidates.length ?? 0, + }; + } catch (error) { + return { + id: entry.id, + ok: false, + status: null, + ms: Date.now() - started, + error: error.message, + }; + } +} + +const [command = 'check', ...rest] = process.argv.slice(2); +const flag = (name) => rest.includes(name); +const option = (name) => { + const index = rest.indexOf(name); + return index >= 0 ? rest[index + 1] : null; +}; + +if (command === 'check') check(option('--base-ref')); +else if (command === 'next') { + const id = rest.find((arg) => !arg.startsWith('--')); + if (!id) throw new Error('usage: npm run library:next -- '); + if (LIBRARY_LEDGER.some(([existing]) => existing === id)) + throw new Error(`${id} already owns a token`); + console.log(nextLibraryToken(LIBRARY_LEDGER)); +} else if (command === 'docs') { + const text = readFileSync(DATA_SOURCES, 'utf8'); + writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection())); + console.log('DATA_SOURCES.md Data Library section updated.'); +} else if (command === 'probe') { + const results = []; + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack')) + results.push(await probeEntry(entry)); + if (flag('--json')) console.log(JSON.stringify(results, null, 2)); + else + for (const result of results) + console.log( + `${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`, + ); + if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1); +} else { + console.error(`unknown command: ${command}`); + process.exit(2); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3a9950f39..9ffb47f4d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2506,7 +2506,155 @@ "src/voice/providerCapabilities.js", "src/voice/geminiSession.js", "src/voice/geminiAudio.js", - "src/voice/geminiCaptureWorklet.js" + "src/voice/geminiCaptureWorklet.js", + "src/ui/libraryBrowser.js", + "src/library/manifest.js", + "src/library/selection.js", + "src/library/ledger.json", + "src/library/entries/ak-statewide-imagery.json", + "src/library/entries/ar-statewide-imagery.json", + "src/library/entries/az-statewide-imagery.json", + "src/library/entries/bangor-me.json", + "src/library/entries/basemap-at-orthofoto.json", + "src/library/entries/bayern-dop40.json", + "src/library/entries/bismarck-mandan-nd.json", + "src/library/entries/bkg-topplus-open.json", + "src/library/entries/cctv-florida-fl511.json", + "src/library/entries/cctv-lithuania.json", + "src/library/entries/cctv-nyc-dot.json", + "src/library/entries/ct-statewide-imagery.json", + "src/library/entries/dakota-county-mn.json", + "src/library/entries/dc-statewide-imagery.json", + "src/library/entries/de-statewide-imagery.json", + "src/library/entries/ee-maa-amet-ortofoto.json", + "src/library/entries/eia-power-plants.json", + "src/library/entries/emodnet-bathymetry.json", + "src/library/entries/epa-superfund-sites.json", + "src/library/entries/epa-toxic-release-facilities.json", + "src/library/entries/epc-england-wales.json", + "src/library/entries/fema-usa-structures.json", + "src/library/entries/gbif-occurrence-density.json", + "src/library/entries/gdacs-disaster-alerts.json", + "src/library/entries/geneva-sitg-ortho.json", + "src/library/entries/gpw-population-density.json", + "src/library/entries/grand-forks-nd.json", + "src/library/entries/gsi-japan-map.json", + "src/library/entries/gsi-japan-photo.json", + "src/library/entries/gvp-holocene-volcanoes.json", + "src/library/entries/ia-statewide-imagery.json", + "src/library/entries/icgc-orto-catalunya.json", + "src/library/entries/id-statewide-imagery.json", + "src/library/entries/ign-orthophotos-france.json", + "src/library/entries/il-statewide-imagery.json", + "src/library/entries/imf-portwatch-chokepoints.json", + "src/library/entries/imf-portwatch-disruptions.json", + "src/library/entries/in-statewide-imagery.json", + "src/library/entries/jrc-surface-water.json", + "src/library/entries/kartverket-admin-units.json", + "src/library/entries/kartverket-cadastre.json", + "src/library/entries/kartverket-depth.json", + "src/library/entries/kartverket-hillshade.json", + "src/library/entries/kartverket-nautical-charts.json", + "src/library/entries/kartverket-terrain-relief.json", + "src/library/entries/kartverket-topo-grey.json", + "src/library/entries/kartverket-topo.json", + "src/library/entries/kartverket-toporaster.json", + "src/library/entries/ks-statewide-imagery.json", + "src/library/entries/ky-statewide-imagery.json", + "src/library/entries/lake-county-in.json", + "src/library/entries/lawrence-ks.json", + "src/library/entries/lee-county-fl.json", + "src/library/entries/loudoun-county-va.json", + "src/library/entries/louisville-lojic-ky.json", + "src/library/entries/lu-ortho-latest.json", + "src/library/entries/ma-statewide-imagery.json", + "src/library/entries/macrostrat-geology.json", + "src/library/entries/mapterhorn-terrain.json", + "src/library/entries/maris-ms.json", + "src/library/entries/md-statewide-imagery.json", + "src/library/entries/me-statewide-imagery.json", + "src/library/entries/mi-statewide-imagery.json", + "src/library/entries/mn-statewide-imagery.json", + "src/library/entries/mountain-village-co.json", + "src/library/entries/ms-statewide-imagery.json", + "src/library/entries/mt-statewide-imagery.json", + "src/library/entries/naip.json", + "src/library/entries/nasa-black-marble.json", + "src/library/entries/nasa-eonet-events.json", + "src/library/entries/nc-statewide-imagery.json", + "src/library/entries/nctcog-tx.json", + "src/library/entries/nd-statewide-imagery.json", + "src/library/entries/new-orleans-rpc-la.json", + "src/library/entries/nh-statewide-imagery.json", + "src/library/entries/nj-statewide-imagery.json", + "src/library/entries/noaa-tide-stations.json", + "src/library/entries/nrw-dop.json", + "src/library/entries/nsw-imagery.json", + "src/library/entries/ntad-national-bridges.json", + "src/library/entries/nwa-benton-washington-ar.json", + "src/library/entries/nws-active-alerts.json", + "src/library/entries/ny-statewide-imagery.json", + "src/library/entries/oakland-county-mi.json", + "src/library/entries/oh-statewide-imagery.json", + "src/library/entries/openseamap-seamarks.json", + "src/library/entries/or-statewide-imagery.json", + "src/library/entries/pa-statewide-imagery.json", + "src/library/entries/pdok-aerial-netherlands.json", + "src/library/entries/pl-ortofotomapa.json", + "src/library/entries/pnoa-ma.json", + "src/library/entries/portland-metro-or.json", + "src/library/entries/pt-dgt-ortos.json", + "src/library/entries/ri-statewide-imagery.json", + "src/library/entries/swisstopo-swissimage.json", + "src/library/entries/tx-statewide-imagery.json", + "src/library/entries/union-county-oh.json", + "src/library/entries/usgs-hydrography.json", + "src/library/entries/usgs-topo.json", + "src/library/entries/va-statewide-imagery.json", + "src/library/entries/vlaanderen-omw.json", + "src/library/entries/vt-statewide-imagery.json", + "src/library/entries/wallonie-ortho.json", + "src/library/entries/wi-statewide-imagery.json", + "src/library/entries/wri-global-power-plants.json", + "src/library/entries/wv-statewide-imagery.json", + "src/library/formats.js", + "src/library/entries/gvp-holocene-volcanoes.json", + "src/library/entries/nasa-black-marble.json", + "src/library/entries/nasa-eonet-events.json", + "src/library/entries/openseamap-seamarks.json", + "src/library/catalog.js", + "src/library/source.js", + "src/library/layers.js", + "src/library/controller.js", + "src/library/extent.js", + "src/library/camera.js", + "src/library/entries/imf-portwatch-disruptions.json", + "src/library/entries/imf-portwatch-chokepoints.json", + "src/library/entries/noaa-tide-stations.json", + "src/library/entries/nws-active-alerts.json", + "src/library/entries/wri-global-power-plants.json", + "src/library/entries/fema-usa-structures.json", + "src/library/entries/epc-england-wales.json", + "src/library/parquet.js", + "src/library/entries/bkg-topplus-open.json", + "src/library/entries/emodnet-bathymetry.json", + "src/library/entries/epa-superfund-sites.json", + "src/library/entries/epa-toxic-release-facilities.json", + "src/library/entries/gbif-occurrence-density.json", + "src/library/entries/gpw-population-density.json", + "src/library/entries/gdacs-disaster-alerts.json", + "src/library/entries/gsi-japan-map.json", + "src/library/entries/ign-orthophotos-france.json", + "src/library/entries/gsi-japan-photo.json", + "src/library/entries/jrc-surface-water.json", + "src/library/entries/ntad-national-bridges.json", + "src/library/entries/usgs-hydrography.json", + "src/library/entries/macrostrat-geology.json", + "src/library/entries/pdok-aerial-netherlands.json", + "src/library/entries/swisstopo-swissimage.json", + "src/library/entries/usgs-topo.json", + "src/library/outlines.js", + "src/library/relief.js" ], "external": [ "hls.js", @@ -2517,7 +2665,9 @@ "pbf", "satellite.js", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "hyparquet", + "fzstd" ] }, "application-catalog": { @@ -3156,7 +3306,11 @@ }, "layer-lifecycle": { "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js", + "src/library/manifest.js" + ], "external": [] }, "ui-navigation": { diff --git a/scripts/qa-data-library.mjs b/scripts/qa-data-library.mjs new file mode 100644 index 000000000..e8adc49c7 --- /dev/null +++ b/scripts/qa-data-library.mjs @@ -0,0 +1,636 @@ +#!/usr/bin/env node +/** + * Data Library live gate. Run against this candidate's dev or preview server: + * + * npm run qa:data-library -- --url http://localhost:4173 + * + * Every upstream a library entry names is answered from fixtures in the + * browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied + * volcano route), so the gate is deterministic and offline apart from the + * app itself. It drives the real panel and dialog: Browse opens a modal, + * Add registers a layer under the Library heading and switches it on, the + * share link carries `lib=`, a reload restores the selection, Escape and + * clean view close the dialog, and Remove destroys the layer. + */ +import assert from 'node:assert/strict'; + +const { default: puppeteer } = await import('puppeteer'); +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + +const point = (lon, lat, properties) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, +}); +const collection = (features, extra = {}) => + JSON.stringify({ type: 'FeatureCollection', features, ...extra }); +// 1×1 transparent PNG. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +const seen = { + eonet: 0, + arcgisPages: [], + tiles: 0, + proxied: 0, + structureBoxes: [], +}; +function fixtureFor(url) { + const cors = { 'Access-Control-Allow-Origin': '*' }; + if (url.hostname === 'eonet.gsfc.nasa.gov') { + seen.eonet += 1; + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection([ + point(-120, 38, { + title: 'Fixture Fire', + date: '2026-10-20T00:00:00Z', + }), + point(140, 35, { + title: 'Fixture Storm', + date: '2026-10-19T00:00:00Z', + }), + ]), + }; + } + if ( + url.hostname === 'services2.arcgis.com' && + url.pathname.includes('USA_Structures') + ) { + seen.structureBoxes.push(url.searchParams.get('geometry')); + const square = (lon, lat) => ({ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [lon, lat], + [lon + 0.0004, lat], + [lon + 0.0004, lat + 0.0003], + [lon, lat + 0.0003], + [lon, lat], + ], + ], + }, + properties: { OCC_CLS: 'Government', HEIGHT: 30 }, + }); + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection([ + square(-77.035, 38.892), + square(-77.03, 38.893), + square(-77.025, 38.894), + ]), + }; + } + if (url.hostname === 'services2.arcgis.com') { + const offset = Number(url.searchParams.get('resultOffset')); + seen.arcgisPages.push(offset); + const features = + offset === 0 + ? [ + point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }), + point(-112.9, 33.4, { + Plant_Name: 'Fixture Nuclear', + Total_MW: 3937, + }), + ] + : [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })]; + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection(features, { + properties: { exceededTransferLimit: offset === 0 }, + }), + }; + } + if (url.hostname === 'gibs.earthdata.nasa.gov') { + seen.tiles += 1; + return { status: 200, headers: cors, contentType: 'image/png', body: PNG }; + } + if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') { + seen.proxied += 1; + return { + status: 200, + contentType: 'application/json', + body: collection([ + point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }), + ]), + }; + } + return null; +} + +const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + defaultViewport: { width: 1440, height: 900 }, + protocolTimeout: 300_000, + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const errors = []; +let count = 0; +const step = async (label, fn) => { + await fn(); + console.log(`ok ${++count} ${label}`); +}; + +async function open(page, hash = '') { + await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevDataLibrary?.library.ready && + window.__godsEyeView?.dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 180_000 }, + ); +} + +const libraryRows = (page) => + page.evaluate(() => + [...document.querySelectorAll('#data-toggles [data-library-entry]')].map( + (row) => ({ + id: row.dataset.libraryEntry, + on: row.querySelector('.data-toggle-btn')?.classList.contains('active'), + count: row.querySelector('.data-count')?.textContent, + }), + ), + ); + +try { + const page = await browser.newPage(); + page.on('console', (message) => { + if (message.type() === 'error' && /Library|library/.test(message.text())) + errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(String(error))); + await page.setRequestInterception(true); + page.on('request', (request) => { + const fixture = fixtureFor(new URL(request.url())); + if (fixture) request.respond(fixture); + else request.continue(); + }); + await page.evaluateOnNewDocument(() => { + try { + // Start empty once; the reload step needs what this run stored. + if (!sessionStorage.getItem('qa-data-library')) { + localStorage.removeItem('gev:library:v1'); + sessionStorage.setItem('qa-data-library', '1'); + } + // Skip the first-run chooser (src/firstRunExperience.js). + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + } catch { + /* storage may be blocked; the gate still runs */ + } + }); + await open(page); + + await step( + 'the panel offers Browse and lists no library layer by default', + async () => { + assert.deepEqual(await libraryRows(page), []); + const hidden = await page.$eval( + '#data-library-browse', + (node) => node.hidden, + ); + assert.equal(hidden, false); + }, + ); + + await step( + 'Browse opens a labelled modal with addable entries first and packs last', + async () => { + await page.evaluate(() => { + document.getElementById('data-panel')?.classList.remove('collapsed'); + document.getElementById('data-library-browse').click(); + }); + const dialog = await page.$eval('#data-library', (node) => ({ + open: node.open, + modal: node.matches(':modal'), + title: document.getElementById(node.getAttribute('aria-labelledby')) + ?.textContent, + focus: document.activeElement?.type, + order: [...node.querySelectorAll('.data-library-item')].map( + (item) => item.dataset.libraryId, + ), + })); + assert.equal(dialog.open, true); + assert.equal(dialog.modal, true); + assert.equal(dialog.title, 'DATA LIBRARY'); + assert.equal(dialog.focus, 'search'); + assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join()); + assert.ok(!dialog.order[0].startsWith('cctv-')); + }, + ); + + await step( + 'a pack explains the operator switch instead of offering Add', + async () => { + const pack = await page.$eval( + '#data-library .data-library-item[data-library-id="cctv-nyc-dot"]', + (item) => ({ + action: Boolean(item.querySelector('.data-library-action')), + reason: item.querySelector('.data-library-reason')?.textContent, + }), + ); + assert.equal(pack.action, false); + assert.match(pack.reason, /CCTV_NYC_ENABLED=1/); + }, + ); + + for (const [id, expected] of [ + ['nasa-eonet-events', '2'], + ['eia-power-plants', '3'], + ['gvp-holocene-volcanoes', '1'], + ['nasa-black-marble', 'DRAPED'], + ]) + await step( + `Add ${id} registers it under Library and switches it on`, + async () => { + await page.click( + `#data-library button[data-library-id="${id}"][data-library-action="add"]`, + ); + await page.waitForFunction( + (layerId, text) => { + const row = document.querySelector( + `#data-toggles [data-layer-id="${layerId}"]`, + ); + return ( + row + ?.querySelector('.data-toggle-btn') + ?.classList.contains('active') && + row.querySelector('.data-count')?.textContent === text + ); + }, + { timeout: 60_000 }, + `lib-${id}`, + expected, + ); + }, + ); + + await step( + 'the fixtures were reached the way each manifest says', + async () => { + assert.ok(seen.eonet >= 1); + assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]); + assert.ok(seen.proxied >= 1); + const draped = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('lib-nasa-black-marble') + .module.getDiagnostics().draped, + ); + assert.equal(draped, true); + // Imagery requests tiles on the following frames, not on enable. + for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250) + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.ok(seen.tiles >= 1, 'the drape requested its tiles'); + const headings = await page.$$eval( + '#data-toggles .data-layer-group-heading', + (nodes) => nodes.map((node) => node.textContent), + ); + assert.equal(headings.at(-1), 'Library'); + }, + ); + + await step( + 'the stats strip sits above the intro and counts what is on the map', + async () => { + await page.waitForFunction( + () => + document.querySelector( + '[data-stat="features"] .data-library-stat-value', + )?.textContent === '6', + { timeout: 15_000 }, + ); + const strip = await page.evaluate(() => { + const stats = document.querySelector( + '#data-library .data-library-stats', + ); + const read = (id) => + [...document.querySelectorAll(`[data-stat="${id}"] dd`)].map( + (node) => node.textContent, + ); + return { + beforeIntro: + stats?.nextElementSibling?.classList.contains('data-library-intro'), + label: stats?.getAttribute('aria-label'), + entries: read('entries'), + added: read('added'), + features: read('features'), + }; + }); + assert.equal(strip.beforeIntro, true); + assert.equal(strip.label, 'Data Library at a glance'); + // Counted from the shipped catalog, so a new entry does not break this. + const kinds = await page.evaluate(() => { + const counts = { dataset: 0, tiles: 0, pack: 0 }; + for (const { entry } of window.__gevDataLibrary.library.list()) + counts[entry.kind] += 1; + return counts; + }); + assert.deepEqual(strip.entries, [ + String(kinds.dataset + kinds.tiles + kinds.pack), + `${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`, + ]); + assert.deepEqual(strip.added, ['4', '4 on the map']); + // Fixtures: 2 EONET events, 3 power plants, 1 volcano. + assert.deepEqual(strip.features, ['6', 'from 3 datasets']); + }, + ); + + await step('the share link carries lib= with the ledger tokens', async () => { + await page.waitForFunction( + () => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)), + { timeout: 15_000 }, + ); + }); + + await step( + 'Escape closes the dialog and returns focus to Browse', + async () => { + await page.focus('#data-library input[type="search"]'); + await page.keyboard.press('Escape'); + const state = await page.evaluate(() => ({ + open: document.getElementById('data-library').open, + focus: document.activeElement?.id, + })); + assert.deepEqual(state, { open: false, focus: 'data-library-browse' }); + }, + ); + + await step('clean view closes an open dialog', async () => { + const open = await page.evaluate(async () => { + window.__gevDataLibrary.browser.open(); + document.body.classList.add('ui-clean-view'); + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = document.getElementById('data-library').open; + document.body.classList.remove('ui-clean-view'); + return result; + }); + assert.equal(open, false); + }); + + await step( + 'a reload restores the added entries and what was on', + async () => { + await page.evaluate(() => { + document + .querySelector( + '#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn', + ) + .click(); + }); + await page.waitForFunction( + () => + JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3, + ); + await open(page, ''); + await page.waitForFunction( + () => + document.querySelectorAll('#data-toggles [data-library-entry]') + .length === 4, + { timeout: 60_000 }, + ); + const rows = await libraryRows(page); + assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [ + ['eia-power-plants', true], + ['gvp-holocene-volcanoes', false], + ['nasa-black-marble', true], + ['nasa-eonet-events', true], + ]); + }, + ); + + const cameraCenter = () => + page.evaluate(() => { + const { camera } = window.__godsEyeView.viewer; + const carto = camera.positionCartographic; + return { + lon: (carto.longitude * 180) / Math.PI, + lat: (carto.latitude * 180) / Math.PI, + height: carto.height, + }; + }); + const waitForFlight = () => + page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 30_000, polling: 200 }, + ); + + await step( + 'Zoom to layer on the row works while off: it switches on and flies', + async () => { + const chip = + '#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]'; + await page.evaluate(() => + document.getElementById('data-panel')?.classList.remove('collapsed'), + ); + // The icons work while the layer is off; Zoom switches it back on. + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'lib-eia-power-plants', + false, + { origin: 'user' }, + ), + ); + await page.waitForSelector(chip, { visible: true, timeout: 30_000 }); + await page.evaluate( + (selector) => document.querySelector(selector).click(), + chip, + ); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'), + { timeout: 30_000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForFlight(); + const center = await cameraCenter(); + // Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N. + assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center)); + assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center)); + }, + ); + + await step( + 'ZOOM in the dialog closes it and flies to that entry', + async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + const zoom = + '#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]'; + assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off'); + await page.click( + '#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]', + ); + const open = await page.$eval('#data-library', (node) => node.open); + assert.equal(open, false); + await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForFlight(); + const center = await cameraCenter(); + // Fixture events at (-120, 38) and (140, 35): the frame sits between them. + assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center)); + }, + ); + + await step('Remove destroys the layer and forgets it', async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + await page.click( + '#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]', + ); + await page.waitForFunction( + () => + !document.querySelector( + '#data-toggles [data-layer-id="lib-nasa-black-marble"]', + ) && + !window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'), + { timeout: 30_000 }, + ); + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem('gev:library:v1')), + ); + assert.equal(stored.added.includes('nasa-black-marble'), false); + }); + + await step( + 'the row delete icon removes that entry, even while it is off', + async () => { + await page.evaluate(() => { + window.__gevDataLibrary.browser.close(); + document.getElementById('data-panel')?.classList.remove('collapsed'); + }); + const chip = + '#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]'; + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'lib-eia-power-plants', + false, + { origin: 'user' }, + ), + ); + await page.waitForSelector(chip, { visible: true, timeout: 30_000 }); + const label = await page.$eval(chip, (node) => ({ + name: node.getAttribute('aria-label'), + glyph: node.textContent, + pressed: node.getAttribute('aria-pressed'), + })); + assert.deepEqual(label, { + name: 'Remove from Data Layers', + glyph: 'delete', + pressed: null, + }); + await page.evaluate( + (selector) => document.querySelector(selector).click(), + chip, + ); + await page.waitForFunction( + () => + !window.__godsEyeView.dataManager.layers.has( + 'lib-eia-power-plants', + ) && + !document.querySelector( + '#data-toggles [data-layer-id="lib-eia-power-plants"]', + ), + { timeout: 30_000 }, + ); + }, + ); + + await step( + 'a view-bounded layer asks to zoom in, then loads only what is in view', + async () => { + await page.evaluate(() => + window.__gevDataLibrary.library.add('fema-usa-structures'), + ); + const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]'; + await page.waitForFunction( + (selector) => + /zoom in to load this layer/.test( + document.querySelector(`${selector} .data-toggle-meta`) + ?.textContent || '', + ), + { timeout: 30_000 }, + row, + ); + assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit'); + await page.evaluate(() => + window.__gevDataLibrary.library.zoom('fema-usa-structures'), + ); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('lib-fema-usa-structures') + .module.getDiagnostics().count === 3, + { timeout: 60_000, polling: 250 }, + ); + const altitude = (await cameraCenter()).height; + assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`); + const [west, south, east, north] = seen.structureBoxes + .at(-1) + .split(',') + .map(Number); + assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box'); + assert.ok( + west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893, + ); + // Climbing back out clears the buildings and shows the guidance again. + await page.evaluate(() => { + const { viewer, requestRender } = window.__godsEyeView; + viewer.camera.zoomOut(60_000); + requestRender?.('qa-data-library'); + }); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('lib-fema-usa-structures') + .module.getDiagnostics().tooHigh, + { timeout: 30_000, polling: 250 }, + ); + }, + ); + + await step('Remove all asks twice, then clears the library', async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + await page.click('#data-library .data-library-reset'); + const armed = await page.$eval( + '#data-library .data-library-reset', + (node) => ({ + text: node.textContent, + armed: node.classList.contains('is-armed'), + }), + ); + assert.equal(armed.armed, true); + assert.match(armed.text, /CONFIRM/); + assert.ok( + (await page.$$('#data-toggles [data-library-entry]')).length > 0, + 'one press removes nothing', + ); + await page.click('#data-library .data-library-reset'); + await page.waitForFunction( + () => + !document.querySelector('#data-toggles [data-library-entry]') && + document.querySelector('#data-library .data-library-reset').hidden, + { timeout: 30_000 }, + ); + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem('gev:library:v1')), + ); + assert.deepEqual(stored, { added: [], on: [] }); + }); + + assert.deepEqual(errors, [], 'no library errors on the page'); + console.log(`# ${count} passed`); +} finally { + await browser.close(); +} diff --git a/server/providers/library.js b/server/providers/library.js new file mode 100644 index 000000000..c01b45aa3 --- /dev/null +++ b/server/providers/library.js @@ -0,0 +1,363 @@ +import { LIBRARY_ENTRIES } from '../../src/library/catalog.js'; +import { loadLibraryDataset } from '../../src/library/formats.js'; +import { buildPartitionIndex } from '../../src/library/parquet.js'; +import { + libraryUpstreamUrl, + parseLibraryIdList, + libraryTileUrl, +} from '../../src/library/manifest.js'; +import { KEY_SETUP_KEYS } from '../../src/keySetupCore.mjs'; +import { + readResponseBytesCapped, + readResponseTextCapped, + coalesceProxyRequest, +} from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite, admitSameSiteKeyless } from './common/same-site.js'; + +const DATASET_TIMEOUT_MS = 30_000; +const TILE_TIMEOUT_MS = 10_000; +/** Each listing page or footer read while building a partition index. */ +const INDEX_TIMEOUT_MS = 20_000; +const TILE_MAX_BYTES = 2 * 1024 * 1024; +const TILE_CACHE_LIMIT = 512; +const IN_FLIGHT_LIMIT = 64; + +/** + * Availability of every entry on this instance, decided by the operator's + * environment alone: `GEV_LIBRARY_DISABLE` removes entries, opt-in entries + * need `GEV_LIBRARY_ENABLE` (or, for a pack, its host's own switch), and a + * keyed entry needs its key. The answer names a reason, never a secret. + * @param {object[]} entries + * @param {Record} env + * @returns {Map} + */ +export function libraryAvailability(entries, env) { + const enabled = parseLibraryIdList(env.GEV_LIBRARY_ENABLE); + const disabled = parseLibraryIdList(env.GEV_LIBRARY_DISABLE); + const result = new Map(); + for (const entry of entries) { + let reason = null; + if (disabled.has(entry.id)) reason = 'operator-disabled'; + else if (entry.kind === 'pack') { + if (String(env[entry.pack.env] || '').trim() !== '1') + reason = 'operator-opt-in'; + } else if (entry.licence.operatorOptIn && !enabled.has(entry.id)) + reason = 'operator-opt-in'; + else if (entry.source.key && !libraryKeyValue(entry.source.key, env)) + reason = 'key-missing'; + result.set(entry.id, { available: reason === null, reason }); + } + return result; +} + +/** The configured value for a manifest key, through the key registry. */ +export function libraryKeyValue(key, env) { + const registered = KEY_SETUP_KEYS.find(({ id }) => id === key?.id); + for (const name of registered?.envVars || []) { + const value = String(env[name] || '').trim(); + if (value) return value; + } + return null; +} + +function withKey(url, key, env, headers) { + if (!key) return url; + const value = libraryKeyValue(key, env); + if (!value) throw Object.assign(new Error('key_missing'), { status: 503 }); + if (key.in === 'header') { + headers[key.name] = value; + return url; + } + const keyed = new URL(url); + keyed.searchParams.set(key.name, value); + return keyed.href; +} + +/** + * Parse `////` against a tiles entry's zoom range. + * @returns {{ z: number, x: number, y: number } | null} + */ +export function parseLibraryTilePath(parts, source) { + if (parts.length !== 3 || !parts.every((part) => /^\d{1,8}$/.test(part))) + return null; + const [z, x, y] = parts.map(Number); + const minZoom = source.minZoom ?? 0; + const maxZoom = source.maxZoom ?? 22; + if (z < minZoom || z > maxZoom) return null; + const span = 2 ** z; + if (x >= span || y >= span) return null; + return { z, x, y }; +} + +/** + * The Data Library's one server route. Proxied datasets and tiles reach their + * manifest's fixed upstream only: the browser names an entry id, never a URL, + * so the allowlist is exactly the catalog. Redirects are refused, bodies are + * capped while streaming, keys come from this server's environment and + * upstream error text is never returned. + */ +export function libraryProxy({ + entries = LIBRARY_ENTRIES, + env = process.env, + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const datasets = new Map(); + const tiles = new Map(); + const indexes = new Map(); + const inFlight = new Map(); + const allowData = makeRateLimiter({ + windowMs: 60_000, + max: 30, + globalMax: 600, + }); + const allowTiles = makeRateLimiter({ + windowMs: 60_000, + max: 600, + globalMax: 6000, + }); + + async function upstream(url, entry, headers, timeoutMs) { + // Defense in depth: every derived request stays on the manifest's host. + const target = libraryUpstreamUrl(url); + const declared = libraryUpstreamUrl(entry.source.url); + if (!target || !declared || target.host !== declared.host) + throw Object.assign(new Error('off_manifest_host'), { status: 502 }); + const signal = AbortSignal.timeout(timeoutMs); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + headers: { Accept: '*/*', ...headers }, + }); + if (!response.ok) { + await response.body?.cancel?.().catch?.(() => {}); + throw Object.assign(new Error('upstream_unavailable'), { status: 502 }); + } + return { response, signal }; + } + + async function fetchDataset(entry) { + const headers = {}; + const collection = await loadLibraryDataset(entry, async (url, budget) => { + const keyed = withKey(url, entry.source.key, env, headers); + const { response, signal } = await upstream( + keyed, + entry, + headers, + DATASET_TIMEOUT_MS, + ); + const text = await readResponseTextCapped( + response, + budget.remainingBytes, + signal, + ); + budget.remainingBytes -= new TextEncoder().encode(text).byteLength; + return text; + }); + return { ...collection, fetchedAt: now() }; + } + + async function cached(store, key, ttlMs, load, limit = Infinity) { + const previous = store.get(key); + if (previous && now() - previous.savedAt < ttlMs) + return { value: previous.value, stale: false }; + if (!inFlight.has(key) && inFlight.size >= IN_FLIGHT_LIMIT) + throw Object.assign(new Error('busy'), { status: 429 }); + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(); + store.delete(key); + store.set(key, { value, savedAt: now() }); + while (store.size > limit) store.delete(store.keys().next().value); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous.value, stale: true }; + throw error; + } + } + + function json(res, status, value, extra = {}) { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...extra, + }); + res.end(JSON.stringify(value)); + } + + function gate(req, res, entry) { + // A keyed entry spends the operator's credential: refuse forwarded + // requests like the other cost-bearing routes. Keyless entries keep + // working behind a reverse proxy, like terrain heights. + return entry?.source?.key + ? admitSameSite(req, res) + : admitSameSiteKeyless(req, res); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + const parts = path.split('/').filter(Boolean); + const availability = libraryAvailability(entries, env); + + if (parts.length === 1 && parts[0] === 'catalog') { + if (admitSameSiteKeyless(req, res)) return undefined; + return json(res, 200, { + entries: entries.map(({ id }) => ({ id, ...availability.get(id) })), + }); + } + + const [route, id, ...rest] = parts; + const entry = byId.get(id); + if (!entry || !['data', 'tile', 'index'].includes(route)) + return json(res, 404, { error: 'unknown_entry' }); + + // A partitioned GeoParquet entry's index: which file covers where, read + // from each file's footer. Built once per cacheSeconds and shared, so + // visitors do not each list the bucket and read every footer. + if (route === 'index') { + if (rest.length || !entry.source?.partitions) + return json(res, 404, { error: 'not_partitioned' }); + if (admitSameSiteKeyless(req, res)) return undefined; + const { available, reason } = availability.get(id); + if (!available) return json(res, 403, { error: reason }); + if (!allowData(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const { value } = await cached( + indexes, + `index:${id}`, + entry.source.cacheSeconds * 1000, + async () => ({ + partitions: await buildPartitionIndex(entry, { + fetchImpl: (url, options = {}) => + fetchImpl(url, { + ...options, + signal: + options.signal ?? AbortSignal.timeout(INDEX_TIMEOUT_MS), + }), + }), + builtAt: now(), + }), + ); + return json(res, 200, value); + } catch (error) { + const status = error?.status === 429 ? 429 : 502; + return json(res, status, { error: 'library_upstream_unavailable' }); + } + } + const isDataset = route === 'data' && rest.length === 0; + const isTile = route === 'tile'; + if ( + entry.source.fetch !== 'proxied' || + (isDataset && entry.kind !== 'dataset') || + (isTile && entry.kind !== 'tiles') || + (!isDataset && !isTile) + ) + return json(res, 404, { error: 'not_proxied' }); + if (gate(req, res, entry)) return undefined; + const { available, reason } = availability.get(id); + if (!available) return json(res, 403, { error: reason }); + + const ttlMs = entry.source.cacheSeconds * 1000; + if (isDataset) { + if (!allowData(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const { value, stale } = await cached( + datasets, + `data:${id}`, + ttlMs, + () => fetchDataset(entry), + ); + return json( + res, + 200, + stale ? { ...value, stale: true } : value, + stale ? { 'X-Data-Stale': 'true' } : {}, + ); + } catch (error) { + const status = [429, 503].includes(error?.status) ? error.status : 502; + return json(res, status, { + error: + status === 503 ? 'key_missing' : 'library_upstream_unavailable', + }); + } + } + + const tile = parseLibraryTilePath(rest, entry.source); + if (!tile) return json(res, 400, { error: 'invalid_tile' }); + if (!allowTiles(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const key = `tile:${id}:${tile.z}/${tile.x}/${tile.y}`; + const { value } = await cached( + tiles, + key, + ttlMs, + async () => { + const headers = {}; + const url = withKey( + libraryTileUrl(entry.source.url, tile.z, tile.x, tile.y), + entry.source.key, + env, + headers, + ); + const { response, signal } = await upstream( + url, + entry, + headers, + TILE_TIMEOUT_MS, + ); + const type = String(response.headers.get('content-type') || '') + .split(';')[0] + .trim() + .toLowerCase(); + if (!/^image\/(png|jpeg|webp)$/.test(type)) { + await response.body?.cancel?.().catch?.(() => {}); + throw Object.assign(new Error('not_an_image'), { status: 502 }); + } + const bytes = await readResponseBytesCapped( + response, + TILE_MAX_BYTES, + signal, + ); + return { type, bytes }; + }, + TILE_CACHE_LIMIT, + ); + if (res.destroyed) return undefined; + res.writeHead(200, { + 'Content-Type': value.type, + 'Cache-Control': `private, max-age=${entry.source.cacheSeconds}`, + 'X-Content-Type-Options': 'nosniff', + }); + res.end(Buffer.from(value.bytes)); + return undefined; + } catch (error) { + const status = [429, 503].includes(error?.status) ? error.status : 502; + return json(res, status, { + error: status === 503 ? 'key_missing' : 'library_upstream_unavailable', + }); + } + } + + return { + name: 'data-library', + handler, + configureServer({ middlewares }) { + middlewares.use('/api/library', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/library', handler); + }, + }; +} diff --git a/server/providers/library.test.mjs b/server/providers/library.test.mjs new file mode 100644 index 000000000..eca9f7060 --- /dev/null +++ b/server/providers/library.test.mjs @@ -0,0 +1,332 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryAvailability, + libraryProxy, + parseLibraryTilePath, +} from './library.js'; +import { validateLibraryCatalog } from '../../src/library/manifest.js'; + +const base = { + family: 'events', + provider: 'Fixture', + description: 'Fixture entry.', + maintainer: 'test', + coverage: { global: true }, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', +}; +const { entries, errors } = validateLibraryCatalog([ + { + ...base, + id: 'proxied-points', + kind: 'dataset', + title: 'Proxied points', + source: { + format: 'geojson', + url: 'https://data.example.org/points.json', + fetch: 'proxied', + maxBytes: 4096, + }, + display: { label: 'title' }, + }, + { + ...base, + id: 'direct-points', + kind: 'dataset', + title: 'Direct points', + source: { + format: 'geojson', + url: 'https://data.example.org/direct.json', + fetch: 'browser-direct', + }, + }, + { + ...base, + id: 'keyed-points', + kind: 'dataset', + title: 'Keyed points', + source: { + format: 'geojson', + url: 'https://keyed.example.org/points.json', + fetch: 'proxied', + key: { id: 'firms', in: 'query', name: 'MAP_KEY' }, + }, + }, + { + ...base, + id: 'opt-in-points', + kind: 'dataset', + title: 'Opt-in points', + source: { + format: 'geojson', + url: 'https://data.example.org/opt.json', + fetch: 'proxied', + }, + licence: { ...base.licence, operatorOptIn: true }, + }, + { + ...base, + id: 'proxied-tiles', + kind: 'tiles', + title: 'Proxied tiles', + source: { + format: 'xyz', + url: 'https://tiles.example.org/{z}/{x}/{y}.png', + fetch: 'proxied', + minZoom: 2, + maxZoom: 5, + }, + }, + { + ...base, + id: 'fixture-pack', + kind: 'pack', + family: 'cameras', + title: 'Fixture cameras', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' }, + licence: { ...base.licence, operatorOptIn: true }, + }, +]); +assert.deepEqual(errors, []); + +const collection = (features) => + JSON.stringify({ type: 'FeatureCollection', features }); +const point = (title) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { title }, +}); + +function install({ env = {}, fetchImpl, now } = {}) { + const plugin = libraryProxy({ entries, env, fetchImpl, now }); + let handler; + plugin.configurePreviewServer({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/library'); + handler = callback; + }, + }, + }); + return async (url, headers = {}) => { + const res = { + headers: {}, + writeHead(status, values) { + this.status = status; + this.headers = values; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + end(body) { + this.raw = body; + try { + this.body = JSON.parse(body); + } catch { + this.body = null; + } + }, + set statusCode(value) { + this.status = value; + }, + }; + await handler( + { url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } }, + res, + ); + return res; + }; +} + +test('availability is decided by the operator environment and names no secret', () => { + const none = libraryAvailability(entries, {}); + assert.deepEqual(none.get('proxied-points'), { + available: true, + reason: null, + }); + assert.equal(none.get('keyed-points').reason, 'key-missing'); + assert.equal(none.get('opt-in-points').reason, 'operator-opt-in'); + assert.equal(none.get('fixture-pack').reason, 'operator-opt-in'); + const configured = libraryAvailability(entries, { + FIRMS_MAP_KEY: 'secret', + GEV_LIBRARY_ENABLE: 'opt-in-points', + GEV_LIBRARY_DISABLE: 'proxied-points', + CCTV_FIXTURE_ENABLED: '1', + }); + assert.equal(configured.get('keyed-points').available, true); + assert.equal(configured.get('opt-in-points').available, true); + assert.equal(configured.get('proxied-points').reason, 'operator-disabled'); + assert.equal(configured.get('fixture-pack').available, true); + assert.doesNotMatch(JSON.stringify([...configured]), /secret/); +}); + +test('the catalog route lists every entry with its availability', async () => { + const request = install(); + const res = await request('/catalog'); + assert.equal(res.status, 200); + assert.equal(res.body.entries.length, entries.length); + assert.deepEqual( + res.body.entries.find(({ id }) => id === 'opt-in-points'), + { id: 'opt-in-points', available: false, reason: 'operator-opt-in' }, + ); +}); + +test('a proxied dataset reaches only its manifest URL, refuses redirects and is normalized', async () => { + const calls = []; + const request = install({ + now: () => 42, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return new Response(collection([point('Storm'), { geometry: null }])); + }, + }); + const res = await request('/data/proxied-points?url=https://evil.example'); + assert.equal(res.status, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://data.example.org/points.json'); + assert.equal(calls[0].options.redirect, 'error'); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.equal(res.body.features.length, 1); + assert.equal(res.body.features[0].properties.name, 'Storm'); + assert.equal(res.body.dropped, 1); + assert.equal(res.body.fetchedAt, 42); + // A fresh cache answers without another upstream request. + await request('/data/proxied-points'); + assert.equal(calls.length, 1); +}); + +test('browser-direct, unknown, opt-in and wrong-kind entries are not proxied', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response(collection([])); + }, + }); + assert.equal((await request('/data/direct-points')).status, 404); + assert.equal((await request('/data/nope-nope')).status, 404); + assert.equal((await request('/data/proxied-tiles')).status, 404); + assert.equal((await request('/tile/proxied-points/2/0/0')).status, 404); + const optIn = await request('/data/opt-in-points'); + assert.equal(optIn.status, 403); + assert.equal(optIn.body.error, 'operator-opt-in'); + assert.equal(calls, 0); +}); + +test('a keyed entry gets the operator key server-side and refuses forwarded requests', async () => { + const calls = []; + const request = install({ + env: { FIRMS_MAP_KEY: 'k3y' }, + fetchImpl: async (url) => { + calls.push(new URL(url)); + return new Response(collection([point('Fire')])); + }, + }); + const forwarded = await request('/data/keyed-points', { + 'x-forwarded-for': '203.0.113.9', + }); + assert.equal(forwarded.status, 403); + assert.equal(calls.length, 0); + const res = await request('/data/keyed-points'); + assert.equal(res.status, 200); + assert.equal(calls[0].searchParams.get('MAP_KEY'), 'k3y'); + assert.doesNotMatch(res.raw, /k3y/); +}); + +test('an oversized or failing upstream answers 502 without its text, then serves stale', async () => { + let clock = 0; + let mode = 'ok'; + const request = install({ + now: () => clock, + fetchImpl: async () => { + if (mode === 'big') return new Response('x'.repeat(8192)); + if (mode === 'fail') + return new Response('secret upstream text', { status: 500 }); + return new Response(collection([point('A')])); + }, + }); + mode = 'big'; + const big = await request('/data/proxied-points'); + assert.equal(big.status, 502); + assert.doesNotMatch(big.raw, /x{20}/); + mode = 'ok'; + assert.equal((await request('/data/proxied-points')).status, 200); + clock = 10 * 3600 * 1000; + mode = 'fail'; + const stale = await request('/data/proxied-points'); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.doesNotMatch(stale.raw, /secret upstream text/); +}); + +test('cross-site browser requests are refused before any upstream request', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response(collection([])); + }, + }); + const res = await request('/data/proxied-points', { + host: 'localhost:5173', + origin: 'https://evil.example', + 'sec-fetch-site': 'cross-site', + }); + assert.equal(res.status, 403); + assert.equal(calls, 0); +}); + +test('tiles: bounded z/x/y, image types only, cached', async () => { + const calls = []; + const request = install({ + fetchImpl: async (url) => { + calls.push(url); + if (url.includes('/3/')) + return new Response('', { + headers: { 'content-type': 'text/html' }, + }); + return new Response(new Uint8Array([137, 80, 78, 71]), { + headers: { 'content-type': 'image/png' }, + }); + }, + }); + assert.equal((await request('/tile/proxied-tiles/1/0/0')).status, 400); + assert.equal((await request('/tile/proxied-tiles/2/4/0')).status, 400); + assert.equal((await request('/tile/proxied-tiles/2/a/0')).status, 400); + const ok = await request('/tile/proxied-tiles/2/1/3'); + assert.equal(ok.status, 200); + assert.equal(ok.headers['Content-Type'], 'image/png'); + assert.equal(calls[0], 'https://tiles.example.org/2/1/3.png'); + await request('/tile/proxied-tiles/2/1/3'); + assert.equal(calls.length, 1); + assert.equal((await request('/tile/proxied-tiles/3/1/1')).status, 502); +}); + +test('tile paths are parsed against the zoom range', () => { + const source = { minZoom: 0, maxZoom: 3 }; + assert.deepEqual(parseLibraryTilePath(['3', '7', '7'], source), { + z: 3, + x: 7, + y: 7, + }); + assert.equal(parseLibraryTilePath(['3', '8', '0'], source), null); + assert.equal(parseLibraryTilePath(['4', '0', '0'], source), null); + assert.equal(parseLibraryTilePath(['1', '0'], source), null); +}); + +test('only GET is served', async () => { + const plugin = libraryProxy({ entries, env: {} }); + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end() {}, + }; + await plugin.handler({ url: '/catalog', method: 'POST', headers: {} }, res); + assert.equal(res.status, 405); +}); diff --git a/server/providers/local.js b/server/providers/local.js index 5d39c3a01..2eab6ccf2 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -28,6 +28,7 @@ import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; +import { libraryProxy } from './library.js'; /** * Construct the local provider plugins in their established order. @@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) { mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + libraryProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/layerPresentation.js b/src/app/layerPresentation.js index eaa0e0b57..d3f1fb448 100644 --- a/src/app/layerPresentation.js +++ b/src/app/layerPresentation.js @@ -18,6 +18,7 @@ export class LayerPresentation { this.pendingVisible = false; this._unsubscribe = manager.subscribeActivity((change) => { if (change.type === 'status') this.refresh(); + else if (change.type === 'registrations') this._panel?.rerender(); else if (change.type === 'destroy-all') this.destroy(); else { const reason = diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..8cf00ed23 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -4,6 +4,10 @@ import { initDrawTool } from '../annotations/drawTool.js'; import { initGeoFileImport } from '../ui/geoFileImport.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; +import { createDataLibrary } from '../library/controller.js'; +import { flyToLibraryExtent } from '../library/camera.js'; +import { mountLibraryBrowser } from '../ui/libraryBrowser.js'; +import { localGeoJsonServices } from './localGeojsonServices.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; @@ -67,6 +71,41 @@ export function createApplicationTools({ subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), }); defer(() => geoFileImport?.destroy()); + // DATA ▸ Library: entries register their layers only when a viewer adds + // them, after the core catalog is sealed. The browser dialog and the + // controller share the application lifetime. + const dataLibrary = createDataLibrary({ + dataManager, + services: localGeoJsonServices, + getTileset: () => tileset, + mapStackController, + shareLink: styleManager.shareLinkManager, + requestRender: governorRequestRender, + // Zoom to layer: an immediate navigation, so Cockpit can refuse it and + // the current follow owner is released first (as for imported files). + zoomTo: (extent) => + styleManager.runImmediateNavigation('layer', () => + flyToLibraryExtent(viewer, extent), + ) !== false, + }); + defer(() => dataLibrary.destroy()); + const libraryTrigger = document.getElementById('data-library-browse'); + const libraryBrowser = mountLibraryBrowser({ + library: dataLibrary, + trigger: libraryTrigger, + }); + defer(() => libraryBrowser.destroy()); + if (libraryTrigger) libraryTrigger.hidden = false; + dataLibrary.start({ signal }).catch((error) => { + if (!signal?.aborted) console.warn('[Library] start failed:', error); + }); + // The live gate's handle (scripts/qa-data-library.mjs). + const libraryHandle = { library: dataLibrary, browser: libraryBrowser }; + window.__gevDataLibrary = libraryHandle; + defer(() => { + if (window.__gevDataLibrary === libraryHandle) + delete window.__gevDataLibrary; + }); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // panel lives on the right rail, so both belong to the application // lifetime. The tileset lets the layer drape while the globe is hidden. diff --git a/src/data/libraryRegistration.test.mjs b/src/data/libraryRegistration.test.mjs new file mode 100644 index 000000000..e9a09d489 --- /dev/null +++ b/src/data/libraryRegistration.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerLifecycle } from './lifecycle.js'; + +const layer = (id) => ({ + id, + name: id, + icon: '', + source: 'test', + async init() {}, + async enable() {}, + async update() {}, + async disable() {}, + async destroy() {}, + getStats: () => ({ count: 0, lastUpdate: null }), +}); + +test('library layers register only after sealing and only in the lib- namespace', async () => { + const manager = new LayerLifecycle({}); + const activity = []; + manager.subscribeActivity((change) => activity.push(change.type)); + manager.register(layer('earthquakes')); + assert.throws( + () => manager.registerLibraryLayer(layer('lib-night-lights')), + /after the catalog is sealed/, + ); + manager.finalizeRegistrations([ + { id: 'earthquakes', disposition: 'enabled-only' }, + ]); + assert.throws( + () => manager.registerLibraryLayer(layer('radio')), + /lib- namespace/, + ); + assert.throws(() => manager.register(layer('lib-x-y-z')), /finalized/); + manager.registerLibraryLayer(layer('lib-night-lights')); + assert.equal(manager.isLibraryLayer('lib-night-lights'), true); + assert.equal(manager.isLibraryLayer('earthquakes'), false); + const rows = manager.getAll(); + assert.equal( + rows.find(({ id }) => id === 'earthquakes').libraryEntryId, + null, + ); + assert.equal( + rows.find(({ id }) => id === 'lib-night-lights').libraryEntryId, + 'night-lights', + ); + assert.equal(await manager.unregisterLibraryLayer('earthquakes'), false); + assert.equal(manager.layers.has('earthquakes'), true); + await manager.setEnabled('lib-night-lights', true, { origin: 'user' }); + assert.equal(await manager.unregisterLibraryLayer('lib-night-lights'), true); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.equal(manager.isLibraryLayer('lib-night-lights'), false); + assert.equal(activity.filter((type) => type === 'registrations').length, 2); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..914751a7f 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,4 +1,5 @@ import { cancelCameraArrival } from './cameraArrival.js'; +import { libraryEntryIdOf } from '../library/manifest.js'; function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); @@ -90,6 +91,7 @@ export class LayerLifecycle { this._registrationDispositions = null; this._allowQaRegistration = allowQaRegistration === true; this._qaLayerIds = new Set(); + this._libraryLayerIds = new Set(); } register(layerModule) { @@ -118,6 +120,41 @@ export class LayerLifecycle { return destroyed; } + /** + * Register a Data Library layer after the catalog is sealed. Only ids in + * the library namespace (`lib-`) are accepted, so this seam can + * never stand in for a core layer, and library layers never join the core + * share-token registry: their selection travels in `lib=` instead. + * @param {object} layerModule + * @returns {string} The registered id. + */ + registerLibraryLayer(layerModule) { + if (!this._registrationsFinalized) + throw new Error('Library layers register after the catalog is sealed'); + if (!libraryEntryIdOf(layerModule?.id)) + throw new Error('Library layer ids must use the lib- namespace'); + this._registerLayer(layerModule); + this._libraryLayerIds.add(layerModule.id); + this._publishActivity({ type: 'registrations', layerId: layerModule.id }); + return layerModule.id; + } + + /** Destroy a layer registered through `registerLibraryLayer`. */ + async unregisterLibraryLayer(layerId) { + if (!this._libraryLayerIds.has(layerId)) return false; + const destroyed = await this.destroyLayer(layerId); + if (destroyed) { + this._libraryLayerIds.delete(layerId); + this._publishActivity({ type: 'registrations', layerId }); + } + return destroyed; + } + + /** Whether a registered layer came from the Data Library. */ + isLibraryLayer(layerId) { + return this._libraryLayerIds.has(layerId); + } + _registerLayer(layerModule) { if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { throw new Error('Data layer must provide a stable id'); @@ -2179,6 +2216,7 @@ export class LayerLifecycle { } this.layers.delete(layerId); this._qaLayerIds.delete(layerId); + this._libraryLayerIds.delete(layerId); return true; } @@ -2222,6 +2260,10 @@ export class LayerLifecycle { // showInTogglePanel). The layer reports stats.keyRequired while that // key is absent; the panel builds the guidance text from the pair. requiresKeyId: entry.module.requiresKeyId || null, + // Data Library entry id; the panel groups these under Library. + libraryEntryId: this._libraryLayerIds.has(id) + ? libraryEntryIdOf(id) + : null, enabled: entry.enabled, lifecycleState: entry.lifecycleState, lifecycleUncertain: entry.lifecycleUncertain, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 1d04fa46c..451712cec 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -86,6 +86,13 @@ export function localInfrastructureOverlayCopy(properties, layerId) { if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) { details.push(clampCardLine(river)); } + } else if (Array.isArray(props.__gevDetails)) { + // Data Library features carry their card lines from the manifest + // (src/library/formats.js presentLibraryProperties). + for (const line of props.__gevDetails.slice(0, 2)) { + const clean = cleanLabel(line); + if (clean) details.push(clampCardLine(clean)); + } } return { title, details }; @@ -292,7 +299,10 @@ export function localDatasetError(error) { * A minimal, rock-solid native implementation for loading local GeoJSON Data. * Draws 3D stems (polylines) attached to Point entities and ensures * standard scene.pick natively clicks them. - * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. + * @param {object} options Dataset URL (or a `loadFeatures({signal})` loader + * resolving to GeoJSON features), identity, appearance (an optional + * `featureColor(properties)` returns a CSS colour per feature) and optional + * Cesium adapters. * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. * One live instance per layer id is allowed in a given viewer/context/overlay host. @@ -310,6 +320,8 @@ export function createLocalGeoJsonLayer( labels = true, labelMax = DEFAULT_LABEL_MAX, labelGridPx = DEFAULT_LABEL_GRID_PX, + loadFeatures = null, + featureColor = null, screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), projectToWindow = (scene, position) => @@ -585,7 +597,17 @@ export function createLocalGeoJsonLayer( let addedToScene = false; try { let features = _cachedFeatures; - if (!features) { + if (!features && loadFeatures) { + // A caller-owned loader (the Data Library) fetches and bounds + // its own live dataset; this layer only renders it. + features = await loadFeatures({ + signal: _loadController.signal, + }); + if (_destroyed) return; + if (!Array.isArray(features)) + throw new Error('dataset unavailable'); + _cachedFeatures = features; + } else if (!features) { const response = await fetch(url, { signal: _loadController.signal, }); @@ -639,9 +661,21 @@ export function createLocalGeoJsonLayer( _stemRecords = []; _stemGeometryDirty = true; + // An optional per-feature colour (Data Library colorBy); one + // Cesium colour per distinct CSS value. + const colorCache = new Map([[color, baseColor]]); + const colorFor = (entity) => { + if (typeof featureColor !== 'function') return baseColor; + const css = featureColor(propertyObject(entity)) || color; + if (!colorCache.has(css)) + colorCache.set(css, Cesium.Color.fromCssColorString(css)); + return colorCache.get(css); + }; + for (let i = 0; i < entities.length; i++) { const feature = entities[i]; feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer + const featureBaseColor = colorFor(feature); let pos = feature.position?.getValue(Cesium.JulianDate.now()); @@ -649,7 +683,10 @@ export function createLocalGeoJsonLayer( // It's a polygon or line if (feature.polygon) { feature.polygon.outline = true; - feature.polygon.outlineColor = baseColor; + feature.polygon.outlineColor = featureBaseColor; + if (featureBaseColor !== baseColor) + feature.polygon.material = + featureBaseColor.withAlpha(0.3); // Calculate center point for the stem const hierarchy = feature.polygon.hierarchy?.getValue( @@ -716,11 +753,11 @@ export function createLocalGeoJsonLayer( feature.polyline = new Cesium.PolylineGraphics({ positions: stemPositionBuffers[0], width: 3.5, - material: new Cesium.ColorMaterialProperty(baseColor), + material: new Cesium.ColorMaterialProperty(featureBaseColor), }); feature.point = new Cesium.PointGraphics({ pixelSize: 10, - color: baseColor, + color: featureBaseColor, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, // Never depth-cull the anchor against the photoreal mesh — @@ -749,7 +786,7 @@ export function createLocalGeoJsonLayer( position: tip, properties, priority, - accent: color, + accent: featureBaseColor.toCssHexString(), }) : null, }); @@ -1244,6 +1281,10 @@ function labelPriorityFromProperties(props, layerId) { if (props.output || tags['plant:output:electricity']) score += 120; if (layerId === 'local-dams') score += 80; if (layerId === 'local-datacenters') score += 60; + // Data Library features carry their manifest importance (0–600), below the + // named/unnamed gap so a rank never promotes an unnamed feature. + if (Number.isFinite(props.__gevRank)) + score += Math.max(0, Math.min(600, props.__gevRank)); return score; } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..57985a61c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2937,5 +2937,109 @@ "Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.", "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Token request is too large": "La solicitud de credencial es demasiado grande", - "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío" + "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío", + "tiles unavailable": "teselas no disponibles", + "Environment": "Medio ambiente", + "Imagery": "Imágenes", + "DATASET": "DATOS", + "TILES": "TESELAS", + "PACK": "PAQUETE", + "INCUBATING": "EN INCUBACIÓN", + "COMMUNITY": "COMUNIDAD", + "VERIFIED": "VERIFICADO", + "ARCHIVED": "ARCHIVADO", + "Switched on by this instance · part of the CCTV layer": "Activado en esta instancia · parte de la capa CCTV", + "Operator opt-in: set {env}=1 on the server": "Activación del operador: define {env}=1 en el servidor", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activación del operador: añade {id} a GEV_LIBRARY_ENABLE en el servidor", + "Turned off by this instance's operator": "Desactivado por el operador de esta instancia", + "Needs a provider key on the server": "Necesita una clave del proveedor en el servidor", + "Needs this app's server · not available in a static build": "Necesita el servidor de esta app · no disponible en una compilación estática", + "Checking what this instance serves…": "Comprobando qué ofrece esta instancia…", + "Regional": "Regional", + "DATA LIBRARY": "BIBLIOTECA DE DATOS", + "Close data library": "Cerrar la biblioteca de datos", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fuentes en vivo más allá de las capas principales. Cada una se obtiene de su proveedor mientras está activa; esta app no almacena nada. Añade una y aparecerá en Capas de datos, en Biblioteca.", + "Search": "Buscar", + "Name, provider or country": "Nombre, proveedor o país", + "Theme": "Tema", + "All themes": "Todos los temas", + "Only what this instance serves": "Solo lo que ofrece esta instancia", + "OPT-IN": "OPCIONAL", + "KEY": "CLAVE", + "KEYLESS": "SIN CLAVE", + "WORKING…": "PROCESANDO…", + "ADD": "AÑADIR", + "Remove {name} from Data Layers": "Quitar {name} de Capas de datos", + "Add {name} to Data Layers": "Añadir {name} a Capas de datos", + "Library": "Biblioteca", + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DATOS", + "DRAPED": "SUPERPUESTO", + "Zoom to layer": "Acercar a la capa", + "ZOOM": "ACERCAR", + "Zoom to {name}": "Acercar a {name}", + "CONFIRM · REMOVE ALL {count}": { + "one": "CONFIRMAR · QUITAR {count}", + "many": "CONFIRMAR · QUITAR LAS {count}", + "other": "CONFIRMAR · QUITAR LAS {count}" + }, + "REMOVE ALL": "QUITAR TODO", + "Confirm: remove every added entry from Data Layers": "Confirmar: quitar de Capas de datos todas las entradas añadidas", + "Remove every added entry from Data Layers": "Quitar de Capas de datos todas las entradas añadidas", + "Exit cockpit to fly to a layer": "Sal de la cabina para volar a una capa", + "Zoom to layer · fly to where this layer has data": "Acercar a la capa · vuela a donde esta capa tiene datos", + "Remove from Data Layers": "Quitar de Capas de datos", + "Remove from Data Layers · add it again from the library": "Quitar de Capas de datos · vuelve a añadirla desde la biblioteca", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} conjunto de datos", + "many": "{count} de conjuntos de datos", + "other": "{count} conjuntos de datos" + }, + "{count} tile layers": { + "one": "{count} capa de teselas", + "many": "{count} de capas de teselas", + "other": "{count} capas de teselas" + }, + "{count} packs": { + "one": "{count} paquete", + "many": "{count} de paquetes", + "other": "{count} paquetes" + }, + "Added": "Añadidas", + "{count} on the map": { + "one": "{count} en el mapa", + "many": "{count} en el mapa", + "other": "{count} en el mapa" + }, + "none yet": "ninguna todavía", + "from {count} datasets": { + "one": "de {count} conjunto de datos", + "many": "de {count} de conjuntos de datos", + "other": "de {count} conjuntos de datos" + }, + "switch on a dataset to load features": "activa un conjunto de datos para cargar elementos", + "Data Library at a glance": "La biblioteca de datos de un vistazo", + "Map features": "Elementos del mapa", + "Showing {shown} of {total}": "Mostrando {shown} de {total}", + "Datasets": "Conjuntos de datos", + "Themes": "Temas", + "zoom in to load this layer": "acércate para cargar esta capa", + "Demographics": "Demografía", + "Coverage outline": "Contorno de cobertura", + "Orthophoto": "Ortofoto", + "Satellite mosaic": "Mosaico satelital", + "Oblique": "Oblicua", + "Historic imagery": "Imágenes históricas", + "True orthophoto": "Ortofoto verdadera", + "Type": "Tipo", + "Resolution": "Resolución", + "Flown": "Vuelo", + "Updates": "Actualización", + "Ortho quality": "Calidad del orto", + "Below 250 m": "Menos de 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1000 m", + "1,000–2,000 m": "1000–2000 m", + "2,000–3,000 m": "2000–3000 m", + "3,000 m and above": "3000 m o más" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..9c457ee78 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3004,5 +3004,115 @@ "Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.", "Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.", "Token request is too large": "Запрос токена слишком велик", - "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON" + "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON", + "tiles unavailable": "тайлы недоступны", + "Environment": "Окружающая среда", + "Imagery": "Снимки", + "DATASET": "НАБОР ДАННЫХ", + "TILES": "ТАЙЛЫ", + "PACK": "ПАКЕТ", + "INCUBATING": "В ИНКУБАЦИИ", + "COMMUNITY": "СООБЩЕСТВО", + "VERIFIED": "ПРОВЕРЕНО", + "ARCHIVED": "В АРХИВЕ", + "Switched on by this instance · part of the CCTV layer": "Включено на этом экземпляре · часть слоя CCTV", + "Operator opt-in: set {env}=1 on the server": "Включает оператор: задайте {env}=1 на сервере", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Включает оператор: добавьте {id} в GEV_LIBRARY_ENABLE на сервере", + "Turned off by this instance's operator": "Отключено оператором этого экземпляра", + "Needs a provider key on the server": "Нужен ключ провайдера на сервере", + "Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке", + "Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…", + "Regional": "Региональный", + "DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ", + "Close data library": "Закрыть библиотеку данных", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", + "Search": "Поиск", + "Name, provider or country": "Название, провайдер или страна", + "Theme": "Тема", + "All themes": "Все темы", + "Only what this instance serves": "Только то, что доступно на этом экземпляре", + "OPT-IN": "ПО ВЫБОРУ", + "KEY": "КЛЮЧ", + "KEYLESS": "БЕЗ КЛЮЧА", + "WORKING…": "ВЫПОЛНЯЕТСЯ…", + "ADD": "ДОБАВИТЬ", + "Remove {name} from Data Layers": "Убрать {name} из «Слоёв данных»", + "Add {name} to Data Layers": "Добавить {name} в «Слои данных»", + "Library": "Библиотека", + "BROWSE DATA LIBRARY": "ОТКРЫТЬ БИБЛИОТЕКУ ДАННЫХ", + "DRAPED": "НАЛОЖЕНО", + "Zoom to layer": "Приблизить к слою", + "ZOOM": "ПРИБЛИЗИТЬ", + "Zoom to {name}": "Приблизить к «{name}»", + "CONFIRM · REMOVE ALL {count}": { + "one": "ПОДТВЕРДИТЬ · УБРАТЬ {count}", + "few": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}", + "many": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}", + "other": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}" + }, + "REMOVE ALL": "УБРАТЬ ВСЁ", + "Confirm: remove every added entry from Data Layers": "Подтвердите: убрать все добавленные записи из «Слоёв данных»", + "Remove every added entry from Data Layers": "Убрать все добавленные записи из «Слоёв данных»", + "Exit cockpit to fly to a layer": "Выйдите из кабины, чтобы перелететь к слою", + "Zoom to layer · fly to where this layer has data": "Приблизить к слою · перелететь туда, где у слоя есть данные", + "Remove from Data Layers": "Убрать из «Слоёв данных»", + "Remove from Data Layers · add it again from the library": "Убрать из «Слоёв данных» · снова добавить можно из библиотеки", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} набор данных", + "few": "{count} набора данных", + "many": "{count} наборов данных", + "other": "{count} набора данных" + }, + "{count} tile layers": { + "one": "{count} слой тайлов", + "few": "{count} слоя тайлов", + "many": "{count} слоёв тайлов", + "other": "{count} слоя тайлов" + }, + "{count} packs": { + "one": "{count} пакет", + "few": "{count} пакета", + "many": "{count} пакетов", + "other": "{count} пакета" + }, + "Added": "Добавлено", + "{count} on the map": { + "one": "{count} на карте", + "few": "{count} на карте", + "many": "{count} на карте", + "other": "{count} на карте" + }, + "none yet": "пока ничего", + "from {count} datasets": { + "one": "из {count} набора данных", + "few": "из {count} наборов данных", + "many": "из {count} наборов данных", + "other": "из {count} набора данных" + }, + "switch on a dataset to load features": "включите набор данных, чтобы загрузить объекты", + "Data Library at a glance": "Библиотека данных вкратце", + "Map features": "Объекты на карте", + "Showing {shown} of {total}": "Показано {shown} из {total}", + "Datasets": "Наборы данных", + "Themes": "Темы", + "zoom in to load this layer": "приблизьте, чтобы загрузить этот слой", + "Demographics": "Демография", + "Coverage outline": "Граница покрытия", + "Orthophoto": "Ортофото", + "Satellite mosaic": "Спутниковая мозаика", + "Oblique": "Перспективная съёмка", + "Historic imagery": "Исторические снимки", + "True orthophoto": "Истинное ортофото", + "Type": "Тип", + "Resolution": "Разрешение", + "Flown": "Съёмка", + "Updates": "Обновление", + "Ortho quality": "Качество ортофото", + "Below 250 m": "Ниже 250 м", + "250–500 m": "250–500 м", + "500–1,000 m": "500–1000 м", + "1,000–2,000 m": "1000–2000 м", + "2,000–3,000 m": "2000–3000 м", + "3,000 m and above": "3000 м и выше" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..889c02f04 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -116,7 +116,9 @@ "{value} KM/S", "ΔE", "ΔN", - "▲ {frp} MW" + "▲ {frp} MW", + "Regional", + "{datasets} · {tiles} · {packs}" ], "ru": [ "AIS: --", @@ -132,6 +134,7 @@ "R&B", "REC", "VS", - "WHOT/BHOT" + "WHOT/BHOT", + "{datasets} · {tiles} · {packs}" ] } diff --git a/src/library/camera.js b/src/library/camera.js new file mode 100644 index 000000000..e673e1c54 --- /dev/null +++ b/src/library/camera.js @@ -0,0 +1,74 @@ +/** + * "Zoom to layer" for Data Library layers. Callers run this inside the + * shell's immediate navigation (`runImmediateNavigation('layer', …)`), so + * Cockpit can refuse it and the current follow owner is released first. + */ +import * as Cesium from 'cesium'; +import { cancelCameraArrival } from '../data/cameraArrival.js'; +import { isWorldScale, padExtent } from './extent.js'; + +/** Camera height for a world-scale layer: the whole globe in view. */ +const WORLD_HEIGHT_M = 20_000_000; +/** Where a view-bounded zoom lands, as a share of the layer's altitude. */ +const VIEW_LANDING_FRACTION = 0.6; + +/** + * Fly the camera to frame an extent. + * @param {object} viewer + * @param {{ west: number, south: number, east: number, north: number, maxAltitudeM?: number }} extent + * `maxAltitudeM` marks a view-bounded layer's limit. + * @param {{ cesium?: object, reducedMotion?: boolean }} [options] + * @returns {boolean} Whether a flight started. + */ +export function flyToLibraryExtent( + viewer, + extent, + { + cesium = Cesium, + reducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') + ?.matches === true, + } = {}, +) { + if (!viewer?.camera?.flyTo || !extent) return false; + cancelCameraArrival(viewer); + const box = padExtent(extent); + const duration = reducedMotion ? 0 : 1.6; + // A view-bounded layer loads only below its altitude: land straight above + // its sample area, low enough for the layer to load. + if (Number.isFinite(extent.maxAltitudeM)) { + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + (extent.west + extent.east) / 2, + (extent.south + extent.north) / 2, + extent.maxAltitudeM * VIEW_LANDING_FRACTION, + ), + duration, + }); + return true; + } + if (isWorldScale(box)) { + const lon = (box.west + box.east) / 2; + const lat = (box.south + box.north) / 2; + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + lon > 180 ? lon - 360 : lon, + lat, + WORLD_HEIGHT_M, + ), + duration, + }); + return true; + } + // Cesium reads west > east as a box crossing the antimeridian. + const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon); + viewer.camera.flyTo({ + destination: cesium.Rectangle.fromDegrees( + wrap(box.west), + box.south, + wrap(box.east), + box.north, + ), + duration, + }); + return true; +} diff --git a/src/library/catalog.js b/src/library/catalog.js new file mode 100644 index 000000000..c04656e13 --- /dev/null +++ b/src/library/catalog.js @@ -0,0 +1,248 @@ +/** + * The Data Library catalog: every manifest in `src/library/entries/`. + * + * Adding an entry is one JSON file plus one import line here, and a share + * token in `ledger.json` when the entry can be switched on (datasets and + * tiles). `npm run library:check` fails when a file, import or token is + * missing. Manifests are validated when this module loads, so an invalid + * entry fails the unit suite rather than reaching a page. + */ +import { validateLibraryCatalog } from './manifest.js'; +import ledgerRows from './ledger.json' with { type: 'json' }; +import akStatewideImagery from './entries/ak-statewide-imagery.json' with { type: 'json' }; +import arStatewideImagery from './entries/ar-statewide-imagery.json' with { type: 'json' }; +import azStatewideImagery from './entries/az-statewide-imagery.json' with { type: 'json' }; +import bangorMe from './entries/bangor-me.json' with { type: 'json' }; +import basemapAtOrthofoto from './entries/basemap-at-orthofoto.json' with { type: 'json' }; +import bayernDop40 from './entries/bayern-dop40.json' with { type: 'json' }; +import bismarckMandanNd from './entries/bismarck-mandan-nd.json' with { type: 'json' }; +import bkgTopplusOpen from './entries/bkg-topplus-open.json' with { type: 'json' }; +import cctvFloridaFl511 from './entries/cctv-florida-fl511.json' with { type: 'json' }; +import cctvLithuania from './entries/cctv-lithuania.json' with { type: 'json' }; +import cctvNycDot from './entries/cctv-nyc-dot.json' with { type: 'json' }; +import ctStatewideImagery from './entries/ct-statewide-imagery.json' with { type: 'json' }; +import dakotaCountyMn from './entries/dakota-county-mn.json' with { type: 'json' }; +import dcStatewideImagery from './entries/dc-statewide-imagery.json' with { type: 'json' }; +import deStatewideImagery from './entries/de-statewide-imagery.json' with { type: 'json' }; +import eeMaaAmetOrtofoto from './entries/ee-maa-amet-ortofoto.json' with { type: 'json' }; +import eiaPowerPlants from './entries/eia-power-plants.json' with { type: 'json' }; +import emodnetBathymetry from './entries/emodnet-bathymetry.json' with { type: 'json' }; +import epaSuperfundSites from './entries/epa-superfund-sites.json' with { type: 'json' }; +import epaToxicReleaseFacilities from './entries/epa-toxic-release-facilities.json' with { type: 'json' }; +import epcEnglandWales from './entries/epc-england-wales.json' with { type: 'json' }; +import femaUsaStructures from './entries/fema-usa-structures.json' with { type: 'json' }; +import gbifOccurrenceDensity from './entries/gbif-occurrence-density.json' with { type: 'json' }; +import gdacsDisasterAlerts from './entries/gdacs-disaster-alerts.json' with { type: 'json' }; +import genevaSitgOrtho from './entries/geneva-sitg-ortho.json' with { type: 'json' }; +import gpwPopulationDensity from './entries/gpw-population-density.json' with { type: 'json' }; +import grandForksNd from './entries/grand-forks-nd.json' with { type: 'json' }; +import gsiJapanMap from './entries/gsi-japan-map.json' with { type: 'json' }; +import gsiJapanPhoto from './entries/gsi-japan-photo.json' with { type: 'json' }; +import gvpHoloceneVolcanoes from './entries/gvp-holocene-volcanoes.json' with { type: 'json' }; +import iaStatewideImagery from './entries/ia-statewide-imagery.json' with { type: 'json' }; +import icgcOrtoCatalunya from './entries/icgc-orto-catalunya.json' with { type: 'json' }; +import idStatewideImagery from './entries/id-statewide-imagery.json' with { type: 'json' }; +import ignOrthophotosFrance from './entries/ign-orthophotos-france.json' with { type: 'json' }; +import ilStatewideImagery from './entries/il-statewide-imagery.json' with { type: 'json' }; +import imfPortwatchChokepoints from './entries/imf-portwatch-chokepoints.json' with { type: 'json' }; +import imfPortwatchDisruptions from './entries/imf-portwatch-disruptions.json' with { type: 'json' }; +import inStatewideImagery from './entries/in-statewide-imagery.json' with { type: 'json' }; +import jrcSurfaceWater from './entries/jrc-surface-water.json' with { type: 'json' }; +import kartverketAdminUnits from './entries/kartverket-admin-units.json' with { type: 'json' }; +import kartverketCadastre from './entries/kartverket-cadastre.json' with { type: 'json' }; +import kartverketDepth from './entries/kartverket-depth.json' with { type: 'json' }; +import kartverketHillshade from './entries/kartverket-hillshade.json' with { type: 'json' }; +import kartverketNauticalCharts from './entries/kartverket-nautical-charts.json' with { type: 'json' }; +import kartverketTerrainRelief from './entries/kartverket-terrain-relief.json' with { type: 'json' }; +import kartverketTopoGrey from './entries/kartverket-topo-grey.json' with { type: 'json' }; +import kartverketTopo from './entries/kartverket-topo.json' with { type: 'json' }; +import kartverketToporaster from './entries/kartverket-toporaster.json' with { type: 'json' }; +import ksStatewideImagery from './entries/ks-statewide-imagery.json' with { type: 'json' }; +import kyStatewideImagery from './entries/ky-statewide-imagery.json' with { type: 'json' }; +import lakeCountyIn from './entries/lake-county-in.json' with { type: 'json' }; +import lawrenceKs from './entries/lawrence-ks.json' with { type: 'json' }; +import leeCountyFl from './entries/lee-county-fl.json' with { type: 'json' }; +import loudounCountyVa from './entries/loudoun-county-va.json' with { type: 'json' }; +import louisvilleLojicKy from './entries/louisville-lojic-ky.json' with { type: 'json' }; +import luOrthoLatest from './entries/lu-ortho-latest.json' with { type: 'json' }; +import maStatewideImagery from './entries/ma-statewide-imagery.json' with { type: 'json' }; +import macrostratGeology from './entries/macrostrat-geology.json' with { type: 'json' }; +import mapterhornTerrain from './entries/mapterhorn-terrain.json' with { type: 'json' }; +import marisMs from './entries/maris-ms.json' with { type: 'json' }; +import mdStatewideImagery from './entries/md-statewide-imagery.json' with { type: 'json' }; +import meStatewideImagery from './entries/me-statewide-imagery.json' with { type: 'json' }; +import miStatewideImagery from './entries/mi-statewide-imagery.json' with { type: 'json' }; +import mnStatewideImagery from './entries/mn-statewide-imagery.json' with { type: 'json' }; +import mountainVillageCo from './entries/mountain-village-co.json' with { type: 'json' }; +import msStatewideImagery from './entries/ms-statewide-imagery.json' with { type: 'json' }; +import mtStatewideImagery from './entries/mt-statewide-imagery.json' with { type: 'json' }; +import naip from './entries/naip.json' with { type: 'json' }; +import nasaBlackMarble from './entries/nasa-black-marble.json' with { type: 'json' }; +import nasaEonetEvents from './entries/nasa-eonet-events.json' with { type: 'json' }; +import ncStatewideImagery from './entries/nc-statewide-imagery.json' with { type: 'json' }; +import nctcogTx from './entries/nctcog-tx.json' with { type: 'json' }; +import ndStatewideImagery from './entries/nd-statewide-imagery.json' with { type: 'json' }; +import newOrleansRpcLa from './entries/new-orleans-rpc-la.json' with { type: 'json' }; +import nhStatewideImagery from './entries/nh-statewide-imagery.json' with { type: 'json' }; +import njStatewideImagery from './entries/nj-statewide-imagery.json' with { type: 'json' }; +import noaaTideStations from './entries/noaa-tide-stations.json' with { type: 'json' }; +import nrwDop from './entries/nrw-dop.json' with { type: 'json' }; +import nswImagery from './entries/nsw-imagery.json' with { type: 'json' }; +import ntadNationalBridges from './entries/ntad-national-bridges.json' with { type: 'json' }; +import nwaBentonWashingtonAr from './entries/nwa-benton-washington-ar.json' with { type: 'json' }; +import nwsActiveAlerts from './entries/nws-active-alerts.json' with { type: 'json' }; +import nyStatewideImagery from './entries/ny-statewide-imagery.json' with { type: 'json' }; +import oaklandCountyMi from './entries/oakland-county-mi.json' with { type: 'json' }; +import ohStatewideImagery from './entries/oh-statewide-imagery.json' with { type: 'json' }; +import openseamapSeamarks from './entries/openseamap-seamarks.json' with { type: 'json' }; +import orStatewideImagery from './entries/or-statewide-imagery.json' with { type: 'json' }; +import paStatewideImagery from './entries/pa-statewide-imagery.json' with { type: 'json' }; +import pdokAerialNetherlands from './entries/pdok-aerial-netherlands.json' with { type: 'json' }; +import plOrtofotomapa from './entries/pl-ortofotomapa.json' with { type: 'json' }; +import pnoaMa from './entries/pnoa-ma.json' with { type: 'json' }; +import portlandMetroOr from './entries/portland-metro-or.json' with { type: 'json' }; +import ptDgtOrtos from './entries/pt-dgt-ortos.json' with { type: 'json' }; +import riStatewideImagery from './entries/ri-statewide-imagery.json' with { type: 'json' }; +import swisstopoSwissimage from './entries/swisstopo-swissimage.json' with { type: 'json' }; +import txStatewideImagery from './entries/tx-statewide-imagery.json' with { type: 'json' }; +import unionCountyOh from './entries/union-county-oh.json' with { type: 'json' }; +import usgsHydrography from './entries/usgs-hydrography.json' with { type: 'json' }; +import usgsTopo from './entries/usgs-topo.json' with { type: 'json' }; +import vaStatewideImagery from './entries/va-statewide-imagery.json' with { type: 'json' }; +import vlaanderenOmw from './entries/vlaanderen-omw.json' with { type: 'json' }; +import vtStatewideImagery from './entries/vt-statewide-imagery.json' with { type: 'json' }; +import wallonieOrtho from './entries/wallonie-ortho.json' with { type: 'json' }; +import wiStatewideImagery from './entries/wi-statewide-imagery.json' with { type: 'json' }; +import wriGlobalPowerPlants from './entries/wri-global-power-plants.json' with { type: 'json' }; +import wvStatewideImagery from './entries/wv-statewide-imagery.json' with { type: 'json' }; + +/** Raw manifests in file-name order; the validated form is below. */ +export const LIBRARY_MANIFESTS = Object.freeze([ + akStatewideImagery, + arStatewideImagery, + azStatewideImagery, + bangorMe, + basemapAtOrthofoto, + bayernDop40, + bismarckMandanNd, + bkgTopplusOpen, + cctvFloridaFl511, + cctvLithuania, + cctvNycDot, + ctStatewideImagery, + dakotaCountyMn, + dcStatewideImagery, + deStatewideImagery, + eeMaaAmetOrtofoto, + eiaPowerPlants, + emodnetBathymetry, + epaSuperfundSites, + epaToxicReleaseFacilities, + epcEnglandWales, + femaUsaStructures, + gbifOccurrenceDensity, + gdacsDisasterAlerts, + genevaSitgOrtho, + gpwPopulationDensity, + grandForksNd, + gsiJapanMap, + gsiJapanPhoto, + gvpHoloceneVolcanoes, + iaStatewideImagery, + icgcOrtoCatalunya, + idStatewideImagery, + ignOrthophotosFrance, + ilStatewideImagery, + imfPortwatchChokepoints, + imfPortwatchDisruptions, + inStatewideImagery, + jrcSurfaceWater, + kartverketAdminUnits, + kartverketCadastre, + kartverketDepth, + kartverketHillshade, + kartverketNauticalCharts, + kartverketTerrainRelief, + kartverketTopoGrey, + kartverketTopo, + kartverketToporaster, + ksStatewideImagery, + kyStatewideImagery, + lakeCountyIn, + lawrenceKs, + leeCountyFl, + loudounCountyVa, + louisvilleLojicKy, + luOrthoLatest, + maStatewideImagery, + macrostratGeology, + mapterhornTerrain, + marisMs, + mdStatewideImagery, + meStatewideImagery, + miStatewideImagery, + mnStatewideImagery, + mountainVillageCo, + msStatewideImagery, + mtStatewideImagery, + naip, + nasaBlackMarble, + nasaEonetEvents, + ncStatewideImagery, + nctcogTx, + ndStatewideImagery, + newOrleansRpcLa, + nhStatewideImagery, + njStatewideImagery, + noaaTideStations, + nrwDop, + nswImagery, + ntadNationalBridges, + nwaBentonWashingtonAr, + nwsActiveAlerts, + nyStatewideImagery, + oaklandCountyMi, + ohStatewideImagery, + openseamapSeamarks, + orStatewideImagery, + paStatewideImagery, + pdokAerialNetherlands, + plOrtofotomapa, + pnoaMa, + portlandMetroOr, + ptDgtOrtos, + riStatewideImagery, + swisstopoSwissimage, + txStatewideImagery, + unionCountyOh, + usgsHydrography, + usgsTopo, + vaStatewideImagery, + vlaanderenOmw, + vtStatewideImagery, + wallonieOrtho, + wiStatewideImagery, + wriGlobalPowerPlants, + wvStatewideImagery, +]); + +const validated = validateLibraryCatalog(LIBRARY_MANIFESTS); +if (!validated.ok) + throw new TypeError( + `Invalid Data Library manifests:\n${validated.errors.join('\n')}`, + ); + +/** Validated, frozen entries with defaults filled. */ +export const LIBRARY_ENTRIES = Object.freeze(validated.entries); + +const BY_ID = new Map(LIBRARY_ENTRIES.map((entry) => [entry.id, entry])); + +/** One entry by id, or undefined. */ +export function libraryEntry(id) { + return BY_ID.get(id); +} + +/** The share-link ledger rows, `[entryId, token]`, append-only. */ +export const LIBRARY_LEDGER = Object.freeze( + ledgerRows.map((row) => Object.freeze([...row])), +); diff --git a/src/library/catalog.test.mjs b/src/library/catalog.test.mjs new file mode 100644 index 000000000..4de0c44d4 --- /dev/null +++ b/src/library/catalog.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { + LIBRARY_ENTRIES, + LIBRARY_LEDGER, + LIBRARY_MANIFESTS, +} from './catalog.js'; +import { readLibraryLedger } from './selection.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +test('every manifest file is imported, named after its id', () => { + const files = readdirSync(path.join(here, 'entries')) + .filter((name) => name.endsWith('.json')) + .sort(); + assert.deepEqual( + files, + LIBRARY_MANIFESTS.map(({ id }) => `${id}.json`).sort(), + ); + for (const file of files) { + const raw = JSON.parse( + readFileSync(path.join(here, 'entries', file), 'utf8'), + ); + assert.equal(`${raw.id}.json`, file); + } +}); + +test('every switchable entry owns a permanent share token', () => { + const ledger = readLibraryLedger(LIBRARY_LEDGER); + assert.deepEqual(ledger.errors, []); + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack')) + assert.ok(ledger.tokenById.has(entry.id), `${entry.id} has no token`); + // Packs are switched by their host, never by a share link. + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind === 'pack')) + assert.equal(ledger.tokenById.has(entry.id), false, entry.id); +}); + +test('nothing in the library is bundled: every source is a live https upstream', () => { + for (const entry of LIBRARY_ENTRIES) { + assert.match(entry.source.url, /^https:\/\//, entry.id); + assert.ok(entry.licence.name && entry.licence.url, entry.id); + assert.ok(entry.attribution.text, entry.id); + } +}); diff --git a/src/library/controller.js b/src/library/controller.js new file mode 100644 index 000000000..49c02bbae --- /dev/null +++ b/src/library/controller.js @@ -0,0 +1,394 @@ +/** + * The Data Library controller: which entries this instance offers, which the + * viewer has added, and the lifetime of their layers. + * + * Core layers are registered once and sealed; library layers register only + * when a viewer adds them (`LayerLifecycle.registerLibraryLayer`), so the + * default panel stays the curated set and an unused entry costs nothing. + */ +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from './catalog.js'; +import { libraryEntryIdOf, libraryLayerId } from './manifest.js'; +import { + decodeLibraryParam, + encodeLibraryParam, + normalizeLibrarySelection, + readLibraryLedger, + readStoredLibrarySelection, + writeStoredLibrarySelection, +} from './selection.js'; +import { createLibrarySource } from './source.js'; +import { + createLibraryDatasetLayer, + createLibraryTileLayer, + createLibraryViewLayer, +} from './layers.js'; +import { isExplicitLayerStateOrigin } from '../data/layerState.js'; + +function safeLocalStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +/** + * @param {object} options + * @param {object} options.dataManager The sealed LayerLifecycle. + * @param {object} options.services Local GeoJSON layer services. + * @param {() => object|null} [options.getTileset] Photoreal tileset reader. + * @param {object} [options.mapStackController] + * @param {object} [options.shareLink] ShareLinkManager. + * @param {string} [options.initialHash] The page hash at startup. + * @param {(reason: string) => void} [options.requestRender] + * @param {(extent: object) => boolean} [options.zoomTo] Fly to an extent + * through the shell's navigation authority; false when refused. + */ +export function createDataLibrary({ + entries = LIBRARY_ENTRIES, + ledger = LIBRARY_LEDGER, + dataManager, + services, + getTileset = () => null, + mapStackController = null, + shareLink = null, + initialHash = globalThis.location?.hash ?? '', + requestRender = () => {}, + zoomTo = null, + storage = safeLocalStorage(), + source = createLibrarySource({ entries }), +}) { + if (!dataManager?.registerLibraryLayer) + throw new TypeError('A sealed layer manager is required'); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const switchable = new Set( + entries.filter(({ kind }) => kind !== 'pack').map(({ id }) => id), + ); + const tokens = readLibraryLedger(ledger); + let availability = new Map(); + let serverPresent = false; + let selection = { added: [], on: [] }; + let started = false; + let destroyed = false; + const listeners = new Set(); + const pending = new Map(); + + const notify = () => { + for (const listener of [...listeners]) { + try { + listener(); + } catch (error) { + console.warn('[Library] listener error:', error); + } + } + }; + + const persist = () => { + selection = normalizeLibrarySelection(selection, switchable); + writeStoredLibrarySelection(storage, selection); + shareLink?.onLibraryStateChange?.(); + notify(); + }; + + function createLayer(entry) { + // No navigation owner, no Zoom chip: never a button that does nothing. + const onZoom = + typeof zoomTo === 'function' + ? () => { + zoom(entry.id).catch((error) => + console.warn(`[Library] ${entry.id} zoom failed:`, error), + ); + } + : null; + const onRemove = () => { + remove(entry.id).catch((error) => + console.warn(`[Library] ${entry.id} remove failed:`, error), + ); + }; + if (entry.kind === 'dataset' && entry.source.view) + return createLibraryViewLayer(entry, { + source, + onZoom, + onRemove, + requestRender, + }); + if (entry.kind === 'dataset') + return createLibraryDatasetLayer(entry, { + services, + source, + onZoom, + onRemove, + }); + return createLibraryTileLayer(entry, { + source, + getTileset, + mapStackController, + requestRender, + onZoom, + onRemove, + }); + } + + function isRegistered(id) { + return dataManager.layers.has(libraryLayerId(id)); + } + + function register(entry) { + if (isRegistered(entry.id)) return true; + try { + dataManager.registerLibraryLayer(createLayer(entry)); + return true; + } catch (error) { + console.warn(`[Library] ${entry.id} could not register:`, error); + return false; + } + } + + function addable(id) { + return switchable.has(id) && availability.get(id)?.available === true; + } + + const unsubscribeManager = dataManager.subscribe?.((change) => { + if (change?.type !== 'visibility') return; + const id = libraryEntryIdOf(change.layerId); + if (!id || !selection.added.includes(id)) return; + // Like core layer state: only explicit choices are remembered, never a + // Context session's temporary changes or a restore. + if (!isExplicitLayerStateOrigin(change.origin)) { + notify(); + return; + } + const on = new Set(selection.on); + if (change.enabled) on.add(id); + else on.delete(id); + selection = { ...selection, on: [...on] }; + persist(); + }); + + shareLink?.setLibraryParamsEncoder?.((params) => + encodeLibraryParam( + params, + selection.on.filter((id) => isRegistered(id)), + tokens.tokenById, + ), + ); + + /** + * Read what this instance serves, then restore this browser's selection and + * any `lib=` entries in the opening link. + * @param {{ signal?: AbortSignal }} [options] + */ + async function start({ signal } = {}) { + if (started || destroyed) return; + started = true; + const answer = await source.getAvailability({ signal }); + if (destroyed) return; + availability = answer.availability; + serverPresent = answer.server; + const stored = readStoredLibrarySelection(storage, switchable); + const hash = String(initialHash || '').replace(/^#/, ''); + const linked = hash + ? decodeLibraryParam(new URLSearchParams(hash), tokens.idByToken) + : null; + // A link's entries join the viewer's library and open switched on; with a + // link, it alone decides what is on, as it does for core layers. + const added = [...stored.added, ...(linked || [])]; + const on = linked ?? stored.on; + selection = normalizeLibrarySelection({ added, on }, switchable); + const restoreOrigin = linked ? 'share-restore' : 'local-restore'; + for (const id of selection.added) { + const entry = byId.get(id); + if (!entry || !addable(id) || !register(entry)) continue; + if (selection.on.includes(id)) + dataManager + .setEnabled(libraryLayerId(id), true, { origin: restoreOrigin }) + .catch((error) => + console.warn(`[Library] ${id} restore failed:`, error), + ); + } + writeStoredLibrarySelection(storage, selection); + shareLink?.onLibraryStateChange?.(); + notify(); + } + + /** + * Add an entry to the panel and switch it on. + * @param {string} id + * @returns {Promise} + */ + async function add(id) { + if (destroyed || !addable(id) || pending.has(id)) return false; + const entry = byId.get(id); + const work = (async () => { + if (!register(entry)) return false; + selection = { + added: [...selection.added.filter((other) => other !== id), id], + on: selection.on, + }; + persist(); + await dataManager.setEnabled(libraryLayerId(id), true, { + origin: 'user', + }); + return true; + })(); + pending.set(id, work); + notify(); + try { + return await work; + } finally { + pending.delete(id); + notify(); + } + } + + /** + * Remove an entry from the panel and destroy its layer. + * @param {string} id + * @returns {Promise} + */ + async function remove(id) { + if (destroyed || pending.has(id)) return false; + const work = (async () => { + if (isRegistered(id)) + await dataManager.unregisterLibraryLayer(libraryLayerId(id)); + selection = { + added: selection.added.filter((other) => other !== id), + on: selection.on.filter((other) => other !== id), + }; + persist(); + return true; + })(); + pending.set(id, work); + notify(); + try { + return await work; + } finally { + pending.delete(id); + notify(); + } + } + + /** + * Remove every added entry at once (the Browse dialog's reset). + * @returns {Promise} How many entries were removed. + */ + async function reset() { + if (destroyed) return 0; + const ids = [...selection.added]; + let removed = 0; + for (const id of ids) if (await remove(id)) removed += 1; + return removed; + } + + /** The area an added entry's layer covers, or null. */ + function extentOf(id) { + return ( + dataManager.layers.get(libraryLayerId(id))?.module?.getExtent?.() ?? null + ); + } + + /** + * Fly to an added entry's layer, switching it on first when it is off: a + * dataset only knows where its features are once loaded, and an off layer + * has nothing to show at the destination. + * @param {string} id + * @returns {Promise} Whether the flight started. + */ + async function zoom(id) { + if (destroyed || typeof zoomTo !== 'function' || !isRegistered(id)) + return false; + const layerId = libraryLayerId(id); + if (!dataManager.isEnabled?.(layerId)) + await dataManager.setEnabled(layerId, true, { origin: 'user' }); + const extent = extentOf(id); + if (destroyed || !extent) return false; + return zoomTo(extent) !== false; + } + + /** + * Everything the Browse view shows, in catalog order. + * @returns {Array<{ entry: object, available: boolean, reason: string|null, added: boolean, on: boolean, busy: boolean }>} + */ + function list() { + return entries.map((entry) => { + const state = availability.get(entry.id) || { + available: false, + reason: started ? 'server-unavailable' : null, + }; + return { + entry, + available: state.available, + reason: state.reason, + added: selection.added.includes(entry.id) && isRegistered(entry.id), + on: dataManager.isEnabled?.(libraryLayerId(entry.id)) === true, + busy: pending.has(entry.id), + canZoom: typeof zoomTo === 'function' && isRegistered(entry.id), + }; + }); + } + + /** + * The library at a glance, for the Browse dialog's stats strip. + * `features` sums the features currently loaded by dataset layers that are + * on; tiles are drapes and have no count. + * @returns {{ entries: number, kinds: { dataset: number, tiles: number, pack: number }, themes: string[], available: number, unavailable: number, added: number, on: number, features: number, featureLayers: number }} + */ + function summary() { + const rows = list(); + const kinds = { dataset: 0, tiles: 0, pack: 0 }; + let features = 0; + let featureLayers = 0; + for (const row of rows) { + kinds[row.entry.kind] += 1; + if (!row.on || row.entry.kind !== 'dataset') continue; + const count = dataManager.layers + .get(libraryLayerId(row.entry.id)) + ?.module?.getStats?.()?.count; + if (Number.isFinite(count) && count > 0) { + features += count; + featureLayers += 1; + } + } + const available = rows.filter((row) => row.available).length; + return { + entries: rows.length, + kinds, + // Distinct themes (manifest families) the entries cover, sorted. + themes: [...new Set(rows.map((row) => row.entry.family))].sort(), + available, + unavailable: rows.length - available, + added: rows.filter((row) => row.added).length, + on: rows.filter((row) => row.on).length, + features, + featureLayers, + }; + } + + return { + start, + add, + remove, + summary, + reset, + zoom, + list, + get ready() { + return started && availability.size > 0; + }, + get serverPresent() { + return serverPresent; + }, + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribeManager?.(); + shareLink?.setLibraryParamsEncoder?.(null); + listeners.clear(); + }, + }; +} diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs new file mode 100644 index 000000000..2e32a120b --- /dev/null +++ b/src/library/controller.test.mjs @@ -0,0 +1,365 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerLifecycle } from '../data/lifecycle.js'; +import { createDataLibrary } from './controller.js'; +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from './catalog.js'; +import { validateLibraryCatalog } from './manifest.js'; +import { LIBRARY_STORAGE_KEY } from './selection.js'; + +const base = { + family: 'imagery', + provider: 'Fixture', + description: 'Fixture entry.', + maintainer: 'test', + coverage: { global: true }, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', +}; +const tiles = (id) => ({ + ...base, + id, + kind: 'tiles', + title: id, + source: { + format: 'xyz', + url: 'https://tiles.example.org/{z}/{x}/{y}.png', + fetch: 'browser-direct', + }, +}); +const { entries } = validateLibraryCatalog([ + tiles('night-lights'), + tiles('sea-marks'), + { + ...base, + id: 'fixture-pack', + kind: 'pack', + family: 'cameras', + title: 'Pack', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' }, + licence: { ...base.licence, operatorOptIn: true }, + }, +]); +const ledger = [ + ['night-lights', '00'], + ['sea-marks', '01'], +]; + +function sealedManager(t) { + const manager = new LayerLifecycle({}); + manager.finalizeRegistrations([]); + // Enabled layers arm refresh timers; release them with the test. + t.after(() => manager.destroyAll()); + return manager; +} + +function memoryStorage(initial) { + const store = new Map(initial ? [[LIBRARY_STORAGE_KEY, initial]] : []); + return { + store, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }; +} + +const source = (availability) => ({ + async getAvailability() { + return { + server: true, + availability: new Map( + entries.map(({ id }) => [ + id, + availability[id] ?? { available: true, reason: null }, + ]), + ), + }; + }, + async loadDataset() { + return { features: [], truncated: false }; + }, + tileTemplate: (entry) => entry.source.url, +}); + +function shareLinkDouble() { + return { + encoder: null, + changes: 0, + setLibraryParamsEncoder(encoder) { + this.encoder = encoder; + }, + onLibraryStateChange() { + this.changes += 1; + }, + encode() { + const params = new URLSearchParams(); + this.encoder?.(params); + return params.get('lib'); + }, + }; +} + +test('nothing registers until a viewer adds an entry; add turns it on and persists', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const shareLink = shareLinkDouble(); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink, + initialHash: '', + source: source({}), + }); + await library.start(); + assert.equal(manager.layers.size, 0); + assert.equal(await library.add('night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), true); + assert.equal(manager.getAll()[0].libraryEntryId, 'night-lights'); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: ['night-lights'], + on: ['night-lights'], + }); + assert.equal(shareLink.encode(), '00'); + await manager.setEnabled('lib-night-lights', false, { origin: 'user' }); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).on, []); + assert.equal(shareLink.encode(), null); + assert.equal(await library.remove('night-lights'), true); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: [], + on: [], + }); +}); + +test('packs and unavailable entries cannot be added', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({ + 'sea-marks': { available: false, reason: 'operator-opt-in' }, + }), + }); + await library.start(); + assert.equal(await library.add('fixture-pack'), false); + assert.equal(await library.add('sea-marks'), false); + assert.equal(manager.layers.size, 0); + const row = library.list().find(({ entry }) => entry.id === 'sea-marks'); + assert.deepEqual( + { available: row.available, reason: row.reason, added: row.added }, + { available: false, reason: 'operator-opt-in', added: false }, + ); +}); + +test('a stored selection restores; a non-explicit change is not remembered', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights', 'sea-marks'], on: ['sea-marks'] }), + ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '', + source: source({}), + }); + await library.start(); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.layers.has('lib-night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + await manager.setEnabled('lib-sea-marks', false, { origin: 'context' }); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).on, [ + 'sea-marks', + ]); +}); + +test('a link decides what is on and adds its entries to the library', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights'], on: ['night-lights'] }), + ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '#lat=1&lon=2&lib=01.zz', + source: source({}), + }); + await library.start(); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: ['night-lights', 'sea-marks'], + on: ['sea-marks'], + }); +}); + +test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const flights = []; + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '', + source: source({}), + zoomTo: (extent) => { + flights.push(extent); + return true; + }, + }); + await library.start(); + assert.equal(await library.zoom('night-lights'), false, 'not added yet'); + await library.add('night-lights'); + await library.add('sea-marks'); + const row = library.list().find(({ entry }) => entry.id === 'night-lights'); + assert.equal(row.canZoom, true); + // Off: zoom switches the layer on, then flies. + await manager.setEnabled('lib-night-lights', false, { origin: 'user' }); + assert.equal(await library.zoom('night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), true); + assert.deepEqual(flights[0], { + west: -180, + south: -85, + east: 180, + north: 85, + }); + const chips = manager.layers + .get('lib-night-lights') + .module.getRowControls().chips; + assert.equal(chips[0].id, 'zoom'); + chips[0].onClick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(flights.length, 2); + assert.deepEqual( + chips.map(({ id, icon, action, whileOff }) => [id, icon, action, whileOff]), + [ + ['zoom', 'zoom_in_map', true, true], + ['remove', 'delete', true, true], + ], + ); + // The row's delete icon removes that entry from the library. + chips[1].onClick(); + await manager.waitForLayerSettled?.('lib-night-lights'); + await new Promise((resolve) => setTimeout(resolve, 0)); + for ( + let tries = 0; + manager.layers.has('lib-night-lights') && tries < 50; + tries++ + ) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).added, [ + 'sea-marks', + ]); + assert.equal(await library.reset(), 1); + assert.equal(manager.layers.size, 0); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: [], + on: [], + }); +}); + +test('without a navigation owner the zoom control is disabled', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({}), + }); + await library.start(); + await library.add('night-lights'); + assert.equal(await library.zoom('night-lights'), false); + assert.deepEqual( + manager.layers + .get('lib-night-lights') + .module.getRowControls() + .chips.map(({ id }) => id), + ['remove'], + ); +}); + +test('the summary counts entries by kind, availability and what is added and on', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({ + 'sea-marks': { available: false, reason: 'operator-opt-in' }, + }), + }); + await library.start(); + await library.add('night-lights'); + assert.deepEqual(library.summary(), { + entries: 3, + kinds: { dataset: 0, tiles: 2, pack: 1 }, + themes: ['cameras', 'imagery'], + // The pack is available because the fixture source says so. + available: 2, + unavailable: 1, + added: 1, + on: 1, + // Tiles are drapes: no feature count. + features: 0, + featureLayers: 0, + }); +}); + +test('every shipped entry kind registers, view-bounded datasets included', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries: LIBRARY_ENTRIES, + ledger: LIBRARY_LEDGER, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: { + async getAvailability() { + return { + server: true, + availability: new Map( + LIBRARY_ENTRIES.map(({ id }) => [ + id, + { available: true, reason: null }, + ]), + ), + }; + }, + async loadDataset() { + return { features: [], truncated: false }; + }, + tileTemplate: (entry) => entry.source.url, + }, + }); + await library.start(); + assert.equal(await library.add('fema-usa-structures'), true); + const module = manager.layers.get('lib-fema-usa-structures').module; + // No camera to read: the layer waits for one rather than loading. + assert.equal(module.getDiagnostics().tooHigh, true); + assert.equal(await library.add('nasa-black-marble'), true); +}); diff --git a/src/library/entries/ak-statewide-imagery.json b/src/library/entries/ak-statewide-imagery.json new file mode 100644 index 000000000..685e7f2ad --- /dev/null +++ b/src/library/entries/ak-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ak-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Alaska Statewide Imagery (2023)", + "provider": "Alaska Geospatial Council", + "description": "Aerial orthophotos: Statewide coverage of Alaska.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-170, 51.2, -129, 71.4551] + }, + "source": { + "format": "bbox", + "url": "https://apps.geo.fpac.usda.gov/nrcs-imagery/rest/services/ortho_imagery/alaska_vivid_2023_30cm/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Periodic statewide refresh", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public Domain (U.S. Government work)", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USDA / Alaska Geospatial Council", + "url": "https://gis.data.alaska.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ar-statewide-imagery.json b/src/library/entries/ar-statewide-imagery.json new file mode 100644 index 000000000..055eb01bf --- /dev/null +++ b/src/library/entries/ar-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ar-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Arkansas Statewide Imagery (2023)", + "provider": "Arkansas GIS Office (GeoStor)", + "description": "Aerial orthophotos: Statewide coverage of Arkansas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-94.642, 32.9545, -89.584, 36.5414] + }, + "source": { + "format": "bbox", + "url": "https://gis.arkansas.gov/arcgis/rest/services/ImageServices/IMAGERY_9IN_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "9 in / 6 in urban", + "resolutionM": 0.2286, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Multi-year statewide cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Arkansas GIS Office open data", + "url": "https://gis.arkansas.gov/" + }, + "attribution": { + "text": "Arkansas GIS Office", + "url": "https://gis.arkansas.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/az-statewide-imagery.json b/src/library/entries/az-statewide-imagery.json new file mode 100644 index 000000000..d1c23909f --- /dev/null +++ b/src/library/entries/az-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "az-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Arizona Statewide Imagery (2017)", + "provider": "AZGeo", + "description": "Aerial orthophotos: Statewide coverage of Arizona.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-115.1014, 31.2527, -108.8553, 37.1065] + }, + "source": { + "format": "xyz", + "url": "https://azgeo.az.gov/arcgis/rest/services/imagery/NAIP2017/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m (NAIP)", + "resolutionM": 1, + "trueOrtho": false, + "vintage": "2017–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://azgeo.az.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "AZGEO", + "url": "https://azgeo.az.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bangor-me.json b/src/library/entries/bangor-me.json new file mode 100644 index 000000000..ee3848d0e --- /dev/null +++ b/src/library/entries/bangor-me.json @@ -0,0 +1,48 @@ +{ + "id": "bangor-me", + "kind": "tiles", + "family": "imagery", + "title": "Bangor, Maine Orthoimagery", + "provider": "City of Bangor GIS", + "description": "Aerial orthophotos: Bangor, Maine jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-68.9126, 44.7674, -68.6451, 44.8951] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/yIxdYDwtUG29jfAN/arcgis/rest/services/2024_Orthoimagery_Tiles/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.bangormaine.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "City of Bangor GIS", + "url": "https://www.bangormaine.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/basemap-at-orthofoto.json b/src/library/entries/basemap-at-orthofoto.json new file mode 100644 index 000000000..48e9eca2a --- /dev/null +++ b/src/library/entries/basemap-at-orthofoto.json @@ -0,0 +1,48 @@ +{ + "id": "basemap-at-orthofoto", + "kind": "tiles", + "family": "imagery", + "title": "basemap.at Orthofoto", + "provider": "basemap.at / geoland.at", + "description": "Aerial orthophotos: 100% of Austria (mosaic of state orthophotos).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["AT"], + "bbox": [9.53, 46.37, 17.17, 49.02] + }, + "source": { + "format": "xyz", + "url": "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "20–30 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Rolling (per state flight cycles)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://basemap.at/nutzungsbedingungen/" + }, + "attribution": { + "text": "basemap.at — Datenquelle: basemap.at", + "url": "https://basemap.at/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bayern-dop40.json b/src/library/entries/bayern-dop40.json new file mode 100644 index 000000000..265c4d159 --- /dev/null +++ b/src/library/entries/bayern-dop40.json @@ -0,0 +1,48 @@ +{ + "id": "bayern-dop40", + "kind": "tiles", + "family": "imagery", + "title": "Digitales Orthophoto Bayern (DOP 40 cm, open)", + "provider": "LDBV Bayern", + "description": "Aerial orthophotos: 100% of Bavaria (~70,550 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["DE"], + "bbox": [8.97, 47.27, 13.84, 50.56] + }, + "source": { + "format": "bbox", + "url": "https://geoservices.bayern.de/od/wms/dop/v1/dop40?service=WMS&request=GetMap&version=1.3.0&layers=by_dop40c&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "40 cm (20 cm is paid)", + "resolutionM": 0.4, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial (rolling)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://geodaten.bayern.de/opengeodata/" + }, + "attribution": { + "text": "© Bayerische Vermessungsverwaltung", + "url": "https://geodaten.bayern.de/opengeodata/OpenDataDetail.html?pn=dop40" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bismarck-mandan-nd.json b/src/library/entries/bismarck-mandan-nd.json new file mode 100644 index 000000000..a6c09c4ba --- /dev/null +++ b/src/library/entries/bismarck-mandan-nd.json @@ -0,0 +1,48 @@ +{ + "id": "bismarck-mandan-nd", + "kind": "tiles", + "family": "imagery", + "title": "Bismarck–Mandan, North Dakota Aerial Photography", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Bismarck–Mandan, North Dakota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-101.0948, 46.6527, -100.5815, 46.9844] + }, + "source": { + "format": "bbox", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_BisMan_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "95West Aerial Mapping", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bkg-topplus-open.json b/src/library/entries/bkg-topplus-open.json new file mode 100644 index 000000000..efa989dcf --- /dev/null +++ b/src/library/entries/bkg-topplus-open.json @@ -0,0 +1,34 @@ +{ + "id": "bkg-topplus-open", + "kind": "tiles", + "family": "imagery", + "title": "TopPlusOpen basemap (BKG)", + "provider": "BKG", + "description": "Germany's federal topographic basemap, detailed for Germany and Europe and generalised worldwide.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 18 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "Datenlizenz Deutschland – Namensnennung 2.0", + "url": "https://www.govdata.de/dl-de/by-2-0" + }, + "attribution": { + "text": "© BKG, dl-de/by-2-0", + "url": "https://gdz.bkg.bund.de/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/cctv-florida-fl511.json b/src/library/entries/cctv-florida-fl511.json new file mode 100644 index 000000000..5af3ed4ec --- /dev/null +++ b/src/library/entries/cctv-florida-fl511.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-florida-fl511", + "kind": "pack", + "family": "cameras", + "title": "Florida cameras (FL511)", + "provider": "FL511", + "description": "Statewide FL511 traffic camera stills, added to the CCTV layer. Individual personal use only; other use needs FDOT's written consent.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["US"], "bbox": [-87.64, 24.4, -79.97, 31.0] }, + "source": { "format": "host", "url": "https://fl511.com/" }, + "pack": { "host": "cctv", "env": "CCTV_FLORIDA_ENABLED" }, + "licence": { + "name": "FL511 content: individual personal use only", + "url": "https://fl511.com/privacy#disclaimer", + "operatorOptIn": true + }, + "attribution": { + "text": "Florida DOT — FL511.com", + "url": "https://fl511.com/" + }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/cctv-lithuania.json b/src/library/entries/cctv-lithuania.json new file mode 100644 index 000000000..7b036c6ce --- /dev/null +++ b/src/library/entries/cctv-lithuania.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-lithuania", + "kind": "pack", + "family": "cameras", + "title": "Lithuania road cameras (Via Lietuva)", + "provider": "Via Lietuva", + "description": "Eismoinfo road-camera stills across Lithuania, added to the CCTV layer. Off until Via Lietuva confirms the photo licence.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["LT"], "bbox": [20.9, 53.89, 26.84, 56.45] }, + "source": { "format": "host", "url": "https://eismoinfo.lt/" }, + "pack": { "host": "cctv", "env": "CCTV_LITHUANIA_ENABLED" }, + "licence": { + "name": "Via Lietuva open-data statement; camera photo coverage unconfirmed", + "url": "https://eismoinfo.lt/eismoinfo-backend/content?id=44", + "operatorOptIn": true + }, + "attribution": { + "text": "Via Lietuva / eismoinfo.lt", + "url": "https://eismoinfo.lt/" + }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/cctv-nyc-dot.json b/src/library/entries/cctv-nyc-dot.json new file mode 100644 index 000000000..e6f936fd3 --- /dev/null +++ b/src/library/entries/cctv-nyc-dot.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-nyc-dot", + "kind": "pack", + "family": "cameras", + "title": "New York City cameras (NYC DOT TMC)", + "provider": "NYC DOT", + "description": "About 980 NYC DOT traffic cameras across the five boroughs, added to the CCTV layer. Needs your own NYC DOT data-sharing agreement.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["US"], "bbox": [-74.26, 40.49, -73.69, 40.92] }, + "source": { + "format": "host", + "url": "https://webcams.nyctmc.org/api/cameras" + }, + "pack": { "host": "cctv", "env": "CCTV_NYC_ENABLED" }, + "licence": { + "name": "NYC DOT camera feed: data-sharing agreement required", + "url": "https://www.nyc.gov/html/dot/html/about/datafeeds.shtml", + "operatorOptIn": true + }, + "attribution": { "text": "NYC DOT Traffic Management Center" }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/ct-statewide-imagery.json b/src/library/entries/ct-statewide-imagery.json new file mode 100644 index 000000000..e93283574 --- /dev/null +++ b/src/library/entries/ct-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ct-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Connecticut Statewide Imagery (2019)", + "provider": "CT ECO (UConn / CT DEEP)", + "description": "Aerial orthophotos: Statewide coverage of Connecticut.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.7389, 40.9738, -71.7784, 42.0598] + }, + "source": { + "format": "bbox", + "url": "https://cteco.uconn.edu/ctraster/rest/services/images/Ortho_2019_tiled/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "CT ECO — UConn CLEAR / CT DEEP", + "url": "https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/dakota-county-mn.json b/src/library/entries/dakota-county-mn.json new file mode 100644 index 000000000..6b0560e63 --- /dev/null +++ b/src/library/entries/dakota-county-mn.json @@ -0,0 +1,48 @@ +{ + "id": "dakota-county-mn", + "kind": "tiles", + "family": "imagery", + "title": "Dakota County, Minnesota Air Photo", + "provider": "Dakota County GIS", + "description": "Aerial orthophotos: Dakota County, Minnesota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-93.3479, 44.4582, -92.7129, 44.9358] + }, + "source": { + "format": "bbox", + "url": "https://gisimg.co.dakota.mn.us/arcgis/rest/services/AerialPhotography/2025AirPhotoLeafOff6Inch_Spring/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://gis.co.dakota.mn.us/", + "operatorOptIn": true + }, + "attribution": { + "text": "Dakota County GIS", + "url": "https://gis.co.dakota.mn.us/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/dc-statewide-imagery.json b/src/library/entries/dc-statewide-imagery.json new file mode 100644 index 000000000..7660aaf74 --- /dev/null +++ b/src/library/entries/dc-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "dc-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "District of Columbia Statewide Imagery (2025)", + "provider": "DC GIS (OCTO)", + "description": "Aerial orthophotos: Statewide coverage of District of Columbia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-77.1224, 38.7855, -76.9007, 39.0018] + }, + "source": { + "format": "bbox", + "url": "https://imagery.dcgis.dc.gov/dcgis/rest/services/Ortho/Ortho_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open data — DC public domain terms", + "url": "https://opendata.dc.gov/pages/terms-of-use" + }, + "attribution": { + "text": "Office of the Chief Technology Officer", + "url": "https://opendata.dc.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/de-statewide-imagery.json b/src/library/entries/de-statewide-imagery.json new file mode 100644 index 000000000..83d3b2cd0 --- /dev/null +++ b/src/library/entries/de-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "de-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Delaware Statewide Imagery (2024)", + "provider": "Delaware FirstMap", + "description": "Aerial orthophotos: Statewide coverage of Delaware.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-75.8081, 38.4498, -75.0334, 39.8439] + }, + "source": { + "format": "bbox", + "url": "https://imagery.firstmap.delaware.gov/imagery/rest/services/DE_Imagery_SP/DE_Imagery_2024/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3–6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Roughly every 2 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://firstmap.delaware.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent.", + "url": "https://firstmap.delaware.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ee-maa-amet-ortofoto.json b/src/library/entries/ee-maa-amet-ortofoto.json new file mode 100644 index 000000000..d4aeb6122 --- /dev/null +++ b/src/library/entries/ee-maa-amet-ortofoto.json @@ -0,0 +1,47 @@ +{ + "id": "ee-maa-amet-ortofoto", + "kind": "tiles", + "family": "imagery", + "title": "Eesti ortofoto (Maa-amet)", + "provider": "Estonian Land Board (Maa-amet)", + "description": "Aerial orthophotos: 100% of Estonia (2-year cycle; cities yearly).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["EE"], + "bbox": [21.76, 57.51, 28.21, 59.68] + }, + "source": { + "format": "xyz", + "url": "https://tiles.maaamet.ee/tm/tms/1.0.0/foto@GMC/{z}/{x}/{reverseY}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "10–25 cm", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial (annual in cities)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Maa-amet open data licence (attribution)", + "url": "https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html" + }, + "attribution": { + "text": "© Maa-amet (Estonian Land Board)", + "url": "https://geoportaal.maaamet.ee/eng/Download-p609.html" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/eia-power-plants.json b/src/library/entries/eia-power-plants.json new file mode 100644 index 000000000..13372ccfd --- /dev/null +++ b/src/library/entries/eia-power-plants.json @@ -0,0 +1,68 @@ +{ + "id": "eia-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "US power plants, 100 MW and up (EIA)", + "provider": "EIA", + "description": "Operating US power plants with at least 100 MW of capacity, with their primary energy source, from the EIA's US Energy Atlas.", + "icon": "ϟ", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/FiaPA4ga0iQKduv3/arcgis/rest/services/Power_Plants_in_the_US/FeatureServer/0", + "query": { + "where": "Total_MW >= 100", + "outFields": "Plant_Name,Utility_Na,PrimSource,Total_MW,State" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 4000, + "maxPages": 3 + }, + "display": { + "color": "#a0aec0", + "label": "Plant_Name", + "fields": ["PrimSource", "Total_MW"], + "rank": { + "field": "Total_MW", + "order": "desc" + }, + "colorBy": { + "field": "PrimSource", + "colors": { + "coal": "#d55181", + "natural gas": "#d55181", + "petroleum": "#d55181", + "nuclear": "#c98500", + "hydroelectric": "#3987e5", + "pumped storage": "#3987e5", + "wind": "#008300", + "solar": "#008300" + }, + "labels": { + "coal": "Fossil (coal, gas, oil)", + "natural gas": "Fossil (coal, gas, oil)", + "petroleum": "Fossil (coal, gas, oil)", + "nuclear": "Nuclear", + "hydroelectric": "Hydro", + "pumped storage": "Hydro", + "wind": "Wind and solar", + "solar": "Wind and solar" + } + } + }, + "licence": { + "name": "U.S. Energy Information Administration data: public domain", + "url": "https://www.eia.gov/about/copyrights_reuse.php" + }, + "attribution": { + "text": "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)", + "url": "https://atlas.eia.gov/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/emodnet-bathymetry.json b/src/library/entries/emodnet-bathymetry.json new file mode 100644 index 000000000..58b99fea3 --- /dev/null +++ b/src/library/entries/emodnet-bathymetry.json @@ -0,0 +1,34 @@ +{ + "id": "emodnet-bathymetry", + "kind": "tiles", + "family": "environment", + "title": "European seabed depth (EMODnet)", + "provider": "EMODnet", + "description": "Shaded relief of the seabed across European seas from the EMODnet Digital Bathymetry model.", + "icon": "≈", + "maintainer": "github:asturksever", + "coverage": { + "bbox": [-36, 15, 43, 90] + }, + "source": { + "format": "xyz", + "url": "https://tiles.emodnet-bathymetry.eu/2020/baselayer/web_mercator/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 12 + }, + "display": { + "opacity": 0.85 + }, + "licence": { + "name": "EMODnet data: free use with attribution", + "url": "https://emodnet.ec.europa.eu/en/emodnet-data-policy" + }, + "attribution": { + "text": "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)", + "url": "https://emodnet.ec.europa.eu/en/bathymetry" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epa-superfund-sites.json b/src/library/entries/epa-superfund-sites.json new file mode 100644 index 000000000..2f9d63049 --- /dev/null +++ b/src/library/entries/epa-superfund-sites.json @@ -0,0 +1,40 @@ +{ + "id": "epa-superfund-sites", + "kind": "dataset", + "family": "environment", + "title": "Superfund sites (US EPA)", + "provider": "US EPA", + "description": "The about 1,400 hazardous waste sites on the EPA's Superfund list, from the Facility Registry Service.", + "icon": "☢", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://geopub.epa.gov/arcgis/rest/services/EMEF/efpoints/MapServer/0", + "query": { + "outFields": "primary_name,city_name,state_code" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#d69e2e", + "label": "primary_name", + "fields": ["city_name", "state_code"] + }, + "licence": { + "name": "U.S. EPA data: public domain", + "url": "https://www.epa.gov/web-policies-and-procedures/epa-disclaimers" + }, + "attribution": { + "text": "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)", + "url": "https://www.epa.gov/frs" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epa-toxic-release-facilities.json b/src/library/entries/epa-toxic-release-facilities.json new file mode 100644 index 000000000..4cf515fea --- /dev/null +++ b/src/library/entries/epa-toxic-release-facilities.json @@ -0,0 +1,45 @@ +{ + "id": "epa-toxic-release-facilities", + "kind": "dataset", + "family": "environment", + "title": "Toxic release facilities (US EPA TRI)", + "provider": "US EPA", + "description": "The about 64,500 facilities that report releases to the EPA's Toxics Release Inventory. Loads the facilities in view below 15 km.", + "icon": "☣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://geopub.epa.gov/arcgis/rest/services/EMEF/efpoints/MapServer/1", + "query": { + "outFields": "primary_name,city_name,state_code" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 15000, + "sample": [-95.2, 29.7, -95.1, 29.76] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#ed8936", + "label": "primary_name", + "fields": ["city_name", "state_code"] + }, + "licence": { + "name": "U.S. EPA data: public domain", + "url": "https://www.epa.gov/web-policies-and-procedures/epa-disclaimers" + }, + "attribution": { + "text": "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service", + "url": "https://www.epa.gov/toxics-release-inventory-tri-program" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epc-england-wales.json b/src/library/entries/epc-england-wales.json new file mode 100644 index 000000000..70741b59a --- /dev/null +++ b/src/library/entries/epc-england-wales.json @@ -0,0 +1,66 @@ +{ + "id": "epc-england-wales", + "kind": "dataset", + "family": "infrastructure", + "title": "Home energy ratings, England and Wales (EPC)", + "provider": "Addresscloud / DLUHC", + "description": "Every domestic Energy Performance Certificate in England and Wales at its UPRN, coloured by current rating A–G. Loads the homes in view once you zoom in below 4 km.", + "icon": "⌂", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["GB"], + "bbox": [-5.8, 49.8, 1.8, 55.9] + }, + "source": { + "format": "geoparquet", + "url": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop/addresscloud/epc/geoparquet-local-authority/", + "partitions": { + "listUrl": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop?list-type=2&prefix=addresscloud/epc/geoparquet-local-authority/", + "keyBase": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop/" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 4000, + "sample": [-3.19, 51.47, -3.16, 51.49] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 6000 + }, + "display": { + "color": "#a0aec0", + "label": "UPRN", + "fields": ["CURRENT_ENERGY_RATING", "POTENTIAL_ENERGY_RATING"], + "colorBy": { + "field": "CURRENT_ENERGY_RATING", + "colors": { + "A": "#008054", + "B": "#19b459", + "C": "#8dce46", + "D": "#ffd500", + "E": "#fcaa65", + "F": "#ef8023", + "G": "#e9153b" + }, + "labels": { + "A": "A · 92–100", + "B": "B · 81–91", + "C": "C · 69–80", + "D": "D · 55–68", + "E": "E · 39–54", + "F": "F · 21–38", + "G": "G · 1–20" + } + } + }, + "licence": { + "name": "Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL", + "url": "https://epc.opendatacommunities.org/docs/copyright" + }, + "attribution": { + "text": "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative", + "url": "https://source.coop/addresscloud/epc" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/fema-usa-structures.json b/src/library/entries/fema-usa-structures.json new file mode 100644 index 000000000..8d915d6c3 --- /dev/null +++ b/src/library/entries/fema-usa-structures.json @@ -0,0 +1,65 @@ +{ + "id": "fema-usa-structures", + "kind": "dataset", + "family": "infrastructure", + "title": "US building footprints (FEMA USA Structures)", + "provider": "FEMA / ORNL", + "description": "Footprints of every US structure over 450 sq ft, about 135 million, with occupancy class. Loads the buildings in view once you zoom in below 4 km.", + "icon": "▦", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US", "PR", "GU", "VI", "AS", "MP"] + }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/FiaPA4ga0iQKduv3/arcgis/rest/services/USA_Structures_View/FeatureServer/0", + "query": { + "outFields": "OCC_CLS,PRIM_OCC,HEIGHT,SQFEET" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 4000, + "sample": [-77.04, 38.885, -77.02, 38.9] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "PRIM_OCC", + "fields": ["OCC_CLS", "HEIGHT"], + "colorBy": { + "field": "OCC_CLS", + "colors": { + "Residential": "#3987e5", + "Commercial": "#c98500", + "Industrial": "#d55181", + "Utility and Misc": "#d55181", + "Government": "#008300", + "Education": "#008300", + "Assembly": "#008300" + }, + "labels": { + "Residential": "Residential", + "Commercial": "Commercial", + "Industrial": "Industrial and utility", + "Utility and Misc": "Industrial and utility", + "Government": "Public (government, education, assembly)", + "Education": "Public (government, education, assembly)", + "Assembly": "Public (government, education, assembly)" + } + } + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://creativecommons.org/licenses/by/4.0/" + }, + "attribution": { + "text": "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures", + "url": "https://www.arcgis.com/home/item.html?id=0ec8512ad21e4bb987d7e848d14e7e24" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gbif-occurrence-density.json b/src/library/entries/gbif-occurrence-density.json new file mode 100644 index 000000000..c4b0a293d --- /dev/null +++ b/src/library/entries/gbif-occurrence-density.json @@ -0,0 +1,60 @@ +{ + "id": "gbif-occurrence-density", + "kind": "tiles", + "family": "environment", + "title": "Biodiversity records (GBIF)", + "provider": "GBIF", + "description": "Where more than two billion plant, animal and fungus observations have been recorded, as a hexagon density map.", + "icon": "❦", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://api.gbif.org/v2/map/occurrence/density/{z}/{x}/{y}@1x.png?style=classic.poly&bin=hex", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 14 + }, + "display": { + "opacity": 0.8, + "legend": [ + { + "label": "≤ 10 records", + "color": "#ffff00" + }, + { + "label": "11–100 records", + "color": "#ffcc00" + }, + { + "label": "101–1,000 records", + "color": "#ff9900" + }, + { + "label": "1,001–10,000 records", + "color": "#ff6600" + }, + { + "label": "10,001–100,000 records", + "color": "#d60a00" + }, + { + "label": "> 100,000 records", + "color": "#c2002d" + } + ] + }, + "licence": { + "name": "GBIF data use agreement (records under CC0, CC BY or CC BY-NC)", + "url": "https://www.gbif.org/terms" + }, + "attribution": { + "text": "GBIF.org — Global Biodiversity Information Facility", + "url": "https://www.gbif.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gdacs-disaster-alerts.json b/src/library/entries/gdacs-disaster-alerts.json new file mode 100644 index 000000000..b7d7787d2 --- /dev/null +++ b/src/library/entries/gdacs-disaster-alerts.json @@ -0,0 +1,56 @@ +{ + "id": "gdacs-disaster-alerts", + "kind": "dataset", + "family": "events", + "title": "Disaster alerts (GDACS)", + "provider": "GDACS", + "description": "Current earthquakes, tropical cyclones, floods, volcanoes, droughts and wildfires with UN–EC GDACS alert levels.", + "icon": "⚑", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH", + "query": { + "eventlist": "EQ;TC;FL;VO;DR;WF" + }, + "fetch": "browser-direct", + "cacheSeconds": 900, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "name", + "fields": ["alertlevel", "country"], + "rank": { + "field": "fromdate", + "order": "desc" + }, + "colorBy": { + "field": "alertlevel", + "colors": { + "Red": "#d03b3b", + "Orange": "#ec835a", + "Green": "#0ca30c" + }, + "labels": { + "Red": "Red alert", + "Orange": "Orange alert", + "Green": "Green alert" + } + } + }, + "licence": { + "name": "GDACS terms of use (free, attribution required)", + "url": "https://www.gdacs.org/About/termofuse.aspx" + }, + "attribution": { + "text": "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)", + "url": "https://www.gdacs.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/geneva-sitg-ortho.json b/src/library/entries/geneva-sitg-ortho.json new file mode 100644 index 000000000..73eafc001 --- /dev/null +++ b/src/library/entries/geneva-sitg-ortho.json @@ -0,0 +1,48 @@ +{ + "id": "geneva-sitg-ortho", + "kind": "tiles", + "family": "imagery", + "title": "Orthophotos Canton de Genève (SITG)", + "provider": "SITG — Canton de Genève", + "description": "Aerial orthophotos: Canton of Geneva (~282 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["CH"], + "bbox": [5.95, 46.12, 6.32, 46.37] + }, + "source": { + "format": "bbox", + "url": "https://ge.ch/terags/rest/services/RASTER/ORTHOPHOTOS/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpg&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 10, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "5 cm", + "resolutionM": 0.05, + "trueOrtho": true, + "vintage": "2023–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "SITG open data — free use with attribution", + "url": "https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf" + }, + "attribution": { + "text": "© SITG — Etat de Genève", + "url": "https://ge.ch/sitg/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gpw-population-density.json b/src/library/entries/gpw-population-density.json new file mode 100644 index 000000000..10e31493e --- /dev/null +++ b/src/library/entries/gpw-population-density.json @@ -0,0 +1,60 @@ +{ + "id": "gpw-population-density", + "kind": "tiles", + "family": "demographics", + "title": "Population density 2020 (SEDAC GPW)", + "provider": "NASA GIBS", + "description": "Estimated people per square kilometre in 2020 from SEDAC's Gridded Population of the World v4.", + "icon": "◍", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GPW_Population_Density_2020/default/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 7 + }, + "display": { + "opacity": 0.75, + "legend": [ + { + "label": "< 1 people per km²", + "color": "#fff2d1" + }, + { + "label": "5–7.5 people per km²", + "color": "#fed59a" + }, + { + "label": "25–50 people per km²", + "color": "#faab4d" + }, + { + "label": "100–125 people per km²", + "color": "#fc9943" + }, + { + "label": "250–325 people per km²", + "color": "#f55e2c" + }, + { + "label": "≥ 1,000 people per km²", + "color": "#bd0026" + } + ] + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use" + }, + "attribution": { + "text": "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS", + "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/grand-forks-nd.json b/src/library/entries/grand-forks-nd.json new file mode 100644 index 000000000..ee8cbb379 --- /dev/null +++ b/src/library/entries/grand-forks-nd.json @@ -0,0 +1,48 @@ +{ + "id": "grand-forks-nd", + "kind": "tiles", + "family": "imagery", + "title": "Grand Forks, North Dakota Aerial Photography", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Grand Forks, North Dakota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.2308, 47.8072, -96.92, 48.0153] + }, + "source": { + "format": "bbox", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_GrandForks_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gsi-japan-map.json b/src/library/entries/gsi-japan-map.json new file mode 100644 index 000000000..e7e1201c2 --- /dev/null +++ b/src/library/entries/gsi-japan-map.json @@ -0,0 +1,35 @@ +{ + "id": "gsi-japan-map", + "kind": "tiles", + "family": "imagery", + "title": "Japan standard map (GSI)", + "provider": "GSI", + "description": "The Geospatial Information Authority of Japan's standard map tiles.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["JP"], + "bbox": [122, 20, 154, 46] + }, + "source": { + "format": "xyz", + "url": "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 2, + "maxZoom": 18 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "GSI Tiles terms (free use with attribution)", + "url": "https://maps.gsi.go.jp/development/ichiran.html" + }, + "attribution": { + "text": "Geospatial Information Authority of Japan (GSI Tiles)", + "url": "https://maps.gsi.go.jp/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gsi-japan-photo.json b/src/library/entries/gsi-japan-photo.json new file mode 100644 index 000000000..e5eef262e --- /dev/null +++ b/src/library/entries/gsi-japan-photo.json @@ -0,0 +1,48 @@ +{ + "id": "gsi-japan-photo", + "kind": "tiles", + "family": "imagery", + "title": "Japan aerial photos (GSI)", + "provider": "GSI", + "description": "Seamless aerial and satellite photography of Japan from the Geospatial Information Authority of Japan.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["JP"], + "bbox": [122, 20, 154, 46] + }, + "source": { + "format": "xyz", + "url": "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 2, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "countries", + "aerial": true, + "noDataColor": "#343434" + }, + "imagery": { + "type": "ortho", + "resolution": "20 cm (cities) – 40 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2007–present", + "updateCycle": "Rolling", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "GSI Tiles terms (free use with attribution)", + "url": "https://maps.gsi.go.jp/development/ichiran.html" + }, + "attribution": { + "text": "Geospatial Information Authority of Japan (GSI Tiles)", + "url": "https://maps.gsi.go.jp/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gvp-holocene-volcanoes.json b/src/library/entries/gvp-holocene-volcanoes.json new file mode 100644 index 000000000..fc0b8edd5 --- /dev/null +++ b/src/library/entries/gvp-holocene-volcanoes.json @@ -0,0 +1,47 @@ +{ + "id": "gvp-holocene-volcanoes", + "kind": "dataset", + "family": "environment", + "title": "Holocene volcanoes (Smithsonian GVP)", + "provider": "Smithsonian GVP", + "description": "The roughly 1,200 volcanoes with eruptions in the last 12,000 years, from the Smithsonian Global Volcanism Program's Volcanoes of the World.", + "icon": "▲", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://webservices.volcano.si.edu/geoserver/GVP-VOTW/ows", + "query": { + "service": "WFS", + "version": "2.0.0", + "request": "GetFeature", + "typeName": "GVP-VOTW:Smithsonian_VOTW_Holocene_Volcanoes", + "outputFormat": "application/json" + }, + "fetch": "proxied", + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#ff3d6b", + "label": "Volcano_Name", + "fields": ["Primary_Volcano_Type", "Last_Eruption_Year"], + "rank": { + "field": "Last_Eruption_Year", + "order": "desc" + } + }, + "licence": { + "name": "Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)", + "url": "https://www.si.edu/termsofuse" + }, + "attribution": { + "text": "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World", + "url": "https://volcano.si.edu/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/ia-statewide-imagery.json b/src/library/entries/ia-statewide-imagery.json new file mode 100644 index 000000000..9319b4b32 --- /dev/null +++ b/src/library/entries/ia-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ia-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Iowa Statewide Imagery (2023)", + "provider": "Iowa State Univ. GIS / Iowa DNR", + "description": "Aerial orthophotos: Statewide coverage of Iowa.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-96.7456, 40.3273, -90.0366, 43.5847] + }, + "source": { + "format": "bbox", + "url": "https://ortho.gis.iastate.edu/arcgis/rest/services/ortho/naip_2023_cir/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m / 60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://geodata.iowa.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA NAIP, Iowa State University GIS Facility", + "url": "https://geodata.iowa.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/icgc-orto-catalunya.json b/src/library/entries/icgc-orto-catalunya.json new file mode 100644 index 000000000..bb0280b22 --- /dev/null +++ b/src/library/entries/icgc-orto-catalunya.json @@ -0,0 +1,47 @@ +{ + "id": "icgc-orto-catalunya", + "kind": "tiles", + "family": "imagery", + "title": "Ortofoto de Catalunya 25 cm", + "provider": "ICGC", + "description": "Aerial orthophotos: Catalonia (~32,100 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["ES"], + "bbox": [0.15, 40.52, 3.34, 42.87] + }, + "source": { + "format": "xyz", + "url": "https://geoserveis.icgc.cat/icc_mapesmultibase/noutm/wmts/orto/GRID3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "25 cm (15 cm in cities)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.icgc.cat/en/Ambits-tematics/Ortofoto" + }, + "attribution": { + "text": "© Institut Cartogràfic i Geològic de Catalunya", + "url": "https://www.icgc.cat/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/id-statewide-imagery.json b/src/library/entries/id-statewide-imagery.json new file mode 100644 index 000000000..e3b02557a --- /dev/null +++ b/src/library/entries/id-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "id-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Idaho Statewide Imagery (2023)", + "provider": "INSIDE Idaho", + "description": "Aerial orthophotos: Statewide coverage of Idaho.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-117.5533, 41.8913, -110.597, 49.0918] + }, + "source": { + "format": "bbox", + "url": "https://insideidaho.org/arcgis/rest/services/imagery/idaho_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain / open — INSIDE Idaho terms", + "url": "https://insideidaho.org/" + }, + "attribution": { + "text": "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)", + "url": "https://insideidaho.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ign-orthophotos-france.json b/src/library/entries/ign-orthophotos-france.json new file mode 100644 index 000000000..410546629 --- /dev/null +++ b/src/library/entries/ign-orthophotos-france.json @@ -0,0 +1,48 @@ +{ + "id": "ign-orthophotos-france", + "kind": "tiles", + "family": "imagery", + "title": "France aerial photos (IGN)", + "provider": "IGN", + "description": "IGN's national orthophotography of metropolitan France and Corsica.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["FR"], + "bbox": [-5.5, 41.2, 9.8, 51.2] + }, + "source": { + "format": "xyz", + "url": "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#0a3b59" + }, + "imagery": { + "type": "ortho", + "resolution": "20 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2018–present", + "updateCycle": "3-year rolling refresh", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Licence Ouverte / Etalab 2.0", + "url": "https://www.etalab.gouv.fr/licence-ouverte-open-licence/" + }, + "attribution": { + "text": "© IGN — Géoplateforme", + "url": "https://geoservices.ign.fr/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/il-statewide-imagery.json b/src/library/entries/il-statewide-imagery.json new file mode 100644 index 000000000..111d0fbb5 --- /dev/null +++ b/src/library/entries/il-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "il-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Illinois Statewide Imagery", + "provider": "ISGS / IDOT", + "description": "Aerial orthophotos: Statewide coverage of Illinois.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-91.3996, 36.97, -87.469, 41.0314] + }, + "source": { + "format": "bbox", + "url": "https://data.isgs.illinois.edu/arcgis/rest/services/Imagery/IDOT_2017/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://clearinghouse.isgs.illinois.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "Illinois State Geological Survey / IDOT", + "url": "https://clearinghouse.isgs.illinois.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/imf-portwatch-chokepoints.json b/src/library/entries/imf-portwatch-chokepoints.json new file mode 100644 index 000000000..cb224073a --- /dev/null +++ b/src/library/entries/imf-portwatch-chokepoints.json @@ -0,0 +1,39 @@ +{ + "id": "imf-portwatch-chokepoints", + "kind": "dataset", + "family": "infrastructure", + "title": "Maritime chokepoints (IMF PortWatch)", + "provider": "IMF PortWatch", + "description": "The 28 maritime chokepoints IMF PortWatch tracks, such as Suez and Hormuz, with their annual vessel transits and top cargo.", + "icon": "⧗", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "arcgis", + "url": "https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_chokepoints_database/FeatureServer/0", + "query": { + "outFields": "portname,vessel_count_total,vessel_count_tanker,industry_top1" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 100, + "maxPages": 1 + }, + "display": { + "color": "#4fd1c5", + "label": "portname", + "fields": ["vessel_count_total", "industry_top1"], + "rank": { "field": "vessel_count_total", "order": "desc" } + }, + "licence": { + "name": "IMF PortWatch terms; attribution required (reuse terms under review)", + "url": "https://www.imf.org/external/terms.htm", + "operatorOptIn": true + }, + "attribution": { + "text": "IMF PortWatch (portwatch.imf.org)", + "url": "https://portwatch.imf.org/" + }, + "status": "incubating", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/imf-portwatch-disruptions.json b/src/library/entries/imf-portwatch-disruptions.json new file mode 100644 index 000000000..c120eba2c --- /dev/null +++ b/src/library/entries/imf-portwatch-disruptions.json @@ -0,0 +1,57 @@ +{ + "id": "imf-portwatch-disruptions", + "kind": "dataset", + "family": "events", + "title": "Port disruptions (IMF PortWatch)", + "provider": "IMF PortWatch", + "description": "Areas of natural-hazard events that disrupted ports since 2019, such as cyclones and floods, with the ports affected, from IMF PortWatch and GDACS.", + "icon": "◌", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "arcgis", + "url": "https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_disruptions_database/FeatureServer/0", + "query": { + "outFields": "htmlname,alertlevel,country,fromdate,n_affectedports" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 500, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "htmlname", + "fields": ["alertlevel", "n_affectedports"], + "rank": { + "field": "fromdate", + "order": "desc" + }, + "colorBy": { + "field": "alertlevel", + "colors": { + "RED": "#d03b3b", + "ORANGE": "#ec835a", + "GREEN": "#0ca30c" + }, + "labels": { + "RED": "Red alert", + "ORANGE": "Orange alert", + "GREEN": "Green alert" + } + } + }, + "licence": { + "name": "IMF PortWatch terms; attribution required (reuse terms under review)", + "url": "https://www.imf.org/external/terms.htm", + "operatorOptIn": true + }, + "attribution": { + "text": "IMF PortWatch (portwatch.imf.org), with GDACS event data", + "url": "https://portwatch.imf.org/" + }, + "status": "incubating", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/in-statewide-imagery.json b/src/library/entries/in-statewide-imagery.json new file mode 100644 index 000000000..09538ce3c --- /dev/null +++ b/src/library/entries/in-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "in-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Indiana Statewide Imagery (2016)", + "provider": "IndianaMap", + "description": "Aerial orthophotos: Statewide coverage of Indiana.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-88.1, 37.77, -84.78, 41.76] + }, + "source": { + "format": "bbox", + "url": "https://di-ingov.img.arcgis.com/arcgis/rest/services/DynamicWebMercator/Indiana_2016_2019_Imagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2016–present", + "updateCycle": "4-year statewide cycle (counties in 3 groups)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC0 1.0 Universal (public domain dedication)", + "url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode" + }, + "attribution": { + "text": "Indiana Geographic Information Office (CC0)", + "url": "https://registry.opendata.aws/in-imagery/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/jrc-surface-water.json b/src/library/entries/jrc-surface-water.json new file mode 100644 index 000000000..9bb0a8811 --- /dev/null +++ b/src/library/entries/jrc-surface-water.json @@ -0,0 +1,48 @@ +{ + "id": "jrc-surface-water", + "kind": "tiles", + "family": "environment", + "title": "Surface water occurrence (JRC)", + "provider": "EC JRC", + "description": "How often each place was covered by water between 1984 and 2021, from 38 years of Landsat imagery.", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://storage.googleapis.com/global-surface-water/tiles2021/occurrence/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 13 + }, + "display": { + "opacity": 0.85, + "legend": [ + { + "label": "Sometimes water (> 0%)", + "color": "#ffffff" + }, + { + "label": "Half the time (50%)", + "color": "#bf7fbf" + }, + { + "label": "Always water (100%)", + "color": "#0000ff" + } + ] + }, + "licence": { + "name": "Copernicus programme: free, without restriction of use", + "url": "https://global-surface-water.appspot.com/download" + }, + "attribution": { + "text": "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water", + "url": "https://global-surface-water.appspot.com/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/kartverket-admin-units.json b/src/library/entries/kartverket-admin-units.json new file mode 100644 index 000000000..e45921125 --- /dev/null +++ b/src/library/entries/kartverket-admin-units.json @@ -0,0 +1,46 @@ +{ + "id": "kartverket-admin-units", + "kind": "tiles", + "family": "infrastructure", + "title": "Norway counties and municipalities (Kartverket)", + "provider": "Kartverket", + "description": "Current county (fylke) and municipality (kommune) boundaries from Kartverket's administrative units.", + "icon": "⌗", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.adm_enheter2?service=WMS&request=GetMap&version=1.3.0&layers=fylker_gjel,kommuner_gjel&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 4, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "legend": [ + { + "label": "County boundary", + "color": "#6464ff" + }, + { + "label": "Municipality boundary", + "color": "#8a3e4e" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-cadastre.json b/src/library/entries/kartverket-cadastre.json new file mode 100644 index 000000000..13334eba2 --- /dev/null +++ b/src/library/entries/kartverket-cadastre.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-cadastre", + "kind": "tiles", + "family": "infrastructure", + "title": "Norway cadastral map (Kartverket)", + "provider": "Kartverket", + "description": "Property boundaries, parcel numbers and building outlines from the Norwegian cadastre (Matrikkelen), shown at street scale.", + "icon": "⌗", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.matrikkelkart?service=WMS&request=GetMap&version=1.3.0&layers=matrikkelkart&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 14, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-depth.json b/src/library/entries/kartverket-depth.json new file mode 100644 index 000000000..d1ecd811a --- /dev/null +++ b/src/library/entries/kartverket-depth.json @@ -0,0 +1,102 @@ +{ + "id": "kartverket-depth", + "kind": "tiles", + "family": "environment", + "title": "Norway sea depths (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's depth areas and depth contours of Norwegian waters, shown from coastal scale (zoom in to see them).", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.dybdedata2?service=WMS&request=GetMap&version=1.3.0&layers=Dybdelag,Dybdekontur&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "legend": [ + { + "label": "Minimum depth 0–5 m", + "color": "#e6ffff" + }, + { + "label": "Minimum depth 5–10 m", + "color": "#dbfaff" + }, + { + "label": "Minimum depth 10–20 m", + "color": "#d1f5ff" + }, + { + "label": "Minimum depth 20–30 m", + "color": "#c7f2ff" + }, + { + "label": "Minimum depth 30–40 m", + "color": "#c3f1ff" + }, + { + "label": "Minimum depth 40–50 m", + "color": "#bdefff" + }, + { + "label": "Minimum depth 50–100 m", + "color": "#b8edff" + }, + { + "label": "Minimum depth 100–150 m", + "color": "#abe8ff" + }, + { + "label": "Minimum depth 150–200 m", + "color": "#a6e5ff" + }, + { + "label": "Minimum depth 200–250 m", + "color": "#a1e5ff" + }, + { + "label": "Minimum depth 250–300 m", + "color": "#9ce3ff" + }, + { + "label": "Minimum depth 300–350 m", + "color": "#96e3ff" + }, + { + "label": "Minimum depth 350–400 m", + "color": "#91e0ff" + }, + { + "label": "Minimum depth 400–450 m", + "color": "#8ce0ff" + }, + { + "label": "Minimum depth 450–500 m", + "color": "#89deff" + }, + { + "label": "Minimum depth 500–2,000 m", + "color": "#87dcff" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-hillshade.json b/src/library/entries/kartverket-hillshade.json new file mode 100644 index 000000000..1198b30e7 --- /dev/null +++ b/src/library/entries/kartverket-hillshade.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-hillshade", + "kind": "tiles", + "family": "environment", + "title": "Norway hillshade (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's hillshade of Norway's terrain, a translucent shading to lay over imagery or maps.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.fjellskygge?service=WMS&request=GetMap&version=1.3.0&layers=fjellskygge&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-nautical-charts.json b/src/library/entries/kartverket-nautical-charts.json new file mode 100644 index 000000000..f1e736bca --- /dev/null +++ b/src/library/entries/kartverket-nautical-charts.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-nautical-charts", + "kind": "tiles", + "family": "movement", + "title": "Norway nautical charts (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's official raster nautical charts of the Norwegian coast and waters. Not for navigation.", + "icon": "⚓", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/sjokartraster/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-terrain-relief.json b/src/library/entries/kartverket-terrain-relief.json new file mode 100644 index 000000000..6f80a45f8 --- /dev/null +++ b/src/library/entries/kartverket-terrain-relief.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-terrain-relief", + "kind": "tiles", + "family": "environment", + "title": "Norway terrain relief (Kartverket DTM)", + "provider": "Kartverket", + "description": "Shaded colour relief of Kartverket's national terrain model, built from laser scanning.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.terrengmodell?service=WMS&request=GetMap&version=1.3.0&layers=relieff&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-topo-grey.json b/src/library/entries/kartverket-topo-grey.json new file mode 100644 index 000000000..4c1693aac --- /dev/null +++ b/src/library/entries/kartverket-topo-grey.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-topo-grey", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic map, greyscale (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's topographic map of Norway in greyscale, a quiet base for other layers.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/topograatone/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-topo.json b/src/library/entries/kartverket-topo.json new file mode 100644 index 000000000..b96e705b6 --- /dev/null +++ b/src/library/entries/kartverket-topo.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-topo", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic map (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's national topographic map of Norway, from overview scale down to buildings and paths.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-toporaster.json b/src/library/entries/kartverket-toporaster.json new file mode 100644 index 000000000..d23948733 --- /dev/null +++ b/src/library/entries/kartverket-toporaster.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-toporaster", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic raster map (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's raster topographic map series of Norway, in the style of the printed maps.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/toporaster/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ks-statewide-imagery.json b/src/library/entries/ks-statewide-imagery.json new file mode 100644 index 000000000..393ec24cb --- /dev/null +++ b/src/library/entries/ks-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ks-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Kansas Statewide Imagery (2021)", + "provider": "Kansas DASC / KGS", + "description": "Aerial orthophotos: Statewide coverage of Kansas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-102.472, 36.738, -94.035, 40.211] + }, + "source": { + "format": "bbox", + "url": "https://services.kgs.ku.edu/arcgis7/rest/services/IMAGERY_STATEWIDE/FSA_NAIP_2021_Color/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.kansasgis.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "FSA, USDA,", + "url": "https://www.kansasgis.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ky-statewide-imagery.json b/src/library/entries/ky-statewide-imagery.json new file mode 100644 index 000000000..af604ca85 --- /dev/null +++ b/src/library/entries/ky-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ky-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Kentucky Statewide Imagery", + "provider": "KyFromAbove", + "description": "Aerial orthophotos: Statewide coverage of Kentucky.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-89.7044, 36.457, -81.8842, 39.1519] + }, + "source": { + "format": "bbox", + "url": "https://kyraster.ky.gov/arcgis/rest/services/ImageServices/Ky_KYAPED_Phase3_3IN_IR/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in (statewide), 3 in (urban)", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Annual partial; full refresh every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain with attribution", + "url": "https://kygeonet.ky.gov/" + }, + "attribution": { + "text": "KyFromAbove", + "url": "https://registry.opendata.aws/kyfromabove/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lake-county-in.json b/src/library/entries/lake-county-in.json new file mode 100644 index 000000000..8c9fb3e91 --- /dev/null +++ b/src/library/entries/lake-county-in.json @@ -0,0 +1,48 @@ +{ + "id": "lake-county-in", + "kind": "tiles", + "family": "imagery", + "title": "Lake County, Indiana Aerial Imagery", + "provider": "Lake County GIS", + "description": "Aerial orthophotos: Lake County, Indiana jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-87.5421, 41.1501, -87.2023, 41.7204] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/8CXRnvSfSpwdf0R6/arcgis/rest/services/LakeIN2023Aerial/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.lakecountyin.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "Lake County Surveyor's Office", + "url": "https://www.lakecountyin.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lawrence-ks.json b/src/library/entries/lawrence-ks.json new file mode 100644 index 000000000..ad49183e0 --- /dev/null +++ b/src/library/entries/lawrence-ks.json @@ -0,0 +1,48 @@ +{ + "id": "lawrence-ks", + "kind": "tiles", + "family": "imagery", + "title": "Lawrence, Kansas Aerial Photos", + "provider": "City of Lawrence GIS", + "description": "Aerial orthophotos: Lawrence, Kansas jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-95.3997, 38.8881, -95.113, 39.0507] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/8O9UlSTnqjKptoda/arcgis/rest/services/LAWRENCE_KS_AERIAL_2022/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://gis-lawrenceks.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "City of Lawrence GIS", + "url": "https://gis-lawrenceks.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lee-county-fl.json b/src/library/entries/lee-county-fl.json new file mode 100644 index 000000000..d453e9399 --- /dev/null +++ b/src/library/entries/lee-county-fl.json @@ -0,0 +1,48 @@ +{ + "id": "lee-county-fl", + "kind": "tiles", + "family": "imagery", + "title": "Lee County, Florida Aerial Imagery", + "provider": "Lee County GIS", + "description": "Aerial orthophotos: Lee County, Florida jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-82.3101, 26.3138, -81.5591, 26.796] + }, + "source": { + "format": "bbox", + "url": "https://gisimageserver.leegov.com/imageserver/rest/services/Aerials/Aerials2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.leegov.com/gis", + "operatorOptIn": true + }, + "attribution": { + "text": "Eagle View, Lee County Property Appraiser, Lee County GIS", + "url": "https://www.leegov.com/gis" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/loudoun-county-va.json b/src/library/entries/loudoun-county-va.json new file mode 100644 index 000000000..d5cfb57bc --- /dev/null +++ b/src/library/entries/loudoun-county-va.json @@ -0,0 +1,48 @@ +{ + "id": "loudoun-county-va", + "kind": "tiles", + "family": "imagery", + "title": "Loudoun County, Virginia Imagery", + "provider": "Loudoun County GIS", + "description": "Aerial orthophotos: Loudoun County, Virginia jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-77.9661, 38.823, -77.2907, 39.3358] + }, + "source": { + "format": "bbox", + "url": "https://logis.loudoun.gov/image/rest/services/Aerial/COLOR_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://geohub-loudoungis.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Loudoun County GIS", + "url": "https://geohub-loudoungis.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/louisville-lojic-ky.json b/src/library/entries/louisville-lojic-ky.json new file mode 100644 index 000000000..82316012e --- /dev/null +++ b/src/library/entries/louisville-lojic-ky.json @@ -0,0 +1,49 @@ +{ + "id": "louisville-lojic-ky", + "kind": "tiles", + "family": "imagery", + "title": "Louisville / LOJIC, Kentucky Imagery", + "provider": "LOJIC", + "description": "Aerial orthophotos: Louisville / LOJIC, Kentucky jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-86.5156, 37.4289, -84.8574, 38.7994] + }, + "source": { + "format": "bbox", + "url": "https://services.lojic.org/arcgis/rest/services/Lojic/Imagery/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.lojic.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "LOJIC, USDA-NAIP", + "url": "https://www.lojic.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lu-ortho-latest.json b/src/library/entries/lu-ortho-latest.json new file mode 100644 index 000000000..cbeb9bb6a --- /dev/null +++ b/src/library/entries/lu-ortho-latest.json @@ -0,0 +1,48 @@ +{ + "id": "lu-ortho-latest", + "kind": "tiles", + "family": "imagery", + "title": "Orthophoto officielle du Luxembourg", + "provider": "ACT Luxembourg / geoportail.lu", + "description": "Aerial orthophotos: 100% of Luxembourg (~2,586 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["LU"], + "bbox": [5.73, 49.44, 6.53, 50.19] + }, + "source": { + "format": "xyz", + "url": "https://wmts1.geoportail.lu/opendata/wmts/ortho_latest/GLOBAL_WEBMERCATOR_4_V3/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "5–20 cm", + "resolutionM": 0.05, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Annual (spring flight)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC0 1.0 (Licence ouverte Luxembourg)", + "url": "https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/" + }, + "attribution": { + "text": "Administration du cadastre et de la topographie, Luxembourg (CC0)", + "url": "https://data.public.lu/en/datasets/?q=orthophoto" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ma-statewide-imagery.json b/src/library/entries/ma-statewide-imagery.json new file mode 100644 index 000000000..960711fee --- /dev/null +++ b/src/library/entries/ma-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ma-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Massachusetts Statewide Imagery", + "provider": "MassGIS (EOTSS)", + "description": "Aerial orthophotos: Statewide coverage of Massachusetts.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.51, 41.24, -69.93, 42.89] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/hGdibHYSPO59RG1h/arcgis/rest/services/orthos2021/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "15–30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://gis.data.mass.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "MassGIS, Commonwealth of Massachusetts", + "url": "https://gis.data.mass.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/macrostrat-geology.json b/src/library/entries/macrostrat-geology.json new file mode 100644 index 000000000..42f1ce711 --- /dev/null +++ b/src/library/entries/macrostrat-geology.json @@ -0,0 +1,88 @@ +{ + "id": "macrostrat-geology", + "kind": "tiles", + "family": "environment", + "title": "Geologic map (Macrostrat)", + "provider": "Macrostrat", + "description": "Bedrock geology worldwide, harmonised from hundreds of published geologic maps and coloured by rock age on the international (ICS) time-scale colours.", + "icon": "◒", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://tiles.macrostrat.org/carto/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 14 + }, + "display": { + "opacity": 0.7, + "legend": [ + { + "label": "Quaternary", + "color": "#f9f97f" + }, + { + "label": "Neogene", + "color": "#ffe619" + }, + { + "label": "Paleogene", + "color": "#fd9a52" + }, + { + "label": "Cretaceous", + "color": "#7fc64e" + }, + { + "label": "Jurassic", + "color": "#34b2c9" + }, + { + "label": "Triassic", + "color": "#812b92" + }, + { + "label": "Permian", + "color": "#f04028" + }, + { + "label": "Carboniferous", + "color": "#67a599" + }, + { + "label": "Devonian", + "color": "#cb8c37" + }, + { + "label": "Silurian", + "color": "#b3e1b6" + }, + { + "label": "Ordovician", + "color": "#009270" + }, + { + "label": "Cambrian", + "color": "#7fa056" + }, + { + "label": "Precambrian", + "color": "#f74370" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Macrostrat; source maps credited by their publishers)", + "url": "https://macrostrat.org/about" + }, + "attribution": { + "text": "Macrostrat — geologic maps from their original publishers", + "url": "https://macrostrat.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/mapterhorn-terrain.json b/src/library/entries/mapterhorn-terrain.json new file mode 100644 index 000000000..59adc91ee --- /dev/null +++ b/src/library/entries/mapterhorn-terrain.json @@ -0,0 +1,32 @@ +{ + "id": "mapterhorn-terrain", + "kind": "tiles", + "family": "environment", + "title": "Global terrain shaded relief (Mapterhorn)", + "provider": "Mapterhorn", + "description": "Elevation of the whole land surface from Mapterhorn's open terrain tiles (Copernicus and national elevation models), coloured by height and hill-shaded.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "encoding": "terrarium", + "tileSize": 512, + "minZoom": 0, + "maxZoom": 15 + }, + "display": { "opacity": 0.85 }, + "licence": { + "name": "Open data, per source (mostly CC BY 4.0 and public domain)", + "url": "https://mapterhorn.com/attribution" + }, + "attribution": { + "text": "© Mapterhorn and its open elevation sources", + "url": "https://mapterhorn.com/attribution" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/maris-ms.json b/src/library/entries/maris-ms.json new file mode 100644 index 000000000..4766e23ef --- /dev/null +++ b/src/library/entries/maris-ms.json @@ -0,0 +1,48 @@ +{ + "id": "maris-ms", + "kind": "tiles", + "family": "imagery", + "title": "MARIS Imagery, Mississippi", + "provider": "MARIS", + "description": "Aerial orthophotos: MARIS Imagery, Mississippi jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-89.5658, 34.3427, -89.5141, 34.3953] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/EAeOFj04EVkIaWLk/arcgis/rest/services/OleMiss_Basemap_ZL2/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 13, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.ms.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "MARIS", + "url": "https://www.gis.ms.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/md-statewide-imagery.json b/src/library/entries/md-statewide-imagery.json new file mode 100644 index 000000000..81cae225f --- /dev/null +++ b/src/library/entries/md-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "md-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Maryland Statewide Imagery", + "provider": "Maryland iMAP / MD DoIT", + "description": "Aerial orthophotos: Statewide coverage of Maryland.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-79.5157, 37.8702, -74.9964, 39.778] + }, + "source": { + "format": "bbox", + "url": "https://mdgeodata.md.gov/imagery/rest/services/NAIP/MD_NAIPImagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in – 1 m", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Annual/biennial by region", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://imap.maryland.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Maryland iMAP", + "url": "https://imap.maryland.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/me-statewide-imagery.json b/src/library/entries/me-statewide-imagery.json new file mode 100644 index 000000000..cbb823998 --- /dev/null +++ b/src/library/entries/me-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "me-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Maine Statewide Imagery", + "provider": "Maine GeoLibrary / MEGIS", + "description": "Aerial orthophotos: Statewide coverage of Maine.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-71.08, 42.98, -66.95, 47.46] + }, + "source": { + "format": "bbox", + "url": "https://gis.maine.gov/image/rest/services/NAIP/orthoNaip2006/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm – 1 m", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling regional", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.maine.gov/geolib/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA Aerial Photography Field Office", + "url": "https://www.maine.gov/geolib/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mi-statewide-imagery.json b/src/library/entries/mi-statewide-imagery.json new file mode 100644 index 000000000..eb0e52e52 --- /dev/null +++ b/src/library/entries/mi-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "mi-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Michigan Statewide Imagery (2024)", + "provider": "MiSAIL / DTMB", + "description": "Aerial orthophotos: Statewide coverage of Michigan (preview bounds cover the Lower Peninsula; service covers both peninsulas).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-86.5, 41.7, -82.41, 45.9] + }, + "source": { + "format": "bbox", + "url": "https://imagery.michigan.gov/server/rest/services/Michigan_imagery_2024/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm – 1 m", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Rolling", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://gis-michigan.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "State of Michigan", + "url": "https://gis-michigan.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mn-statewide-imagery.json b/src/library/entries/mn-statewide-imagery.json new file mode 100644 index 000000000..7469e1b55 --- /dev/null +++ b/src/library/entries/mn-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "mn-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Minnesota Statewide Imagery", + "provider": "MnGeo", + "description": "Aerial orthophotos: Statewide coverage of Minnesota.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.24, 43.5, -89.49, 49.38] + }, + "source": { + "format": "bbox", + "url": "https://imageserver.gisdata.mn.gov/cgi-bin/wms?service=WMS&request=GetMap&version=1.1.1&layers=fsa&styles=&srs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (FSA/NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain / open — MnGeo terms", + "url": "https://www.mngeo.state.mn.us/" + }, + "attribution": { + "text": "MnGeo / State of Minnesota", + "url": "https://www.mngeo.state.mn.us/chouse/airphoto/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mountain-village-co.json b/src/library/entries/mountain-village-co.json new file mode 100644 index 000000000..e1c4462fa --- /dev/null +++ b/src/library/entries/mountain-village-co.json @@ -0,0 +1,48 @@ +{ + "id": "mountain-village-co", + "kind": "tiles", + "family": "imagery", + "title": "Mountain Village, Colorado Orthoimagery", + "provider": "Town of Mountain Village", + "description": "Aerial orthophotos: Mountain Village, Colorado jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-107.8864, 37.9114, -107.8244, 37.9537] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/Nefdxa42x2DnAd5Z/arcgis/rest/services/Mountain_Village_Orthos/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 13, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.townofmountainvillage.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Town of Mountain Village", + "url": "https://www.townofmountainvillage.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ms-statewide-imagery.json b/src/library/entries/ms-statewide-imagery.json new file mode 100644 index 000000000..415168521 --- /dev/null +++ b/src/library/entries/ms-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ms-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Mississippi Statewide Imagery", + "provider": "MARIS", + "description": "Aerial orthophotos: Statewide coverage of Mississippi.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-91.66, 30.17, -88.1, 35] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/LLVEmB8Lsae3Um4s/arcgis/rest/services/ms_napp2/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 16, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m (NAPP/NAIP)", + "resolutionM": 1, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.gis.ms.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA-FSA-APFO", + "url": "https://www.gis.ms.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mt-statewide-imagery.json b/src/library/entries/mt-statewide-imagery.json new file mode 100644 index 000000000..4c9b7754b --- /dev/null +++ b/src/library/entries/mt-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "mt-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Montana Statewide Imagery (2023)", + "provider": "Montana State Library (MSDI)", + "description": "Aerial orthophotos: Statewide coverage of Montana.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-116.0637, 44.3115, -103.9988, 49.064] + }, + "source": { + "format": "bbox", + "url": "https://gisservice.mt.gov/arcgis/rest/services/MSDI_Framework/NAIP_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Montana State Library open data", + "url": "https://msl.mt.gov/geoinfo/" + }, + "attribution": { + "text": "Montana State Library", + "url": "https://msl.mt.gov/geoinfo/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/naip.json b/src/library/entries/naip.json new file mode 100644 index 000000000..0e4c7462d --- /dev/null +++ b/src/library/entries/naip.json @@ -0,0 +1,47 @@ +{ + "id": "naip", + "kind": "tiles", + "family": "imagery", + "title": "NAIP — National Agriculture Imagery Program", + "provider": "USDA FSA / USGS", + "description": "Aerial orthophotos: Conterminous US, each state every 2–3 years.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-125, 24, -66.9, 49.4] + }, + "source": { + "format": "bbox", + "url": "https://imagery.nationalmap.gov/arcgis/rest/services/USGSNAIPImagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (30 cm in some states)", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2003–present", + "updateCycle": "2–3 year state cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public Domain (U.S. Government work)", + "url": "https://naip-usdaonline.hub.arcgis.com/" + }, + "attribution": { + "text": "USDA NAIP", + "url": "https://naip-usdaonline.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nasa-black-marble.json b/src/library/entries/nasa-black-marble.json new file mode 100644 index 000000000..5abaefa24 --- /dev/null +++ b/src/library/entries/nasa-black-marble.json @@ -0,0 +1,30 @@ +{ + "id": "nasa-black-marble", + "kind": "tiles", + "family": "imagery", + "title": "Earth at night (NASA Black Marble 2016)", + "provider": "NASA GIBS", + "description": "VIIRS night lights composite: where people light the planet after dark. Drapes on globe maps and on Google 3D tiles.", + "icon": "☾", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 8 + }, + "display": { "opacity": 0.85 }, + "licence": { + "name": "NASA data: U.S. public domain; no endorsement implied", + "url": "https://www.earthdata.nasa.gov/engage/open-data-services-software-policies" + }, + "attribution": { + "text": "NASA Earth Observatory / GIBS — VIIRS Black Marble", + "url": "https://earthobservatory.nasa.gov/features/NightLights" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/nasa-eonet-events.json b/src/library/entries/nasa-eonet-events.json new file mode 100644 index 000000000..b5e747d80 --- /dev/null +++ b/src/library/entries/nasa-eonet-events.json @@ -0,0 +1,58 @@ +{ + "id": "nasa-eonet-events", + "kind": "dataset", + "family": "events", + "title": "NASA EONET open natural events", + "provider": "NASA EONET", + "description": "Wildfires, severe storms, volcanoes, sea and lake ice and other natural events NASA's Earth Observatory is tracking now.", + "icon": "✦", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://eonet.gsfc.nasa.gov/api/v3/events/geojson", + "query": { + "status": "open", + "limit": "1000" + }, + "fetch": "browser-direct", + "cacheSeconds": 900, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "title", + "fields": ["date", "magnitudeValue"], + "rank": { + "field": "date", + "order": "desc" + }, + "colorBy": { + "field": "categories.0.title", + "colors": { + "Wildfires": "#c98500", + "Severe Storms": "#d55181", + "Sea and Lake Ice": "#3987e5", + "Volcanoes": "#008300" + }, + "labels": { + "Wildfires": "Wildfires", + "Severe Storms": "Severe storms", + "Sea and Lake Ice": "Sea and lake ice", + "Volcanoes": "Volcanoes" + } + } + }, + "licence": { + "name": "NASA data: U.S. public domain; no endorsement implied", + "url": "https://www.nasa.gov/nasa-brand-center/images-and-media/" + }, + "attribution": { + "text": "NASA Earth Observatory Natural Event Tracker (EONET)", + "url": "https://eonet.gsfc.nasa.gov/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/nc-statewide-imagery.json b/src/library/entries/nc-statewide-imagery.json new file mode 100644 index 000000000..d41db21b7 --- /dev/null +++ b/src/library/entries/nc-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "nc-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "North Carolina Statewide Imagery (2023)", + "provider": "NC OneMap", + "description": "Aerial orthophotos: Statewide coverage of North Carolina.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-84.452, 33.6938, -75.4041, 36.6379] + }, + "source": { + "format": "bbox", + "url": "https://services.nconemap.gov/secure/rest/services/Imagery/Orthoimagery_2020_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Statewide 4-year cycle (quarterly regions)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — NC OneMap terms of use", + "url": "https://www.nconemap.gov/pages/terms" + }, + "attribution": { + "text": "NC911, NCCGIA", + "url": "https://www.nconemap.gov/pages/imagery" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nctcog-tx.json b/src/library/entries/nctcog-tx.json new file mode 100644 index 000000000..d4cd59236 --- /dev/null +++ b/src/library/entries/nctcog-tx.json @@ -0,0 +1,48 @@ +{ + "id": "nctcog-tx", + "kind": "tiles", + "family": "imagery", + "title": "North Central Texas (NCTCOG) Ortho", + "provider": "NCTCOG", + "description": "Aerial orthophotos: North Central Texas (NCTCOG) jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.5865, 32.5326, -96.9946, 33.0056] + }, + "source": { + "format": "bbox", + "url": "https://mapitimage.tarrantcounty.com/arcgis/rest/services/Aerials/2011_NCTCOG_Ortho/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2011–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.nctcog.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "North Central Texas Council of Governments", + "url": "https://www.nctcog.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nd-statewide-imagery.json b/src/library/entries/nd-statewide-imagery.json new file mode 100644 index 000000000..dfa9e88d1 --- /dev/null +++ b/src/library/entries/nd-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nd-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "North Dakota Statewide Imagery (2013)", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Statewide coverage of North Dakota.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-104.3616, 45.7993, -96.3961, 49.1166] + }, + "source": { + "format": "xyz", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_DES_BaseMap_Cached/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 21, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft – 1 m", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2013–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/new-orleans-rpc-la.json b/src/library/entries/new-orleans-rpc-la.json new file mode 100644 index 000000000..d2fb99732 --- /dev/null +++ b/src/library/entries/new-orleans-rpc-la.json @@ -0,0 +1,48 @@ +{ + "id": "new-orleans-rpc-la", + "kind": "tiles", + "family": "imagery", + "title": "New Orleans Regional Planning Commission Imagery", + "provider": "New Orleans RPC", + "description": "Aerial orthophotos: New Orleans Regional Planning Commission jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-90.0974, 29.8415, -89.9951, 29.9701] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/3OuwdDUqRzQS6PA4/arcgis/rest/services/RPC2022imagery/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.norpc.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)", + "url": "https://www.norpc.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nh-statewide-imagery.json b/src/library/entries/nh-statewide-imagery.json new file mode 100644 index 000000000..da1549ff4 --- /dev/null +++ b/src/library/entries/nh-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nh-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New Hampshire Statewide Imagery (2022)", + "provider": "NH GRANIT", + "description": "Aerial orthophotos: Statewide coverage of New Hampshire.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-72.6173, 42.6862, -70.5548, 45.3128] + }, + "source": { + "format": "bbox", + "url": "https://nhgeodata.unh.edu/image/rest/services/ImageServices/NH_2021_2022_6in_RGB/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — NH GRANIT terms", + "url": "https://www.granit.unh.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "NH GRANIT, University of New Hampshire", + "url": "https://www.granit.unh.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nj-statewide-imagery.json b/src/library/entries/nj-statewide-imagery.json new file mode 100644 index 000000000..098aad738 --- /dev/null +++ b/src/library/entries/nj-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nj-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New Jersey Statewide Imagery (2015)", + "provider": "NJ Office of GIS (NJOGIS)", + "description": "Aerial orthophotos: Statewide coverage of New Jersey.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-75.56, 38.93, -73.89, 41.36] + }, + "source": { + "format": "xyz", + "url": "https://maps.nj.gov/arcgis/rest/services/Basemap/Orthos_Natural_2015_NJ_WM/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — State of New Jersey provides data 'as is'", + "url": "https://njgin.nj.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by", + "url": "https://registry.opendata.aws/nj-imagery/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/noaa-tide-stations.json b/src/library/entries/noaa-tide-stations.json new file mode 100644 index 000000000..f195724ae --- /dev/null +++ b/src/library/entries/noaa-tide-stations.json @@ -0,0 +1,37 @@ +{ + "id": "noaa-tide-stations", + "kind": "dataset", + "family": "environment", + "title": "US tide stations (NOAA CO-OPS)", + "provider": "NOAA CO-OPS", + "description": "About 300 NOAA water-level stations on US coasts, the Great Lakes and Pacific islands, with their tide type.", + "icon": "≈", + "maintainer": "github:asturksever", + "coverage": { "countries": ["US"], "bbox": [-180, -15, 180, 72] }, + "source": { + "format": "json", + "url": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json", + "query": { "type": "waterlevels" }, + "records": "stations", + "columns": { "lat": "lat", "lon": "lng" }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxBytes": 4194304, + "maxFeatures": 1000 + }, + "display": { + "color": "#63b3ed", + "label": "name", + "fields": ["state", "tideType"] + }, + "licence": { + "name": "NOAA data: U.S. public domain", + "url": "https://tidesandcurrents.noaa.gov/disclaimers.html" + }, + "attribution": { + "text": "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)", + "url": "https://tidesandcurrents.noaa.gov/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/nrw-dop.json b/src/library/entries/nrw-dop.json new file mode 100644 index 000000000..cb5c07da6 --- /dev/null +++ b/src/library/entries/nrw-dop.json @@ -0,0 +1,48 @@ +{ + "id": "nrw-dop", + "kind": "tiles", + "family": "imagery", + "title": "Digitale Orthophotos NRW (DOP 10 cm)", + "provider": "Geobasis NRW", + "description": "Aerial orthophotos: 100% of North Rhine-Westphalia (~34,100 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["DE"], + "bbox": [5.86, 50.32, 9.46, 52.53] + }, + "source": { + "format": "bbox", + "url": "https://www.wms.nrw.de/geobasis/wms_nw_dop?service=WMS&request=GetMap&version=1.3.0&layers=nw_dop_rgb&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial per zone (rolling)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Datenlizenz Deutschland – Zero (public-domain-like)", + "url": "https://www.govdata.de/dl-de/zero-2-0" + }, + "attribution": { + "text": "Geobasis NRW (dl-de/zero-2-0)", + "url": "https://www.opengeodata.nrw.de/produkte/geobasis/lusat/akt/dop/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nsw-imagery.json b/src/library/entries/nsw-imagery.json new file mode 100644 index 000000000..64a7fb94f --- /dev/null +++ b/src/library/entries/nsw-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "nsw-imagery", + "kind": "tiles", + "family": "imagery", + "title": "NSW Imagery (Six Maps)", + "provider": "Spatial Services NSW (DCS)", + "description": "Aerial orthophotos: New South Wales (~801,000 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["AU"], + "bbox": [140.9, -37.6, 153.7, -28.1] + }, + "source": { + "format": "xyz", + "url": "https://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Imagery/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 4, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "7.5 cm (metro) – 50 cm", + "resolutionM": 0.075, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling (metro yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.spatial.nsw.gov.au/products_and_services/web_services" + }, + "attribution": { + "text": "© Spatial Services NSW", + "url": "https://portal.spatial.nsw.gov.au/portal/home/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ntad-national-bridges.json b/src/library/entries/ntad-national-bridges.json new file mode 100644 index 000000000..a3828fedb --- /dev/null +++ b/src/library/entries/ntad-national-bridges.json @@ -0,0 +1,57 @@ +{ + "id": "ntad-national-bridges", + "kind": "dataset", + "family": "infrastructure", + "title": "US bridges and their condition (NBI)", + "provider": "USDOT BTS", + "description": "All of the about 624,000 highway bridges in the National Bridge Inventory, coloured by condition. Loads the bridges in view below 15 km.", + "icon": "⌒", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US", "PR"] + }, + "source": { + "format": "arcgis", + "url": "https://services.arcgis.com/xOi1kZaI0eWDREZv/arcgis/rest/services/NTAD_National_Bridge_Inventory/FeatureServer/0", + "query": { + "outFields": "BRIDGE_CONDITION,FACILITY_CARRIED_007,FEATURES_DESC_006A,YEAR_BUILT_027,ADT_029" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 15000, + "sample": [-80.02, 40.43, -79.98, 40.46] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "FACILITY_CARRIED_007", + "fields": ["BRIDGE_CONDITION", "YEAR_BUILT_027"], + "colorBy": { + "field": "BRIDGE_CONDITION", + "colors": { + "G": "#0ca30c", + "F": "#fab219", + "P": "#d03b3b" + }, + "labels": { + "G": "Good", + "F": "Fair", + "P": "Poor" + } + } + }, + "licence": { + "name": "U.S. government work: unrestricted public use", + "url": "https://www.bts.gov/ntad" + }, + "attribution": { + "text": "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory", + "url": "https://www.bts.gov/ntad" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/nwa-benton-washington-ar.json b/src/library/entries/nwa-benton-washington-ar.json new file mode 100644 index 000000000..7ddaa5e21 --- /dev/null +++ b/src/library/entries/nwa-benton-washington-ar.json @@ -0,0 +1,48 @@ +{ + "id": "nwa-benton-washington-ar", + "kind": "tiles", + "family": "imagery", + "title": "Northwest Arkansas (Benton & Washington County) Imagery", + "provider": "Northwest Arkansas regional GIS", + "description": "Aerial orthophotos: Northwest Arkansas (Benton & Washington County) jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-94.6282, 35.7407, -93.7939, 36.5179] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/G4bYaaas92zuKaUR/arcgis/rest/services/BentWashImg2024Pack/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.nwarpc.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "NWARPC; Washington County; Benton County", + "url": "https://www.nwarpc.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nws-active-alerts.json b/src/library/entries/nws-active-alerts.json new file mode 100644 index 000000000..fce1bc393 --- /dev/null +++ b/src/library/entries/nws-active-alerts.json @@ -0,0 +1,56 @@ +{ + "id": "nws-active-alerts", + "kind": "dataset", + "family": "weather", + "title": "US weather alerts with drawn areas (NWS)", + "provider": "NWS", + "description": "Active National Weather Service alerts that carry their own polygon, such as storm-based warnings. Zone-wide alerts have no drawn area and are left out.", + "icon": "⚠", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "geojson", + "url": "https://api.weather.gov/alerts/active", + "fetch": "browser-direct", + "cacheSeconds": 300, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "event", + "fields": ["severity", "areaDesc"], + "rank": { + "field": "onset", + "order": "desc" + }, + "colorBy": { + "field": "severity", + "colors": { + "Extreme": "#d03b3b", + "Severe": "#ec835a", + "Moderate": "#fab219", + "Minor": "#0ca30c" + }, + "labels": { + "Extreme": "Extreme", + "Severe": "Severe", + "Moderate": "Moderate", + "Minor": "Minor" + } + } + }, + "licence": { + "name": "NWS data: U.S. public domain", + "url": "https://www.weather.gov/disclaimer" + }, + "attribution": { + "text": "NOAA National Weather Service", + "url": "https://www.weather.gov/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/ny-statewide-imagery.json b/src/library/entries/ny-statewide-imagery.json new file mode 100644 index 000000000..29afcb556 --- /dev/null +++ b/src/library/entries/ny-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ny-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New York Statewide Imagery (2019)", + "provider": "NYS GIS Program Office (ITS)", + "description": "Aerial orthophotos: Statewide coverage of New York.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-80.1606, 40.2704, -71.2889, 45.1325] + }, + "source": { + "format": "xyz", + "url": "https://orthos.its.ny.gov/arcgis/rest/services/wms/NAIP_2019/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in – 1 ft", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Statewide 3-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — NYS open data", + "url": "https://data.gis.ny.gov/" + }, + "attribution": { + "text": "USDA; NYS ITS Geospatial Services", + "url": "https://data.gis.ny.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/oakland-county-mi.json b/src/library/entries/oakland-county-mi.json new file mode 100644 index 000000000..3e0507562 --- /dev/null +++ b/src/library/entries/oakland-county-mi.json @@ -0,0 +1,48 @@ +{ + "id": "oakland-county-mi", + "kind": "tiles", + "family": "imagery", + "title": "Oakland County, Michigan Ortho Imagery", + "provider": "Oakland County GIS", + "description": "Aerial orthophotos: Oakland County, Michigan jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.7199, 42.4077, -83.0521, 42.9141] + }, + "source": { + "format": "bbox", + "url": "https://gisservices.oakgov.com/arcgis/rest/services/ImageServices/EnterpriseOrthoTC2025ImageService/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://oakgov.maps.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Oakland County GIS", + "url": "https://oakgov.maps.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/oh-statewide-imagery.json b/src/library/entries/oh-statewide-imagery.json new file mode 100644 index 000000000..48ccf2484 --- /dev/null +++ b/src/library/entries/oh-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "oh-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Ohio Statewide Imagery", + "provider": "Ohio OGRIP (OSIP)", + "description": "Aerial orthophotos: Statewide coverage of Ohio.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-84.9418, 38.3739, -80.4714, 42.0065] + }, + "source": { + "format": "xyz", + "url": "https://maps.ohio.gov/image/rest/services/osip_most_current_cache/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling statewide (OSIP)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Ohio open data", + "url": "https://maps.ohio.gov/" + }, + "attribution": { + "text": "Ohio Geographically Referenced Information Program (OGRIP)", + "url": "https://maps.ohio.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/openseamap-seamarks.json b/src/library/entries/openseamap-seamarks.json new file mode 100644 index 000000000..5512aaec0 --- /dev/null +++ b/src/library/entries/openseamap-seamarks.json @@ -0,0 +1,30 @@ +{ + "id": "openseamap-seamarks", + "kind": "tiles", + "family": "infrastructure", + "title": "Seamarks (OpenSeaMap)", + "provider": "OpenSeaMap", + "description": "Buoys, beacons, lights and harbour marks from OpenSeaMap, drawn over any map. Useful with AIS vessels.", + "icon": "⚓", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18 + }, + "display": { "opacity": 1 }, + "licence": { + "name": "CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)", + "url": "https://openseamap.org/index.php?id=imprint&L=1" + }, + "attribution": { + "text": "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors", + "url": "https://openseamap.org/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/or-statewide-imagery.json b/src/library/entries/or-statewide-imagery.json new file mode 100644 index 000000000..ed6cde3d4 --- /dev/null +++ b/src/library/entries/or-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "or-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Oregon Statewide Imagery (2000)", + "provider": "Oregon OSIP (DAS GEO)", + "description": "Aerial orthophotos: Statewide coverage of Oregon.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-124.8956, 41.7968, -116.3087, 46.4332] + }, + "source": { + "format": "bbox", + "url": "https://imagery.oregonexplorer.info/arcgis/rest/services/NAIP_2000/NAIP_2000_SL/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2000–present", + "updateCycle": "OSIP cycles (~3 years)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Oregon open data", + "url": "https://www.oregon.gov/geo/" + }, + "attribution": { + "text": "Oregon, GEO, DAS", + "url": "https://www.oregon.gov/geo/Pages/imagery.aspx" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pa-statewide-imagery.json b/src/library/entries/pa-statewide-imagery.json new file mode 100644 index 000000000..ffe6d0018 --- /dev/null +++ b/src/library/entries/pa-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "pa-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Pennsylvania Statewide Imagery", + "provider": "PASDA / PAMAP (Penn State)", + "description": "Aerial orthophotos: Statewide coverage of Pennsylvania.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-80.52, 39.72, -74.69, 42.27] + }, + "source": { + "format": "bbox", + "url": "https://imagery.pasda.psu.edu/arcgis/rest/services/pasda/Aerials0306StateWide/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft – 2 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "PAMAP multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — PASDA open data", + "url": "https://www.pasda.psu.edu/" + }, + "attribution": { + "text": "PASDA / PAMAP, Commonwealth of Pennsylvania", + "url": "https://www.pasda.psu.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pdok-aerial-netherlands.json b/src/library/entries/pdok-aerial-netherlands.json new file mode 100644 index 000000000..40401f241 --- /dev/null +++ b/src/library/entries/pdok-aerial-netherlands.json @@ -0,0 +1,48 @@ +{ + "id": "pdok-aerial-netherlands", + "kind": "tiles", + "family": "imagery", + "title": "Netherlands aerial photos (PDOK)", + "provider": "PDOK", + "description": "The latest high-resolution aerial photography of the whole Netherlands, from Beeldmateriaal Nederland.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NL"], + "bbox": [3.2, 50.7, 7.3, 53.6] + }, + "source": { + "format": "xyz", + "url": "https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0/Actueel_orthoHR/EPSG:3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "7.5–8 cm", + "resolutionM": 0.08, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.pdok.nl/introductie/-/article/luchtfoto-pdok" + }, + "attribution": { + "text": "Beeldmateriaal Nederland via PDOK", + "url": "https://www.pdok.nl/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pl-ortofotomapa.json b/src/library/entries/pl-ortofotomapa.json new file mode 100644 index 000000000..8ca6f3426 --- /dev/null +++ b/src/library/entries/pl-ortofotomapa.json @@ -0,0 +1,48 @@ +{ + "id": "pl-ortofotomapa", + "kind": "tiles", + "family": "imagery", + "title": "Ortofotomapa Polski (GUGiK)", + "provider": "GUGiK / geoportal.gov.pl", + "description": "Aerial orthophotos: 100% of Poland (rolling; cities at 5 cm).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["PL"], + "bbox": [14.12, 49, 24.15, 54.84] + }, + "source": { + "format": "bbox", + "url": "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution?service=WMS&request=GetMap&version=1.3.0&layers=Raster&styles=default&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "5–25 cm", + "resolutionM": 0.05, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Rolling (1–3 years per zone)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Polish open geodata (free reuse, Prawo geodezyjne art. 40a)", + "url": "https://www.geoportal.gov.pl/" + }, + "attribution": { + "text": "© GUGiK / geoportal.gov.pl", + "url": "https://mapy.geoportal.gov.pl/imap/Imgp_2.html" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pnoa-ma.json b/src/library/entries/pnoa-ma.json new file mode 100644 index 000000000..5ee8b2d6d --- /dev/null +++ b/src/library/entries/pnoa-ma.json @@ -0,0 +1,48 @@ +{ + "id": "pnoa-ma", + "kind": "tiles", + "family": "imagery", + "title": "PNOA Máxima Actualidad", + "provider": "IGN España / CNIG", + "description": "Aerial orthophotos: Mainland Spain and Balearic Islands (Canary Islands served by a separate PNOA service).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["ES"], + "bbox": [-9.3, 36, 3.35, 43.8] + }, + "source": { + "format": "bbox", + "url": "https://www.ign.es/wms-inspire/pnoa-ma?service=WMS&request=GetMap&version=1.3.0&layers=OI.OrthoimageCoverage&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "15–25 cm", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "2–3 year refresh per zone", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0 (compatible) — Orden FOM/2807/2015", + "url": "https://www.scne.es/productos.html" + }, + "attribution": { + "text": "PNOA © Instituto Geográfico Nacional de España", + "url": "https://centrodedescargas.cnig.es/CentroDescargas/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/portland-metro-or.json b/src/library/entries/portland-metro-or.json new file mode 100644 index 000000000..239f5ec51 --- /dev/null +++ b/src/library/entries/portland-metro-or.json @@ -0,0 +1,48 @@ +{ + "id": "portland-metro-or", + "kind": "tiles", + "family": "imagery", + "title": "Portland Metro (RLIS), Oregon Aerial Photos", + "provider": "Oregon Metro RLIS", + "description": "Aerial orthophotos: Portland Metro (RLIS), Oregon jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-123.1535, 45.234, -122.3675, 45.7026] + }, + "source": { + "format": "xyz", + "url": "https://utility.arcgis.com/usrsvcs/servers/41cd821260944a668ef228f671b3ff46/rest/services/photo/2023aerialphoto/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://rlisdiscovery.oregonmetro.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Oregon Metro RLIS", + "url": "https://rlisdiscovery.oregonmetro.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pt-dgt-ortos.json b/src/library/entries/pt-dgt-ortos.json new file mode 100644 index 000000000..cb3854958 --- /dev/null +++ b/src/library/entries/pt-dgt-ortos.json @@ -0,0 +1,48 @@ +{ + "id": "pt-dgt-ortos", + "kind": "tiles", + "family": "imagery", + "title": "Ortofotos de Portugal Continental (DGT)", + "provider": "Direção-Geral do Território", + "description": "Aerial orthophotos: Portugal Continental.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["PT"], + "bbox": [-9.52, 36.96, -6.19, 42.15] + }, + "source": { + "format": "bbox", + "url": "https://ortos.dgterritorio.gov.pt/wms/ortosat2023?service=WMS&request=GetMap&version=1.3.0&layers=ortoSat2023-CorVerdadeira&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "25–30 cm", + "resolutionM": 0.25, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Multi-year campaigns", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.dgterritorio.gov.pt/" + }, + "attribution": { + "text": "© Direção-Geral do Território", + "url": "https://snig.dgterritorio.gov.pt/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ri-statewide-imagery.json b/src/library/entries/ri-statewide-imagery.json new file mode 100644 index 000000000..e6895458a --- /dev/null +++ b/src/library/entries/ri-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ri-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Rhode Island Statewide Imagery (2019)", + "provider": "RIGIS", + "description": "Aerial orthophotos: Statewide coverage of Rhode Island.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-71.9065, 41.1437, -71.1078, 42.0276] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/S8zZg9pg23JUEexQ/arcgis/rest/services/RI_201811_RGB_3in_web/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3–6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — RIGIS terms", + "url": "https://www.rigis.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "URI EDC, RIGIS", + "url": "https://www.rigis.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/swisstopo-swissimage.json b/src/library/entries/swisstopo-swissimage.json new file mode 100644 index 000000000..48c5c5e21 --- /dev/null +++ b/src/library/entries/swisstopo-swissimage.json @@ -0,0 +1,49 @@ +{ + "id": "swisstopo-swissimage", + "kind": "tiles", + "family": "imagery", + "title": "Switzerland aerial photos (SWISSIMAGE 10 cm)", + "provider": "swisstopo", + "description": "swisstopo's orthophoto mosaic of Switzerland and Liechtenstein: 10 cm in the lowlands and main Alpine valleys, 25 cm over the Alps, renewed every three years.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["CH", "LI"], + "bbox": [5.9, 45.8, 10.5, 47.9] + }, + "source": { + "format": "xyz", + "url": "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "countries", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm (plains) / 25 cm (Alps)", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "3-year rolling refresh", + "orthoQuality": "±0.15 m (1σ) plains / ±0.25 m Alps, DTM-orthorectified", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "swisstopo free geodata (OGD): free use, including commercial, with source credit", + "url": "https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices" + }, + "attribution": { + "text": "©swisstopo — SWISSIMAGE 10 cm", + "url": "https://www.swisstopo.admin.ch/en/orthoimage-swissimage-10" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/tx-statewide-imagery.json b/src/library/entries/tx-statewide-imagery.json new file mode 100644 index 000000000..66e4edc52 --- /dev/null +++ b/src/library/entries/tx-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "tx-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Texas Statewide Imagery (2022)", + "provider": "TxGIO / TNRIS", + "description": "Aerial orthophotos: Statewide coverage of Texas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-106.65, 25.84, -93.51, 36.5] + }, + "source": { + "format": "bbox", + "url": "https://imagery.geographic.texas.gov/server/rest/services/NAIP/NAIP22_NCCIR_60cm/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "NAIP 2-year; StratMap regional", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — TxGIO open data", + "url": "https://geographic.texas.gov/" + }, + "attribution": { + "text": "Texas Geographic Information Office (TxGIO)", + "url": "https://data.geographic.texas.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/union-county-oh.json b/src/library/entries/union-county-oh.json new file mode 100644 index 000000000..7ccf9ff49 --- /dev/null +++ b/src/library/entries/union-county-oh.json @@ -0,0 +1,49 @@ +{ + "id": "union-county-oh", + "kind": "tiles", + "family": "imagery", + "title": "Union County, Ohio Aerial Imagery", + "provider": "Union County GIS", + "description": "Aerial orthophotos: Union County, Ohio jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.6732, 39.9845, -83.0745, 40.5493] + }, + "source": { + "format": "bbox", + "url": "https://www7.co.union.oh.us/unioncountyohio/rest/services/AerialPhoto2023/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://union-county-auditor-unionco.hub.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Union County / City of Marysville GIS", + "url": "https://union-county-auditor-unionco.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/usgs-hydrography.json b/src/library/entries/usgs-hydrography.json new file mode 100644 index 000000000..5a1ff23e4 --- /dev/null +++ b/src/library/entries/usgs-hydrography.json @@ -0,0 +1,35 @@ +{ + "id": "usgs-hydrography", + "kind": "tiles", + "family": "environment", + "title": "US rivers and lakes (USGS)", + "provider": "USGS", + "description": "The National Hydrography Dataset's rivers, streams and water bodies, drawn over any map.", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "xyz", + "url": "https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 16 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "USGS The National Map: U.S. public domain", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USGS The National Map — National Hydrography Dataset", + "url": "https://www.usgs.gov/national-hydrography" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/usgs-topo.json b/src/library/entries/usgs-topo.json new file mode 100644 index 000000000..ed708f6db --- /dev/null +++ b/src/library/entries/usgs-topo.json @@ -0,0 +1,35 @@ +{ + "id": "usgs-topo", + "kind": "tiles", + "family": "imagery", + "title": "US topographic map (USGS)", + "provider": "USGS", + "description": "The National Map's topographic basemap of the United States: contours, roads, places and land cover.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "xyz", + "url": "https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 16 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "USGS The National Map: U.S. public domain", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USGS The National Map", + "url": "https://www.usgs.gov/programs/national-geospatial-program/national-map" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/va-statewide-imagery.json b/src/library/entries/va-statewide-imagery.json new file mode 100644 index 000000000..97a585104 --- /dev/null +++ b/src/library/entries/va-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "va-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Virginia Statewide Imagery", + "provider": "VGIN / VDEM", + "description": "Aerial orthophotos: Statewide coverage of Virginia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.68, 36.54, -75.24, 39.47] + }, + "source": { + "format": "xyz", + "url": "https://vginmaps.vdem.virginia.gov/arcgis/rest/services/VBMP_Imagery/MostRecentImagery_WGS/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft (VBMP)", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "VBMP 3-year statewide cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — VGIN VBMP terms", + "url": "https://vgin.vdem.virginia.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Virginia Geographic Information Network (VGIN)", + "url": "https://vgin.vdem.virginia.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/vlaanderen-omw.json b/src/library/entries/vlaanderen-omw.json new file mode 100644 index 000000000..5b3fa6eb5 --- /dev/null +++ b/src/library/entries/vlaanderen-omw.json @@ -0,0 +1,48 @@ +{ + "id": "vlaanderen-omw", + "kind": "tiles", + "family": "imagery", + "title": "Orthofotomozaïek Vlaanderen (winter)", + "provider": "Digitaal Vlaanderen", + "description": "Aerial orthophotos: Flanders + Brussels (~13,600 km²), flown yearly.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["BE"], + "bbox": [2.54, 50.68, 5.92, 51.51] + }, + "source": { + "format": "bbox", + "url": "https://geo.api.vlaanderen.be/OMWRGBMRVL/wms?service=WMS&request=GetMap&version=1.3.0&layers=Ortho&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm (winter) / 25 cm (summer)", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Vlaanderen model licence for free reuse", + "url": "https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken" + }, + "attribution": { + "text": "© Digitaal Vlaanderen", + "url": "https://download.vlaanderen.be/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/vt-statewide-imagery.json b/src/library/entries/vt-statewide-imagery.json new file mode 100644 index 000000000..54c696580 --- /dev/null +++ b/src/library/entries/vt-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "vt-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Vermont Statewide Imagery", + "provider": "VCGI", + "description": "Aerial orthophotos: Statewide coverage of Vermont.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.5011, 42.6871, -71.4363, 45.0631] + }, + "source": { + "format": "bbox", + "url": "https://maps.vcgi.vermont.gov/arcgis/rest/services/EGC_services/IMG_VCGI_CIR_WM_CACHE/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Statewide roughly every 2 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Vermont open data", + "url": "https://vcgi.vermont.gov/" + }, + "attribution": { + "text": "Vermont Center for Geographic Information (VCGI)", + "url": "https://registry.opendata.aws/vt-opendata/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wallonie-ortho.json b/src/library/entries/wallonie-ortho.json new file mode 100644 index 000000000..820fd79f7 --- /dev/null +++ b/src/library/entries/wallonie-ortho.json @@ -0,0 +1,48 @@ +{ + "id": "wallonie-ortho", + "kind": "tiles", + "family": "imagery", + "title": "Orthophotos de Wallonie", + "provider": "Service public de Wallonie (SPW)", + "description": "Aerial orthophotos: Wallonia (~16,900 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["BE"], + "bbox": [2.84, 49.49, 6.41, 50.85] + }, + "source": { + "format": "bbox", + "url": "https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&request=GetMap&version=1.3.0&layers=0&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "25 cm", + "resolutionM": 0.25, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Conditions ouvertes SPW (attribution)", + "url": "https://geoportail.wallonie.be/catalogue-donnees" + }, + "attribution": { + "text": "© Service public de Wallonie", + "url": "https://geoportail.wallonie.be/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wi-statewide-imagery.json b/src/library/entries/wi-statewide-imagery.json new file mode 100644 index 000000000..2f2bb3e0d --- /dev/null +++ b/src/library/entries/wi-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "wi-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Wisconsin Statewide Imagery (2020)", + "provider": "Wisconsin DOT / WisconsinView", + "description": "Aerial orthophotos: Statewide coverage of Wisconsin.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-93.0454, 42.3829, -86.6018, 47.1427] + }, + "source": { + "format": "bbox", + "url": "https://di-wisdot.img.arcgis.com/arcgis/rest/services/Statewide/WisconsinNAIP20/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — WisconsinView / state open data", + "url": "https://www.wisconsinview.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "WisDOT - NAIP 2020", + "url": "https://www.wisconsinview.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wri-global-power-plants.json b/src/library/entries/wri-global-power-plants.json new file mode 100644 index 000000000..512da80a1 --- /dev/null +++ b/src/library/entries/wri-global-power-plants.json @@ -0,0 +1,65 @@ +{ + "id": "wri-global-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "Global power plants (WRI)", + "provider": "WRI", + "description": "The 10,000 largest of about 35,000 power plants worldwide, with fuel and capacity, from the WRI Global Power Plant Database (v1.3, 2021).", + "icon": "ϟ", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "csv", + "url": "https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxBytes": 16777216, + "maxFeatures": 10000 + }, + "display": { + "color": "#a0aec0", + "label": "name", + "fields": ["primary_fuel", "capacity_mw"], + "rank": { + "field": "capacity_mw", + "order": "desc" + }, + "colorBy": { + "field": "primary_fuel", + "colors": { + "Coal": "#d55181", + "Gas": "#d55181", + "Oil": "#d55181", + "Petcoke": "#d55181", + "Cogeneration": "#d55181", + "Nuclear": "#c98500", + "Hydro": "#3987e5", + "Wind": "#008300", + "Solar": "#008300" + }, + "labels": { + "Coal": "Fossil (coal, gas, oil)", + "Gas": "Fossil (coal, gas, oil)", + "Oil": "Fossil (coal, gas, oil)", + "Petcoke": "Fossil (coal, gas, oil)", + "Cogeneration": "Fossil (coal, gas, oil)", + "Nuclear": "Nuclear", + "Hydro": "Hydro", + "Wind": "Wind and solar", + "Solar": "Wind and solar" + } + } + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://creativecommons.org/licenses/by/4.0/" + }, + "attribution": { + "text": "World Resources Institute, Global Power Plant Database (2021)", + "url": "https://datasets.wri.org/dataset/globalpowerplantdatabase" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/wv-statewide-imagery.json b/src/library/entries/wv-statewide-imagery.json new file mode 100644 index 000000000..f5e9c31ea --- /dev/null +++ b/src/library/entries/wv-statewide-imagery.json @@ -0,0 +1,49 @@ +{ + "id": "wv-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "West Virginia Statewide Imagery", + "provider": "WV GIS Technical Center (WVU)", + "description": "Aerial orthophotos: Statewide coverage of West Virginia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-82.6924, 37.1844, -77.681, 40.6905] + }, + "source": { + "format": "bbox", + "url": "https://services.wvgis.wvu.edu/arcgis/rest/services/Imagery_BaseMaps_EarthCover/wv_imagery_NAIP_2018_60cm/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — WV GIS Technical Center terms", + "url": "https://wvgis.wvu.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "WV GIS Technical Center, West Virginia University", + "url": "https://wvgis.wvu.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/extent.js b/src/library/extent.js new file mode 100644 index 000000000..ca07816e4 --- /dev/null +++ b/src/library/extent.js @@ -0,0 +1,104 @@ +/** + * The area a Data Library layer covers, for "Zoom to layer". Pure: no + * Cesium, so the server, tests and the browser share it. + * + * An extent is `{ west, south, east, north }` in degrees. `east` may exceed + * 180 when the set crosses the antimeridian (Fiji, the Aleutians), so the + * shorter way round is kept rather than a box spanning the whole planet. + */ + +/** The whole world, short of the poles a Web Mercator map cannot show. */ +export const WORLD_EXTENT = Object.freeze({ + west: -180, + south: -85, + east: 180, + north: 85, +}); +/** Smallest box a zoom frames, so a single point is not a ground-level dive. */ +export const MIN_EXTENT_SPAN_DEG = 0.05; + +function visit(coordinates, push) { + if (!Array.isArray(coordinates)) return; + if (typeof coordinates[0] === 'number') { + const [lon, lat] = coordinates; + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lon) <= 180 && + Math.abs(lat) <= 90 + ) + push(lon, lat); + return; + } + for (const child of coordinates) visit(child, push); +} + +/** + * The bounding box of GeoJSON features, taking the shorter way round the + * antimeridian. + * @param {Array<{ geometry?: { coordinates?: unknown } }>} features + * @returns {{ west: number, south: number, east: number, north: number } | null} + */ +export function featureExtent(features) { + let south = Infinity; + let north = -Infinity; + let west = Infinity; + let east = -Infinity; + // The same longitudes shifted to 0–360, for sets that straddle ±180. + let westShifted = Infinity; + let eastShifted = -Infinity; + let count = 0; + for (const feature of features || []) + visit(feature?.geometry?.coordinates, (lon, lat) => { + count += 1; + south = Math.min(south, lat); + north = Math.max(north, lat); + west = Math.min(west, lon); + east = Math.max(east, lon); + const shifted = lon < 0 ? lon + 360 : lon; + westShifted = Math.min(westShifted, shifted); + eastShifted = Math.max(eastShifted, shifted); + }); + if (!count) return null; + // A set that straddles ±180 is narrower in 0–360 terms; `east` then + // exceeds 180 (all-negative longitudes never take this branch). + if (eastShifted - westShifted < east - west) + return { west: westShifted, south, east: eastShifted, north }; + return { west, south, east, north }; +} + +/** A manifest's declared coverage as an extent (the world when global). */ +export function coverageExtent(entry) { + const bbox = entry?.coverage?.bbox; + if (!entry?.coverage?.global && Array.isArray(bbox) && bbox.length === 4) { + const [west, south, east, north] = bbox; + return { west, south, east, north }; + } + return { ...WORLD_EXTENT }; +} + +/** + * Grow a box to at least `minSpan` degrees each way and clamp it to what the + * map can frame. + * @param {{ west: number, south: number, east: number, north: number }} extent + * @param {number} [minSpan] + */ +export function padExtent(extent, minSpan = MIN_EXTENT_SPAN_DEG) { + const grow = (low, high) => { + const missing = Math.max(0, minSpan - (high - low)) / 2; + return [low - missing, high + missing]; + }; + const [west, east] = grow(extent.west, extent.east); + const [south, north] = grow(extent.south, extent.north); + return { + west, + east, + south: Math.max(WORLD_EXTENT.south, south), + north: Math.min(WORLD_EXTENT.north, north), + }; +} + +/** Whether a box is so wide the zoom should show the whole globe instead. */ +export function isWorldScale(extent) { + return extent.east - extent.west >= 300; +} diff --git a/src/library/extent.test.mjs b/src/library/extent.test.mjs new file mode 100644 index 000000000..db41cad51 --- /dev/null +++ b/src/library/extent.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + WORLD_EXTENT, + coverageExtent, + featureExtent, + isWorldScale, + padExtent, +} from './extent.js'; + +const point = (lon, lat) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, +}); + +test('the extent covers every coordinate, nested geometries included', () => { + assert.deepEqual( + featureExtent([ + point(-122, 37), + { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-80, 25], + [-79, 25], + [-79, 26], + [-80, 25], + ], + ], + }, + }, + { geometry: null }, + point(500, 0), + ]), + { west: -122, south: 25, east: -79, north: 37 }, + ); + assert.equal(featureExtent([]), null); + assert.equal(featureExtent([{ geometry: { coordinates: [NaN, 1] } }]), null); +}); + +test('a set straddling the antimeridian takes the short way round', () => { + assert.deepEqual(featureExtent([point(178, -17), point(-179, -16)]), { + west: 178, + south: -17, + east: 181, + north: -16, + }); + assert.deepEqual(featureExtent([point(-170, 0), point(-160, 1)]), { + west: -170, + south: 0, + east: -160, + north: 1, + }); +}); + +test('coverage falls back to the world, a point grows to a frameable box', () => { + assert.deepEqual( + coverageExtent({ coverage: { global: true } }), + WORLD_EXTENT, + ); + assert.deepEqual(coverageExtent({ coverage: { bbox: [1, 2, 3, 4] } }), { + west: 1, + south: 2, + east: 3, + north: 4, + }); + const padded = padExtent({ west: 10, south: 20, east: 10, north: 20 }); + assert.ok(Math.abs(padded.east - padded.west - 0.05) < 1e-9); + assert.equal( + padExtent({ west: 0, south: -89, east: 1, north: 89 }).north, + 85, + ); + assert.equal(isWorldScale(WORLD_EXTENT), true); + assert.equal(isWorldScale({ west: -125, east: -66 }), false); +}); diff --git a/src/library/formats.js b/src/library/formats.js new file mode 100644 index 000000000..e603a1276 --- /dev/null +++ b/src/library/formats.js @@ -0,0 +1,453 @@ +/** + * Turn what a Data Library upstream returns into bounded GeoJSON features. + * + * Shared by the browser (browser-direct entries) and the server's generic + * proxy (proxied entries), so a dataset looks the same whichever way it is + * fetched. Pure: no fetch, no DOM. Callers own transport, caps on bytes and + * cancellation; this module owns shape, coordinate sanity and feature caps. + */ + +const GEOMETRY_TYPES = new Set([ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', +]); +const LAT_COLUMNS = ['lat', 'latitude', 'y']; +const LON_COLUMNS = ['lon', 'lng', 'long', 'longitude', 'x']; +const MAX_DETAIL_CHARS = 80; +const MAX_NAME_CHARS = 120; + +/** Error for an upstream body that is not the declared format. */ +export class LibraryFormatError extends Error { + constructor(message) { + super(message); + this.name = 'LibraryFormatError'; + this.code = 'malformed'; + } +} + +const validPosition = (position) => + Array.isArray(position) && + position.length >= 2 && + Number.isFinite(position[0]) && + Number.isFinite(position[1]) && + Math.abs(position[0]) <= 180 && + Math.abs(position[1]) <= 90; + +function validCoordinates(type, coordinates, depth = 0) { + if (!Array.isArray(coordinates) || depth > 4) return false; + if (type === 'Point') return validPosition(coordinates); + if (type === 'MultiPoint' || type === 'LineString') + return coordinates.length > 0 && coordinates.every(validPosition); + if (type === 'MultiLineString' || type === 'Polygon') + return ( + coordinates.length > 0 && + coordinates.every((ring) => validCoordinates('LineString', ring)) + ); + if (type === 'MultiPolygon') + return ( + coordinates.length > 0 && + coordinates.every((polygon) => validCoordinates('Polygon', polygon)) + ); + return false; +} + +/** Only real, in-range geometries survive; everything else is dropped. */ +export function validLibraryGeometry(geometry) { + return ( + Boolean(geometry) && + GEOMETRY_TYPES.has(geometry.type) && + validCoordinates(geometry.type, geometry.coordinates) + ); +} + +function cleanValue(value, max) { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return ''; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (!text || text === 'null' || text === 'undefined') return ''; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +function readPath(properties, path) { + let value = properties; + for (const part of String(path).split('.')) { + if (!value || typeof value !== 'object') return undefined; + value = value[part]; + } + return value; +} + +/** + * Give a feature the presentation fields the map reads: `name` for its label + * and `__gevDetails` (up to two lines) for its card. Upstream properties are + * kept as they are; feed data is never translated. + * @param {object} properties + * @param {{ label?: string, fields?: string[] }} display + * @returns {object} + */ +export function presentLibraryProperties(properties, display = {}) { + const source = + properties && typeof properties === 'object' && !Array.isArray(properties) + ? properties + : {}; + const result = { ...source }; + const label = display.label + ? cleanValue(readPath(source, display.label), MAX_NAME_CHARS) + : cleanValue(source.name ?? source.title, MAX_NAME_CHARS); + if (label) result.name = label; + else delete result.name; + const details = []; + for (const field of display.fields || []) { + const value = cleanValue(readPath(source, field), MAX_DETAIL_CHARS); + if (value) details.push(`${field.split('.').pop()}: ${value}`); + if (details.length === 2) break; + } + result.__gevDetails = details; + // The value a colorBy legend groups on, flattened so nested fields + // (EONET's categories.0.title) colour the same in every renderer. + if (display.colorBy) + result.__gevColorKey = cleanValue( + readPath(source, display.colorBy.field), + MAX_NAME_CHARS, + ); + return result; +} + +/** Highest importance a manifest rank can add (below the named/unnamed gap). */ +export const LIBRARY_RANK_MAX = 600; + +function rankValue(properties, field) { + const raw = readPath(properties, field); + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (typeof raw !== 'string' || !raw.trim()) return null; + const number = Number(raw); + if (Number.isFinite(number)) return number; + const time = Date.parse(raw); + return Number.isFinite(time) ? time : null; +} + +/** + * Order candidates by the manifest's importance field, most important first, + * so a capped dataset keeps its top features and the map's level of detail + * can prefer them. Features without a value keep their order, last. + * @param {object[]} features + * @param {{ field: string, order?: 'desc'|'asc' }} rank + * @returns {Array<{ feature: object, value: number|null }>} + */ +function rankCandidates(features, rank) { + const sign = rank.order === 'asc' ? 1 : -1; + return features + .map((feature, index) => ({ + feature, + index, + value: rankValue(feature?.properties, rank.field), + })) + .sort((a, b) => { + if (a.value === null || b.value === null) + return (a.value === null) - (b.value === null) || a.index - b.index; + return sign * (a.value - b.value) || a.index - b.index; + }); +} + +/** + * Bound and present a list of candidate features. + * @param {unknown[]} candidates + * @param {{ maxFeatures: number, display?: object }} options + * @returns {{ features: object[], dropped: number, truncated: boolean }} + */ +export function boundLibraryFeatures(candidates, { maxFeatures, display }) { + const valid = []; + let dropped = 0; + for (const candidate of candidates) { + if (validLibraryGeometry(candidate?.geometry)) valid.push(candidate); + else dropped += 1; + } + const ranked = display?.rank + ? rankCandidates(valid, display.rank) + : valid.map((feature) => ({ feature, value: null })); + const kept = ranked.slice(0, maxFeatures); + const scored = kept.filter(({ value }) => value !== null).length; + const features = kept.map(({ feature, value }, position) => { + const properties = presentLibraryProperties(feature.properties, display); + // Importance for the map's level of detail: the top feature earns the + // most, the least important ranked feature none. + if (display?.rank) + properties.__gevRank = + value === null || scored < 2 + ? 0 + : Math.round(LIBRARY_RANK_MAX * (1 - position / (scored - 1))); + return { type: 'Feature', geometry: feature.geometry, properties }; + }); + return { features, dropped, truncated: ranked.length > maxFeatures }; +} + +function geojsonCandidates(payload) { + if (Array.isArray(payload)) return payload; + if (payload?.type === 'FeatureCollection' && Array.isArray(payload.features)) + return payload.features; + if (payload?.type === 'Feature') return [payload]; + throw new LibraryFormatError('not a GeoJSON FeatureCollection'); +} + +/** Parse JSON text, reporting a format error rather than a SyntaxError. */ +export function parseLibraryJson(text) { + try { + return JSON.parse(text); + } catch { + throw new LibraryFormatError('response is not JSON'); + } +} + +/** + * Split CSV text into rows (RFC 4180: quoted fields, doubled quotes, CRLF). + * @param {string} text + * @returns {string[][]} + */ +export function parseCsvRows(text) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + const input = String(text).replace(/^/, ''); + for (let index = 0; index < input.length; index++) { + const char = input[index]; + if (quoted) { + if (char === '"') { + if (input[index + 1] === '"') { + field += '"'; + index += 1; + } else quoted = false; + } else field += char; + continue; + } + if (char === '"' && field === '') quoted = true; + else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && input[index + 1] === '\n') index += 1; + row.push(field); + if (row.some((value) => value !== '')) rows.push(row); + row = []; + field = ''; + } else field += char; + } + if (quoted) throw new LibraryFormatError('unterminated quoted CSV field'); + row.push(field); + if (row.some((value) => value !== '')) rows.push(row); + return rows; +} + +function csvCandidates(text, columns) { + const rows = parseCsvRows(text); + if (rows.length < 1) throw new LibraryFormatError('CSV has no header'); + const header = rows[0].map((name) => name.trim()); + const lower = header.map((name) => name.toLowerCase()); + const find = (explicit, fallbacks) => + explicit + ? header.indexOf(explicit) + : lower.findIndex((name) => fallbacks.includes(name)); + const latIndex = find(columns?.lat, LAT_COLUMNS); + const lonIndex = find(columns?.lon, LON_COLUMNS); + if (latIndex < 0 || lonIndex < 0) + throw new LibraryFormatError('CSV has no latitude/longitude columns'); + return rows.slice(1).map((values) => { + const properties = {}; + header.forEach((name, index) => { + if (index !== latIndex && index !== lonIndex && name) + properties[name] = values[index] ?? ''; + }); + const lat = Number.parseFloat(values[latIndex]); + const lon = Number.parseFloat(values[lonIndex]); + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, + }; + }); +} + +/** + * Point features from a JSON array of records (an API that answers + * `{ "stations": [{ "lat": …, "lng": … }] }` rather than GeoJSON). + * @param {unknown} payload Parsed JSON. + * @param {{ records?: string, columns?: { lat: string, lon: string } }} source + */ +function jsonCandidates(payload, { records, columns } = {}) { + const list = records ? readPath(payload, records) : payload; + if (!Array.isArray(list)) + throw new LibraryFormatError('JSON has no record list at source.records'); + const pick = (record, explicit, fallbacks) => { + if (explicit) return explicit; + return Object.keys(record).find((key) => + fallbacks.includes(key.toLowerCase()), + ); + }; + return list.map((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) + return { geometry: null }; + const latKey = pick(record, columns?.lat, LAT_COLUMNS); + const lonKey = pick(record, columns?.lon, LON_COLUMNS); + const properties = {}; + for (const [key, value] of Object.entries(record)) + if (key !== latKey && key !== lonKey) properties[key] = value; + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + Number.parseFloat(record[lonKey]), + Number.parseFloat(record[latKey]), + ], + }, + properties, + }; + }); +} + +/** + * Parse one upstream body in a declared vector format. + * @param {'geojson'|'geojsonl'|'csv'|'json'|'arcgis'} format + * @param {string} text + * @param {{ columns?: { lat: string, lon: string }, records?: string }} [source] + * @returns {{ candidates: unknown[], exceededTransferLimit: boolean }} + */ +export function parseLibraryBody(format, text, source = {}) { + if (format === 'csv') + return { + candidates: csvCandidates(text, source.columns), + exceededTransferLimit: false, + }; + if (format === 'geojsonl') { + const candidates = String(text) + .split('\n') + .filter((line) => line.trim()) + .map(parseLibraryJson); + return { candidates, exceededTransferLimit: false }; + } + const payload = parseLibraryJson(text); + if (format === 'json') + return { + candidates: jsonCandidates(payload, source), + exceededTransferLimit: false, + }; + if (format === 'arcgis' && payload?.error) + throw new LibraryFormatError('ArcGIS service returned an error'); + return { + candidates: geojsonCandidates(payload), + exceededTransferLimit: + format === 'arcgis' && + (payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true), + }; +} + +/** + * The ArcGIS FeatureServer query URL for one page. The manifest's query may + * set `where` and `outFields`; geometry is always WGS84 GeoJSON. + * @param {string} layerUrl `.../FeatureServer/` or `.../MapServer/`. + * @param {Record|undefined} query + * @param {number} offset + * @param {number} pageSize + * @returns {string} + */ +export function arcgisPageUrl( + layerUrl, + query = {}, + offset = 0, + pageSize = 2000, + bbox = null, +) { + const url = new URL(`${String(layerUrl).replace(/\/+$/, '')}/query`); + const params = new URLSearchParams({ + where: '1=1', + outFields: '*', + ...query, + outSR: '4326', + f: 'geojson', + resultOffset: String(offset), + resultRecordCount: String(pageSize), + // A view-bounded load asks for what intersects the camera's box only. + ...(bbox + ? { + geometry: bbox.map((value) => value.toFixed(6)).join(','), + geometryType: 'esriGeometryEnvelope', + inSR: '4326', + spatialRel: 'esriSpatialRelIntersects', + } + : {}), + }); + url.search = params.toString(); + return url.href; +} + +/** + * The URL to request for a non-ArcGIS dataset: the manifest URL plus its + * declared query parameters. + * @param {{ url: string, query?: Record }} source + * @returns {string} + */ +export function datasetRequestUrl(source) { + const url = new URL(source.url); + for (const [name, value] of Object.entries(source.query || {})) + url.searchParams.set(name, value); + return url.href; +} + +/** + * Fetch and normalize a whole dataset through a caller-supplied page reader, + * following ArcGIS paging. The reader returns one page's text and owns byte + * caps, redirects and cancellation. + * @param {object} entry A validated library entry (kind dataset). + * @param {(url: string, budget: { remainingBytes: number }) => Promise} readPage + * @param {{ bbox?: number[]|null }} [options] A view-bounded ArcGIS load's + * `[west, south, east, north]`. + * @returns {Promise<{ type: 'FeatureCollection', features: object[], dropped: number, truncated: boolean }>} + */ +export async function loadLibraryDataset( + entry, + readPage, + { bbox = null } = {}, +) { + const { source, display } = entry; + const budget = { remainingBytes: source.maxBytes }; + const candidates = []; + let truncated = false; + if (source.format === 'arcgis') { + for (let page = 0; page < source.maxPages; page++) { + const url = arcgisPageUrl( + source.url, + source.query, + candidates.length, + 2000, + bbox, + ); + const parsed = parseLibraryBody('arcgis', await readPage(url, budget)); + candidates.push(...parsed.candidates); + if (!parsed.exceededTransferLimit || !parsed.candidates.length) break; + if (candidates.length >= source.maxFeatures) break; + if (page === source.maxPages - 1) truncated = true; + } + } else { + const parsed = parseLibraryBody( + source.format, + await readPage(datasetRequestUrl(source), budget), + source, + ); + candidates.push(...parsed.candidates); + } + const bounded = boundLibraryFeatures(candidates, { + maxFeatures: source.maxFeatures, + display, + }); + return { + type: 'FeatureCollection', + features: bounded.features, + dropped: bounded.dropped, + truncated: truncated || bounded.truncated, + }; +} diff --git a/src/library/formats.test.mjs b/src/library/formats.test.mjs new file mode 100644 index 000000000..4337ba818 --- /dev/null +++ b/src/library/formats.test.mjs @@ -0,0 +1,282 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + arcgisPageUrl, + boundLibraryFeatures, + loadLibraryDataset, + parseCsvRows, + parseLibraryBody, + presentLibraryProperties, + validLibraryGeometry, +} from './formats.js'; +import { validateLibraryManifest } from './manifest.js'; + +const point = (lon, lat, properties = {}) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, +}); + +function entry(source, display = {}) { + const { + ok, + errors, + entry: value, + } = validateLibraryManifest({ + id: 'fixture-entry', + kind: 'dataset', + family: 'events', + title: 'Fixture', + provider: 'Fixture', + description: 'Fixture dataset.', + maintainer: 'test', + coverage: { global: true }, + source: { fetch: 'proxied', url: 'https://data.example.org/x', ...source }, + display, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', + }); + assert.deepEqual(errors, []); + assert.ok(ok); + return value; +} + +test('only real, in-range geometries survive', () => { + assert.ok(validLibraryGeometry({ type: 'Point', coordinates: [1, 2] })); + assert.equal( + validLibraryGeometry({ type: 'Point', coordinates: [181, 2] }), + false, + ); + assert.equal( + validLibraryGeometry({ type: 'Point', coordinates: [NaN, 2] }), + false, + ); + assert.equal( + validLibraryGeometry({ type: 'GeometryCollection', geometries: [] }), + false, + ); + assert.ok( + validLibraryGeometry({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }), + ); + assert.equal( + validLibraryGeometry({ type: 'Polygon', coordinates: [[]] }), + false, + ); +}); + +test('features are capped and counted, never silently passed whole', () => { + const candidates = [point(0, 0), point(500, 0), point(1, 1), point(2, 2)]; + const result = boundLibraryFeatures(candidates, { maxFeatures: 2 }); + assert.equal(result.features.length, 2); + assert.equal(result.dropped, 1); + assert.equal(result.truncated, true); +}); + +test('the manifest label and fields become the card copy; values stay as sent', () => { + const properties = presentLibraryProperties( + { + Plant_Name: ' Grand Coulee ', + PrimSource: 'hydroelectric', + Total_MW: 6809, + nested: { a: 1 }, + }, + { label: 'Plant_Name', fields: ['PrimSource', 'Total_MW', 'nested'] }, + ); + assert.equal(properties.name, 'Grand Coulee'); + assert.deepEqual(properties.__gevDetails, [ + 'PrimSource: hydroelectric', + 'Total_MW: 6809', + ]); + assert.equal(properties.Plant_Name, ' Grand Coulee '); +}); + +test('CSV parsing follows RFC 4180 and finds lat/lon columns', () => { + assert.deepEqual(parseCsvRows('a,b\r\n"x, y","he said ""hi"""\n\n'), [ + ['a', 'b'], + ['x, y', 'he said "hi"'], + ]); + const { candidates } = parseLibraryBody( + 'csv', + 'name,Latitude,Longitude\n"Pier, 39",37.8087,-122.4098\n', + ); + assert.deepEqual(candidates[0].geometry.coordinates, [-122.4098, 37.8087]); + assert.deepEqual(candidates[0].properties, { name: 'Pier, 39' }); + assert.throws( + () => parseLibraryBody('csv', 'name,value\na,1'), + /latitude\/longitude/, + ); + assert.throws(() => parseCsvRows('"open'), /unterminated/); +}); + +test('explicit CSV columns win over the guessed ones', () => { + const { candidates } = parseLibraryBody( + 'csv', + 'y,x,lat_wgs,lon_wgs\n1,2,10,20\n', + { + columns: { lat: 'lat_wgs', lon: 'lon_wgs' }, + }, + ); + assert.deepEqual(candidates[0].geometry.coordinates, [20, 10]); +}); + +test('GeoJSON Lines and malformed bodies', () => { + const { candidates } = parseLibraryBody( + 'geojsonl', + `${JSON.stringify(point(1, 2))}\n\n${JSON.stringify(point(3, 4))}\n`, + ); + assert.equal(candidates.length, 2); + assert.throws(() => parseLibraryBody('geojson', ''), /not JSON/); + assert.throws( + () => parseLibraryBody('geojson', '{"a":1}'), + /FeatureCollection/, + ); + assert.throws( + () => parseLibraryBody('arcgis', '{"error":{"code":499}}'), + /ArcGIS service returned an error/, + ); +}); + +test('ArcGIS pages are WGS84 GeoJSON with the manifest query', () => { + const url = new URL( + arcgisPageUrl( + 'https://s.example.org/FeatureServer/0/', + { where: 'MW >= 100', f: 'json' }, + 2000, + ), + ); + assert.equal(url.pathname, '/FeatureServer/0/query'); + assert.equal(url.searchParams.get('where'), 'MW >= 100'); + assert.equal(url.searchParams.get('f'), 'geojson'); + assert.equal(url.searchParams.get('outSR'), '4326'); + assert.equal(url.searchParams.get('resultOffset'), '2000'); +}); + +test('ArcGIS paging follows exceededTransferLimit up to maxPages', async () => { + const value = entry({ + format: 'arcgis', + url: 'https://s.example.org/FeatureServer/0', + maxPages: 2, + }); + const offsets = []; + const collection = await loadLibraryDataset(value, async (url) => { + offsets.push(new URL(url).searchParams.get('resultOffset')); + return JSON.stringify({ + type: 'FeatureCollection', + properties: { exceededTransferLimit: true }, + features: [point(offsets.length, 0)], + }); + }); + assert.deepEqual(offsets, ['0', '1']); + assert.equal(collection.features.length, 2); + assert.equal(collection.truncated, true); +}); + +test('a plain dataset request carries the manifest query once', async () => { + const value = entry({ + format: 'geojson', + url: 'https://data.example.org/x?keep=1', + query: { status: 'open' }, + }); + let requested; + const collection = await loadLibraryDataset(value, async (url) => { + requested = new URL(url); + return JSON.stringify({ + type: 'FeatureCollection', + features: [point(1, 1, { title: 'Storm' })], + }); + }); + assert.equal(requested.searchParams.get('keep'), '1'); + assert.equal(requested.searchParams.get('status'), 'open'); + assert.equal(collection.features[0].properties.name, 'Storm'); + assert.equal(collection.truncated, false); +}); + +test('a manifest rank keeps the most important features and scores them for the map', () => { + const candidates = [ + point(0, 0, { Plant_Name: 'small', Total_MW: '120' }), + point(1, 1, { Plant_Name: 'none' }), + point(2, 2, { Plant_Name: 'huge', Total_MW: 6809 }), + point(3, 3, { Plant_Name: 'mid', Total_MW: 900 }), + ]; + const { features, truncated } = boundLibraryFeatures(candidates, { + maxFeatures: 3, + display: { label: 'Plant_Name', rank: { field: 'Total_MW' } }, + }); + assert.equal(truncated, true); + assert.deepEqual( + features.map(({ properties }) => [properties.name, properties.__gevRank]), + [ + ['huge', 600], + ['mid', 300], + ['small', 0], + ], + ); + const dated = boundLibraryFeatures( + [ + point(0, 0, { date: '2026-10-01T00:00:00Z' }), + point(0, 0, { date: '2026-10-20T00:00:00Z' }), + ], + { maxFeatures: 5, display: { rank: { field: 'date', order: 'desc' } } }, + ); + assert.deepEqual( + dated.features.map(({ properties }) => properties.date), + ['2026-10-20T00:00:00Z', '2026-10-01T00:00:00Z'], + ); +}); + +test('a JSON record list becomes points from named or guessed lat/lon fields', () => { + const body = JSON.stringify({ + count: 3, + stations: [ + { id: '1611400', name: 'Nawiliwili', lat: 21.9544, lng: -159.3561 }, + { id: 'x', name: 'Bad', lat: 'n/a', lng: 1 }, + 'not a record', + ], + }); + const { candidates } = parseLibraryBody('json', body, { + records: 'stations', + columns: { lat: 'lat', lon: 'lng' }, + }); + assert.deepEqual(candidates[0].geometry.coordinates, [-159.3561, 21.9544]); + assert.deepEqual(candidates[0].properties, { + id: '1611400', + name: 'Nawiliwili', + }); + const bounded = boundLibraryFeatures(candidates, { maxFeatures: 10 }); + assert.equal(bounded.features.length, 1); + assert.equal(bounded.dropped, 2); + // Without columns, lat/latitude and lon/lng/longitude are found by name. + const guessed = parseLibraryBody( + 'json', + JSON.stringify([{ Latitude: '10', Longitude: '20', name: 'A' }]), + ); + assert.deepEqual(guessed.candidates[0].geometry.coordinates, [20, 10]); + assert.throws( + () => parseLibraryBody('json', '{"items":[]}', { records: 'stations' }), + /no record list/, + ); +}); + +test('a colorBy field, nested or not, becomes the flat colour key', () => { + const properties = presentLibraryProperties( + { categories: [{ title: 'Wildfires' }], title: 'Fire' }, + { + colorBy: { + field: 'categories.0.title', + colors: { Wildfires: '#c98500' }, + }, + }, + ); + assert.equal(properties.__gevColorKey, 'Wildfires'); +}); diff --git a/src/library/layers.js b/src/library/layers.js new file mode 100644 index 000000000..68d382dc6 --- /dev/null +++ b/src/library/layers.js @@ -0,0 +1,800 @@ +/** + * Layer modules for Data Library entries. A dataset renders through the same + * GeoJSON layer as Datacenters and Dams; tiles drape on whichever imagery + * host the active map stack offers, as Recent Imagery does. + */ +import * as Cesium from 'cesium'; +import { + createLocalGeoJsonLayer, + localDatasetError, +} from '../data/localGeojsonCore.js'; +import { + NATURAL_EARTH_CREDIT, + registerDynamicCredit, +} from '../data/dataCredits.js'; +import { loadCountryOutlines, outlineRings } from './outlines.js'; +import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; +import { msg, t } from '../i18n/index.js'; +import { libraryLayerId } from './manifest.js'; +import { coverageExtent, featureExtent } from './extent.js'; +import { RELIEF_CLASSES, metresPerPixel, terrariumToRelief } from './relief.js'; + +const DEFAULT_DATASET_COLOR = '#8ad4ff'; +const TILE_ERROR_LIMIT = 24; +const TILES_UNAVAILABLE = msg('tiles unavailable'); +/** Imagery coverage outlines: a thin bright line that reads on photos. */ +const OUTLINE_COLOR = '#ffffff'; +const OUTLINE_WIDTH_PX = 2.5; +const ZOOM_IN_HINT = msg('zoom in to load this layer'); +const VIEW_DEBOUNCE_MS = 450; +// The query window's half-size: half the camera height each way, between +// about 330 m and 5.5 km. +const VIEW_HALF_PER_METRE = 0.5; +const VIEW_MIN_HALF_DEG = 0.003; +const VIEW_MAX_HALF_DEG = 0.05; + +/** + * The row's chips. Unlike other layers' row controls they stay visible while + * the layer is off (layerPanel.js honours `whileOff`). + * Both are icon actions that also work while the layer is off: Zoom to + * layer (the controller switches the layer on first) and Remove. + * @param {() => object|null} getExtent + * @param {(() => void) | null} onZoom + * @param {(() => void) | null} onRemove + */ +function libraryRowControls(getExtent, onZoom, onRemove, legend = []) { + const chips = []; + if (typeof onZoom === 'function') + chips.push({ + id: 'zoom', + icon: 'zoom_in_map', + action: true, + whileOff: true, + label: t('Zoom to layer'), + title: t('Zoom to layer · fly to where this layer has data'), + onClick: onZoom, + }); + if (typeof onRemove === 'function') + chips.push({ + id: 'remove', + icon: 'delete', + action: true, + whileOff: true, + label: t('Remove from Data Layers'), + title: t('Remove from Data Layers · add it again from the library'), + onClick: onRemove, + }); + return legend.length ? { chips, legend } : { chips }; +} + +/** + * The row legend: for `display.colorBy` one swatch per value, labelled from + * `colorBy.labels` (else the value itself) and counted when counts are + * given; otherwise the manifest's static `display.legend` (a tile layer's + * colour scale). Labels travel with the colours, so colour is never alone. + */ +export function colorByLegend(display, counts = null) { + const colorBy = display?.colorBy; + if (!colorBy) + return (display?.legend || []).map(({ label, color }) => ({ + label, + color, + })); + const labels = colorBy.labels || {}; + // Values sharing a label are one legend group (coal, gas and oil are + // "Fossil"); the first value's colour stands for the group. + const groups = new Map(); + for (const [value, color] of Object.entries(colorBy.colors)) { + const label = labels[value] || value; + const group = groups.get(label) || { label, color, count: 0 }; + group.count += counts?.get(value) || 0; + groups.set(label, group); + } + const items = [...groups.values()]; + // Values the manifest does not colour draw in the entry's colour. + let other = 0; + for (const [value, count] of counts || []) + if (!Object.hasOwn(colorBy.colors, value)) other += count; + if (other) + items.push({ + label: t('Other'), + color: display.color || DEFAULT_DATASET_COLOR, + count: other, + }); + return counts ? items : items.map(({ label, color }) => ({ label, color })); +} + +/** Count features by their colorBy key. */ +function countColorKeys(features) { + const counts = new Map(); + for (const feature of features) { + const key = String(feature?.properties?.__gevColorKey ?? ''); + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; +} + +function escapeHtml(text) { + return String(text).replace( + /[&<>"']/g, + (char) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + char + ], + ); +} + +/** + * The attribution credit an entry registers while it is in use. + * @param {object} entry + * @returns {{ key: string, html: string }} + */ +export function libraryCredit(entry) { + const text = escapeHtml(entry.attribution.text); + const linked = entry.attribution.url + ? `${text}` + : text; + return { + key: `library:${entry.id}`, + html: `${linked} · ${escapeHtml(entry.licence.name)}`, + }; +} + +/** + * A dataset entry as a manager layer. + * @param {object} entry Validated dataset entry. + * @param {{ services: object, source: ReturnType }} options + */ +export function createLibraryDatasetLayer( + entry, + { services, source, onZoom = null, onRemove = null }, +) { + let truncated = false; + let loaded = 0; + let extent = null; + let rowListener = null; + let legendCounts = null; + const colorBy = entry.display.colorBy || null; + const layer = createLocalGeoJsonLayer( + { + id: libraryLayerId(entry.id), + url: null, + name: entry.title, + color: entry.display.color || DEFAULT_DATASET_COLOR, + icon: entry.icon, + source: entry.provider, + labels: true, + featureColor: colorBy + ? (properties) => + colorBy.colors[String(properties?.__gevColorKey ?? '')] || null + : null, + loadFeatures: async ({ signal }) => { + const result = await source.loadDataset(entry, { signal }); + truncated = result.truncated; + loaded = result.features.length; + extent = featureExtent(result.features); + if (colorBy) legendCounts = countColorKeys(result.features); + rowListener?.(); + return result.features; + }, + }, + services, + ); + const enable = layer.enable; + const getStats = layer.getStats; + return Object.assign(layer, { + libraryEntryId: entry.id, + async enable(viewer, ...rest) { + registerDynamicCredit(viewer, libraryCredit(entry)); + return enable(viewer, ...rest); + }, + getStats() { + const stats = getStats(); + // A capped dataset reads PARTIAL ("incomplete snapshot"), never as + // the whole upstream set. + return truncated && !stats.error + ? { ...stats, partial: true, acceptedRowCount: loaded } + : stats; + }, + /** Where the loaded features are, or null before the first load. */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls( + () => extent, + onZoom, + onRemove, + colorByLegend(entry.display, legendCounts), + ), + setRowControlsListener(listener) { + rowListener = typeof listener === 'function' ? listener : null; + }, + }); +} + +/** + * Draw one Terrarium tile as shaded relief on a canvas. Cesium loads imagery + * bitmaps already flipped for WebGL and flips a canvas on upload, so a bitmap + * is drawn back north-up first and the canvas goes out north-up. + */ +function renderReliefTile(image, level, y) { + const { width, height } = image; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) { + context.translate(0, height); + context.scale(1, -1); + } + context.drawImage(image, 0, 0); + context.setTransform(1, 0, 0, 1, 0, 0); + const pixels = context.getImageData(0, 0, width, height).data; + const relief = terrariumToRelief( + pixels, + width, + height, + metresPerPixel(level, y, width), + ); + context.putImageData(new ImageData(relief, width, height), 0, 0); + image.close?.(); + return canvas; +} + +/** Wrap a provider so each elevation tile it loads becomes shaded relief. */ +function asReliefProvider(provider) { + const load = provider.requestImage.bind(provider); + provider.requestImage = (x, y, level, request) => { + const pending = load(x, y, level, request); + // Undefined means throttled: Cesium asks again later. + return pending?.then((image) => renderReliefTile(image, level, y)); + }; + return provider; +} + +/** The relief legend: elevation classes in metres. */ +const reliefLegend = () => + RELIEF_CLASSES.map(({ label, color }) => ({ label: t(label), color })); + +/** colorToAlpha threshold (max channel distance, 0–1) for `noDataColor`. */ +const NO_DATA_THRESHOLD = 0.02; + +/** + * A tiles entry as a manager layer. + * @param {object} entry Validated tiles entry. + * @param {{ source: object, getTileset?: () => object|null, mapStackController?: object, cesium?: object, requestRender?: (reason: string) => void }} options + */ +export function createLibraryTileLayer( + entry, + { + source, + getTileset = () => null, + mapStackController = null, + cesium = Cesium, + requestRender = () => {}, + onZoom = null, + onRemove = null, + }, +) { + const id = libraryLayerId(entry.id); + const extent = coverageExtent(entry); + const outlineMode = entry.display.outline || null; + let outline = null; + let outlineEpoch = 0; + + function removeOutline() { + outlineEpoch += 1; + if (outline && viewer?.dataSources) + viewer.dataSources.remove(outline, true); + outline = null; + } + + /** + * Draw the covered countries' borders as ground-clamped lines, so the edge + * of the imagery is plain on the map. A failed border fetch costs only the + * outline, never the imagery. + */ + async function drawOutline() { + if (!outlineMode || !viewer) return; + const epoch = ++outlineEpoch; + try { + const rings = outlineRings( + outlineMode === 'bbox' ? [] : await loadCountryOutlines(), + entry, + ); + if (epoch !== outlineEpoch || !enabled || !rings.length) return; + const source = new cesium.CustomDataSource(`${id}:outline`); + for (const ring of rings) + source.entities.add({ + polyline: { + positions: cesium.Cartesian3.fromDegreesArray(ring.flat()), + clampToGround: true, + width: OUTLINE_WIDTH_PX, + material: cesium.Color.fromCssColorString(OUTLINE_COLOR), + }, + }); + await viewer.dataSources.add(source); + if (epoch !== outlineEpoch || !enabled) { + viewer.dataSources.remove(source, true); + return; + } + outline = source; + if (outlineMode !== 'bbox') + registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); + requestRender(`library-tiles:${entry.id}`); + } catch (error) { + console.warn(`[Library] ${entry.id} outline unavailable:`, error); + } + } + const baseLegend = + entry.source.encoding === 'terrarium' + ? reliefLegend() + : colorByLegend(entry.display); + const legend = outlineMode + ? [...baseLegend, { label: t('Coverage outline'), color: OUTLINE_COLOR }] + : baseLegend; + let viewer = null; + let enabled = false; + let imagery = null; + let collection = null; + let tileErrors = 0; + let lastUpdate = null; + let unsubscribeMap = null; + let removeErrorListener = null; + let stopKeepingOnTop = null; + + /** + * Aerial photography sits above every other drape (basemaps, weather, + * other library tiles): raise it now and whenever a layer is added above. + */ + function keepOnTop(target, layer) { + const raise = () => { + if (target.isDestroyed?.() || !target.contains?.(layer)) return; + if (target.indexOf(layer) === target.length - 1) return; + target.raiseToTop(layer); + requestRender(`library-tiles:${entry.id}`); + }; + raise(); + const off = target.layerAdded?.addEventListener?.(raise); + stopKeepingOnTop = typeof off === 'function' ? off : null; + } + + function detach() { + removeErrorListener?.(); + removeErrorListener = null; + stopKeepingOnTop?.(); + stopKeepingOnTop = null; + if (imagery && collection && !collection.isDestroyed?.()) + collection.remove(imagery, true); + imagery = null; + collection = null; + } + + function attach() { + detach(); + const host = resolveImageryHost({ viewer, tileset: getTileset() }); + if (!host.collection) return false; + const bbox = entry.source.bounded ? entry.coverage.bbox : null; + const tileSize = entry.source.tileSize ?? 256; + const template = new cesium.UrlTemplateImageryProvider({ + url: source.tileTemplate(entry), + tileWidth: tileSize, + tileHeight: tileSize, + minimumLevel: entry.source.minZoom ?? 0, + maximumLevel: entry.source.maxZoom ?? 18, + tilingScheme: new cesium.WebMercatorTilingScheme(), + credit: entry.provider, + hasAlphaChannel: true, + // A bounded entry requests tiles inside its coverage only. + ...(bbox && cesium.Rectangle + ? { rectangle: cesium.Rectangle.fromDegrees(...bbox) } + : {}), + }); + const provider = + entry.source.encoding === 'terrarium' + ? asReliefProvider(template) + : template; + const onError = (event) => { + // A 4xx is the provider saying "no imagery here" (outside its + // coverage), not a failure: do not retry it or count it. + const status = event?.error?.statusCode; + if (status >= 400 && status < 500) { + if (event) event.retry = false; + return; + } + tileErrors += 1; + }; + provider.errorEvent?.addEventListener?.(onError); + removeErrorListener = () => + provider.errorEvent?.removeEventListener?.(onError); + imagery = host.collection.addImageryProvider(provider); + imagery.alpha = entry.display.opacity ?? 1; + if (entry.display.noDataColor && cesium.Color) { + // The provider's "no imagery here" fill, drawn transparent (JPEG + // noise along the edge of the fill sits just inside the threshold). + imagery.colorToAlpha = cesium.Color.fromCssColorString( + entry.display.noDataColor, + ); + imagery.colorToAlphaThreshold = NO_DATA_THRESHOLD; + } + collection = host.collection; + if (entry.display.aerial) keepOnTop(collection, imagery); + lastUpdate = Date.now(); + return true; + } + + function followMap() { + if (!enabled) return; + attach(); + requestRender(`library-tiles:${entry.id}`); + } + + return { + id, + name: entry.title, + icon: entry.icon, + source: entry.provider, + libraryEntryId: entry.id, + updateInterval: 0, + statsRefreshInterval: 1000, + async init(nextViewer) { + viewer = nextViewer; + }, + async update() {}, + async enable(nextViewer) { + viewer = nextViewer || viewer; + enabled = true; + tileErrors = 0; + registerDynamicCredit(viewer, libraryCredit(entry)); + attach(); + const off = mapStackController?.subscribe?.(followMap); + unsubscribeMap = typeof off === 'function' ? off : null; + void drawOutline(); + requestRender(`library-tiles:${entry.id}`); + }, + async disable() { + enabled = false; + unsubscribeMap?.(); + unsubscribeMap = null; + detach(); + removeOutline(); + requestRender(`library-tiles:${entry.id}`); + }, + async destroy() { + enabled = false; + unsubscribeMap?.(); + unsubscribeMap = null; + detach(); + removeOutline(); + viewer = null; + }, + getStats() { + if (!enabled) return { count: 0, lastUpdate }; + if (!imagery) + return { + count: 0, + lastUpdate, + status: 'idle', + statusMessage: NO_IMAGERY_HOST, + }; + return { + count: 1, + // A drape has no feature count; say what the row's ON means. + countLabel: t('DRAPED'), + lastUpdate, + error: tileErrors >= TILE_ERROR_LIMIT ? TILES_UNAVAILABLE : null, + }; + }, + /** The manifest's declared coverage (the world when global). */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls(() => extent, onZoom, onRemove, legend), + /** QA/test view of where the drape lives. */ + getDiagnostics() { + return { + draped: Boolean(imagery), + tileErrors, + outlineRings: outline?.entities.values.length ?? 0, + }; + }, + }; +} + +/** + * A view-bounded dataset (`source.view`): a layer too large to load whole, + * such as every US building footprint. Below `view.maxAltitudeM` it loads + * what intersects the camera's box, after the camera settles, and replaces + * the previous set only once the new one is ready; higher up it asks the + * viewer to zoom in. Polygons are clamped to the ground (and to Google 3D + * tiles), as an outline on the surface. + * @param {object} entry Validated dataset entry with `source.view`. + * @param {{ source: object, onZoom?: Function|null, onRemove?: Function|null, cesium?: object, requestRender?: (reason: string) => void, debounceMs?: number }} options + */ +export function createLibraryViewLayer( + entry, + { + source, + onZoom = null, + onRemove = null, + cesium = Cesium, + requestRender = () => {}, + debounceMs = VIEW_DEBOUNCE_MS, + }, +) { + const id = libraryLayerId(entry.id); + const { maxAltitudeM, sample } = entry.source.view; + // Zoom lands on the sample area, low enough for the layer to load. + const extent = { + ...(sample + ? { west: sample[0], south: sample[1], east: sample[2], north: sample[3] } + : coverageExtent(entry)), + maxAltitudeM, + }; + const color = cesium.Color.fromCssColorString( + entry.display.color || DEFAULT_DATASET_COLOR, + ); + const colorBy = entry.display.colorBy || null; + const colorByValue = new Map( + Object.entries(colorBy?.colors || {}).map(([value, css]) => [ + value, + cesium.Color.fromCssColorString(css), + ]), + ); + let legendCounts = null; + /** A feature's colour: its colorBy value's, else the entry's. */ + function colorOf(entity) { + if (!colorBy) return color; + const properties = entity.properties?.getValue?.( + cesium.JulianDate?.now?.(), + ); + const key = properties?.__gevColorKey ?? properties?.[colorBy.field]; + return colorByValue.get(String(key ?? '')) || color; + } + let viewer = null; + let enabled = false; + let dataSource = null; + let controller = null; + let timer = null; + let removeMoveEnd = null; + let count = 0; + let truncated = false; + let loading = false; + let error = null; + let tooHigh = false; + let lastUpdate = null; + let loadedKey = null; + + const render = () => requestRender(`library-view:${entry.id}`); + + function abort() { + controller?.abort(); + controller = null; + } + + function clear() { + if (dataSource && viewer?.dataSources) + viewer.dataSources.remove(dataSource, true); + dataSource = null; + loadedKey = null; + count = 0; + truncated = false; + legendCounts = null; + } + + /** + * The box to query: a window around the point the camera looks at, sized + * by camera height (an oblique view's visible area reaches the horizon, + * far beyond what one page can fill), clipped to what is in view. + */ + function viewBox(height) { + const scene = viewer?.scene; + const canvas = scene?.canvas; + const ellipsoid = scene?.globe?.ellipsoid ?? cesium.Ellipsoid.WGS84; + const target = + (canvas && + viewer.camera.pickEllipsoid?.( + new cesium.Cartesian2( + canvas.clientWidth / 2, + canvas.clientHeight / 2, + ), + ellipsoid, + )) || + viewer.camera.positionWC; + const center = + target && cesium.Cartographic.fromCartesian(target, ellipsoid); + if (!center) return null; + const lat = (center.latitude * 180) / Math.PI; + const lon = (center.longitude * 180) / Math.PI; + const halfLat = Math.min( + VIEW_MAX_HALF_DEG, + Math.max(VIEW_MIN_HALF_DEG, (height * VIEW_HALF_PER_METRE) / 111_320), + ); + const halfLon = halfLat / Math.max(0.2, Math.cos((lat * Math.PI) / 180)); + let box = [lon - halfLon, lat - halfLat, lon + halfLon, lat + halfLat]; + const visible = viewer.camera.computeViewRectangle?.(ellipsoid); + if (visible && visible.west < visible.east) { + const degrees = [ + visible.west, + visible.south, + visible.east, + visible.north, + ].map((radians) => (radians * 180) / Math.PI); + box = [ + Math.max(box[0], degrees[0]), + Math.max(box[1], degrees[1]), + Math.min(box[2], degrees[2]), + Math.min(box[3], degrees[3]), + ]; + } + // A view across the antimeridian or off the globe is skipped. + return box[0] < box[2] && box[1] < box[3] && box[0] >= -180 && box[2] <= 180 + ? box + : null; + } + + async function refresh() { + timer = null; + if (!enabled || !viewer) return; + const height = viewer.camera?.positionCartographic?.height; + if (!(Number.isFinite(height) && height <= maxAltitudeM)) { + tooHigh = true; + abort(); + loading = false; + clear(); + render(); + return; + } + tooHigh = false; + const box = viewBox(height); + if (!box) return; + const key = box.map((value) => value.toFixed(4)).join(','); + if (key === loadedKey && dataSource) return; + abort(); + const current = new AbortController(); + controller = current; + loading = true; + error = null; + try { + const result = await source.loadDataset(entry, { + signal: current.signal, + bbox: box, + }); + if (!enabled || current.signal.aborted) return; + const loaded = await cesium.GeoJsonDataSource.load( + { type: 'FeatureCollection', features: result.features }, + { + clampToGround: true, + fill: color.withAlpha(0.35), + stroke: color, + markerColor: color, + }, + ); + if (!enabled || current.signal.aborted) return; + loaded.name = entry.title; + for (const entity of loaded.entities.values) { + const fill = colorOf(entity); + // Ground-clamped polygons cannot draw outlines; say so up front + // instead of letting Cesium warn on every entity. + if (entity.polygon) { + entity.polygon.outline = false; + if (colorBy) entity.polygon.material = fill.withAlpha(0.45); + } + // Points draw as small dots, not Cesium's default pins: a view can + // hold thousands of them. + if (entity.billboard && cesium.PointGraphics) { + entity.billboard = undefined; + entity.point = new cesium.PointGraphics({ + pixelSize: 6, + color: fill, + outlineColor: cesium.Color.BLACK.withAlpha(0.55), + outlineWidth: 1, + heightReference: cesium.HeightReference?.CLAMP_TO_GROUND, + }); + } + } + await viewer.dataSources.add(loaded); + if (!enabled || current.signal.aborted) { + viewer.dataSources.remove(loaded, true); + return; + } + const previous = dataSource; + dataSource = loaded; + if (previous) viewer.dataSources.remove(previous, true); + count = result.features.length; + truncated = result.truncated; + if (colorBy) legendCounts = countColorKeys(result.features); + lastUpdate = Date.now(); + loadedKey = key; + } catch (caught) { + if (current.signal.aborted || caught?.name === 'AbortError') return; + error = localDatasetError(caught); + } finally { + if (controller === current) { + controller = null; + loading = false; + } + render(); + } + } + + function schedule() { + clearTimeout(timer); + timer = setTimeout(() => void refresh(), debounceMs); + } + + function stop() { + clearTimeout(timer); + timer = null; + abort(); + loading = false; + removeMoveEnd?.(); + removeMoveEnd = null; + } + + return { + id, + name: entry.title, + icon: entry.icon, + source: entry.provider, + libraryEntryId: entry.id, + updateInterval: 0, + statsRefreshInterval: 1000, + async init(nextViewer) { + viewer = nextViewer; + }, + async update() {}, + async enable(nextViewer) { + viewer = nextViewer || viewer; + enabled = true; + error = null; + registerDynamicCredit(viewer, libraryCredit(entry)); + removeMoveEnd?.(); + removeMoveEnd = null; + const moveEnd = viewer?.camera?.moveEnd; + if (moveEnd?.addEventListener) { + moveEnd.addEventListener(schedule); + removeMoveEnd = () => moveEnd.removeEventListener(schedule); + } + await refresh(); + }, + async disable() { + enabled = false; + stop(); + clear(); + render(); + }, + async destroy() { + enabled = false; + stop(); + clear(); + viewer = null; + }, + getStats() { + if (!enabled) return { count: 0, lastUpdate }; + if (tooHigh) + return { + count: 0, + lastUpdate, + status: 'zoom-in', + statusMessage: ZOOM_IN_HINT, + }; + const stats = { count, lastUpdate, loading, error }; + // One page per view: a dense view reads PARTIAL, never complete. + return truncated && !error + ? { ...stats, partial: true, acceptedRowCount: count } + : stats; + }, + /** The sample area (or coverage) for Zoom to layer. */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls( + () => extent, + onZoom, + onRemove, + colorByLegend(entry.display, legendCounts), + ), + /** QA/test view of the current load. */ + getDiagnostics() { + return { loadedKey, count, tooHigh, loading }; + }, + }; +} diff --git a/src/library/ledger.json b/src/library/ledger.json new file mode 100644 index 000000000..de9dee009 --- /dev/null +++ b/src/library/ledger.json @@ -0,0 +1,105 @@ +[ + ["nasa-eonet-events", "00"], + ["gvp-holocene-volcanoes", "01"], + ["eia-power-plants", "02"], + ["nasa-black-marble", "03"], + ["openseamap-seamarks", "04"], + ["wri-global-power-plants", "05"], + ["nws-active-alerts", "06"], + ["imf-portwatch-chokepoints", "07"], + ["imf-portwatch-disruptions", "08"], + ["noaa-tide-stations", "09"], + ["fema-usa-structures", "0a"], + ["epc-england-wales", "0b"], + ["macrostrat-geology", "0c"], + ["gbif-occurrence-density", "0d"], + ["usgs-topo", "0e"], + ["usgs-hydrography", "0f"], + ["pdok-aerial-netherlands", "0g"], + ["ign-orthophotos-france", "0h"], + ["bkg-topplus-open", "0i"], + ["gsi-japan-map", "0j"], + ["gsi-japan-photo", "0k"], + ["emodnet-bathymetry", "0l"], + ["gpw-population-density", "0m"], + ["jrc-surface-water", "0n"], + ["gdacs-disaster-alerts", "0o"], + ["ntad-national-bridges", "0p"], + ["epa-superfund-sites", "0q"], + ["epa-toxic-release-facilities", "0r"], + ["swisstopo-swissimage", "0s"], + ["ak-statewide-imagery", "0t"], + ["ar-statewide-imagery", "0u"], + ["az-statewide-imagery", "0v"], + ["bangor-me", "0w"], + ["basemap-at-orthofoto", "0x"], + ["bayern-dop40", "0y"], + ["bismarck-mandan-nd", "0z"], + ["ct-statewide-imagery", "10"], + ["dakota-county-mn", "11"], + ["dc-statewide-imagery", "12"], + ["de-statewide-imagery", "13"], + ["ee-maa-amet-ortofoto", "14"], + ["geneva-sitg-ortho", "15"], + ["grand-forks-nd", "16"], + ["ia-statewide-imagery", "17"], + ["icgc-orto-catalunya", "18"], + ["id-statewide-imagery", "19"], + ["il-statewide-imagery", "1a"], + ["in-statewide-imagery", "1b"], + ["ks-statewide-imagery", "1c"], + ["ky-statewide-imagery", "1d"], + ["lake-county-in", "1e"], + ["lawrence-ks", "1f"], + ["lee-county-fl", "1g"], + ["loudoun-county-va", "1h"], + ["louisville-lojic-ky", "1i"], + ["lu-ortho-latest", "1j"], + ["ma-statewide-imagery", "1k"], + ["maris-ms", "1l"], + ["md-statewide-imagery", "1m"], + ["me-statewide-imagery", "1n"], + ["mi-statewide-imagery", "1o"], + ["mn-statewide-imagery", "1p"], + ["mountain-village-co", "1q"], + ["ms-statewide-imagery", "1r"], + ["mt-statewide-imagery", "1s"], + ["naip", "1t"], + ["nc-statewide-imagery", "1u"], + ["nctcog-tx", "1v"], + ["nd-statewide-imagery", "1w"], + ["new-orleans-rpc-la", "1x"], + ["nh-statewide-imagery", "1y"], + ["nj-statewide-imagery", "1z"], + ["nrw-dop", "20"], + ["nsw-imagery", "21"], + ["nwa-benton-washington-ar", "22"], + ["ny-statewide-imagery", "23"], + ["oakland-county-mi", "24"], + ["oh-statewide-imagery", "25"], + ["or-statewide-imagery", "26"], + ["pa-statewide-imagery", "27"], + ["pl-ortofotomapa", "28"], + ["pnoa-ma", "29"], + ["portland-metro-or", "2a"], + ["pt-dgt-ortos", "2b"], + ["ri-statewide-imagery", "2c"], + ["tx-statewide-imagery", "2d"], + ["union-county-oh", "2e"], + ["va-statewide-imagery", "2f"], + ["vlaanderen-omw", "2g"], + ["vt-statewide-imagery", "2h"], + ["wallonie-ortho", "2i"], + ["wi-statewide-imagery", "2j"], + ["wv-statewide-imagery", "2k"], + ["kartverket-admin-units", "2l"], + ["kartverket-cadastre", "2m"], + ["kartverket-depth", "2n"], + ["kartverket-hillshade", "2o"], + ["kartverket-nautical-charts", "2p"], + ["kartverket-terrain-relief", "2q"], + ["kartverket-topo-grey", "2r"], + ["kartverket-topo", "2s"], + ["kartverket-toporaster", "2t"], + ["mapterhorn-terrain", "2u"] +] diff --git a/src/library/manifest.js b/src/library/manifest.js new file mode 100644 index 000000000..5d76af235 --- /dev/null +++ b/src/library/manifest.js @@ -0,0 +1,771 @@ +/** + * Data Library manifest contract. + * + * A library entry is a pointer to a live upstream the provider hosts: a query + * API, a provider-hosted static file or a tile endpoint. Nothing a manifest + * describes is bundled in this repository, so every instance — hosted or + * self-hosted — serves the same entries without hosting any data. + * + * Pure and portable: the server, the browser and the repo scripts all import + * this module, so it loads no catalogs and touches no DOM. See + * docs/DATA-LIBRARY.md for the authoring guide. + */ + +/** The three entry kinds this build renders. */ +export const LIBRARY_KINDS = Object.freeze(['dataset', 'tiles', 'pack']); + +/** Vector formats a `dataset` entry may declare, normalized to GeoJSON. */ +export const DATASET_FORMATS = Object.freeze([ + 'geojson', + 'geojsonl', + 'csv', + 'json', + 'arcgis', + 'geoparquet', +]); + +/** Raster formats a `tiles` entry may declare. */ +export const TILE_FORMATS = Object.freeze(['xyz', 'bbox']); +/** + * What a tile's pixels hold when they are not a picture: `terrarium` is + * elevation as terrain-RGB, drawn as shaded relief by the browser. + */ +export const TILE_ENCODINGS = Object.freeze(['terrarium']); +/** A `bbox` tile template's placeholder (MapLibre's name for it). */ +export const BBOX_TOKEN = '{bbox-epsg-3857}'; +const MERCATOR_HALF_WORLD_M = 20037508.342789244; + +/** + * A tile's Web Mercator bounds as `west,south,east,north` metres. + * @returns {string} + */ +export function tileBbox3857(z, x, y) { + const size = (2 * MERCATOR_HALF_WORLD_M) / 2 ** z; + const west = -MERCATOR_HALF_WORLD_M + x * size; + const north = MERCATOR_HALF_WORLD_M - y * size; + return [west, north - size, west + size, north] + .map((value) => value.toFixed(3)) + .join(','); +} + +/** + * One tile's upstream URL from a tiles entry's template: `{z}/{x}/{y}` for + * `xyz`, the tile's EPSG:3857 bounds for `bbox` (WMS GetMap, ArcGIS export). + */ +export function libraryTileUrl(template, z, x, y) { + return template + .replaceAll(BBOX_TOKEN, tileBbox3857(z, x, y)) + .replaceAll('{z}', String(z)) + .replaceAll('{x}', String(x)) + .replaceAll('{reverseY}', String(2 ** z - 1 - y)) + .replaceAll('{y}', String(y)); +} + +/** How the browser reaches the upstream. */ +export const FETCH_MODES = Object.freeze(['browser-direct', 'proxied']); + +/** Lifecycle states; only maintainers and the health probe change them. */ +export const LIBRARY_STATUSES = Object.freeze([ + 'incubating', + 'community', + 'verified', + 'stale', + 'archived', +]); + +/** Browse filter families, matching the Data Layers panel groups. */ +export const LIBRARY_FAMILIES = Object.freeze([ + 'movement', + 'cameras', + 'infrastructure', + 'events', + 'weather', + 'environment', + 'imagery', + 'demographics', +]); + +/** Host layers a `pack` entry may extend, with the env switch grammar. */ +export const PACK_HOSTS = Object.freeze(['cctv']); + +export const LIBRARY_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/; +export const LIBRARY_LAYER_PREFIX = 'lib-'; +export const DEFAULT_CACHE_SECONDS = 3600; +export const MIN_CACHE_SECONDS = 60; +export const MAX_CACHE_SECONDS = 86_400; +export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; +export const MAX_MAX_BYTES = 32 * 1024 * 1024; +export const DEFAULT_MAX_FEATURES = 10_000; +export const MAX_MAX_FEATURES = 20_000; +export const DEFAULT_ARCGIS_PAGES = 5; +export const MAX_ARCGIS_PAGES = 10; +export const MAX_TILE_ZOOM = 22; +/** View-bounded datasets load only below this camera height range (m). */ +export const MIN_VIEW_ALTITUDE_M = 300; +export const MAX_VIEW_ALTITUDE_M = 50_000; + +const TOP_LEVEL_KEYS = new Set([ + 'id', + 'kind', + 'family', + 'title', + 'provider', + 'description', + 'icon', + 'maintainer', + 'coverage', + 'source', + 'display', + 'pack', + 'licence', + 'attribution', + 'status', + 'regionsServed', + 'reviewed', + 'imagery', +]); +const SOURCE_KEYS = new Set([ + 'format', + 'url', + 'fetch', + 'query', + 'columns', + 'records', + 'partitions', + 'view', + 'cacheSeconds', + 'maxBytes', + 'maxFeatures', + 'maxPages', + 'minZoom', + 'maxZoom', + 'bounded', + 'encoding', + 'tileSize', + 'key', +]); +const DISPLAY_KEYS = new Set([ + 'color', + 'label', + 'fields', + 'rank', + 'colorBy', + 'legend', + 'outline', + 'opacity', + 'aerial', + 'noDataColor', +]); +const COUNTRY_PATTERN = /^[A-Z]{2}$/; +const COLOR_PATTERN = /^#[0-9a-f]{6}$/i; +const ENV_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/; +const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]{0,63}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const KEY_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/; + +const isObject = (value) => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const isText = (value, max) => + typeof value === 'string' && value.trim().length > 0 && value.length <= max; + +/** + * Parse an upstream URL a manifest may name: https, no credentials, no + * fragment, a real hostname. Tile templates keep their `{z}/{x}/{y}` tokens. + * @param {unknown} value + * @returns {URL|null} + */ +export function libraryUpstreamUrl(value) { + if (typeof value !== 'string' || value.length > 2048) return null; + let url; + try { + url = new URL( + value + .replaceAll(BBOX_TOKEN, '0,0,0,0') + .replace(/\{([xyz]|reverseY)\}/g, '0'), + ); + } catch { + return null; + } + if (url.protocol !== 'https:') return null; + if (url.username || url.password || url.hash) return null; + if (!url.hostname.includes('.') || /^[\d.]+$/.test(url.hostname)) return null; + return url; +} + +/** A licence or attribution link: https, no credentials; fragments allowed. */ +function httpsDocumentUrl(value) { + if (typeof value !== 'string' || value.length > 2048) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password; + } catch { + return false; + } +} + +function validBbox(bbox) { + return ( + Array.isArray(bbox) && + bbox.length === 4 && + bbox.every(Number.isFinite) && + bbox[0] >= -180 && + bbox[2] <= 180 && + bbox[1] >= -90 && + bbox[3] <= 90 && + bbox[0] < bbox[2] && + bbox[1] < bbox[3] + ); +} + +function checkCoverage(coverage, errors) { + if (!isObject(coverage)) { + errors.push('coverage must be an object'); + return; + } + const { global, countries, bbox } = coverage; + if (global !== undefined && typeof global !== 'boolean') + errors.push('coverage.global must be a boolean'); + if (countries !== undefined) { + if ( + !Array.isArray(countries) || + !countries.length || + countries.some((code) => !COUNTRY_PATTERN.test(code)) + ) + errors.push('coverage.countries must list ISO 3166-1 alpha-2 codes'); + } + if (bbox !== undefined) { + if (!validBbox(bbox)) + errors.push('coverage.bbox must be [west, south, east, north] degrees'); + } + if (global !== true && countries === undefined && bbox === undefined) + errors.push('coverage needs global, countries or bbox'); +} + +function boundedInteger(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; +} + +function checkSource(entry, errors) { + const { source, kind } = entry; + if (!isObject(source)) { + errors.push('source must be an object'); + return; + } + for (const key of Object.keys(source)) + if (!SOURCE_KEYS.has(key)) + errors.push(`source.${key} is not a known field`); + const formats = + kind === 'dataset' + ? DATASET_FORMATS + : kind === 'tiles' + ? TILE_FORMATS + : ['host']; + if (!formats.includes(source.format)) + errors.push(`source.format must be one of ${formats.join(', ')}`); + if (!libraryUpstreamUrl(source.url)) + errors.push('source.url must be an https URL without credentials'); + if (kind === 'pack') return; + if (!FETCH_MODES.includes(source.fetch)) + errors.push(`source.fetch must be one of ${FETCH_MODES.join(', ')}`); + if (kind === 'tiles' && typeof source.url === 'string') { + if (source.format === 'bbox') { + if (!source.url.includes(BBOX_TOKEN)) + errors.push(`source.url must contain ${BBOX_TOKEN}`); + if (/\{[xyz]\}/.test(source.url)) + errors.push('a bbox template takes no {z}/{x}/{y}'); + } else { + for (const token of ['{z}', '{x}']) + if (!source.url.includes(token)) + errors.push(`source.url must contain ${token}`); + // TMS servers count rows from the south: Cesium's {reverseY}. + if (!source.url.includes('{y}') && !source.url.includes('{reverseY}')) + errors.push('source.url must contain {y} or {reverseY}'); + } + } + if ( + source.cacheSeconds !== undefined && + !boundedInteger(source.cacheSeconds, MIN_CACHE_SECONDS, MAX_CACHE_SECONDS) + ) + errors.push( + `source.cacheSeconds must be ${MIN_CACHE_SECONDS}–${MAX_CACHE_SECONDS}`, + ); + if ( + source.maxBytes !== undefined && + !boundedInteger(source.maxBytes, 1024, MAX_MAX_BYTES) + ) + errors.push(`source.maxBytes must be at most ${MAX_MAX_BYTES}`); + if ( + source.maxFeatures !== undefined && + !boundedInteger(source.maxFeatures, 1, MAX_MAX_FEATURES) + ) + errors.push(`source.maxFeatures must be at most ${MAX_MAX_FEATURES}`); + if (source.maxPages !== undefined) { + if (source.format !== 'arcgis') + errors.push('source.maxPages applies to arcgis only'); + else if (!boundedInteger(source.maxPages, 1, MAX_ARCGIS_PAGES)) + errors.push(`source.maxPages must be 1–${MAX_ARCGIS_PAGES}`); + } + if (source.query !== undefined) { + const valid = + isObject(source.query) && + Object.entries(source.query).every( + ([name, value]) => + FIELD_PATTERN.test(name) && + typeof value === 'string' && + value.length <= 512, + ); + if (!valid) errors.push('source.query must map field names to strings'); + } + if (source.view !== undefined) { + const { view } = source; + const valid = + isObject(view) && + Object.keys(view).every((key) => + ['maxAltitudeM', 'sample'].includes(key), + ) && + boundedInteger( + view.maxAltitudeM, + MIN_VIEW_ALTITUDE_M, + MAX_VIEW_ALTITUDE_M, + ) && + (view.sample === undefined || validBbox(view.sample)); + if (!valid) + errors.push( + `source.view needs maxAltitudeM (${MIN_VIEW_ALTITUDE_M}–${MAX_VIEW_ALTITUDE_M}) and an optional sample bbox`, + ); + // View-bounded loading queries the camera's box; this build does that + // for ArcGIS layers and partitioned GeoParquet, fetched by the browser. + if ( + !['arcgis', 'geoparquet'].includes(source.format) || + source.fetch !== 'browser-direct' + ) + errors.push( + 'source.view needs format arcgis or geoparquet and fetch browser-direct', + ); + } + if (source.partitions !== undefined) { + const { partitions } = source; + const host = libraryUpstreamUrl(source.url)?.host; + const listUrl = libraryUpstreamUrl(partitions?.listUrl); + const keyBase = libraryUpstreamUrl(partitions?.keyBase); + const valid = + source.format === 'geoparquet' && + isObject(partitions) && + Object.keys(partitions).every((key) => + ['listUrl', 'keyBase'].includes(key), + ) && + listUrl && + keyBase && + listUrl.host === host && + keyBase.host === host; + if (!valid) + errors.push( + 'source.partitions needs an S3 listUrl and keyBase on the source host (geoparquet only)', + ); + // A partitioned set is far too large to load whole. + if (source.view === undefined) + errors.push('a partitioned source must be view-bounded (source.view)'); + } + if (source.records !== undefined) { + if (source.format !== 'json') + errors.push('source.records applies to json only'); + else if (!FIELD_PATTERN.test(source.records)) + errors.push('source.records must be a property path'); + } + if (source.columns !== undefined) { + const valid = + ['csv', 'json'].includes(source.format) && + isObject(source.columns) && + FIELD_PATTERN.test(source.columns.lat ?? '') && + FIELD_PATTERN.test(source.columns.lon ?? ''); + if (!valid) + errors.push( + 'source.columns needs lat and lon column names (csv or json only)', + ); + } + if (source.bounded !== undefined) { + // Request tiles only inside coverage.bbox (a provider that answers + // outside its coverage with errors, or asks for fair use). + if (kind !== 'tiles' || typeof source.bounded !== 'boolean') + errors.push('source.bounded is a boolean for tiles'); + else if (source.bounded && !entry.coverage?.bbox) + errors.push('source.bounded needs coverage.bbox'); + } + if (source.encoding !== undefined) { + if (kind !== 'tiles' || !TILE_ENCODINGS.includes(source.encoding)) + errors.push( + `source.encoding is for tiles: one of ${TILE_ENCODINGS.join(', ')}`, + ); + else if (source.format !== 'xyz') + errors.push('an encoded tiles source must be xyz'); + } + if ( + source.tileSize !== undefined && + (kind !== 'tiles' || ![256, 512].includes(source.tileSize)) + ) + errors.push('source.tileSize is 256 or 512, for tiles'); + for (const zoom of ['minZoom', 'maxZoom']) { + if (source[zoom] === undefined) continue; + if (kind !== 'tiles') errors.push(`source.${zoom} applies to tiles only`); + else if (!boundedInteger(source[zoom], 0, MAX_TILE_ZOOM)) + errors.push(`source.${zoom} must be 0–${MAX_TILE_ZOOM}`); + } + if ( + Number.isInteger(source.minZoom) && + Number.isInteger(source.maxZoom) && + source.minZoom > source.maxZoom + ) + errors.push('source.minZoom must not exceed source.maxZoom'); + if (source.key !== undefined && source.key !== null) { + const { key } = source; + const valid = + isObject(key) && + LIBRARY_ID_PATTERN.test(key.id ?? '') && + ['query', 'header'].includes(key.in) && + KEY_PARAM_PATTERN.test(key.name ?? ''); + if (!valid) errors.push('source.key needs id, in (query|header) and name'); + // A key never reaches the browser: keyed entries go through the server. + if (source.fetch !== 'proxied') + errors.push('a keyed source must use fetch: proxied'); + } +} + +function checkDisplay(entry, errors) { + const { display, kind } = entry; + if (display === undefined) return; + if (!isObject(display)) { + errors.push('display must be an object'); + return; + } + for (const key of Object.keys(display)) + if (!DISPLAY_KEYS.has(key)) + errors.push(`display.${key} is not a known field`); + if (display.color !== undefined && !COLOR_PATTERN.test(display.color)) + errors.push('display.color must be #rrggbb'); + if (display.label !== undefined && !FIELD_PATTERN.test(display.label)) + errors.push('display.label must be a property name'); + if ( + display.fields !== undefined && + (!Array.isArray(display.fields) || + display.fields.length > 4 || + display.fields.some((field) => !FIELD_PATTERN.test(field))) + ) + errors.push('display.fields must list up to four property names'); + if ( + display.rank !== undefined && + !( + isObject(display.rank) && + FIELD_PATTERN.test(display.rank.field ?? '') && + ['desc', 'asc'].includes(display.rank.order ?? 'desc') && + Object.keys(display.rank).every((key) => ['field', 'order'].includes(key)) + ) + ) + errors.push('display.rank needs a property field and order (desc|asc)'); + if (display.colorBy !== undefined) { + const { colorBy } = display; + const colors = isObject(colorBy) + ? Object.entries(colorBy.colors || {}) + : []; + const valid = + isObject(colorBy) && + Object.keys(colorBy).every((key) => + ['field', 'colors', 'labels'].includes(key), + ) && + (colorBy.labels === undefined || + (isObject(colorBy.labels) && + Object.entries(colorBy.labels).every( + ([value, label]) => + Object.hasOwn(colorBy.colors || {}, value) && isText(label, 40), + ))) && + FIELD_PATTERN.test(colorBy.field ?? '') && + colors.length > 0 && + colors.length <= 32 && + colors.every( + ([value, color]) => + value.length > 0 && value.length <= 24 && COLOR_PATTERN.test(color), + ); + if (!valid) + errors.push( + 'display.colorBy needs a field and up to 32 value → #rrggbb colours', + ); + // A legend groups values by label; it must stay readable. + const groups = new Set( + colors.map(([value]) => colorBy.labels?.[value] ?? value), + ); + if (groups.size > 12) + errors.push('display.colorBy can show at most 12 legend groups'); + } + if (display.legend !== undefined) { + const valid = + Array.isArray(display.legend) && + display.legend.length > 0 && + display.legend.length <= 16 && + display.legend.every( + (item) => + isObject(item) && + Object.keys(item).every((key) => ['label', 'color'].includes(key)) && + isText(item.label, 40) && + COLOR_PATTERN.test(item.color ?? ''), + ); + if (!valid) + errors.push('display.legend needs 1–16 { label, color: #rrggbb } items'); + if (display.colorBy) + errors.push( + 'display.legend and display.colorBy: the colorBy legend is built from colorBy', + ); + } + if (display.outline !== undefined) { + // Outline the covered countries' borders (imagery edges): `countries` + // draws every part, `coverage` only the parts inside coverage.bbox. + // `bbox` draws coverage.bbox itself: a state or city inside a country. + if (!['coverage', 'countries', 'bbox'].includes(display.outline)) + errors.push('display.outline must be coverage, countries or bbox'); + if (kind !== 'tiles') errors.push('display.outline applies to tiles only'); + if (display.outline !== 'bbox' && !entry.coverage?.countries?.length) + errors.push('display.outline needs coverage.countries'); + if (display.outline !== 'countries' && !entry.coverage?.bbox) + errors.push(`display.outline: ${display.outline} needs coverage.bbox`); + } + if (display.aerial !== undefined) { + // Aerial photography stays above every other imagery drape. + if (kind !== 'tiles' || typeof display.aerial !== 'boolean') + errors.push('display.aerial is a boolean for tiles'); + } + if (display.noDataColor !== undefined) { + // The fill a provider paints where it has no imagery (PDOK's white + // outside the Netherlands); drawn transparent so it never hides the globe. + if (kind !== 'tiles') + errors.push('display.noDataColor applies to tiles only'); + else if (!COLOR_PATTERN.test(String(display.noDataColor))) + errors.push('display.noDataColor must be #rrggbb'); + } + if ( + display.opacity !== undefined && + !( + Number.isFinite(display.opacity) && + display.opacity > 0 && + display.opacity <= 1 + ) + ) + errors.push('display.opacity must be in (0, 1]'); + if ( + kind === 'tiles' && + (display.label || display.fields || display.rank || display.colorBy) + ) + errors.push( + 'display.label, fields, rank and colorBy apply to datasets only', + ); +} + +/** What an imagery entry shows: the registry's product types. */ +export const IMAGERY_TYPES = Object.freeze([ + 'ortho', + 'satellite-mosaic', + 'oblique', + 'historic', +]); +const IMAGERY_KEYS = new Set([ + 'type', + 'resolution', + 'resolutionM', + 'trueOrtho', + 'vintage', + 'updateCycle', + 'orthoQuality', + 'registry', +]); + +/** + * `imagery`: what a photo layer is, for viewers choosing between them — + * product type, ground resolution as published and in metres, whether it is + * a true orthophoto, the years flown and how often it is refreshed. + */ +function checkImagery(raw, errors) { + const { imagery } = raw; + if (raw.kind !== 'tiles' || !isObject(imagery)) { + errors.push('imagery applies to tiles only and is an object'); + return; + } + for (const key of Object.keys(imagery)) + if (!IMAGERY_KEYS.has(key)) + errors.push(`imagery.${key} is not a known field`); + if (!IMAGERY_TYPES.includes(imagery.type)) + errors.push(`imagery.type must be one of ${IMAGERY_TYPES.join(', ')}`); + if (!isText(imagery.resolution, 64)) + errors.push( + 'imagery.resolution is the published ground resolution (≤ 64 chars)', + ); + if ( + !Number.isFinite(imagery.resolutionM) || + imagery.resolutionM <= 0 || + imagery.resolutionM > 10_000 + ) + errors.push('imagery.resolutionM is the finest resolution in metres'); + if (typeof imagery.trueOrtho !== 'boolean') + errors.push('imagery.trueOrtho is a boolean'); + for (const [key, max] of [ + ['vintage', 32], + ['updateCycle', 80], + ['orthoQuality', 120], + ]) + if (imagery[key] !== undefined && !isText(imagery[key], max)) + errors.push(`imagery.${key} must be text (≤ ${max} chars)`); + if (imagery.registry !== undefined && !httpsDocumentUrl(imagery.registry)) + errors.push('imagery.registry must be an https link'); +} + +/** + * Validate one manifest. Strict: unknown fields are errors, so a typo cannot + * silently drop a licence or a cap. + * @param {unknown} raw Parsed manifest JSON. + * @returns {{ ok: boolean, errors: string[], entry: object|null }} + */ +export function validateLibraryManifest(raw) { + const errors = []; + if (!isObject(raw)) + return { ok: false, errors: ['manifest must be an object'], entry: null }; + for (const key of Object.keys(raw)) + if (!TOP_LEVEL_KEYS.has(key)) errors.push(`${key} is not a known field`); + if (!LIBRARY_ID_PATTERN.test(raw.id ?? '')) + errors.push('id must be 3–48 lowercase letters, digits or dashes'); + if (raw.kind === 'provider') + errors.push('provider entries are not supported by this build yet'); + else if (!LIBRARY_KINDS.includes(raw.kind)) + errors.push(`kind must be one of ${LIBRARY_KINDS.join(', ')}`); + if (!LIBRARY_FAMILIES.includes(raw.family)) + errors.push(`family must be one of ${LIBRARY_FAMILIES.join(', ')}`); + if (!isText(raw.title, 60)) errors.push('title is required (≤ 60 chars)'); + if (!isText(raw.provider, 32)) + errors.push('provider is required: a short source name (≤ 32 chars)'); + if (!isText(raw.description, 240)) + errors.push('description is required (≤ 240 chars)'); + if (raw.icon !== undefined && !isText(raw.icon, 4)) + errors.push('icon must be a short glyph'); + if (!isText(raw.maintainer, 80)) errors.push('maintainer is required'); + if (!LIBRARY_STATUSES.includes(raw.status)) + errors.push(`status must be one of ${LIBRARY_STATUSES.join(', ')}`); + if (raw.reviewed !== undefined && !DATE_PATTERN.test(raw.reviewed)) + errors.push('reviewed must be YYYY-MM-DD'); + checkCoverage(raw.coverage, errors); + if (LIBRARY_KINDS.includes(raw.kind)) checkSource(raw, errors); + checkDisplay(raw, errors); + if (raw.imagery !== undefined) checkImagery(raw, errors); + if (raw.kind === 'pack') { + const pack = raw.pack; + if ( + !isObject(pack) || + !PACK_HOSTS.includes(pack.host) || + !ENV_PATTERN.test(pack.env ?? '') + ) + errors.push( + `pack needs host (${PACK_HOSTS.join(', ')}) and its env switch`, + ); + } else if (raw.pack !== undefined) errors.push('pack applies to packs only'); + const { licence, attribution } = raw; + if ( + !isObject(licence) || + !isText(licence.name, 160) || + !httpsDocumentUrl(licence.url) || + (licence.operatorOptIn !== undefined && + typeof licence.operatorOptIn !== 'boolean') + ) + errors.push('licence needs name, an https url and optional operatorOptIn'); + if (raw.kind === 'pack' && licence?.operatorOptIn !== true) + errors.push('a pack is switched by its host: licence.operatorOptIn: true'); + if ( + !isObject(attribution) || + !isText(attribution.text, 200) || + (attribution.url !== undefined && !httpsDocumentUrl(attribution.url)) + ) + errors.push('attribution needs text and an optional https url'); + if (raw.regionsServed !== undefined && raw.regionsServed !== null) { + if ( + !Array.isArray(raw.regionsServed) || + !raw.regionsServed.length || + raw.regionsServed.some((code) => !COUNTRY_PATTERN.test(code)) + ) + errors.push('regionsServed must list ISO 3166-1 alpha-2 codes or null'); + } + if (errors.length) return { ok: false, errors, entry: null }; + return { ok: true, errors: [], entry: freezeEntry(raw) }; +} + +function deepFreeze(value) { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +/** Fill defaults so every consumer reads the same resolved caps. */ +function freezeEntry(raw) { + const entry = structuredClone(raw); + if (entry.kind !== 'pack') { + entry.source.cacheSeconds ??= DEFAULT_CACHE_SECONDS; + if (entry.kind === 'dataset') { + entry.source.maxBytes ??= DEFAULT_MAX_BYTES; + entry.source.maxFeatures ??= DEFAULT_MAX_FEATURES; + if (entry.source.format === 'arcgis') + entry.source.maxPages ??= DEFAULT_ARCGIS_PAGES; + } + entry.source.key ??= null; + } + entry.display ??= {}; + entry.licence.operatorOptIn ??= false; + entry.regionsServed ??= null; + entry.icon ??= entry.kind === 'tiles' ? '▦' : '◆'; + return deepFreeze(entry); +} + +/** + * Validate a whole catalog: every entry valid, ids unique. + * @param {unknown[]} manifests + * @returns {{ ok: boolean, errors: string[], entries: object[] }} + */ +export function validateLibraryCatalog(manifests) { + const errors = []; + const entries = []; + const ids = new Set(); + for (const [index, raw] of (manifests || []).entries()) { + const label = raw?.id || `#${index}`; + const result = validateLibraryManifest(raw); + for (const error of result.errors) errors.push(`${label}: ${error}`); + if (!result.ok) continue; + if (ids.has(result.entry.id)) { + errors.push(`${label}: duplicate id`); + continue; + } + ids.add(result.entry.id); + entries.push(result.entry); + } + return { ok: errors.length === 0, errors, entries }; +} + +/** The manager id a library entry's layer registers under. */ +export function libraryLayerId(entryId) { + return `${LIBRARY_LAYER_PREFIX}${entryId}`; +} + +/** The entry id behind a library layer id, or null. */ +export function libraryEntryIdOf(layerId) { + if (typeof layerId !== 'string' || !layerId.startsWith(LIBRARY_LAYER_PREFIX)) + return null; + const id = layerId.slice(LIBRARY_LAYER_PREFIX.length); + return LIBRARY_ID_PATTERN.test(id) ? id : null; +} + +/** + * Whether the operator switched an opt-in entry on. `GEV_LIBRARY_ENABLE` + * lists opt-in ids; `GEV_LIBRARY_DISABLE` removes any entry outright. + * @param {string|undefined} value Comma-separated ids. + * @returns {Set} + */ +export function parseLibraryIdList(value) { + return new Set( + String(value || '') + .split(',') + .map((id) => id.trim()) + .filter((id) => LIBRARY_ID_PATTERN.test(id)), + ); +} diff --git a/src/library/manifest.test.mjs b/src/library/manifest.test.mjs new file mode 100644 index 000000000..f65ef75c0 --- /dev/null +++ b/src/library/manifest.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryEntryIdOf, + libraryLayerId, + libraryUpstreamUrl, + parseLibraryIdList, + validateLibraryCatalog, + validateLibraryManifest, +} from './manifest.js'; + +const dataset = (overrides = {}) => ({ + id: 'example-points', + kind: 'dataset', + family: 'infrastructure', + title: 'Example points', + provider: 'Example', + description: 'Points from an example provider.', + maintainer: 'github:someone', + coverage: { global: true }, + source: { + format: 'geojson', + url: 'https://data.example.org/points.geojson', + fetch: 'browser-direct', + }, + licence: { + name: 'CC BY 4.0', + url: 'https://creativecommons.org/licenses/by/4.0/', + }, + attribution: { text: 'Example contributors' }, + status: 'community', + ...overrides, +}); + +test('a minimal dataset validates and gets its caps filled', () => { + const { ok, errors, entry } = validateLibraryManifest(dataset()); + assert.deepEqual(errors, []); + assert.equal(ok, true); + assert.equal(entry.source.cacheSeconds, 3600); + assert.equal(entry.source.maxFeatures, 10_000); + assert.equal(entry.source.maxBytes, 16 * 1024 * 1024); + assert.equal(entry.source.key, null); + assert.equal(entry.licence.operatorOptIn, false); + assert.ok(Object.isFrozen(entry.source)); +}); + +test('unknown fields are errors, so a typo cannot drop a cap or a licence', () => { + const result = validateLibraryManifest( + dataset({ license: {}, source: { ...dataset().source, maxFeature: 5 } }), + ); + assert.equal(result.ok, false); + assert.ok(result.errors.includes('license is not a known field')); + assert.ok(result.errors.includes('source.maxFeature is not a known field')); +}); + +test('upstream URLs must be https without credentials or fragments', () => { + assert.ok(libraryUpstreamUrl('https://a.example.org/x?y=1')); + assert.equal(libraryUpstreamUrl('http://a.example.org/x'), null); + assert.equal(libraryUpstreamUrl('https://u:p@a.example.org/x'), null); + assert.equal(libraryUpstreamUrl('https://a.example.org/x#f'), null); + assert.equal(libraryUpstreamUrl('https://127.0.0.1/x'), null); + assert.equal(libraryUpstreamUrl('https://localhost/x'), null); + assert.ok(libraryUpstreamUrl('https://t.example.org/{z}/{x}/{y}.png')); +}); + +test('a keyed source must be proxied, so a key never reaches the browser', () => { + const keyed = dataset({ + source: { + ...dataset().source, + key: { id: 'firms', in: 'query', name: 'MAP_KEY' }, + }, + }); + assert.ok( + validateLibraryManifest(keyed).errors.includes( + 'a keyed source must use fetch: proxied', + ), + ); + keyed.source.fetch = 'proxied'; + assert.equal(validateLibraryManifest(keyed).ok, true); +}); + +test('tiles need a {z}/{x}/{y} template and a sane zoom range', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}.png', + fetch: 'browser-direct', + minZoom: 9, + maxZoom: 4, + }, + }; + const { errors } = validateLibraryManifest(tiles); + assert.ok(errors.includes('source.url must contain {y} or {reverseY}')); + assert.ok(errors.includes('source.minZoom must not exceed source.maxZoom')); +}); + +test('a pack names its host switch and is always operator opt-in', () => { + const pack = { + ...dataset(), + kind: 'pack', + family: 'cameras', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_EXAMPLE_ENABLED' }, + }; + assert.ok( + validateLibraryManifest(pack).errors.includes( + 'a pack is switched by its host: licence.operatorOptIn: true', + ), + ); + pack.licence = { ...pack.licence, operatorOptIn: true }; + assert.equal(validateLibraryManifest(pack).ok, true); + pack.pack = { host: 'radio', env: 'X' }; + assert.equal(validateLibraryManifest(pack).ok, false); +}); + +test('provider entries are refused with a clear reason until the kind ships', () => { + assert.ok( + validateLibraryManifest(dataset({ kind: 'provider' })).errors.includes( + 'provider entries are not supported by this build yet', + ), + ); +}); + +test('coverage needs global, countries or a valid bbox', () => { + assert.ok( + validateLibraryManifest(dataset({ coverage: {} })).errors.includes( + 'coverage needs global, countries or bbox', + ), + ); + assert.equal( + validateLibraryManifest(dataset({ coverage: { countries: ['us'] } })).ok, + false, + ); + assert.equal( + validateLibraryManifest(dataset({ coverage: { bbox: [10, 0, 5, 1] } })).ok, + false, + ); +}); + +test('catalog validation rejects duplicate ids', () => { + const result = validateLibraryCatalog([dataset(), dataset()]); + assert.equal(result.ok, false); + assert.equal(result.entries.length, 1); + assert.match(result.errors[0], /duplicate id/); +}); + +test('library layer ids are namespaced and reversible', () => { + assert.equal(libraryLayerId('example-points'), 'lib-example-points'); + assert.equal(libraryEntryIdOf('lib-example-points'), 'example-points'); + assert.equal(libraryEntryIdOf('cctv'), null); + assert.equal(libraryEntryIdOf('lib-X'), null); +}); + +test('operator id lists keep only well-formed ids', () => { + assert.deepEqual( + [...parseLibraryIdList(' a-b-c , ,BAD,x, cctv-nyc-dot ')], + ['a-b-c', 'cctv-nyc-dot'], + ); +}); + +test('GeoParquet partitions must stay on the source host and be view-bounded', () => { + const parquet = dataset({ + source: { + format: 'geoparquet', + url: 'https://bucket.example.org/set/', + fetch: 'browser-direct', + partitions: { + listUrl: 'https://bucket.example.org?list-type=2&prefix=set/', + keyBase: 'https://elsewhere.example.org/', + }, + }, + }); + const { errors } = validateLibraryManifest(parquet); + assert.ok(errors.some((error) => /partitions needs/.test(error))); + assert.ok( + errors.includes('a partitioned source must be view-bounded (source.view)'), + ); +}); + +test('colorBy needs valid colours and readable legend groups', () => { + const coloured = dataset({ + display: { colorBy: { field: 'rating', colors: { A: '#008054' } } }, + }); + assert.equal(validateLibraryManifest(coloured).ok, true); + coloured.display.colorBy.colors.B = 'green'; + assert.ok( + validateLibraryManifest(coloured).errors.some((error) => + /colorBy needs a field/.test(error), + ), + ); +}); + +test('a static legend needs labelled colours and cannot double a colorBy legend', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}/{y}.png', + fetch: 'browser-direct', + }, + display: { legend: [{ label: '< 1', color: '#fff2d1' }] }, + }; + assert.equal(validateLibraryManifest(tiles).ok, true); + tiles.display.legend.push({ label: '', color: 'red' }); + assert.ok( + validateLibraryManifest(tiles).errors.some((error) => + /display.legend/.test(error), + ), + ); +}); + +test('aerial tiles may be bounded, kept on top and hide a no-data fill', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + coverage: { countries: ['NL'], bbox: [3.2, 50.7, 7.3, 53.6] }, + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}/{y}.jpeg', + fetch: 'browser-direct', + bounded: true, + }, + display: { aerial: true, noDataColor: '#ffffff' }, + }; + assert.deepEqual(validateLibraryManifest(tiles).errors, []); + const broken = { + ...tiles, + coverage: { global: true }, + display: { aerial: 'yes', noDataColor: 'white' }, + }; + const { errors } = validateLibraryManifest(broken); + assert.ok(errors.includes('source.bounded needs coverage.bbox')); + assert.ok(errors.includes('display.aerial is a boolean for tiles')); + assert.ok(errors.includes('display.noDataColor must be #rrggbb')); + const points = dataset({ display: { aerial: true, noDataColor: '#ffffff' } }); + const pointErrors = validateLibraryManifest(points).errors; + assert.ok(pointErrors.includes('display.aerial is a boolean for tiles')); + assert.ok(pointErrors.includes('display.noDataColor applies to tiles only')); +}); diff --git a/src/library/outlines.js b/src/library/outlines.js new file mode 100644 index 000000000..a51a6e4ed --- /dev/null +++ b/src/library/outlines.js @@ -0,0 +1,117 @@ +/** + * Coverage outlines for Data Library imagery: the borders of the countries an + * entry covers, so the edge of an aerial photo layer is plain on the map. + * + * Borders come from Natural Earth's 1:50m admin-0 countries (public domain), + * a provider-hosted file fetched live and kept for the session, so nothing is + * bundled. Pure apart from `loadCountryOutlines`, which takes `fetchImpl`. + */ +import { readResponseTextCapped } from '../sources/httpBody.js'; + +export const COUNTRY_OUTLINES_URL = + 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'; +/** The 1:50m file is about 3 MB. */ +const OUTLINES_MAX_BYTES = 8 * 1024 * 1024; +/** Outline modes a manifest may ask for. */ +export const OUTLINE_MODES = Object.freeze(['coverage', 'countries', 'bbox']); + +let pending = null; + +/** + * Natural Earth country features, fetched once per page. + * @param {typeof fetch} fetchImpl + * @returns {Promise} + */ +export function loadCountryOutlines( + fetchImpl = (...args) => globalThis.fetch(...args), +) { + pending ||= (async () => { + const response = await fetchImpl(COUNTRY_OUTLINES_URL, { + credentials: 'omit', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await readResponseTextCapped(response, OUTLINES_MAX_BYTES); + const features = JSON.parse(text)?.features; + if (!Array.isArray(features)) throw new Error('not a FeatureCollection'); + return features; + })().catch((error) => { + pending = null; + throw error; + }); + return pending; +} + +/** For tests: forget the cached outlines. */ +export function resetCountryOutlines() { + pending = null; +} + +const ringBox = (ring) => { + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const [lon, lat] of ring) { + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + return [west, south, east, north]; +}; + +const intersects = (a, b) => + a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]; + +/** + * The outer rings to draw for an entry. `countries` draws every part of each + * covered country; `coverage` only the parts inside the entry's coverage bbox + * (the European Netherlands, not its Caribbean islands). + * Natural Earth gives France and Norway ISO_A2 "-99"; ISO_A2_EH carries the + * code, so both are read. + * @param {object[]} features Natural Earth admin-0 features. + * @param {object} entry Validated library entry with `display.outline`. + * @returns {Array>} + */ +export function outlineRings(features, entry) { + const box = entry?.coverage?.bbox; + if (entry?.display?.outline === 'bbox') + return box + ? [ + [ + [box[0], box[1]], + [box[2], box[1]], + [box[2], box[3]], + [box[0], box[3]], + [box[0], box[1]], + ], + ] + : []; + const codes = new Set(entry?.coverage?.countries || []); + const bbox = entry?.coverage?.bbox; + const onlyInside = entry?.display?.outline === 'coverage' && bbox; + const rings = []; + for (const feature of features || []) { + const properties = feature?.properties || {}; + const code = [properties.ISO_A2_EH, properties.ISO_A2].find( + (value) => typeof value === 'string' && codes.has(value), + ); + if (!code) continue; + const { type, coordinates } = feature.geometry || {}; + const polygons = + type === 'Polygon' + ? [coordinates] + : type === 'MultiPolygon' + ? coordinates + : []; + for (const polygon of polygons) { + const outer = polygon?.[0]; + if (!Array.isArray(outer) || outer.length < 3) continue; + if (onlyInside && !intersects(ringBox(outer), bbox)) continue; + rings.push(outer); + } + } + return rings; +} diff --git a/src/library/outlines.test.mjs b/src/library/outlines.test.mjs new file mode 100644 index 000000000..a10dbaf18 --- /dev/null +++ b/src/library/outlines.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { outlineRings } from './outlines.js'; +import { libraryEntry } from './catalog.js'; + +const square = (lon, lat, size = 1) => [ + [lon, lat], + [lon + size, lat], + [lon + size, lat + size], + [lon, lat + size], + [lon, lat], +]; +const country = (code, polygons, iso = code) => ({ + properties: { ISO_A2_EH: code, ISO_A2: iso }, + geometry: { + type: 'MultiPolygon', + coordinates: polygons.map((ring) => [ring]), + }, +}); +const features = [ + // Netherlands: the European part and a Caribbean island. + country('NL', [square(4, 52), square(-68.3, 12.1, 0.3)]), + // Natural Earth writes France's ISO_A2 as -99; ISO_A2_EH carries FR. + country('FR', [square(2, 46), square(-53, 4)], '-99'), + country('BE', [square(4, 50.5)]), +]; + +test('coverage outlines keep only the parts inside the declared area', () => { + const rings = outlineRings(features, libraryEntry('pdok-aerial-netherlands')); + assert.equal(rings.length, 1); + assert.equal(rings[0][0][0], 4); +}); + +test('country outlines keep every part and read the ISO_A2_EH code', () => { + const everyPart = { + coverage: { countries: ['FR'] }, + display: { outline: 'countries' }, + }; + assert.equal( + outlineRings(features, everyPart).length, + 2, + 'metropolitan France and French Guiana', + ); + // IGN is clipped to metropolitan France, so its outline is too. + const ign = outlineRings(features, libraryEntry('ign-orthophotos-france')); + assert.equal(ign.length, 1); + assert.equal(ign[0][0][0], 2); + assert.deepEqual( + outlineRings(features, libraryEntry('nasa-black-marble')), + [], + ); +}); + +test('a bbox outline is the coverage rectangle, with no border file needed', () => { + const rings = outlineRings([], libraryEntry('lawrence-ks')); + const [west, south, east, north] = libraryEntry('lawrence-ks').coverage.bbox; + assert.deepEqual(rings, [ + [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ], + ]); +}); diff --git a/src/library/parquet.js b/src/library/parquet.js new file mode 100644 index 000000000..4d5e4f8dc --- /dev/null +++ b/src/library/parquet.js @@ -0,0 +1,246 @@ +/** + * GeoParquet for the Data Library: one file, or a set partitioned across an + * S3-style bucket (such as Source Cooperative), read straight from the + * provider with HTTP range requests. + * + * Portable (server and browser); the caller supplies `fetchImpl`. The + * browser imports this module lazily, so the Parquet reader costs nothing + * until a GeoParquet entry is switched on. + */ +import { parquetMetadataAsync, parquetReadObjects } from 'hyparquet'; +import { decompress as zstdDecompress } from 'fzstd'; +import { libraryUpstreamUrl } from './manifest.js'; +import { readResponseBytesCapped } from '../sources/httpBody.js'; + +/** Footer bytes read first; small files' metadata fits in one request. */ +const FOOTER_FETCH_BYTES = 16 * 1024; +/** Partitions read per view, and how many decoded ones stay in memory. */ +export const MAX_PARTITIONS_PER_VIEW = 4; +const MAX_LISTED_KEYS = 5000; +const MAX_LIST_PAGES = 20; +const INDEX_CONCURRENCY = 8; + +/** Decompressors hyparquet does not carry itself (it has Snappy built in). */ +export const PARQUET_COMPRESSORS = Object.freeze({ + ZSTD: (input) => zstdDecompress(input), +}); + +function onSourceHost(entry, url) { + const target = libraryUpstreamUrl(url); + const declared = libraryUpstreamUrl(entry.source.url); + if (!target || !declared || target.host !== declared.host) + throw Object.assign(new Error('off_manifest_host'), { status: 502 }); + return target.href; +} + +/** + * A hyparquet AsyncBuffer over HTTP range requests, redirects refused, + * credentials and referrer withheld. + */ +export function rangeBuffer(url, byteLength, { fetchImpl, signal }) { + return { + byteLength, + async slice(start, end = byteLength) { + const response = await fetchImpl(url, { + signal, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + headers: { Range: `bytes=${start}-${end - 1}` }, + }); + if (response.status !== 206 && response.status !== 200) { + await response.body?.cancel?.().catch?.(() => {}); + throw new Error(`HTTP ${response.status}`); + } + const buffer = await response.arrayBuffer(); + // A server that ignores Range answers with the whole file. + return response.status === 200 && buffer.byteLength === byteLength + ? buffer.slice(start, end) + : buffer; + }, + }; +} + +/** + * List the `.parquet` objects under an S3 ListObjectsV2 URL, following + * continuation tokens. + * @returns {Promise>} + */ +export async function listPartitions(entry, { fetchImpl, signal }) { + const { listUrl } = entry.source.partitions; + const objects = []; + let token = null; + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const url = new URL(onSourceHost(entry, listUrl)); + if (token) url.searchParams.set('continuation-token', token); + const response = await fetchImpl(url.href, { + signal, + redirect: 'error', + credentials: 'omit', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const xml = await response.text(); + for (const match of xml.matchAll( + /[\s\S]*?([^<]+)<\/Key>[\s\S]*?(\d+)<\/Size>[\s\S]*?<\/Contents>/g, + )) { + if (!match[1].endsWith('.parquet')) continue; + objects.push({ key: match[1], size: Number(match[2]) }); + if (objects.length >= MAX_LISTED_KEYS) return objects; + } + token = /([^<]+)<\/NextContinuationToken>/.exec( + xml, + )?.[1]; + if (!token) break; + } + return objects; +} + +/** The primary geometry column and its bbox from GeoParquet metadata. */ +export function geoParquetInfo(metadata) { + const raw = metadata?.key_value_metadata?.find( + ({ key }) => key === 'geo', + )?.value; + if (!raw) return { column: null, bbox: null }; + try { + const geo = JSON.parse(raw); + const column = geo.primary_column || Object.keys(geo.columns || {})[0]; + const bbox = geo.columns?.[column]?.bbox; + return { + column: column || null, + bbox: + Array.isArray(bbox) && bbox.length >= 4 && bbox.every(Number.isFinite) + ? bbox.length === 6 + ? [bbox[0], bbox[1], bbox[3], bbox[4]] + : bbox.slice(0, 4) + : null, + }; + } catch { + return { column: null, bbox: null }; + } +} + +/** + * Where each partition is: list the bucket, then read each file's footer + * (one small range request) for its GeoParquet bbox. Built once and cached + * by the caller; a partition without a bbox is left out. + * @returns {Promise>} + */ +export async function buildPartitionIndex(entry, { fetchImpl, signal }) { + const { keyBase } = entry.source.partitions; + const objects = await listPartitions(entry, { fetchImpl, signal }); + const index = []; + let next = 0; + async function worker() { + while (next < objects.length) { + const { key, size } = objects[next++]; + signal?.throwIfAborted?.(); + const url = onSourceHost(entry, new URL(key, keyBase).href); + try { + const metadata = await parquetMetadataAsync( + rangeBuffer(url, size, { fetchImpl, signal }), + { initialFetchSize: FOOTER_FETCH_BYTES }, + ); + const { bbox } = geoParquetInfo(metadata); + if (bbox) index.push({ url, bbox, size }); + } catch (error) { + if (signal?.aborted) throw error; + // One unreadable partition does not sink the index. + } + } + } + await Promise.all( + Array.from({ length: Math.min(INDEX_CONCURRENCY, objects.length) }, worker), + ); + return index.sort((a, b) => a.url.localeCompare(b.url)); +} + +const overlapArea = (a, b) => + Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])) * + Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1])); + +const boxArea = (box) => Math.max(1e-12, (box[2] - box[0]) * (box[3] - box[1])); + +/** + * The partitions a box touches, most specific first, at most `limit`. + * Ranked by the share of each partition's own bbox the box covers: a few + * stray geocodes can stretch an area's bbox across the country, and such a + * partition must not outrank the area actually under the camera. + */ +export function partitionsForBox(index, box, limit = MAX_PARTITIONS_PER_VIEW) { + return index + .map((partition) => { + const overlap = overlapArea(partition.bbox, box); + return { partition, overlap, score: overlap / boxArea(partition.bbox) }; + }) + .filter(({ overlap }) => overlap > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ partition }) => partition); +} + +/** The columns an entry needs: its geometry plus what it labels and colours. */ +export function neededColumns(entry, geometryColumn) { + const { display } = entry; + return [ + ...new Set( + [ + geometryColumn, + display.label, + ...(display.fields || []), + display.rank?.field, + display.colorBy?.field, + ] + .filter(Boolean) + .map((field) => field.split('.')[0]), + ), + ]; +} + +/** + * Read one GeoParquet file into GeoJSON features (geometry decoded from WKB + * by hyparquet), reading only the columns the entry uses. + * @returns {Promise} + */ +export async function readGeoParquetFeatures( + entry, + url, + { fetchImpl, signal, maxBytes = entry.source.maxBytes }, +) { + const response = await fetchImpl(onSourceHost(entry, url), { + signal, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = await readResponseBytesCapped(response, maxBytes, signal); + const file = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); + const metadata = await parquetMetadataAsync({ + byteLength: file.byteLength, + slice: async (start, end) => file.slice(start, end), + }); + const { column } = geoParquetInfo(metadata); + if (!column) throw new Error('not a GeoParquet file'); + const rows = await parquetReadObjects({ + file, + metadata, + columns: neededColumns(entry, column), + compressors: PARQUET_COMPRESSORS, + }); + return rows.map((row) => { + const { [column]: geometry, ...properties } = row; + return { type: 'Feature', geometry, properties }; + }); +} + +/** Whether a point or a geometry's first position lies in a box. */ +export function featureInBox(feature, box) { + let position = feature?.geometry?.coordinates; + while (Array.isArray(position?.[0])) position = position[0]; + if (!Array.isArray(position)) return false; + const [lon, lat] = position; + return lon >= box[0] && lon <= box[2] && lat >= box[1] && lat <= box[3]; +} diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs new file mode 100644 index 000000000..5608194e6 --- /dev/null +++ b/src/library/parquet.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + featureInBox, + geoParquetInfo, + neededColumns, + partitionsForBox, +} from './parquet.js'; +import { createLibrarySource, robustExtent } from './source.js'; +import { libraryEntry } from './catalog.js'; + +const epc = libraryEntry('epc-england-wales'); + +test('partitions rank by how specific their bbox is, not by name order', () => { + const index = [ + // A few stray geocodes stretch this area's bbox across the country. + { url: 'https://h/Barnsley.parquet', bbox: [-3, 51, 0, 54] }, + { url: 'https://h/Sheffield.parquet', bbox: [-1.6, 53.3, -1.3, 53.5] }, + { url: 'https://h/Far.parquet', bbox: [1, 52, 2, 53] }, + ]; + const picked = partitionsForBox(index, [-1.48, 53.37, -1.46, 53.385]); + assert.deepEqual( + picked.map(({ url }) => url.split('/').pop()), + ['Sheffield.parquet', 'Barnsley.parquet'], + ); +}); + +test('the GeoParquet footer gives the geometry column and its bbox', () => { + const metadata = { + key_value_metadata: [ + { + key: 'geo', + value: JSON.stringify({ + primary_column: 'geometry', + columns: { + geometry: { encoding: 'WKB', bbox: [-0.36, 50.82, -0.22, 50.86] }, + }, + }), + }, + ], + }; + assert.deepEqual(geoParquetInfo(metadata), { + column: 'geometry', + bbox: [-0.36, 50.82, -0.22, 50.86], + }); + assert.deepEqual(geoParquetInfo({ key_value_metadata: [] }), { + column: null, + bbox: null, + }); +}); + +test('only the columns the entry uses are read', () => { + assert.deepEqual(neededColumns(epc, 'geometry'), [ + 'geometry', + 'UPRN', + 'CURRENT_ENERGY_RATING', + 'POTENTIAL_ENERGY_RATING', + ]); +}); + +test('a learned extent ignores stray points', () => { + const features = Array.from({ length: 200 }, (_, index) => ({ + geometry: { type: 'Point', coordinates: [-1.4 - index / 1e4, 53.4] }, + })); + features.push({ geometry: { type: 'Point', coordinates: [-2.6, 51.36] } }); + const [west, south] = robustExtent(features); + assert.ok(west > -1.5 && south > 53, String([west, south])); + assert.equal(featureInBox(features[0], [-1.5, 53, -1.3, 54]), true); + assert.equal(featureInBox(features.at(-1), [-1.5, 53, -1.3, 54]), false); +}); + +test('a partitioned view reads candidates in batches until one finds features', async () => { + const index = [ + { url: 'https://h/A.parquet', bbox: [-1.5, 53.3, -1.4, 53.45] }, + { url: 'https://h/B.parquet', bbox: [-1.55, 53.3, -1.35, 53.5] }, + ]; + const reads = []; + const point = (lon, lat, rating) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { CURRENT_ENERGY_RATING: rating, UPRN: '1' }, + }); + const store = new Map(); + const source = createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => index, + readGeoParquetFeatures: async (entry, url) => { + reads.push(url); + return url.endsWith('A.parquet') + ? [point(-1.47, 53.38, 'C'), point(-1.2, 53.0, 'D')] + : [point(-1.47, 53.381, 'B')]; + }, + }), + }); + const box = [-1.48, 53.37, -1.46, 53.385]; + const first = await source.loadDataset(epc, { bbox: box }); + assert.equal(first.features.length, 2); + assert.deepEqual(reads.sort(), [ + 'https://h/A.parquet', + 'https://h/B.parquet', + ]); + // The index is kept in this browser; extents are learned per partition. + assert.ok(store.get('gev:library:index:epc-england-wales')); + const learned = JSON.parse(store.get('gev:library:extent:epc-england-wales')); + assert.ok(learned['https://h/A.parquet']); + // A second view of the same area is served from the decoded cache. + await source.loadDataset(epc, { bbox: box }); + assert.equal(reads.length, 2); + // Without a view box nothing is read. + assert.deepEqual(await source.loadDataset(epc), { + features: [], + truncated: false, + }); +}); diff --git a/src/library/relief.js b/src/library/relief.js new file mode 100644 index 000000000..910d8d32a --- /dev/null +++ b/src/library/relief.js @@ -0,0 +1,109 @@ +/** + * Shaded relief from Terrarium-encoded elevation tiles (Mapterhorn and the + * like): each pixel's height is decoded, coloured by elevation class and lit + * by a north-west sun, so a terrain-RGB tile reads as a map instead of as + * noise. Pure: it takes and returns RGBA pixel arrays, so it runs in node + * tests and in the browser alike. + */ +import { msg } from '../i18n/marker.js'; + +/** + * Elevation classes, low to high: one orange ramp, light to dark (validated + * as an ordinal ramp on the dark panel surface). `below` is the class's upper + * bound in metres. + */ +export const RELIEF_CLASSES = Object.freeze([ + { below: 250, color: '#fee1d6', label: msg('Below 250 m') }, + { below: 500, color: '#ffbca3', label: msg('250–500 m') }, + { below: 1000, color: '#f6936f', label: msg('500–1,000 m') }, + { below: 2000, color: '#e46d40', label: msg('1,000–2,000 m') }, + { below: 3000, color: '#c24e1d', label: msg('2,000–3,000 m') }, + { below: Infinity, color: '#983709', label: msg('3,000 m and above') }, +]); + +const EARTH_CIRCUMFERENCE_M = 40075016.686; +// A north-west sun (azimuth 315°) in the mathematical angle hillshade uses. +const SUN_ANGLE = ((360 - 315 + 90) * Math.PI) / 180; +const SUN_ALTITUDE = (45 * Math.PI) / 180; + +const hex = (value) => + [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16)); +const CLASS_RGB = RELIEF_CLASSES.map(({ color }) => hex(color)); + +/** Height in metres of one Terrarium pixel. */ +export function terrariumHeight(r, g, b) { + return r * 256 + g + b / 256 - 32768; +} + +/** Ground metres per pixel at a tile's centre latitude. */ +export function metresPerPixel(level, y, tileSize) { + const n = 2 ** level; + const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 0.5)) / n))); + return (EARTH_CIRCUMFERENCE_M * Math.cos(lat)) / (tileSize * n); +} + +function classIndex(height) { + for (let i = 0; i < RELIEF_CLASSES.length; i++) + if (height < RELIEF_CLASSES[i].below) return i; + return RELIEF_CLASSES.length - 1; +} + +/** + * Turn Terrarium RGBA pixels into shaded-relief RGBA pixels. Sea (encoded as + * exactly 0 m) is transparent; land below sea level keeps the lowest class. + * Slopes use Horn's method, clamping at the tile edge. + * @param {Uint8ClampedArray} input Terrarium RGBA, width × height × 4. + * @param {number} width + * @param {number} height + * @param {number} pixelMetres Ground size of one pixel. + * @returns {Uint8ClampedArray} + */ +export function terrariumToRelief(input, width, height, pixelMetres) { + const heights = new Float32Array(width * height); + for (let i = 0, p = 0; p < heights.length; i += 4, p++) + heights[p] = terrariumHeight(input[i], input[i + 1], input[i + 2]); + const at = (x, y) => + heights[ + Math.min(height - 1, Math.max(0, y)) * width + + Math.min(width - 1, Math.max(0, x)) + ]; + const out = new Uint8ClampedArray(width * height * 4); + const spacing = Math.max(pixelMetres, 1e-3) * 8; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = y * width + x; + const o = p * 4; + const h = heights[p]; + if (h === 0) continue; // sea: leave transparent + const dzdx = + (at(x + 1, y - 1) + + 2 * at(x + 1, y) + + at(x + 1, y + 1) - + (at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1))) / + spacing; + const dzdy = + (at(x - 1, y + 1) + + 2 * at(x, y + 1) + + at(x + 1, y + 1) - + (at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1))) / + spacing; + const slope = Math.atan(Math.hypot(dzdx, dzdy)); + const aspect = Math.atan2(dzdy, -dzdx); + const shade = Math.max( + 0, + Math.cos(SUN_ALTITUDE) * Math.cos(slope) + + Math.sin(SUN_ALTITUDE) * + Math.sin(slope) * + Math.cos(SUN_ANGLE - aspect), + ); + // Flat ground keeps its class colour; lit slopes brighten, shaded ones darken. + const light = 0.55 + 0.6 * shade; + const [r, g, b] = CLASS_RGB[classIndex(h)]; + out[o] = r * light; + out[o + 1] = g * light; + out[o + 2] = b * light; + out[o + 3] = 255; + } + } + return out; +} diff --git a/src/library/relief.test.mjs b/src/library/relief.test.mjs new file mode 100644 index 000000000..fc224ac46 --- /dev/null +++ b/src/library/relief.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RELIEF_CLASSES, + metresPerPixel, + terrariumHeight, + terrariumToRelief, +} from './relief.js'; +import { libraryEntry } from './catalog.js'; +import { validateLibraryManifest } from './manifest.js'; + +/** Terrarium RGB for a height in metres. */ +function encode(height) { + const value = height + 32768; + const r = Math.floor(value / 256); + const g = Math.floor(value - r * 256); + const b = Math.round((value - Math.floor(value)) * 256); + return [r, g, b, 255]; +} + +function tile(width, height, heightAt) { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + pixels.set(encode(heightAt(x, y)), (y * width + x) * 4); + return pixels; +} + +test('Terrarium pixels decode to metres', () => { + assert.equal(terrariumHeight(...encode(0)), 0); + assert.equal(terrariumHeight(...encode(4478)), 4478); + assert.equal(terrariumHeight(...encode(-432)), -432); +}); + +test('sea is transparent and flat land takes its elevation class colour', () => { + const flat = (height) => + terrariumToRelief( + tile(3, 3, () => height), + 3, + 3, + 30, + ); + assert.equal(flat(0)[3], 0, 'sea (exactly 0 m) stays transparent'); + const near = (out, hex) => { + const target = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return target.every((value, i) => Math.abs(out[16 + i] - value) <= 12); + }; + assert.equal(flat(-6)[19], 255); + assert.ok( + near(flat(-6), RELIEF_CLASSES[0].color), + 'land below sea level is the lowest class', + ); + assert.ok(near(flat(600), RELIEF_CLASSES[2].color), '600 m is 500–1,000 m'); + assert.ok( + near(flat(3500), RELIEF_CLASSES[5].color), + '3,500 m is the top class', + ); +}); + +test('a north-west sun lights north-west slopes and shades south-east ones', () => { + // Height rises to the south-east: the slope faces north-west, into the sun. + const facingSun = terrariumToRelief( + tile(5, 5, (x, y) => 400 + 40 * (x + y)), + 5, + 5, + 10, + ); + const facingAway = terrariumToRelief( + tile(5, 5, (x, y) => 800 - 40 * (x + y)), + 5, + 5, + 10, + ); + const centre = 12 * 4; + assert.ok(facingSun[centre] > facingAway[centre]); +}); + +test('pixel size shrinks with zoom and latitude', () => { + assert.ok(Math.abs(metresPerPixel(0, 0, 256) - 156543 * Math.cos(0)) > 0); + assert.ok(metresPerPixel(10, 300, 512) < metresPerPixel(9, 150, 512)); + const equator = metresPerPixel(4, 7.5, 512); + assert.ok(Math.abs(equator - 40075016.686 / (512 * 16)) / equator < 0.01); +}); + +test('only xyz tiles may declare an encoding and a 512 px tile size', () => { + const mapterhorn = libraryEntry('mapterhorn-terrain'); + assert.equal(mapterhorn.source.encoding, 'terrarium'); + assert.equal(mapterhorn.source.tileSize, 512); + const raw = structuredClone(mapterhorn); + raw.source.encoding = 'mapbox'; + raw.source.tileSize = 300; + const { errors } = validateLibraryManifest(raw); + assert.ok(errors.some((e) => e.startsWith('source.encoding is for tiles'))); + assert.ok(errors.includes('source.tileSize is 256 or 512, for tiles')); +}); diff --git a/src/library/selection.js b/src/library/selection.js new file mode 100644 index 000000000..513cedf4b --- /dev/null +++ b/src/library/selection.js @@ -0,0 +1,134 @@ +/** + * Which library entries a viewer has added and switched on, and how that + * travels in a share link. + * + * Library selections never use the core layer-token ledger. They persist in + * this browser under `gev:library:v1` and travel as `lib=.` with the + * two-character tokens from `ledger.json`. Tokens are permanent: a removed + * entry keeps its row so published links never change meaning. + */ +import { LIBRARY_ID_PATTERN } from './manifest.js'; + +export const LIBRARY_STORAGE_KEY = 'gev:library:v1'; +export const LIBRARY_SHARE_PARAM = 'lib'; +export const LIBRARY_TOKEN_PATTERN = /^[0-9a-z]{2}$/; +const MAX_SELECTED = 64; +const MAX_PARAM_CHARS = 3 * MAX_SELECTED; + +/** + * Validate the ledger: unique ids, unique two-character tokens, in order. + * @param {Array<[string, string]>} rows + * @returns {{ ok: boolean, errors: string[], tokenById: Map, idByToken: Map }} + */ +export function readLibraryLedger(rows) { + const errors = []; + const tokenById = new Map(); + const idByToken = new Map(); + for (const [index, row] of (rows || []).entries()) { + const [id, token] = Array.isArray(row) ? row : []; + if (!LIBRARY_ID_PATTERN.test(id ?? '')) + errors.push(`row ${index}: invalid id`); + else if (!LIBRARY_TOKEN_PATTERN.test(token ?? '')) + errors.push(`row ${index}: token must be two base-36 characters`); + else if (tokenById.has(id)) errors.push(`row ${index}: duplicate id ${id}`); + else if (idByToken.has(token)) + errors.push(`row ${index}: duplicate token ${token}`); + else { + tokenById.set(id, token); + idByToken.set(token, id); + } + } + return { ok: errors.length === 0, errors, tokenById, idByToken }; +} + +/** + * The next free token after the ledger's highest one ('00', '01', … 'zz'). + * @param {Array<[string, string]>} rows + * @returns {string|null} + */ +export function nextLibraryToken(rows) { + const used = (rows || []).map(([, token]) => Number.parseInt(token, 36)); + const next = used.length ? Math.max(...used) + 1 : 0; + if (next >= 36 * 36) return null; + return next.toString(36).padStart(2, '0'); +} + +function cleanIds(ids, allowed) { + const result = []; + for (const id of Array.isArray(ids) ? ids : []) { + if (typeof id !== 'string' || !LIBRARY_ID_PATTERN.test(id)) continue; + if (allowed && !allowed.has(id)) continue; + if (!result.includes(id)) result.push(id); + if (result.length === MAX_SELECTED) break; + } + return result; +} + +/** + * Normalize a stored or decoded selection. `on` is always a subset of + * `added`. + * @param {unknown} raw + * @param {Set} [allowed] Known entry ids. + * @returns {{ added: string[], on: string[] }} + */ +export function normalizeLibrarySelection(raw, allowed) { + const added = cleanIds(raw?.added, allowed); + const on = cleanIds(raw?.on, allowed).filter((id) => added.includes(id)); + return { added, on }; +} + +/** Read this browser's selection; an unreadable store reads as empty. */ +export function readStoredLibrarySelection(storage, allowed) { + try { + const text = storage?.getItem?.(LIBRARY_STORAGE_KEY); + return normalizeLibrarySelection(text ? JSON.parse(text) : null, allowed); + } catch { + return { added: [], on: [] }; + } +} + +/** Write this browser's selection; a refused write is not an error. */ +export function writeStoredLibrarySelection(storage, selection) { + try { + storage?.setItem?.( + LIBRARY_STORAGE_KEY, + JSON.stringify(normalizeLibrarySelection(selection)), + ); + return true; + } catch { + return false; + } +} + +/** + * Encode the switched-on entries as `lib=`; removes the param when none. + * @param {URLSearchParams} params + * @param {string[]} onIds + * @param {Map} tokenById + */ +export function encodeLibraryParam(params, onIds, tokenById) { + const tokens = cleanIds(onIds) + .map((id) => tokenById.get(id)) + .filter(Boolean); + if (tokens.length) params.set(LIBRARY_SHARE_PARAM, tokens.join('.')); + else params.delete(LIBRARY_SHARE_PARAM); +} + +/** + * Decode `lib=`. Unknown tokens are skipped, not fatal: a link made on a + * newer build still restores the entries this build knows. + * @param {URLSearchParams} params + * @param {Map} idByToken + * @returns {string[]|null} Entry ids, or null when the link has no `lib`. + */ +export function decodeLibraryParam(params, idByToken) { + const value = params?.get?.(LIBRARY_SHARE_PARAM); + if (value === null || value === undefined) return null; + if (value.length > MAX_PARAM_CHARS) return []; + return cleanIds( + value + .split('.') + .filter((token) => LIBRARY_TOKEN_PATTERN.test(token)) + .map((token) => idByToken.get(token)), + ); +} diff --git a/src/library/selection.test.mjs b/src/library/selection.test.mjs new file mode 100644 index 000000000..3ab8b9e0f --- /dev/null +++ b/src/library/selection.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LIBRARY_STORAGE_KEY, + decodeLibraryParam, + encodeLibraryParam, + nextLibraryToken, + normalizeLibrarySelection, + readLibraryLedger, + readStoredLibrarySelection, + writeStoredLibrarySelection, +} from './selection.js'; + +const rows = [ + ['nasa-eonet-events', '00'], + ['eia-power-plants', '01'], +]; + +test('the ledger maps ids to two-character tokens both ways', () => { + const ledger = readLibraryLedger(rows); + assert.equal(ledger.ok, true); + assert.equal(ledger.tokenById.get('eia-power-plants'), '01'); + assert.equal(ledger.idByToken.get('00'), 'nasa-eonet-events'); + assert.equal(readLibraryLedger([...rows, ['other-entry', '01']]).ok, false); + assert.equal(readLibraryLedger([['bad-token', 'a']]).ok, false); + assert.equal(nextLibraryToken(rows), '02'); + assert.equal(nextLibraryToken([]), '00'); + assert.equal(nextLibraryToken([['x-y-z', 'zz']]), null); +}); + +test('lib= round-trips the switched-on entries and skips unknown tokens', () => { + const ledger = readLibraryLedger(rows); + const params = new URLSearchParams('lat=1'); + encodeLibraryParam( + params, + ['eia-power-plants', 'nasa-eonet-events', 'missing-entry'], + ledger.tokenById, + ); + assert.equal(params.get('lib'), '01.00'); + assert.deepEqual(decodeLibraryParam(params, ledger.idByToken), [ + 'eia-power-plants', + 'nasa-eonet-events', + ]); + assert.deepEqual( + decodeLibraryParam(new URLSearchParams('lib=zz.01.!!'), ledger.idByToken), + ['eia-power-plants'], + ); + assert.equal( + decodeLibraryParam(new URLSearchParams('lat=1'), ledger.idByToken), + null, + ); + encodeLibraryParam(params, [], ledger.tokenById); + assert.equal(params.has('lib'), false); +}); + +test('a selection keeps on within added and drops unknown ids', () => { + const allowed = new Set(['a-entry', 'b-entry']); + assert.deepEqual( + normalizeLibrarySelection( + { + added: ['a-entry', 'a-entry', 'zzz-unknown', 7], + on: ['b-entry', 'a-entry'], + }, + allowed, + ), + { added: ['a-entry'], on: ['a-entry'] }, + ); +}); + +test('storage failures read as empty and never throw', () => { + const throwing = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + assert.deepEqual(readStoredLibrarySelection(throwing), { added: [], on: [] }); + assert.equal( + writeStoredLibrarySelection(throwing, { added: ['a-entry'] }), + false, + ); + const store = new Map(); + const storage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }; + writeStoredLibrarySelection(storage, { added: ['a-entry'], on: ['a-entry'] }); + assert.deepEqual(JSON.parse(store.get(LIBRARY_STORAGE_KEY)), { + added: ['a-entry'], + on: ['a-entry'], + }); + store.set(LIBRARY_STORAGE_KEY, '{not json'); + assert.deepEqual(readStoredLibrarySelection(storage), { added: [], on: [] }); +}); diff --git a/src/library/source.js b/src/library/source.js new file mode 100644 index 000000000..8e15ec0d0 --- /dev/null +++ b/src/library/source.js @@ -0,0 +1,341 @@ +/** + * The browser's side of the Data Library: what this instance offers, and how + * an entry's data reaches the page. + * + * Browser-direct entries are fetched from their upstream without cookies or + * a referrer; proxied entries go through `/api/library`, which names entries + * by id only. A static build with no server still serves every browser-direct + * entry that is not operator opt-in. + */ +import { loadLibraryDataset, boundLibraryFeatures } from './formats.js'; +import { readResponseTextCapped } from '../sources/httpBody.js'; +import { BBOX_TOKEN } from './manifest.js'; + +/** Partitions read per view before giving up, in parallel batches. */ +const MAX_PARTITION_READS_PER_VIEW = 8; +const PARTITION_BATCH = 4; +/** Decoded partitions kept in memory, most recent first. */ +const PARTITION_CACHE_LIMIT = 4; +const INDEX_STORAGE_PREFIX = 'gev:library:index:'; +const EXTENT_STORAGE_PREFIX = 'gev:library:extent:'; + +const overlaps = (a, b) => + a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]; + +/** + * Where a partition's points really are: the box holding 98 % of them, so a + * few stray geocodes do not stretch it across the country. + */ +export function robustExtent(features) { + const lons = []; + const lats = []; + for (const feature of features) { + let position = feature?.geometry?.coordinates; + while (Array.isArray(position?.[0])) position = position[0]; + if (Array.isArray(position)) { + lons.push(position[0]); + lats.push(position[1]); + } + } + if (!lons.length) return null; + const at = (values, share) => { + values.sort((a, b) => a - b); + return values[ + Math.min(values.length - 1, Math.floor(share * values.length)) + ]; + }; + return [at(lons, 0.01), at(lats, 0.01), at(lons, 0.99), at(lats, 0.99)]; +} + +function storedJson(storage, key) { + try { + const text = storage?.getItem?.(key); + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function storeJson(storage, key, value) { + try { + storage?.setItem?.(key, JSON.stringify(value)); + } catch { + /* a full or blocked store only costs a rebuild later */ + } +} + +export const LIBRARY_API_BASE = '/api/library'; + +/** Reason codes the server or this module can report for an entry. */ +export const LIBRARY_UNAVAILABLE_REASONS = Object.freeze([ + 'operator-disabled', + 'operator-opt-in', + 'key-missing', + 'server-unavailable', +]); + +/** + * Availability when no library server answers: browser-direct entries work, + * proxied entries and anything the operator must switch on do not. + * @param {object[]} entries + * @returns {Map} + */ +export function staticLibraryAvailability(entries) { + return new Map( + entries.map((entry) => { + let reason = null; + if (entry.kind === 'pack' || entry.licence.operatorOptIn) + reason = 'operator-opt-in'; + else if (entry.source.fetch === 'proxied') reason = 'server-unavailable'; + return [entry.id, { available: reason === null, reason }]; + }), + ); +} + +/** + * @param {{ entries: object[], fetchImpl?: typeof fetch, base?: string }} options + */ +export function createLibrarySource({ + entries, + fetchImpl = (...args) => globalThis.fetch(...args), + base = LIBRARY_API_BASE, + storage = (() => { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } + })(), + now = () => Date.now(), + loadParquet = () => import('./parquet.js'), +}) { + const known = new Set(entries.map(({ id }) => id)); + const indexes = new Map(); + const partitionCache = new Map(); + + /** + * A partitioned entry's index: from this app's server (built once and + * shared), else built here and kept in this browser for `cacheSeconds`. + */ + function partitionIndex(entry, { signal } = {}) { + if (!indexes.has(entry.id)) + indexes.set( + entry.id, + (async () => { + try { + const response = await fetchImpl( + `${base}/index/${encodeURIComponent(entry.id)}`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (response.ok) { + const body = await response.json(); + if (Array.isArray(body?.partitions)) return body.partitions; + } + } catch (error) { + if (signal?.aborted) throw error; + } + const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; + const stored = storedJson(storage, key); + if ( + stored && + Array.isArray(stored.partitions) && + now() - stored.savedAt < entry.source.cacheSeconds * 1000 + ) + return stored.partitions; + const { buildPartitionIndex } = await loadParquet(); + const partitions = await buildPartitionIndex(entry, { + fetchImpl, + signal, + }); + storeJson(storage, key, { savedAt: now(), partitions }); + return partitions; + })().catch((error) => { + indexes.delete(entry.id); + throw error; + }), + ); + return indexes.get(entry.id); + } + + /** One partition's features, decoded once and kept for a few views. */ + async function partitionFeatures(entry, partition, { signal }) { + const cached = partitionCache.get(partition.url); + if (cached) { + partitionCache.delete(partition.url); + partitionCache.set(partition.url, cached); + return cached; + } + const { readGeoParquetFeatures } = await loadParquet(); + const features = await readGeoParquetFeatures(entry, partition.url, { + fetchImpl, + signal, + }); + partitionCache.set(partition.url, features); + while (partitionCache.size > PARTITION_CACHE_LIMIT) + partitionCache.delete(partitionCache.keys().next().value); + // Learn where this partition really is, for every later view. + const extent = robustExtent(features); + if (extent) { + const key = `${EXTENT_STORAGE_PREFIX}${entry.id}`; + const learned = storedJson(storage, key) || {}; + learned[partition.url] = extent; + storeJson(storage, key, learned); + } + return features; + } + + /** + * The features of a partitioned GeoParquet entry inside a box. Candidates + * come from the index, ranked by the partitions' footer bboxes; those whose + * learned extent misses the box are skipped; the rest are read in parallel + * batches until one batch finds features in view. + */ + async function loadPartitioned(entry, box, { signal }) { + const [{ partitionsForBox, featureInBox }, index] = await Promise.all([ + loadParquet(), + partitionIndex(entry, { signal }), + ]); + const learned = + storedJson(storage, `${EXTENT_STORAGE_PREFIX}${entry.id}`) || {}; + const candidates = partitionsForBox(index, box, Infinity) + .filter(({ url }) => !learned[url] || overlaps(learned[url], box)) + .slice(0, MAX_PARTITION_READS_PER_VIEW); + const inView = []; + for (let start = 0; start < candidates.length; start += PARTITION_BATCH) { + const batch = candidates.slice(start, start + PARTITION_BATCH); + const results = await Promise.all( + batch.map((partition) => + partitionFeatures(entry, partition, { signal }), + ), + ); + signal?.throwIfAborted?.(); + let found = false; + for (const features of results) { + const hits = features.filter((feature) => featureInBox(feature, box)); + if (hits.length) { + found = true; + inView.push(...hits); + } + } + if (found) break; + } + const bounded = boundLibraryFeatures(inView, { + maxFeatures: entry.source.maxFeatures, + display: entry.display, + }); + return { features: bounded.features, truncated: bounded.truncated }; + } + + /** + * What this instance can serve. Falls back to the static rule when the + * server has no library route (a static build) or does not answer. + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise<{ server: boolean, availability: Map }>} + */ + async function getAvailability({ signal } = {}) { + try { + const response = await fetchImpl(`${base}/catalog`, { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + const availability = staticLibraryAvailability(entries); + for (const row of Array.isArray(body?.entries) ? body.entries : []) { + if (!known.has(row?.id)) continue; + availability.set(row.id, { + available: row.available === true, + reason: row.available === true ? null : String(row.reason || ''), + }); + } + return { server: true, availability }; + } catch (error) { + if (signal?.aborted) throw error; + return { + server: false, + availability: staticLibraryAvailability(entries), + }; + } + } + + /** + * Load a dataset entry's features. + * @param {object} entry + * @param {{ signal?: AbortSignal, bbox?: number[]|null }} [options] `bbox` + * bounds a view-bounded ArcGIS entry to the camera's box. + * @returns {Promise<{ features: object[], truncated: boolean }>} + */ + async function loadDataset(entry, { signal, bbox = null } = {}) { + if (entry.source.format === 'geoparquet') { + if (entry.source.partitions) { + if (!bbox) return { features: [], truncated: false }; + return loadPartitioned(entry, bbox, { signal }); + } + const { readGeoParquetFeatures, featureInBox } = await loadParquet(); + const features = await readGeoParquetFeatures(entry, entry.source.url, { + fetchImpl, + signal, + }); + const bounded = boundLibraryFeatures( + bbox + ? features.filter((feature) => featureInBox(feature, bbox)) + : features, + { maxFeatures: entry.source.maxFeatures, display: entry.display }, + ); + return { features: bounded.features, truncated: bounded.truncated }; + } + if (entry.source.fetch === 'proxied') { + const response = await fetchImpl( + `${base}/data/${encodeURIComponent(entry.id)}`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + // The server already bounded the set; bound again so a page never + // trusts more than its manifest allows. + const bounded = boundLibraryFeatures(body?.features || [], { + maxFeatures: entry.source.maxFeatures, + display: entry.display, + }); + return { + features: bounded.features, + truncated: Boolean(body?.truncated) || bounded.truncated, + }; + } + const collection = await loadLibraryDataset( + entry, + async (url, budget) => { + const response = await fetchImpl(url, { + signal, + mode: 'cors', + credentials: 'omit', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await readResponseTextCapped( + response, + budget.remainingBytes, + signal, + ); + budget.remainingBytes -= new TextEncoder().encode(text).byteLength; + return text; + }, + { bbox }, + ); + return { features: collection.features, truncated: collection.truncated }; + } + + /** The tile URL template the imagery provider requests. */ + function tileTemplate(entry) { + if (entry.source.fetch === 'proxied') + return `${base}/tile/${encodeURIComponent(entry.id)}/{z}/{x}/{y}`; + // Cesium fills a tile's projected corners itself. + return entry.source.url.replaceAll( + BBOX_TOKEN, + '{westProjected},{southProjected},{eastProjected},{northProjected}', + ); + } + + return { getAvailability, loadDataset, tileTemplate }; +} diff --git a/src/library/tileLayer.test.mjs b/src/library/tileLayer.test.mjs new file mode 100644 index 000000000..c50df4b9a --- /dev/null +++ b/src/library/tileLayer.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createLibraryTileLayer } from './layers.js'; +import { LIBRARY_ENTRIES, libraryEntry } from './catalog.js'; +import { libraryTileUrl } from './manifest.js'; +import { createLibrarySource } from './source.js'; + +/** A tiles entry without its border outline, so no network is touched. */ +function entryWithoutOutline(id) { + const entry = libraryEntry(id); + const { outline: _outline, ...display } = entry.display; + return { ...entry, display }; +} + +function globeViewer() { + return { + scene: { globe: { show: true } }, + imageryLayers: new Cesium.ImageryLayerCollection(), + dataSources: { add: async () => {}, remove: () => {} }, + }; +} + +const source = { tileTemplate: (entry) => entry.source.url }; +const tileProvider = (url) => + new Cesium.UrlTemplateImageryProvider({ + url, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + }); + +test('aerial imagery drapes above every other imagery layer', async () => { + const viewer = globeViewer(); + const collection = viewer.imageryLayers; + const base = collection.addImageryProvider( + tileProvider('https://base.example.org/{z}/{x}/{y}.png'), + ); + const photos = createLibraryTileLayer( + entryWithoutOutline('pdok-aerial-netherlands'), + { source, cesium: Cesium }, + ); + await photos.init(viewer); + await photos.enable(viewer); + const top = () => collection.get(collection.length - 1); + assert.equal(collection.length, 2); + assert.notEqual(top(), base); + const pdok = top(); + + // A drape added later (weather, another library basemap) goes under it. + const weather = collection.addImageryProvider( + tileProvider('https://weather.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(top(), pdok); + assert.equal(collection.indexOf(weather), 1); + + // PDOK's white "no imagery here" fill is drawn transparent, and its tiles + // are requested only inside the Netherlands bbox. + assert.ok(pdok.colorToAlpha.equals(Cesium.Color.WHITE)); + assert.ok( + pdok.colorToAlphaThreshold > 0 && pdok.colorToAlphaThreshold < 0.05, + ); + const west = Cesium.Math.toDegrees(pdok.imageryProvider.rectangle.west); + assert.ok(Math.abs(west - 3.2) < 1e-9); + + // Off: the drape and its listener are gone, so later layers stay put. + await photos.disable(); + assert.equal(collection.contains(pdok), false); + const later = collection.addImageryProvider( + tileProvider('https://later.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(top(), later); +}); + +test('a non-aerial tile layer keeps its place and its own colours', async () => { + const viewer = globeViewer(); + const collection = viewer.imageryLayers; + const topo = createLibraryTileLayer(entryWithoutOutline('usgs-topo'), { + source, + cesium: Cesium, + }); + await topo.init(viewer); + await topo.enable(viewer); + const drape = collection.get(0); + collection.addImageryProvider( + tileProvider('https://weather.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(collection.indexOf(drape), 0); + assert.equal(drape.colorToAlpha, undefined); + await topo.destroy(); +}); + +test('every shipped aerial photo entry is clipped to its coverage', () => { + const aerial = LIBRARY_ENTRIES.filter((entry) => entry.display.aerial); + assert.ok(aerial.length >= 60, 'the Open Imagery Register entries ship'); + for (const entry of aerial) { + // Kept on top, an unclipped drape would cover the whole globe with the + // provider's out-of-area fill. + assert.equal(entry.source.bounded, true, entry.id); + assert.ok(entry.imagery?.resolution, `${entry.id} names its resolution`); + } +}); + +test('bbox and TMS templates become the URLs Cesium and the proxy request', () => { + const source = createLibrarySource({ + entries: [], + base: '/api/library', + storage: null, + }); + const wms = { + id: 'example-wms', + source: { + fetch: 'browser-direct', + url: 'https://w.example.org/wms?bbox={bbox-epsg-3857}&width=256', + }, + }; + assert.equal( + source.tileTemplate(wms), + 'https://w.example.org/wms?bbox={westProjected},{southProjected},{eastProjected},{northProjected}&width=256', + ); + assert.equal( + source.tileTemplate({ + ...wms, + source: { ...wms.source, fetch: 'proxied' }, + }), + '/api/library/tile/example-wms/{z}/{x}/{y}', + ); + // The proxy fills the same tile's bounds in EPSG:3857 metres. + assert.equal( + libraryTileUrl(wms.source.url, 1, 1, 0), + 'https://w.example.org/wms?bbox=0.000,0.000,20037508.343,20037508.343&width=256', + ); + assert.equal( + libraryTileUrl('https://t.example.org/{z}/{x}/{reverseY}.png', 3, 2, 1), + 'https://t.example.org/3/2/6.png', + ); +}); diff --git a/src/library/viewLayer.test.mjs b/src/library/viewLayer.test.mjs new file mode 100644 index 000000000..9f9fd7abc --- /dev/null +++ b/src/library/viewLayer.test.mjs @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createLibraryViewLayer } from './layers.js'; +import { libraryEntry } from './catalog.js'; + +const entry = libraryEntry('fema-usa-structures'); + +function fakeViewer({ height, lon = -77.03, lat = 38.89 }) { + const listeners = new Set(); + const added = []; + const viewer = { + camera: { + positionCartographic: { height }, + positionWC: Cesium.Cartesian3.fromDegrees(lon, lat, height), + moveEnd: { + addEventListener: (listener) => listeners.add(listener), + removeEventListener: (listener) => listeners.delete(listener), + }, + pickEllipsoid: () => Cesium.Cartesian3.fromDegrees(lon, lat, 0), + computeViewRectangle: () => + Cesium.Rectangle.fromDegrees(lon - 1, lat - 1, lon + 1, lat + 1), + }, + scene: { canvas: { clientWidth: 800, clientHeight: 600 }, globe: {} }, + dataSources: { + add: async (source) => added.push(source), + remove: (source) => added.splice(added.indexOf(source), 1), + }, + }; + return { viewer, listeners, added }; +} + +const fakeCesium = { + ...Cesium, + GeoJsonDataSource: { + load: async (collection) => ({ + collection, + entities: { values: collection.features.map(() => ({ polygon: {} })) }, + }), + }, +}; + +const polygon = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77.03, 38.89], + [-77.029, 38.89], + [-77.029, 38.891], + [-77.03, 38.89], + ], + ], + }, + properties: { OCC_CLS: 'Government' }, +}; + +test('above its altitude a view-bounded layer loads nothing and asks to zoom in', async () => { + const calls = []; + const layer = createLibraryViewLayer(entry, { + source: { loadDataset: async (...args) => calls.push(args) }, + cesium: fakeCesium, + }); + const { viewer, listeners } = fakeViewer({ height: 900_000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.equal(calls.length, 0); + assert.equal(listeners.size, 1); + assert.deepEqual(layer.getStats(), { + count: 0, + lastUpdate: null, + status: 'zoom-in', + statusMessage: 'zoom in to load this layer', + }); + await layer.disable(viewer); + assert.equal(listeners.size, 0); +}); + +test('below it, the layer loads the box around the view centre and reads PARTIAL when capped', async () => { + const calls = []; + const layer = createLibraryViewLayer(entry, { + source: { + loadDataset: async (target, options) => { + calls.push(options.bbox); + return { features: [polygon, polygon], truncated: true }; + }, + }, + cesium: fakeCesium, + }); + const { viewer, added } = fakeViewer({ height: 2000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.equal(calls.length, 1); + const [west, south, east, north] = calls[0]; + // A window around the centre sized by height (±1 km here), not the + // ±1° visible rectangle. + assert.ok(north - south < 0.05 && north - south > 0.005, String(calls[0])); + assert.ok(west < -77.03 && east > -77.03 && south < 38.89 && north > 38.89); + assert.equal(added.length, 1); + assert.equal(added[0].entities.values[0].polygon.outline, false); + const stats = layer.getStats(); + assert.equal(stats.count, 2); + assert.equal(stats.partial, true); + // The same view does not refetch. + await layer.enable(viewer); + assert.equal(calls.length, 1); + assert.deepEqual(layer.getExtent(), { + west: -77.04, + south: 38.885, + east: -77.02, + north: 38.9, + maxAltitudeM: 4000, + }); + await layer.destroy(viewer); + assert.equal(added.length, 0); +}); + +test('the row legend labels each colour and counts what is in view', async () => { + const { colorByLegend } = await import('./layers.js'); + const bridges = libraryEntry('ntad-national-bridges'); + assert.deepEqual(colorByLegend(bridges.display), [ + { label: 'Good', color: '#0ca30c' }, + { label: 'Fair', color: '#fab219' }, + { label: 'Poor', color: '#d03b3b' }, + ]); + assert.deepEqual( + colorByLegend(bridges.display, new Map([['P', 2]])).map( + ({ count }) => count, + ), + [0, 0, 2], + ); + // A tile layer's static scale. + assert.equal( + colorByLegend(libraryEntry('gpw-population-density').display).length, + 6, + ); + assert.deepEqual( + colorByLegend(libraryEntry('nasa-black-marble').display), + [], + ); + // A loaded view layer counts by value. + const layer = createLibraryViewLayer(bridges, { + source: { + loadDataset: async () => ({ + features: [ + { ...polygon, properties: { __gevColorKey: 'G' } }, + { ...polygon, properties: { __gevColorKey: 'G' } }, + { ...polygon, properties: { __gevColorKey: 'P' } }, + ], + truncated: false, + }), + }, + cesium: fakeCesium, + }); + const { viewer } = fakeViewer({ height: 3000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.deepEqual( + layer.getRowControls().legend.map(({ label, count }) => [label, count]), + [ + ['Good', 2], + ['Fair', 0], + ['Poor', 1], + ], + ); + await layer.destroy(viewer); +}); + +test('values sharing a label are one legend group, and uncoloured values count as Other', async () => { + const { colorByLegend } = await import('./layers.js'); + const legend = colorByLegend( + libraryEntry('wri-global-power-plants').display, + new Map([ + ['Coal', 3], + ['Gas', 2], + ['Wind', 4], + ['Biomass', 1], + ]), + ); + assert.deepEqual( + legend.map(({ label, count }) => [label, count]), + [ + ['Fossil (coal, gas, oil)', 5], + ['Nuclear', 0], + ['Hydro', 0], + ['Wind and solar', 4], + ['Other', 1], + ], + ); + assert.equal(legend.at(-1).color, '#a0aec0'); +}); diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index d2ed6ff39..b2f7c33a6 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({ fire: msg('Exit cockpit to fly to a fire'), route: msg('Exit cockpit to fly to a route'), file: msg('Exit cockpit to fly to a file'), + layer: msg('Exit cockpit to fly to a layer'), scene: msg('Exit cockpit to fly to a scene'), // Radar, satellite and lightning fly to the layer's coverage area (their // «View coverage» button); the cyclone layer flies to the selected storm. diff --git a/src/sharelink.js b/src/sharelink.js index 6072fb1bd..d2496260e 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -121,6 +121,7 @@ export class ShareLinkManager { this._scopeTerminusPct = null; this._mapStack = 'photoreal'; this._layerStateProvider = null; + this._libraryParamsEncoder = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; @@ -402,6 +403,20 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the Data Library's `lib=` writer. Library selections travel in + * their own field and never touch the core layer-token ledger. + * @param {((params: URLSearchParams) => void) | null} encoder + */ + setLibraryParamsEncoder(encoder) { + this._libraryParamsEncoder = typeof encoder === 'function' ? encoder : null; + } + + /** Called when the Data Library selection changes. */ + onLibraryStateChange() { + this._scheduleUpdate(); + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -590,6 +605,7 @@ export class ShareLinkManager { params.set('map', this._mapStack); const layerState = this._layerStateProvider?.(); if (layerState) encodeLayerStateParams(params, layerState); + this._libraryParamsEncoder?.(params); this._encodePanelStateParam(params, this._panelStateProvider?.()); encodeStyleParamState( params, @@ -622,6 +638,7 @@ export class ShareLinkManager { this._removeCameraChanged?.(); this._removeCameraChanged = null; this._layerStateProvider = null; + this._libraryParamsEncoder = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._onRestore = null; diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 04c24b92d..a8ae86e76 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -116,6 +116,9 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/mapillary/status', 200], ['/api/mapillary/tiles/coverage/14/1/2', 503], ['/api/radio/unknown', 404], + ['/api/library/catalog', 200], + ['/api/library/tile/nasa-black-marble/0/0/0', 404], + ['/api/library/tile/gvp-holocene-volcanoes/0/0/0', 404], ['/api/setup/status', isPreview ? 404 : 200], ['/api/setup/update', 404], ['/api/does-not-exist', 404], diff --git a/src/ui/chipGroup.js b/src/ui/chipGroup.js index 24eb44f3b..93b399ce6 100644 --- a/src/ui/chipGroup.js +++ b/src/ui/chipGroup.js @@ -25,9 +25,26 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) { set( button, 'className', - `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`, + `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}${chip.icon ? ' chip-icon' : ''}`, ); - set(button, 'textContent', chip.label); + if (chip.icon) { + // An icon chip shows a Material Symbol (named in index.html's + // `icon_names` subset); its label is the accessible name and tooltip. + let glyph = button.children[0]; + if (!glyph?.classList?.contains('material-symbols-outlined')) { + button.textContent = ''; + glyph = document.createElement('span'); + glyph.className = 'material-symbols-outlined'; + glyph.setAttribute('aria-hidden', 'true'); + button.append(glyph); + } + set(glyph, 'textContent', chip.icon); + attribute(button, 'aria-label', chip.label); + } else { + if (button.getAttribute('aria-label') !== null) + button.removeAttribute('aria-label'); + set(button, 'textContent', chip.label); + } // Optional per-chip colour; CSS decides how it is used. const color = chip.color || ''; if ((button.dataset.chipColor || '') !== color) { @@ -39,9 +56,13 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) { delete button.dataset.chipColor; } } - set(button, 'title', chip.title || ''); + set(button, 'title', chip.title || (chip.icon ? chip.label : '')); set(button, 'disabled', Boolean(chip.disabled)); - attribute(button, 'aria-pressed', String(Boolean(chip.active))); + // An action (Zoom, Remove) is a plain button, not an on/off toggle. + if (chip.action) { + if (button.getAttribute('aria-pressed') !== null) + button.removeAttribute('aria-pressed'); + } else attribute(button, 'aria-pressed', String(Boolean(chip.active))); attribute(button, 'aria-busy', String(Boolean(chip.busy))); ordered.push(button); } diff --git a/src/ui/chipGroup.test.mjs b/src/ui/chipGroup.test.mjs index 784029a5c..8ea2ad111 100644 --- a/src/ui/chipGroup.test.mjs +++ b/src/ui/chipGroup.test.mjs @@ -52,3 +52,33 @@ test('a chip colour is set once, changed, and cleared through a CSS variable', ( ]); assert.equal(chip.dataset.chipColor, undefined); }); + +test('an icon action chip draws its glyph, names itself by its label and is not a toggle', () => { + let writes = 0; + const f = railFixture(() => writes++); + const zoom = { + id: 'zoom', + icon: 'zoom_in_map', + action: true, + label: 'Zoom to layer', + }; + syncChipGroup(f.container, [zoom]); + const button = f.container.children[0]; + const glyph = button.children[0]; + assert.equal(glyph.className, 'material-symbols-outlined'); + assert.equal(glyph.textContent, 'zoom_in_map'); + assert.equal(glyph.getAttribute('aria-hidden'), 'true'); + assert.equal(button.getAttribute('aria-label'), 'Zoom to layer'); + assert.equal(button.title, 'Zoom to layer'); + assert.equal(button.getAttribute('aria-pressed'), null); + assert.ok(button.className.includes('chip-icon')); + writes = 0; + syncChipGroup(f.container, [zoom]); + assert.equal(writes, 0, 'a resync writes nothing'); + assert.equal(button.children[0], glyph); + // The same id as a text toggle drops the icon semantics. + syncChipGroup(f.container, [{ id: 'zoom', label: 'ZOOM', active: true }]); + assert.equal(button.textContent, 'ZOOM'); + assert.equal(button.getAttribute('aria-label'), null); + assert.equal(button.getAttribute('aria-pressed'), 'true'); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 109d96c60..c518f13a8 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -97,6 +97,17 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) => const PANEL_POSITIONS = new Map( PANEL_ORDER.map(({ id }, index) => [id, index]), ); +// Data Library layers follow every core group, in the order they were added. +const LIBRARY_GROUP = msg('Library'); +const panelPosition = (layer) => + layer.libraryEntryId + ? PANEL_ORDER.length + 1 + : (PANEL_POSITIONS.get(layer.id) ?? PANEL_ORDER.length); +const panelGroup = (layer) => + layer.libraryEntryId + ? LIBRARY_GROUP + : (PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? + msg('Other layers')); function englishPanelLabel(layer) { return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name; @@ -276,6 +287,10 @@ export class LayerPanel { this._recentImageryFactory = null; this._toggleContainer = null; } + /** Rebuild the rows after the layer set changed (a Data Library add/remove). */ + rerender() { + this._renderToggles(); + } _renderToggles() { if (this._destroyed || !this._toggleContainer) return; this._releaseBindings(); @@ -284,17 +299,11 @@ export class LayerPanel { const generation = this._generation; const layers = this.getAll() .slice() - .sort( - (a, b) => - (PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) - - (PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length), - ); + .sort((a, b) => panelPosition(a) - panelPosition(b)); let previousGroup = ''; for (const layer of layers) { if (!layer.showInTogglePanel) continue; - const group = - PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? - msg('Other layers'); + const group = panelGroup(layer); if (group && group !== previousGroup) { const heading = document.createElement('h3'); heading.className = 'data-layer-group-heading'; @@ -305,6 +314,7 @@ export class LayerPanel { const row = document.createElement('div'); row.className = 'data-toggle-row'; row.dataset.layerId = layer.id; + if (layer.libraryEntryId) row.dataset.libraryEntry = layer.libraryEntryId; // English name for voice tools, which must not receive translated text. row.dataset.layerName = englishPanelLabel(layer); @@ -394,7 +404,12 @@ export class LayerPanel { const chip = this._rowControlsFor(layer.id)?.chips?.find( (entry) => entry.id === button.dataset.chipId, ); - if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; + if ( + !chip || + chip.disabled || + (!this.isEnabled(layer.id) && !chip.whileOff) + ) + return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); @@ -511,7 +526,12 @@ export class LayerPanel { */ _syncRowControls(container, layer, listContainer = null) { if (!container) return; - const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; + // A quiet row stays quiet while off, except Data Library rows, whose + // actions (zoom, remove) work on a layer that is off. + const controls = + layer.enabled || layer.libraryEntryId + ? this._rowControlsFor(layer.id) + : null; if (controls?.readout) { container.remove(); listContainer?.remove(); diff --git a/src/ui/libraryBrowser.js b/src/ui/libraryBrowser.js new file mode 100644 index 000000000..857b0f04d --- /dev/null +++ b/src/ui/libraryBrowser.js @@ -0,0 +1,604 @@ +/** + * DATA LAYERS ▸ Browse data library: the dialog that lists every Data Library + * entry this build knows, with its licence, coverage and what this instance + * needs to serve it, and adds or removes it from the panel. + * + * A `dialog[data-panel-surface]` per docs/panel-surfaces.md: this module owns + * width, height, labels, initial and return focus, Escape and dismissal, and + * closes itself when clean view, recording or Cockpit hides the panels. + */ +import { formatNumber, msg, t, tn } from '../i18n/index.js'; + +const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; +/** How long Remove all stays armed after its first press. */ +const RESET_CONFIRM_MS = 4000; +/** How often the open dialog rereads its stat tiles (the panel's own pace). */ +const STATS_REFRESH_MS = 1000; + +const FAMILY_LABELS = Object.freeze({ + movement: msg('Movement'), + cameras: msg('Cameras'), + infrastructure: msg('Infrastructure'), + events: msg('Events'), + weather: msg('Weather'), + environment: msg('Environment'), + imagery: msg('Imagery'), + demographics: msg('Demographics'), +}); + +const KIND_LABELS = Object.freeze({ + dataset: msg('DATASET'), + tiles: msg('TILES'), + pack: msg('PACK'), +}); + +const STATUS_LABELS = Object.freeze({ + incubating: msg('INCUBATING'), + community: msg('COMMUNITY'), + verified: msg('VERIFIED'), + stale: msg('STALE'), + archived: msg('ARCHIVED'), +}); + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function badge(text, tone = '') { + const node = element( + 'span', + `data-library-badge${tone ? ` data-library-badge--${tone}` : ''}`, + text, + ); + return node; +} + +const compact = (value) => + formatNumber(value, { notation: 'compact', maximumFractionDigits: 1 }); + +/** + * The four stat tiles at the top of Browse: label, value and one line of + * breakdown each. Text only; nothing here is a data colour. + * @param {ReturnType['summary']>} summary + * @returns {Array<{ id: string, label: string, value: string, detail: string }>} + */ +export function libraryStatTiles(summary) { + const { kinds } = summary; + return [ + { + id: 'entries', + label: t('Datasets'), + value: compact(summary.entries), + detail: t('{datasets} · {tiles} · {packs}', { + datasets: tn(kinds.dataset, '{count} dataset', '{count} datasets'), + tiles: tn(kinds.tiles, '{count} tile layer', '{count} tile layers'), + packs: tn(kinds.pack, '{count} pack', '{count} packs'), + }), + }, + { + id: 'themes', + label: t('Themes'), + value: compact(summary.themes.length), + // The count alone: the theme names are the Theme filter below. + detail: '', + }, + { + id: 'added', + label: t('Added'), + value: compact(summary.added), + detail: summary.added + ? tn(summary.on, '{count} on the map', '{count} on the map') + : t('none yet'), + }, + { + id: 'features', + label: t('Map features'), + value: summary.featureLayers ? compact(summary.features) : '—', + detail: summary.featureLayers + ? tn( + summary.featureLayers, + 'from {count} dataset', + 'from {count} datasets', + ) + : t('switch on a dataset to load features'), + }, + ]; +} + +/** The sentence explaining what this instance needs to serve an entry. */ +export function libraryReasonText({ entry, available, reason }) { + if (entry.kind === 'pack') + return available + ? t('Switched on by this instance · part of the CCTV layer') + : t('Operator opt-in: set {env}=1 on the server', { + env: entry.pack.env, + }); + if (available) return ''; + if (reason === 'operator-opt-in') + return t('Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server', { + id: entry.id, + }); + if (reason === 'operator-disabled') + return t("Turned off by this instance's operator"); + if (reason === 'key-missing') return t('Needs a provider key on the server'); + if (reason === 'server-unavailable') + return t("Needs this app's server · not available in a static build"); + return t('Checking what this instance serves…'); +} + +function coverageText(entry) { + const { coverage } = entry; + if (coverage.global) return t('Global'); + if (coverage.countries?.length) return coverage.countries.join(' · '); + return t('Regional'); +} + +const IMAGERY_TYPE_LABELS = Object.freeze({ + ortho: msg('Orthophoto'), + 'satellite-mosaic': msg('Satellite mosaic'), + oblique: msg('Oblique'), + historic: msg('Historic imagery'), +}); + +/** + * An imagery entry's specs as label/value pairs for its card: what it is, + * how sharp, when it was flown and how often it is refreshed. + * @param {object} entry + * @returns {Array<{ label: string, value: string }>} + */ +export function imagerySpecs(entry) { + const imagery = entry?.imagery; + if (!imagery) return []; + const type = + imagery.type === 'ortho' && imagery.trueOrtho + ? t('True orthophoto') + : t(IMAGERY_TYPE_LABELS[imagery.type]); + return [ + { label: t('Type'), value: type }, + { label: t('Resolution'), value: imagery.resolution }, + imagery.vintage && { label: t('Flown'), value: imagery.vintage }, + imagery.updateCycle && { label: t('Updates'), value: imagery.updateCycle }, + imagery.orthoQuality && { + label: t('Ortho quality'), + value: imagery.orthoQuality, + }, + ].filter(Boolean); +} + +/** + * Whether a row matches the current filters. + * @param {{ entry: object, available: boolean, added: boolean }} row + * @param {{ query: string, family: string, availableOnly: boolean }} filter + */ +export function libraryRowMatches(row, { query, family, availableOnly }) { + const { entry } = row; + if (entry.status === 'archived' && !row.added) return false; + if (family && entry.family !== family) return false; + if (availableOnly && !row.available) return false; + const needle = String(query || '') + .trim() + .toLocaleLowerCase(); + if (!needle) return true; + return [ + entry.title, + entry.provider, + entry.description, + entry.id, + entry.imagery?.resolution, + ...(entry.coverage.countries || []), + ] + .join(' ') + .toLocaleLowerCase() + .includes(needle); +} + +/** + * Browse order: what a viewer can add first, then what this instance would + * need to switch on, then packs (which a host layer owns), each by title. + * @param {Array<{ entry: object, available: boolean }>} rows + */ +export function sortLibraryRows(rows) { + const rank = ({ entry, available }) => + entry.kind === 'pack' ? 2 : available ? 0 : 1; + return [...rows].sort( + (a, b) => + rank(a) - rank(b) || a.entry.title.localeCompare(b.entry.title, 'en'), + ); +} + +/** + * @param {{ library: ReturnType, trigger: HTMLElement|null }} options + * @returns {{ open: () => void, close: () => void, destroy: () => void }} + */ +export function mountLibraryBrowser({ library, trigger }) { + const removers = []; + const listen = (node, type, listener) => { + node.addEventListener(type, listener); + removers.push(() => node.removeEventListener(type, listener)); + }; + const filter = { query: '', family: '', availableOnly: false }; + let dialog = null; + let list = null; + let status = null; + let stats = null; + let statsTimer = null; + let resetButton = null; + // Remove all asks twice: the first press arms it for a few seconds. + let resetArmed = false; + let resetTimer = null; + let resetting = false; + + function disarmReset() { + clearTimeout(resetTimer); + resetTimer = null; + resetArmed = false; + } + + function build() { + dialog = element('dialog', 'data-library-dialog'); + dialog.id = 'data-library'; + dialog.setAttribute('data-panel-surface', ''); + dialog.setAttribute('aria-labelledby', 'data-library-title'); + dialog.setAttribute('aria-describedby', 'data-library-intro'); + + const header = element('header', 'data-library-header'); + header.setAttribute('data-panel-header', ''); + const title = element( + 'h2', + 'panel-title panel-surface-title', + t('DATA LIBRARY'), + ); + title.id = 'data-library-title'; + const close = element( + 'button', + 'panel-surface-control data-library-close', + '×', + ); + close.type = 'button'; + close.setAttribute('aria-label', t('Close data library')); + listen(close, 'click', () => api.close()); + header.append(title, close); + + const body = element('div', 'data-library-body'); + body.setAttribute('data-panel-body', ''); + stats = element('dl', 'data-library-stats'); + stats.setAttribute('aria-label', t('Data Library at a glance')); + const intro = element( + 'p', + 'data-library-intro', + t( + 'Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.', + ), + ); + intro.id = 'data-library-intro'; + + const filters = element('div', 'data-library-filters'); + const searchLabel = element('label', 'data-library-field'); + searchLabel.append(element('span', '', t('Search'))); + const search = element('input', 'panel-surface-control'); + search.type = 'search'; + search.autocomplete = 'off'; + search.placeholder = t('Name, provider or country'); + listen(search, 'input', () => { + filter.query = search.value; + render(); + }); + searchLabel.append(search); + + const familyLabel = element('label', 'data-library-field'); + familyLabel.append(element('span', '', t('Theme'))); + const family = element('select', 'panel-surface-control'); + family.append(new Option(t('All themes'), '')); + for (const [value, label] of Object.entries(FAMILY_LABELS)) + family.append(new Option(t(label), value)); + listen(family, 'change', () => { + filter.family = family.value; + render(); + }); + familyLabel.append(family); + + const availableLabel = element('label', 'data-library-check'); + const available = element('input'); + available.type = 'checkbox'; + listen(available, 'change', () => { + filter.availableOnly = available.checked; + render(); + }); + availableLabel.append( + available, + element('span', '', t('Only what this instance serves')), + ); + filters.append(searchLabel, familyLabel, availableLabel); + + const toolbar = element('div', 'data-library-toolbar'); + status = element('p', 'data-library-status'); + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + resetButton = element('button', 'panel-surface-control data-library-reset'); + resetButton.type = 'button'; + listen(resetButton, 'click', () => { + if (resetting) return; + if (!resetArmed) { + resetArmed = true; + resetTimer = setTimeout(() => { + disarmReset(); + render(); + }, RESET_CONFIRM_MS); + render(); + return; + } + disarmReset(); + resetting = true; + render(); + Promise.resolve(library.reset()) + .catch((error) => console.warn('[Library] reset failed:', error)) + .finally(() => { + resetting = false; + render(); + dialog?.querySelector('input[type="search"]')?.focus(); + }); + }); + toolbar.append(status, resetButton); + + list = element('ul', 'data-library-list'); + listen(list, 'click', (event) => { + const button = event.target?.closest?.('button[data-library-action]'); + if (!button || button.disabled) return; + const id = button.dataset.libraryId; + const kind = button.dataset.libraryAction; + if (kind === 'zoom') { + // Close first so the flight is visible; focus returns to Browse. + // An entry that is off is switched on first. + api.close(); + Promise.resolve(library.zoom(id)).catch((error) => + console.warn(`[Library] ${id} zoom failed:`, error), + ); + return; + } + const action = kind === 'add' ? library.add(id) : library.remove(id); + Promise.resolve(action).catch((error) => + console.warn(`[Library] ${id} failed:`, error), + ); + }); + + body.append(stats, intro, filters, toolbar, list); + dialog.append(header, body); + listen(dialog, 'cancel', (event) => { + event.preventDefault(); + api.close(); + }); + // A click on the backdrop (the dialog box itself, outside its content) + // dismisses, like Escape. + listen(dialog, 'click', (event) => { + if (event.target === dialog) api.close(); + }); + document.body.append(dialog); + return search; + } + + function renderRow({ entry, available, reason, added, on, busy, canZoom }) { + const item = element('li', 'data-library-item'); + item.dataset.libraryId = entry.id; + item.classList.toggle('is-unavailable', !available); + + const head = element('div', 'data-library-item-head'); + const icon = element('span', 'data-library-icon', entry.icon); + icon.setAttribute('aria-hidden', 'true'); + const name = element('span', 'data-library-name', entry.title); + const badges = element('span', 'data-library-badges'); + badges.append(badge(t(KIND_LABELS[entry.kind]))); + badges.append( + badge( + t(STATUS_LABELS[entry.status]), + entry.status === 'verified' + ? 'good' + : entry.status === 'stale' + ? 'warn' + : '', + ), + ); + if (entry.licence.operatorOptIn) badges.append(badge(t('OPT-IN'), 'warn')); + else if (entry.kind !== 'pack' && entry.source.key) + badges.append(badge(t('KEY'), 'warn')); + else if (entry.kind !== 'pack') badges.append(badge(t('KEYLESS'))); + head.append(icon, name, badges); + + const description = element('p', 'data-library-description'); + description.textContent = entry.description; + + const meta = element('p', 'data-library-meta'); + const provider = element('span', '', entry.provider); + provider.setAttribute('translate', 'no'); + const licence = element('a', '', entry.licence.name); + licence.href = entry.licence.url; + licence.target = '_blank'; + licence.rel = 'noopener noreferrer'; + meta.append( + provider, + document.createTextNode(' · '), + element('span', '', coverageText(entry)), + document.createTextNode(' · '), + licence, + ); + + item.append(head, description); + const specs = imagerySpecs(entry); + if (specs.length) { + const list = element('dl', 'data-library-specs'); + for (const { label, value } of specs) { + const row = element('div', 'data-library-spec'); + row.append(element('dt', '', label), element('dd', '', value)); + list.append(row); + } + item.append(list); + } + item.append(meta); + const note = libraryReasonText({ entry, available, reason }); + if (note) item.append(element('p', 'data-library-reason', note)); + + if (entry.kind !== 'pack') { + const actions = element('div', 'data-library-actions'); + if (added && canZoom) { + const zoom = element( + 'button', + 'panel-surface-control data-library-action', + t('ZOOM'), + ); + zoom.type = 'button'; + zoom.dataset.libraryId = entry.id; + zoom.dataset.libraryAction = 'zoom'; + zoom.disabled = busy; + zoom.setAttribute( + 'aria-label', + t('Zoom to {name}', { name: entry.title }), + ); + actions.append(zoom); + } + const action = element( + 'button', + 'panel-surface-control data-library-action', + ); + action.type = 'button'; + action.dataset.libraryId = entry.id; + action.dataset.libraryAction = added ? 'remove' : 'add'; + action.textContent = busy + ? t('WORKING…') + : added + ? t('REMOVE') + : t('ADD'); + action.disabled = busy || (!added && !available); + action.setAttribute( + 'aria-label', + added + ? t('Remove {name} from Data Layers', { name: entry.title }) + : t('Add {name} to Data Layers', { name: entry.title }), + ); + if (added && on) item.classList.add('is-on'); + actions.append(action); + item.append(actions); + } + return item; + } + + /** Rewrite the stat tiles in place; unchanged text is not touched. */ + function renderStats() { + if (!dialog?.open || !stats) return; + for (const tile of libraryStatTiles(library.summary())) { + let node = stats.querySelector(`[data-stat="${tile.id}"]`); + if (!node) { + node = element('div', 'data-library-stat'); + node.dataset.stat = tile.id; + node.append( + element('dt', 'data-library-stat-label'), + element('dd', 'data-library-stat-value'), + element('dd', 'data-library-stat-detail'), + ); + stats.append(node); + } + const [label, value, detail] = node.children; + if (label.textContent !== tile.label) label.textContent = tile.label; + if (value.textContent !== tile.value) value.textContent = tile.value; + if (detail.textContent !== tile.detail) detail.textContent = tile.detail; + detail.hidden = !tile.detail; + } + } + + function render() { + if (!dialog?.open) return; + renderStats(); + const rows = library.list(); + const shown = sortLibraryRows( + rows.filter((row) => libraryRowMatches(row, filter)), + ); + const focusedId = document.activeElement?.dataset?.libraryId; + const focusedAction = document.activeElement?.dataset?.libraryAction; + list.replaceChildren(...shown.map(renderRow)); + if (focusedId) { + const row = `button[data-library-id="${CSS.escape(focusedId)}"]`; + // The same control when it survived the render (Add becomes Remove). + ( + list.querySelector( + `${row}[data-library-action="${CSS.escape(focusedAction || '')}"]`, + ) || + list.querySelector(`${row}:not([data-library-action="zoom"])`) || + list.querySelector(row) + )?.focus(); + } + const added = rows.filter((row) => row.added).length; + if (!added && !resetting) disarmReset(); + resetButton.hidden = added === 0 && !resetting; + resetButton.disabled = resetting; + resetButton.classList.toggle('is-armed', resetArmed); + resetButton.textContent = resetting + ? t('WORKING…') + : resetArmed + ? tn(added, 'CONFIRM · REMOVE {count}', 'CONFIRM · REMOVE ALL {count}') + : t('REMOVE ALL'); + resetButton.setAttribute( + 'aria-label', + resetArmed + ? t('Confirm: remove every added entry from Data Layers') + : t('Remove every added entry from Data Layers'), + ); + // The stat tiles carry the totals; this line answers the filter. + status.textContent = !library.ready + ? t('Checking what this instance serves…') + : t('Showing {shown} of {total}', { + shown: shown.length, + total: rows.length, + }); + } + + const api = { + open() { + const search = dialog ? null : build(); + if (dialog.open) return; + render(); + dialog.showModal(); + render(); + // Feature counts arrive as datasets finish loading, outside the + // library's own notifications; refresh the tiles while open. + clearInterval(statsTimer); + statsTimer = setInterval(renderStats, STATS_REFRESH_MS); + (search || dialog.querySelector('input[type="search"]'))?.focus(); + }, + close() { + disarmReset(); + clearInterval(statsTimer); + statsTimer = null; + if (!dialog?.open) return; + dialog.close(); + trigger?.focus?.(); + }, + destroy() { + api.close(); + unsubscribe(); + observer?.disconnect(); + for (const remove of removers.splice(0)) remove(); + dialog?.remove(); + dialog = null; + }, + }; + + const unsubscribe = library.subscribe(render); + if (trigger) listen(trigger, 'click', () => api.open()); + const observer = + typeof MutationObserver === 'function' + ? new MutationObserver(() => { + if ( + PANEL_HIDING_MODES.some((mode) => + document.body.classList.contains(mode), + ) + ) + api.close(); + }) + : null; + observer?.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + return api; +} diff --git a/src/ui/libraryBrowser.test.mjs b/src/ui/libraryBrowser.test.mjs new file mode 100644 index 000000000..061024a09 --- /dev/null +++ b/src/ui/libraryBrowser.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryReasonText, + libraryRowMatches, + libraryStatTiles, + sortLibraryRows, +} from './libraryBrowser.js'; +import { libraryEntry } from '../library/catalog.js'; +import { localInfrastructureOverlayCopy } from '../data/localGeojsonCore.js'; + +const row = (id, extra = {}) => ({ + entry: libraryEntry(id), + available: true, + added: false, + ...extra, +}); +const all = { query: '', family: '', availableOnly: false }; + +test('Browse filters by text, theme and what this instance serves', () => { + assert.ok( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'eia' }), + ); + assert.ok( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'us' }), + ); + assert.equal( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'volcano' }), + false, + ); + assert.equal( + libraryRowMatches(row('eia-power-plants'), { ...all, family: 'events' }), + false, + ); + assert.equal( + libraryRowMatches(row('cctv-nyc-dot', { available: false }), { + ...all, + availableOnly: true, + }), + false, + ); +}); + +test('the guidance names the exact operator switch', () => { + assert.match( + libraryReasonText({ ...row('cctv-nyc-dot'), available: false }), + /CCTV_NYC_ENABLED=1/, + ); + assert.match( + libraryReasonText({ ...row('cctv-nyc-dot'), available: true }), + /part of the CCTV layer/, + ); + assert.match( + libraryReasonText({ + ...row('gvp-holocene-volcanoes'), + available: false, + reason: 'server-unavailable', + }), + /static build/, + ); + assert.equal(libraryReasonText(row('nasa-eonet-events')), ''); +}); + +test('library features show their manifest card lines on the map', () => { + const copy = localInfrastructureOverlayCopy( + { + name: 'Grand Coulee', + __gevDetails: ['PrimSource: hydroelectric', 'Total_MW: 6809', 'extra'], + }, + 'lib-eia-power-plants', + ); + assert.deepEqual(copy, { + title: 'Grand Coulee', + details: ['PrimSource: hydroelectric', 'Total_MW: 6809'], + }); +}); + +test('Browse lists what a viewer can add first and packs last', () => { + const order = sortLibraryRows([ + row('cctv-nyc-dot', { available: false }), + row('nasa-eonet-events'), + row('gvp-holocene-volcanoes', { available: false }), + row('eia-power-plants'), + ]).map(({ entry }) => entry.id); + assert.deepEqual(order, [ + 'nasa-eonet-events', + 'eia-power-plants', + 'gvp-holocene-volcanoes', + 'cctv-nyc-dot', + ]); +}); + +test('the stat tiles summarise the library in words and compact numbers', () => { + const tiles = libraryStatTiles({ + entries: 8, + kinds: { dataset: 3, tiles: 2, pack: 3 }, + themes: ['cameras', 'environment', 'events', 'imagery', 'infrastructure'], + available: 5, + unavailable: 3, + added: 2, + on: 1, + features: 3712, + featureLayers: 2, + }); + assert.deepEqual( + tiles.map(({ id, value, detail }) => [id, value, detail]), + [ + ['entries', '8', '3 datasets · 2 tile layers · 3 packs'], + ['themes', '5', ''], + ['added', '2', '1 on the map'], + ['features', '3.7K', 'from 2 datasets'], + ], + ); + const empty = libraryStatTiles({ + entries: 1, + kinds: { dataset: 1, tiles: 0, pack: 0 }, + themes: ['events'], + available: 1, + unavailable: 0, + added: 0, + on: 0, + features: 0, + featureLayers: 0, + }); + assert.deepEqual( + empty.map(({ value, detail }) => [value, detail]), + [ + ['1', '1 dataset · 0 tile layers · 0 packs'], + ['1', ''], + ['0', 'none yet'], + ['—', 'switch on a dataset to load features'], + ], + ); +}); + +test('an imagery card lists type, resolution, years flown and refresh', async () => { + const { imagerySpecs } = await import('./libraryBrowser.js'); + const specs = imagerySpecs(libraryEntry('geneva-sitg-ortho')); + assert.deepEqual( + specs.map(({ label }) => label), + ['Type', 'Resolution', 'Flown', 'Updates'], + ); + assert.equal(specs[0].value, 'True orthophoto'); + assert.equal(specs[1].value, '5 cm'); + assert.deepEqual(imagerySpecs(libraryEntry('eia-power-plants')), []); +}); diff --git a/src/ui/railTestFixture.mjs b/src/ui/railTestFixture.mjs index 23b4d7528..650d4ff12 100644 --- a/src/ui/railTestFixture.mjs +++ b/src/ui/railTestFixture.mjs @@ -26,6 +26,10 @@ export function railFixture(onWrite = () => {}) { getAttribute(key) { return this.attributes.get(key) ?? null; }, + removeAttribute(key) { + onWrite('attribute'); + this.attributes.delete(key); + }, appendChild(child) { return this.insertBefore(child, null); }, diff --git a/src/ui/styles/library.css b/src/ui/styles/library.css new file mode 100644 index 000000000..3ad671e8e --- /dev/null +++ b/src/ui/styles/library.css @@ -0,0 +1,368 @@ +/* Data Library: the Browse button under DATA LAYERS and its dialog. + The dialog is a dialog[data-panel-surface] (docs/panel-surfaces.md): the + shared skin draws the surface; this file owns geometry and the list. */ + +.data-library-footer { + padding: 8px 10px 4px; + border-top: 1px solid var(--glass-border); +} + +.data-library-browse { + width: 100%; + min-height: 30px; + padding: 6px 10px; + border: 1px dashed var(--glass-border-hover); + border-radius: var(--btn-radius); + background: transparent; + color: var(--text-secondary); + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1.2px; + cursor: pointer; + transition: + color var(--transition-fast), + border-color var(--transition-fast), + background var(--transition-fast); +} + +.data-library-browse:hover, +.data-library-browse:focus-visible { + color: var(--text-primary); + border-color: var(--accent); + background: var(--accent-dim); +} + +.data-library-browse:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; +} + +.data-library-dialog { + /* Frosted rather than opaque: a lighter surface than the rail panels + (0.72) keeps the globe's colour visible, and the blur stops the HUD text + behind the dialog from competing with the dialog's own. */ + --glass-bg: rgba(12, 12, 20, 0.55); + width: min(560px, calc(100vw - 32px)); + max-height: min(720px, calc(100dvh - 48px)); + margin: auto; + color: var(--text-primary); + -webkit-backdrop-filter: blur(22px) saturate(1.3); + backdrop-filter: blur(22px) saturate(1.3); + /* The blur follows the surface's shape (Cyber's cut corners). The dialog + has no outward popups for a clip to hide. */ + clip-path: var(--panel-surface-frame); +} + +.data-library-dialog::backdrop { + background: rgba(0, 0, 0, 0.12); +} + +.data-library-close { + min-width: 30px; + font-size: 16px; + line-height: 1; +} + +/* At a glance: four stat tiles above the intro. Text tokens only. */ +.data-library-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin: 0 0 12px; +} + +.data-library-stat { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--glass-border); + border-radius: 8px; + background: rgba(8, 10, 16, 0.38); +} + +.data-library-stat-label { + color: var(--text-secondary); + font: 600 9px/1.3 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.data-library-stat-value { + margin: 0; + color: var(--text-primary); + font: 600 20px/1.15 var(--font-sans); +} + +.data-library-stat-detail { + margin: 0; + color: rgba(232, 234, 237, 0.68); + font-size: 10px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +:root[data-ui-theme='cyber'] .data-library-stat { + border-radius: 0; +} + +:root[data-ui-theme='cyber'] .data-library-stat-value { + font-family: var(--font-mono); +} + +.data-library-intro { + margin: 0 0 10px; + color: rgba(232, 234, 237, 0.72); +} + +.data-library-filters { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 8px; + margin-bottom: 6px; +} + +.data-library-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + color: var(--text-secondary); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.data-library-field .panel-surface-control { + text-transform: none; + letter-spacing: normal; + font-size: 12px; +} + +.data-library-check { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.data-library-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 4px 0 8px; +} + +.data-library-status { + margin: 0; + color: var(--text-secondary); + font: 10px/1.4 var(--font-mono); +} + +.data-library-reset { + min-height: 26px; + padding: 3px 10px; + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1px; +} + +.data-library-reset.is-armed { + border-color: var(--panel-surface-error); + color: var(--panel-surface-error); +} + +.data-library-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.data-library-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 10px; + row-gap: 4px; + padding: 10px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: rgba(8, 10, 16, 0.38); +} + +.data-library-item.is-on { + border-color: var(--accent-glow); +} + +.data-library-item.is-unavailable .data-library-name, +.data-library-item.is-unavailable .data-library-description { + color: var(--text-secondary); +} + +.data-library-item > :not(.data-library-actions) { + grid-column: 1; +} + +.data-library-actions { + grid-column: 2; + grid-row: 1 / span 3; + align-self: center; + display: flex; + flex-direction: column; + gap: 6px; +} + +.data-library-action { + min-width: 82px; + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1px; +} + +.data-library-item-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.data-library-icon { + width: 14px; + color: var(--accent); + text-align: center; +} + +.data-library-name { + font-weight: 600; + overflow-wrap: anywhere; +} + +.data-library-badges { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.data-library-badge { + padding: 0 5px; + border: 1px solid var(--glass-border-hover); + border-radius: 4px; + color: var(--text-secondary); + font: 600 9px/1.6 var(--font-mono); + letter-spacing: 0.08em; +} + +.data-library-badge--good { + border-color: var(--accent-glow); + color: var(--accent); +} + +.data-library-badge--warn { + border-color: rgba(255, 194, 61, 0.45); + color: #ffc23d; +} + +.data-library-description, +.data-library-meta, +.data-library-reason { + margin: 0; + overflow-wrap: anywhere; +} + +.data-library-meta { + color: rgba(232, 234, 237, 0.68); + font-size: 11px; +} + +.data-library-meta a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.data-library-meta a:hover, +.data-library-meta a:focus-visible { + color: var(--text-primary); +} + +/* Imagery specs: label over value, wrapping into as many columns as fit. */ +.data-library-specs { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 0; +} + +.data-library-spec { + display: flex; + flex-direction: column; + min-width: 0; +} + +.data-library-spec dt { + color: rgba(232, 234, 237, 0.56); + font: 9px/1.4 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.data-library-spec dd { + margin: 0; + color: var(--text-primary); + font-size: 11px; + overflow-wrap: anywhere; +} + +.data-library-reason { + color: #ffc23d; + font: 10px/1.5 var(--font-mono); +} + +:root[data-ui-theme='cyber'] .data-library-dialog { + --glass-bg: rgba(18, 24, 27, 0.62); + width: min(600px, calc(100vw - 32px)); +} + +:root[data-ui-theme='cyber'] .data-library-item { + border-radius: 0; +} + +:root[data-ui-theme='cyber'] + :is(.data-library-icon, .data-library-badge--good) { + color: var(--cyber-red-bright); + border-color: var(--cyber-red-bright); +} + +@media (max-width: 720px) { + .data-library-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .data-library-filters { + grid-template-columns: minmax(0, 1fr); + } + + .data-library-item { + grid-template-columns: minmax(0, 1fr); + } + + .data-library-actions { + grid-column: 1; + grid-row: auto; + flex-direction: row; + justify-self: start; + } +} + +body:is(.ui-clean-view, .recording-mode) .data-library-dialog { + display: none; +} + +/* Library rows list their legend one item per line, as Fire Perimeters' + longer labels do, with the zoom and remove icons on their own line. */ +.data-toggle-row[data-library-entry] .data-toggle-legend-item { + flex-basis: 100%; +} diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 568a60a9e..eb71ff2e6 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -869,6 +869,30 @@ html:not([lang='en']) outline: none; } +/* An icon action chip (chipGroup.js `icon`): a square button sized to the + text chips beside it. */ +.data-toggle-chip.chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 20px; + padding: 0 4px; + letter-spacing: 0; + /* A glyph alone needs more contrast than a text chip to read. */ + color: var(--text-secondary); +} + +.data-toggle-chip.chip-icon:hover, +.data-toggle-chip.chip-icon:focus-visible { + color: var(--text-primary); +} + +.data-toggle-chip.chip-icon .material-symbols-outlined { + font-size: 15px; + line-height: 1; +} + .data-toggle-chip:hover, .data-toggle-chip:focus-visible { border-color: var(--glass-border-hover); diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 287b8b842..ff27f5989 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -406,6 +406,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #control-panel.collapsed .button-grid, #data-panel.collapsed .data-toggle-list, +#data-panel.collapsed .data-library-footer, #global-context-panel.collapsed #radio-panel, #global-context-panel.collapsed #military-awareness-panel, #global-context-panel.collapsed .global-context-actions, diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 78ecfd777..eff5b671c 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -9,6 +9,11 @@
+ + diff --git a/style.css b/style.css index 89d1371ae..0019bfa7b 100644 --- a/style.css +++ b/style.css @@ -29,4 +29,5 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; +@import './src/ui/styles/library.css'; @import './src/ui/styles/cyber.css';