mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 22:32:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
871b7f0fcc | ||
|
|
11706a68cb | ||
|
|
db80d82b8d |
No files matched your search
@@ -280,6 +280,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.
|
||||
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
|
||||
OVERPASS_UPSTREAMS=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -129,6 +129,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. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **📚 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/<family>/`, 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, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ 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/`.
|
||||
@@ -228,7 +229,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/<pr-number>-<slug>.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).
|
||||
6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer).
|
||||
|
||||
|
||||
+107
@@ -288,6 +288,113 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
|
||||
---
|
||||
|
||||
<!-- data-library:start (generated by npm run library:docs) -->
|
||||
|
||||
## 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" |
|
||||
| **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" |
|
||||
| **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" |
|
||||
| **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)" |
|
||||
| **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)" |
|
||||
| **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" |
|
||||
| **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" |
|
||||
| **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" |
|
||||
| **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)" |
|
||||
| **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" |
|
||||
| **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)" |
|
||||
| **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" |
|
||||
|
||||
<!-- data-library:end -->
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -73,6 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **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.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **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/<id>`, `/api/library/tile/<id>/<z>/<x>/<y>` and `/api/library/index/<id>` (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/<id>` 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.
|
||||
- **Rail fetches registered feeds only, and none needs a credential.** `/api/rail/feeds` lists the offered feeds; `/api/rail/trains/<id>`, `/api/rail/updates/<id>`, `/api/rail/paths/<id>` and `/api/rail/schedule/<id>` resolve the id against `src/data/railFeeds.js` (`RAIL_FEED_ID_PATTERN`: lowercase letters, digits and hyphens). The browser never supplies a URL, and an id that is not registered, or a trip-updates route for a feed that registers none, does not resolve at all; any method but GET answers 405 (`Allow: GET`, never cached). Every registered feed and every static timetable is open: no rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`), and the only rail setting the server reads is `RAIL_PATHS_DIR`. Upstream requests follow the Transit proxy's policy: https only, each redirect hop validated against the feed's own https origin before it is requested (three hops at most), a request timeout, bodies capped at 8 MiB unless the registry sets a feed's own cap (never above 64 MiB), and a per-feed admission limiter and the failure cooldown ladder bound what the process asks of any operator. Responses are decoded server-side: GTFS-Realtime under entity-count and string-length ceilings, a differential feed is refused, and an empty or header-less GTFS-Realtime reply (an error page served as 200, say) is refused as unreadable, so the last good snapshot is served stale or the request fails with 502. The Irish Rail and SIRI VM XML is read by small scanners, not an XML library: a document that declares a DTD or an entity is refused, and the whole text, the number of trains and every field are capped. Only vehicles the feed's classifier recognises as trains leave the server, and no train position inside Ukraine is ever sent (`src/data/railNoPositions.js`). Snapshots and trip updates live in memory for the feed's own freshness window, with serve-stale after a failure; there is no disk cache and no history. Path and schedule packs are optional build output, read from disk (`.cache/rail-paths/` or `RAIL_PATHS_DIR`) by a reader that re-checks the id against the same pattern, so a name never leaves that directory. A pack over 24 MiB is refused before it is read and answers 503, never a partial file, and so does a pack that cannot be read: only a missing pack is 404; the rest pass through as bytes, through a 64 MiB in-memory read cache keyed by each file's modification time and size, and are served as JSON holding timetables and track shapes only, `no-cache` with an ETag from that time and size (304 when unchanged). A schedule pack of another format version or another feed, or whose window has ended, is not used, and a feed that cannot be read without it answers 503 rather than an empty list. The pack build (`scripts/build-rail-paths.mjs`) runs only when a person runs it, never on app start: it downloads over https only (a redirect to plain http is refused), caps each download in size and time, sends no credential, and refuses an `--out` folder (or a `RAIL_PATHS_DIR` it writes to) inside `src/` or `public/`.
|
||||
- **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.
|
||||
|
||||
+17
-2
@@ -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: <https://github.com/mrdoob/three.js>
|
||||
- License: MIT
|
||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||
|
||||
## hyparquet
|
||||
|
||||
- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library
|
||||
GeoParquet entry is switched on)
|
||||
- Author: Hyperparam
|
||||
- Source: <https://github.com/hyparam/hyparquet>
|
||||
- License: MIT
|
||||
|
||||
## fzstd (hyparquet ZSTD decompressor)
|
||||
|
||||
- Package: `fzstd` 0.1.1
|
||||
- Author: Copyright (c) 2020 Arjun Barrett
|
||||
- Source: <https://github.com/101arrowz/fzstd>
|
||||
- License: MIT
|
||||
@@ -0,0 +1,29 @@
|
||||
- Add the Data Library. New regional and niche sources no longer join the
|
||||
Data Layers panel: **Browse data library** at its foot lists live entries
|
||||
(a manifest each in `src/library/entries/`, fetched from the provider, never
|
||||
bundled) with their licence, coverage and what this instance needs to serve
|
||||
them, and **Add** puts one under a Library heading. Datasets (GeoJSON, CSV,
|
||||
ArcGIS), raster tiles and host-layer packs are supported; selections persist
|
||||
and travel in share links as `lib=`, separate from the layer tokens; one
|
||||
generic `/api/library` proxy serves entries without CORS. Seeded with NASA
|
||||
EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations,
|
||||
Smithsonian volcanoes, NASA Black Marble, and the
|
||||
opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's
|
||||
traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in
|
||||
#251). FEMA USA Structures and the
|
||||
National Bridge Inventory load what is in view once you zoom in, and
|
||||
Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on
|
||||
S3, coloured A–G with live legend counts. Open tile overlays (geology,
|
||||
biodiversity, surface water, national aerial photos and maps)
|
||||
join GDACS alerts and EPA Superfund and TRI facilities. 57 aerial photo
|
||||
layers from the Open Imagery Register (national programmes across Europe,
|
||||
NSW, NAIP and US state, county and city services, WMS and ArcGIS export
|
||||
included) show their resolution, true-ortho flag, years flown and refresh
|
||||
cycle in Browse; aerial photos stay above every other drape, are clipped to
|
||||
their coverage, and the providers' blank fill no longer whitens the globe.
|
||||
Kartverket's open Norwegian maps (topographic, nautical charts, hillshade,
|
||||
terrain relief, sea depths, boundaries and the cadastre) and Mapterhorn's
|
||||
global terrain, drawn in the browser as shaded relief with an elevation
|
||||
legend, are in the library too. Stat tiles head the dialog. A library layer's row has
|
||||
Zoom to layer and Remove icons, and the dialog has Zoom and a confirmed
|
||||
Remove all (asturksever, #1604).
|
||||
@@ -1,6 +0,0 @@
|
||||
- Fix the page freezing for seconds at a time with Transit on over a terrain
|
||||
globe, most visibly while zooming over a large fleet. Every reread asked the
|
||||
terrain for the height of every trail fix of every vehicle in view; heights
|
||||
are now kept per point, read again only while the camera moves or tiles
|
||||
load, and read within a time budget that puts vehicle positions first
|
||||
(mazy1998, #1688).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Read terrain heights through one shared, cached and budgeted reader
|
||||
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
|
||||
data layers. Opening Data Centers no longer blocks the page for over two
|
||||
seconds, opening Bike Share for most of a second, and Rail asks the terrain
|
||||
about a twentieth as often (mazy1998, #1689).
|
||||
+201
-33
@@ -384,6 +384,207 @@ 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/<id>`
|
||||
(built once per `cacheSeconds` and shared), or is built in the browser and kept
|
||||
in `gev:library:index:<id>` 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:<id>`), 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, Macrostrat's ICS period colours). Aerial photo layers (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` (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
|
||||
swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey
|
||||
(#343434). Without both, a 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 60
|
||||
entries: 57 imported from the Open Imagery Register (national programmes in
|
||||
Belgium (Wallonia), Germany, Estonia, Spain, Poland, Portugal, Australia's
|
||||
NSW, and 49 US services: NAIP plus state, county and city programmes) plus
|
||||
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-<entry id>` 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=<t>.<t>` 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/<id>` and `/tile/<id>/<z>/<x>/<y>` 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 (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, IGN (France) aerial photos, BKG
|
||||
TopPlusOpen, GSI Japan map and photos and JRC surface water occurrence.
|
||||
Entries whose licence or provider pages no longer resolve (404 or unreachable
|
||||
host, checked 2026-11-12) were removed: OpenSeaMap seamarks, EMODnet
|
||||
bathymetry, SEDAC GPW population density, PDOK, basemap.at, Geneva SITG,
|
||||
ICGC Catalunya, Luxembourg, Vlaanderen, Minnesota and Bangor aerial photos. `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).
|
||||
|
||||
## Utilities panel — October 29, 2026
|
||||
|
||||
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
|
||||
@@ -5578,39 +5779,6 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
|
||||
settings and screen position rather than observed brightness. Selected-trail
|
||||
diagnostics report every condition even when selection fails.
|
||||
|
||||
Heights of the terrain globe's loaded surface come from one shared reader,
|
||||
`src/services/terrainSurface.js`, which the ground service hands to every layer
|
||||
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
|
||||
drawn tile's mesh, so the reader keeps each answer per point, about a metre
|
||||
apart. While the camera moves or tiles load a kept height is read again after
|
||||
2 s; a height read once both have been still for 5 s is kept until either
|
||||
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
|
||||
of it for re-reads, longest-kept first. A request ranks as a marker (always
|
||||
read), drawn geometry, or background (a quarter of each budget). Past the
|
||||
budget a kept point keeps its last height and a new point takes the last
|
||||
height read in its ~111 m floor cell or waits.
|
||||
|
||||
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
|
||||
the path a marker is drawn along next, the undrawn trail history of
|
||||
unselected vehicles as background, and its reread cycle continues until no
|
||||
point waits. Before this, every reread cast a ray for every trail fix of
|
||||
every vehicle in view, which blocked the page for up to 19 s at a time over
|
||||
a 650-vehicle fleet while zooming.
|
||||
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
|
||||
so a moving train reads a few times a second and a standing one not at all.
|
||||
- Local data layers (Data Centers, Dams) read terrain floors through it, and
|
||||
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
|
||||
frame per layer and continue on the next frames.
|
||||
- Bike Share draws a city's stations at once at the fallback height and finds
|
||||
their ground heights afterwards in 4 ms steps, from the terrain reader on a
|
||||
terrain globe and from depth samples where the globe is hidden.
|
||||
|
||||
Verified in the browser on a keyless terrain globe only: Boston for Transit,
|
||||
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
|
||||
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
|
||||
and any city steeper than Boston. Street Traffic keeps its own height cache
|
||||
and ALPR cameras their own sample budget.
|
||||
|
||||
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
||||
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
||||
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
||||
|
||||
@@ -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/<id>.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 -- <id>` and
|
||||
append `["<id>", "<token>"]` 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": "<S3 ListObjectsV2 URL with prefix>", "keyBase": "<URL keys are relative to>" }`, 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": "<key registry 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": { "<value>": "#rrggbb" }, "labels": { "<value>": "…" } }`, 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.
|
||||
@@ -34,7 +34,6 @@ const layers = createInfrastructureLayers({
|
||||
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
|
||||
|
||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||
|
||||
+1
-1
@@ -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. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
@@ -2460,6 +2462,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",
|
||||
@@ -2570,6 +2578,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -54,6 +58,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": {
|
||||
@@ -63,7 +68,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",
|
||||
|
||||
@@ -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 -- <entry-id>
|
||||
* 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 = '<!-- data-library:start (generated by npm run library:docs) -->';
|
||||
const END = '<!-- data-library: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 -- <entry-id>');
|
||||
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);
|
||||
}
|
||||
@@ -11,8 +11,7 @@
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/services/terrainSurface.js"
|
||||
"src/sources/infrastructureData.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2057,7 +2056,6 @@
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/i18n/index.js",
|
||||
@@ -3005,7 +3003,6 @@
|
||||
"src/search/placeSearch.js",
|
||||
"src/services/application.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
@@ -3496,6 +3493,140 @@
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.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/amsterdam-traffic-cameras.json",
|
||||
"src/library/entries/ar-statewide-imagery.json",
|
||||
"src/library/entries/az-statewide-imagery.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/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/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/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/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/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/or-statewide-imagery.json",
|
||||
"src/library/entries/pa-statewide-imagery.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/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/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/epa-superfund-sites.json",
|
||||
"src/library/entries/epa-toxic-release-facilities.json",
|
||||
"src/library/entries/gbif-occurrence-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/swisstopo-swissimage.json",
|
||||
"src/library/entries/usgs-topo.json",
|
||||
"src/library/outlines.js",
|
||||
"src/library/relief.js",
|
||||
"src/sceneCommandRelease.js",
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/visualStylePolicy.js",
|
||||
@@ -3631,6 +3762,8 @@
|
||||
"satellite.js",
|
||||
"mapillary-js",
|
||||
"@jtarrio/webrtlsdr",
|
||||
"hyparquet",
|
||||
"fzstd",
|
||||
"vue",
|
||||
"pinia",
|
||||
"vue-i18n"
|
||||
@@ -4037,7 +4170,6 @@
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/scopeMask.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
@@ -4382,7 +4514,6 @@
|
||||
"src/search/placeSearch.js",
|
||||
"src/services/application.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
@@ -4431,7 +4562,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": {
|
||||
@@ -5043,7 +5178,6 @@
|
||||
"src/renderGovernor.js",
|
||||
"src/services/application.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
"src/services/terrainHeights.js",
|
||||
@@ -5088,7 +5222,6 @@
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/services/terrainSurface.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<string, string|undefined>} env
|
||||
* @returns {Map<string, { available: boolean, reason: string|null }>}
|
||||
*/
|
||||
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 `/<id>/<z>/<x>/<y>` 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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('<html>', {
|
||||
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);
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { inaturalistProxy } from './inaturalist/observations.js';
|
||||
import { acartiaProxy } from './acartia.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.
|
||||
@@ -85,6 +86,7 @@ function localProviderPlugins({
|
||||
mapillaryProxy(),
|
||||
firePerimetersProxy(),
|
||||
gnssIntegrityProxy(),
|
||||
libraryProxy(),
|
||||
ghostMapsProxy(),
|
||||
shipwrecksProxy(),
|
||||
droneAirspaceProxy(),
|
||||
|
||||
@@ -168,7 +168,7 @@ export function createApplicationCatalog({
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ surface, source: sources.bikeshare }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
createApplicationOsmPlaces(),
|
||||
@@ -198,10 +198,7 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers({
|
||||
...localGeoJsonServices,
|
||||
terrainSurface: surface.groundFloor.terrainSurface,
|
||||
}),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationRail({ surface, source: sources.rail }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -5,16 +5,9 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationBikeshare({ source, surface }) {
|
||||
export function createApplicationBikeshare({ source }) {
|
||||
return createBikeshareLayer({
|
||||
source,
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
// Station heights come from the terrain surface every layer shares.
|
||||
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
|
||||
},
|
||||
services: { render, sprites, picking, overlays },
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.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 {
|
||||
@@ -109,6 +113,41 @@ export function createApplicationTools({
|
||||
connect: connectDisplay,
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -196,7 +196,6 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
'terrainSurface.js',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entry.imports,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -97,6 +98,7 @@ export class LayerLifecycle {
|
||||
this._registrationDispositions = null;
|
||||
this._allowQaRegistration = allowQaRegistration === true;
|
||||
this._qaLayerIds = new Set();
|
||||
this._libraryLayerIds = new Set();
|
||||
}
|
||||
|
||||
register(layerModule) {
|
||||
@@ -125,6 +127,41 @@ export class LayerLifecycle {
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Data Library layer after the catalog is sealed. Only ids in
|
||||
* the library namespace (`lib-<entry id>`) 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');
|
||||
@@ -2231,6 +2268,7 @@ export class LayerLifecycle {
|
||||
}
|
||||
this.layers.delete(layerId);
|
||||
this._qaLayerIds.delete(layerId);
|
||||
this._libraryLayerIds.delete(layerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2274,6 +2312,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,
|
||||
|
||||
@@ -23,10 +23,9 @@ import {
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options, services = {}) =>
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
...services,
|
||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||
});
|
||||
|
||||
@@ -59,15 +58,10 @@ class MockLayerEvent {
|
||||
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
||||
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
||||
* (default: throws like a scene whose tiles are not sampleable yet).
|
||||
* @param {number[][]} [options.points] `[lon, lat]` sites to load
|
||||
* instead of the single dam polygon.
|
||||
* @param {object} [options.services] Extra services for the layer.
|
||||
*/
|
||||
async function createRealLocalLayerHarness({
|
||||
sampleHeightSupported = false,
|
||||
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
||||
points = null,
|
||||
services = {},
|
||||
} = {}) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -78,22 +72,7 @@ async function createRealLocalLayerHarness({
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: `site-${index}`,
|
||||
properties: { name: `Site ${index}` },
|
||||
// A small outline: the layer stems its centre. (Cesium draws a point
|
||||
// feature as a canvas pin, which Node cannot make.)
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[
|
||||
[lon - 0.001, lat - 0.001],
|
||||
[lon + 0.001, lat - 0.001],
|
||||
[lon, lat + 0.001],
|
||||
[lon - 0.001, lat - 0.001],
|
||||
]],
|
||||
},
|
||||
})).join('\n') : JSON.stringify({
|
||||
text: async () => JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: 'real-dam',
|
||||
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
||||
@@ -170,7 +149,7 @@ async function createRealLocalLayerHarness({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
}, services);
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
} finally {
|
||||
@@ -924,13 +903,12 @@ test('settled terrain refinement lifts an already sampled stem without another G
|
||||
env.preRender.raise();
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
||||
globe.tilesLoaded = true;
|
||||
// The terrain surface reuses a height for 2 s before reading the mesh again.
|
||||
clock.advance(2_100);
|
||||
clock.advance(500);
|
||||
env.preRender.raise();
|
||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
||||
|
||||
terrain = 110;
|
||||
clock.advance(2_100);
|
||||
clock.advance(500);
|
||||
env.preRender.raise();
|
||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
||||
terrain = 140;
|
||||
@@ -1515,161 +1493,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||
});
|
||||
|
||||
|
||||
/** A loaded terrain globe that counts its ray casts. */
|
||||
function countingTerrain(height) {
|
||||
const globe = {
|
||||
show: true,
|
||||
tilesLoaded: true,
|
||||
reads: 0,
|
||||
getHeight: () => { globe.reads += 1; return height; },
|
||||
};
|
||||
return globe;
|
||||
}
|
||||
|
||||
/** Height of every stem base, in entity order. */
|
||||
function allBaseHeightsM(env) {
|
||||
return env.dataSources[0].entities.values.map(
|
||||
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
|
||||
);
|
||||
}
|
||||
|
||||
const SAMPLE_SITES = [
|
||||
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
|
||||
];
|
||||
|
||||
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
const globe = countingTerrain(117);
|
||||
env.viewer.scene.globe = globe;
|
||||
setCameraAltitude(env, 20_000);
|
||||
for (let walk = 0; walk < 40; walk += 1) {
|
||||
env.preRender.raise();
|
||||
clock.advance(500);
|
||||
}
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||
// 20 s parked: a few reads while the surface settles, then none.
|
||||
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
|
||||
const settled = globe.reads;
|
||||
for (let walk = 0; walk < 20; walk += 1) {
|
||||
env.preRender.raise();
|
||||
clock.advance(500);
|
||||
}
|
||||
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
|
||||
});
|
||||
|
||||
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
const globe = countingTerrain(117);
|
||||
env.viewer.scene.globe = globe;
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.setBaseInView(false);
|
||||
for (let walk = 0; walk < 40; walk += 1) {
|
||||
env.preRender.raise();
|
||||
clock.advance(500);
|
||||
}
|
||||
assert.equal(env.sampleCalls.count, 0);
|
||||
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
|
||||
});
|
||||
|
||||
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
|
||||
const asked = [];
|
||||
const terrainSurface = {
|
||||
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
|
||||
};
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
points: [[-97.695, 30.205]],
|
||||
services: { terrainSurface },
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
const globe = countingTerrain(117);
|
||||
env.viewer.scene.globe = globe;
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.preRender.raise();
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
|
||||
clock.advance(500);
|
||||
env.preRender.raise();
|
||||
assert.ok(asked.length >= 2);
|
||||
for (const call of asked) {
|
||||
assert.equal(call.viewer, env.viewer);
|
||||
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
|
||||
`asked at ${call.lat}, ${call.lon}`);
|
||||
}
|
||||
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
|
||||
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
|
||||
});
|
||||
|
||||
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
|
||||
const heights = new Map();
|
||||
let clock = null;
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true,
|
||||
points: SAMPLE_SITES,
|
||||
sampleHeight: (carto) => {
|
||||
clock.advance(15); // one GPU depth render
|
||||
const height = 100 + heights.size;
|
||||
heights.set(`${carto.longitude},${carto.latitude}`, height);
|
||||
return height;
|
||||
},
|
||||
});
|
||||
_resetRenderGovernorForTest();
|
||||
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
||||
installRenderGovernor({ scene: governorScene });
|
||||
governorScene.renders = 0; // discard the governor's own install settling frame
|
||||
clock = installFakeClock(t);
|
||||
t.after(() => {
|
||||
env.layer.destroy(env.viewer);
|
||||
env.cleanup();
|
||||
_resetRenderGovernorForTest();
|
||||
});
|
||||
setCameraAltitude(env, 20_000);
|
||||
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
|
||||
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
|
||||
|
||||
// The following frames arrive well inside the 450 ms walk interval.
|
||||
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
|
||||
clock.advance(16);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
|
||||
}
|
||||
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
|
||||
clock.advance(16);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
|
||||
|
||||
const expected = env.dataSources[0].entities.values.map((entity) => {
|
||||
const carto = entity.__localBaseCarto;
|
||||
return heights.get(`${carto.longitude},${carto.latitude}`);
|
||||
});
|
||||
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
|
||||
for (const entity of env.dataSources[0].entities.values) {
|
||||
const stem = entity.polyline.positions.getValue();
|
||||
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
|
||||
}
|
||||
});
|
||||
|
||||
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true,
|
||||
points: SAMPLE_SITES,
|
||||
sampleHeight: () => 117,
|
||||
});
|
||||
installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
|
||||
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
|
||||
});
|
||||
+62
-140
@@ -11,10 +11,6 @@ import {
|
||||
applyInfraEvictionGrace,
|
||||
shouldRecomputeInfraLod,
|
||||
} from './localGeojsonLod.js';
|
||||
import {
|
||||
createTerrainSurface,
|
||||
TERRAIN_NEED,
|
||||
} from '../services/terrainSurface.js';
|
||||
|
||||
const DEFAULT_LABEL_MAX = 900;
|
||||
const DEFAULT_LABEL_GRID_PX = 132;
|
||||
@@ -42,15 +38,6 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
||||
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
||||
*/
|
||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||
/**
|
||||
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
|
||||
* offscreen depth render (about 15 ms measured), and a first walk near a dense
|
||||
* cluster wants one per site in view: 168 of them held the main thread for
|
||||
* 2.6 s. Past the budget the remaining sites queue, and each following frame
|
||||
* takes as many as fit, so the sites reach the same heights a frame or a few
|
||||
* apart instead of all inside one blocked frame.
|
||||
*/
|
||||
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
|
||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||
@@ -99,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 };
|
||||
@@ -305,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.
|
||||
@@ -323,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) =>
|
||||
@@ -337,20 +336,8 @@ export function createLocalGeoJsonLayer(
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
terrainSurface,
|
||||
},
|
||||
) {
|
||||
/**
|
||||
* What ground sampling shares across a layer's records: the terrain
|
||||
* surface (the application's, so every layer reads a point once and spends
|
||||
* one budget, or this layer's own), the sample time spent this frame, and
|
||||
* the records the frame budget left for a later frame.
|
||||
*/
|
||||
const _ground = {
|
||||
terrain: terrainSurface || createTerrainSurface(),
|
||||
sampleMs: 0,
|
||||
queued: [],
|
||||
};
|
||||
let _dataSource = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
@@ -451,42 +438,6 @@ export function createLocalGeoJsonLayer(
|
||||
clearTimeout(_groundRetryTimer);
|
||||
_groundRetryTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the sites an earlier frame's budget left over, within this
|
||||
* frame's. They were never attempted, so the 2 s retry spacing does not
|
||||
* apply to them.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {number} now
|
||||
* @returns {void}
|
||||
*/
|
||||
function sampleQueuedGround(viewer, now) {
|
||||
const queued = _ground.queued;
|
||||
if (queued.length === 0) return;
|
||||
_ground.queued = [];
|
||||
for (const record of queued) {
|
||||
record.groundSampleQueued = false;
|
||||
// The camera may have left the site since it queued.
|
||||
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
);
|
||||
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
|
||||
// Past the budget this puts the record back in the queue.
|
||||
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
|
||||
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||
_groundRetryArms = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask for the frame that samples what this one's budget left over. */
|
||||
function requestQueuedGroundFrame() {
|
||||
if (_ground.queued.length > 0)
|
||||
governorRequestRender(`local-ground-sample:${id}`);
|
||||
}
|
||||
|
||||
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: id,
|
||||
host: overlayHost,
|
||||
@@ -503,7 +454,6 @@ export function createLocalGeoJsonLayer(
|
||||
const source = _dataSource;
|
||||
_dataSource = null;
|
||||
_stemRecords = [];
|
||||
_ground.queued = [];
|
||||
_stemGeometryDirty = true;
|
||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||
removeEntityContextsForLayer(id);
|
||||
@@ -647,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,
|
||||
});
|
||||
@@ -701,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());
|
||||
|
||||
@@ -711,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(
|
||||
@@ -751,8 +726,6 @@ export function createLocalGeoJsonLayer(
|
||||
// Store references for bounded stem scaling and native picking.
|
||||
feature.__localBaseCarto = carto;
|
||||
feature.__localBaseCartesian = base;
|
||||
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
||||
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
||||
registerEntityContext(feature, {
|
||||
id: `${id}:${recordId}`,
|
||||
layerId: id,
|
||||
@@ -780,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 —
|
||||
@@ -803,11 +776,7 @@ export function createLocalGeoJsonLayer(
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
groundHeight,
|
||||
// Degrees, as the terrain surface takes them.
|
||||
latDeg,
|
||||
lonDeg,
|
||||
groundSampled: false,
|
||||
groundSampleQueued: false,
|
||||
lastGroundSampleMs: 0,
|
||||
priority,
|
||||
entry: labels
|
||||
@@ -817,7 +786,7 @@ export function createLocalGeoJsonLayer(
|
||||
position: tip,
|
||||
properties,
|
||||
priority,
|
||||
accent: color,
|
||||
accent: featureBaseColor.toCssHexString(),
|
||||
})
|
||||
: null,
|
||||
});
|
||||
@@ -928,13 +897,7 @@ export function createLocalGeoJsonLayer(
|
||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
if (!_enabled || !_dataSource) return;
|
||||
const now = performance.now();
|
||||
// One sample budget per frame, shared by the queue and the walk.
|
||||
_ground.sampleMs = 0;
|
||||
sampleQueuedGround(viewer, now);
|
||||
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
|
||||
requestQueuedGroundFrame();
|
||||
return;
|
||||
}
|
||||
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
|
||||
_lastVisibilityUpdate = now;
|
||||
|
||||
const cameraPos = viewer.camera.positionWC;
|
||||
@@ -985,9 +948,6 @@ export function createLocalGeoJsonLayer(
|
||||
_lastGroundSampleCapability = canSampleGround;
|
||||
let groundRetryPending = false;
|
||||
let groundSampleProgress = false;
|
||||
// This walk asks for no height while tiles load, so it shows the
|
||||
// surface the load itself; see terrainSurface.js observe().
|
||||
_ground.terrain.observe?.(viewer);
|
||||
|
||||
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
||||
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
||||
@@ -1049,10 +1009,9 @@ export function createLocalGeoJsonLayer(
|
||||
const terrainFloorChanged = refreshLocalTerrainFloor(
|
||||
viewer,
|
||||
record,
|
||||
_ground,
|
||||
);
|
||||
if (refreshStemGeometry || terrainFloorChanged) {
|
||||
updateLocalStemGeometry(viewer, record, now, _ground);
|
||||
updateLocalStemGeometry(viewer, record, now);
|
||||
} else if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
@@ -1066,9 +1025,9 @@ export function createLocalGeoJsonLayer(
|
||||
);
|
||||
if (
|
||||
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
sampleLocalGroundHeight(viewer, record, now, _ground)
|
||||
sampleLocalGroundHeight(viewer, record, now)
|
||||
) {
|
||||
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||
updateLocalStemGeometry(viewer, record, now, distance);
|
||||
}
|
||||
}
|
||||
if (!wasGroundSampled && record.groundSampled)
|
||||
@@ -1080,19 +1039,16 @@ export function createLocalGeoJsonLayer(
|
||||
// the user happens to move. Schedule the frame the retry needs.
|
||||
// Gated on a sampleable scene and in-range, in-view records only,
|
||||
// so a far camera (or a keyless scene) stays fully idle; the
|
||||
// distance is only computed for still-unsampled stems. A record
|
||||
// the frame budget queued is sampled by the frame the queue asks
|
||||
// for, with no timer.
|
||||
// distance is only computed for still-unsampled stems.
|
||||
if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
!record.groundSampleQueued &&
|
||||
!groundRetryPending &&
|
||||
Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundSampleInView(viewer, record, _ground)
|
||||
groundSampleInView(viewer, record)
|
||||
) {
|
||||
groundRetryPending = true;
|
||||
}
|
||||
@@ -1103,7 +1059,6 @@ export function createLocalGeoJsonLayer(
|
||||
// so the records still waiting get their own bounded run of retries.
|
||||
if (groundSampleProgress) _groundRetryArms = 0;
|
||||
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
||||
requestQueuedGroundFrame();
|
||||
|
||||
const canvas = viewer.scene.canvas;
|
||||
const cohort = selectLocalInfrastructureOverlayCohort(
|
||||
@@ -1190,7 +1145,7 @@ function insertLocalCellContender(contenders, record) {
|
||||
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||
}
|
||||
|
||||
function refreshLocalTerrainFloor(viewer, record, ground) {
|
||||
function refreshLocalTerrainFloor(viewer, record) {
|
||||
const globe = viewer.scene.globe;
|
||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
||||
return false;
|
||||
@@ -1202,15 +1157,7 @@ function refreshLocalTerrainFloor(viewer, record, ground) {
|
||||
// Camera motion can refine terrain after the first successful depth sample.
|
||||
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
||||
// another GPU readback just to maintain this floor. Keep roof elevations.
|
||||
// The surface keeps the height per point and casts the ray again only after
|
||||
// the camera moves or tiles load. MARKER: the answer is this point's own
|
||||
// height, never a neighbour's, since a height taken here is kept for good.
|
||||
const height = ground.terrain.height(
|
||||
viewer,
|
||||
record.latDeg,
|
||||
record.lonDeg,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
const height = globe.getHeight?.(record.carto);
|
||||
if (
|
||||
!Number.isFinite(height) ||
|
||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||
@@ -1253,20 +1200,11 @@ function groundSamplePoint(record, height, result) {
|
||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||
* sees it.
|
||||
*
|
||||
* The terrain height comes from the shared surface, which keeps it per point:
|
||||
* this runs for every unsampled site in range on every walk, and a ray cast
|
||||
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
|
||||
* height only roughly, so a height the surface has not read yet, or tiles
|
||||
* still loading, count as unknown ground.
|
||||
*/
|
||||
function groundSampleInView(viewer, record, ground) {
|
||||
function groundSampleInView(viewer, record) {
|
||||
const camera = viewer.camera;
|
||||
const globe = viewer.scene.globe;
|
||||
const terrain =
|
||||
globe?.show && globe.tilesLoaded !== false
|
||||
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
|
||||
: undefined;
|
||||
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
||||
const known =
|
||||
Number.isFinite(terrain) &&
|
||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
@@ -1307,30 +1245,18 @@ function groundSampleInView(viewer, record, ground) {
|
||||
);
|
||||
}
|
||||
|
||||
function sampleLocalGroundHeight(viewer, record, now, ground) {
|
||||
function sampleLocalGroundHeight(viewer, record, now) {
|
||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||
// This frame's samples have used its budget: leave the record for the next
|
||||
// frame, unattempted, so the retry spacing does not hold it back.
|
||||
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
|
||||
if (!record.groundSampleQueued) {
|
||||
record.groundSampleQueued = true;
|
||||
ground.queued.push(record);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!groundSampleInView(viewer, record, ground)) return false;
|
||||
if (!groundSampleInView(viewer, record)) return false;
|
||||
record.lastGroundSampleMs = now;
|
||||
const globe = viewer.scene.globe;
|
||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||
let sampled;
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
||||
} catch {
|
||||
return false; // tiles not ready; retry on a later bounded update
|
||||
} finally {
|
||||
ground.sampleMs += performance.now() - startedAt;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(sampled) ||
|
||||
@@ -1340,9 +1266,7 @@ function sampleLocalGroundHeight(viewer, record, now, ground) {
|
||||
// A coarse scene-depth sample can lie below the terrain already loaded by
|
||||
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
||||
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
||||
// inactive terrain must not constrain their geometry. Read from the globe
|
||||
// itself, once per site: the floor under a sample must be the mesh that is
|
||||
// loaded now, not a height kept from a moment before.
|
||||
// inactive terrain must not constrain their geometry.
|
||||
const terrainHeight = globe?.show
|
||||
? globe.getHeight?.(record.carto)
|
||||
: undefined;
|
||||
@@ -1369,18 +1293,12 @@ function setLocalGroundHeight(record, height) {
|
||||
record.entity.__localBaseCartesian = record.base;
|
||||
}
|
||||
|
||||
function updateLocalStemGeometry(
|
||||
viewer,
|
||||
record,
|
||||
now,
|
||||
ground,
|
||||
knownDistance = null,
|
||||
) {
|
||||
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
||||
const distance = Number.isFinite(knownDistance)
|
||||
? knownDistance
|
||||
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
||||
sampleLocalGroundHeight(viewer, record, now, ground);
|
||||
sampleLocalGroundHeight(viewer, record, now);
|
||||
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
||||
// minimum made a marker hundreds of metres tall beside a nearby building.
|
||||
const effectiveDistance = Math.max(distance, 1);
|
||||
@@ -1442,6 +1360,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;
|
||||
}
|
||||
|
||||
|
||||
+122
-1
@@ -3830,5 +3830,126 @@
|
||||
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
|
||||
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
|
||||
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})",
|
||||
"tiles unavailable": "البلاطات غير متاحة",
|
||||
"zoom in to load this layer": "كبّر لتحميل هذه الطبقة",
|
||||
"Zoom to 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": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة",
|
||||
"Coverage outline": "حدود التغطية",
|
||||
"DRAPED": "على التضاريس",
|
||||
"Below 250 m": "أقل من 250 م",
|
||||
"250–500 m": "250–500 م",
|
||||
"500–1,000 m": "500–1,000 م",
|
||||
"1,000–2,000 m": "1,000–2,000 م",
|
||||
"2,000–3,000 m": "2,000–3,000 م",
|
||||
"3,000 m and above": "3,000 م فأكثر",
|
||||
"Exit cockpit to fly to a layer": "اخرج من قمرة القيادة للطيران إلى طبقة",
|
||||
"Library": "المكتبة",
|
||||
"Environment": "البيئة",
|
||||
"Imagery": "الصور",
|
||||
"Demographics": "التركيبة السكانية",
|
||||
"DATASET": "مجموعة بيانات",
|
||||
"TILES": "بلاطات",
|
||||
"PACK": "حزمة",
|
||||
"INCUBATING": "قيد الاحتضان",
|
||||
"COMMUNITY": "مجتمعي",
|
||||
"VERIFIED": "موثّق",
|
||||
"ARCHIVED": "مؤرشف",
|
||||
"Datasets": "مجموعات البيانات",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"zero": "{count} مجموعة بيانات",
|
||||
"one": "{count} مجموعة بيانات",
|
||||
"two": "{count} مجموعتا بيانات",
|
||||
"few": "{count} مجموعات بيانات",
|
||||
"many": "{count} مجموعة بيانات",
|
||||
"other": "{count} مجموعة بيانات"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"zero": "{count} طبقة بلاطات",
|
||||
"one": "{count} طبقة بلاطات",
|
||||
"two": "{count} طبقتا بلاطات",
|
||||
"few": "{count} طبقات بلاطات",
|
||||
"many": "{count} طبقة بلاطات",
|
||||
"other": "{count} طبقة بلاطات"
|
||||
},
|
||||
"{count} packs": {
|
||||
"zero": "{count} حزمة",
|
||||
"one": "{count} حزمة",
|
||||
"two": "{count} حزمتان",
|
||||
"few": "{count} حزم",
|
||||
"many": "{count} حزمة",
|
||||
"other": "{count} حزمة"
|
||||
},
|
||||
"Themes": "المواضيع",
|
||||
"Added": "المُضافة",
|
||||
"{count} on the map": {
|
||||
"zero": "{count} على الخريطة",
|
||||
"one": "{count} على الخريطة",
|
||||
"two": "{count} على الخريطة",
|
||||
"few": "{count} على الخريطة",
|
||||
"many": "{count} على الخريطة",
|
||||
"other": "{count} على الخريطة"
|
||||
},
|
||||
"none yet": "لا شيء بعد",
|
||||
"Map features": "معالم الخريطة",
|
||||
"from {count} datasets": {
|
||||
"zero": "من {count} مجموعة بيانات",
|
||||
"one": "من {count} مجموعة بيانات",
|
||||
"two": "من {count} مجموعتَي بيانات",
|
||||
"few": "من {count} مجموعات بيانات",
|
||||
"many": "من {count} مجموعة بيانات",
|
||||
"other": "من {count} مجموعة بيانات"
|
||||
},
|
||||
"switch on a dataset to load features": "شغّل مجموعة بيانات لتحميل المعالم",
|
||||
"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": "إقليمي",
|
||||
"Orthophoto": "صورة متعامدة",
|
||||
"Satellite mosaic": "فسيفساء أقمار صناعية",
|
||||
"Oblique": "مائلة",
|
||||
"Historic imagery": "صور تاريخية",
|
||||
"True orthophoto": "صورة متعامدة حقيقية",
|
||||
"Type": "النوع",
|
||||
"Resolution": "الدقة",
|
||||
"Flown": "تاريخ التصوير",
|
||||
"Updates": "التحديث",
|
||||
"Ortho quality": "جودة التعامد",
|
||||
"DATA LIBRARY": "مكتبة البيانات",
|
||||
"Close data library": "إغلاق مكتبة البيانات",
|
||||
"Data Library at a glance": "مكتبة البيانات في لمحة",
|
||||
"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.": "مصادر مباشرة تتجاوز الطبقات الأساسية. يُجلب كل منها من مزوّده ما دام مفعّلًا؛ ولا يُخزَّن شيء في هذا التطبيق. أضِف واحدًا وسيظهر في طبقات البيانات ضمن المكتبة.",
|
||||
"Name, provider or country": "الاسم أو المزوّد أو البلد",
|
||||
"Theme": "الموضوع",
|
||||
"All themes": "كل المواضيع",
|
||||
"Only what this instance serves": "فقط ما يقدّمه هذا المثيل",
|
||||
"OPT-IN": "اختياري",
|
||||
"KEY": "مفتاح",
|
||||
"KEYLESS": "بلا مفتاح",
|
||||
"ZOOM": "تكبير",
|
||||
"Zoom to {name}": "تكبير إلى {name}",
|
||||
"WORKING…": "جارٍ العمل…",
|
||||
"ADD": "إضافة",
|
||||
"Remove {name} from Data Layers": "إزالة {name} من طبقات البيانات",
|
||||
"Add {name} to Data Layers": "إضافة {name} إلى طبقات البيانات",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"zero": "تأكيد · إزالة الكل ({count})",
|
||||
"one": "تأكيد · إزالة {count}",
|
||||
"two": "تأكيد · إزالة الكل ({count})",
|
||||
"few": "تأكيد · إزالة الكل ({count})",
|
||||
"many": "تأكيد · إزالة الكل ({count})",
|
||||
"other": "تأكيد · إزالة الكل ({count})"
|
||||
},
|
||||
"REMOVE ALL": "إزالة الكل",
|
||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
|
||||
}
|
||||
@@ -3506,5 +3506,102 @@
|
||||
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
|
||||
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
|
||||
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})",
|
||||
"tiles unavailable": "Kacheln nicht verfügbar",
|
||||
"zoom in to load this layer": "hineinzoomen, um diese Ebene zu laden",
|
||||
"Zoom to layer": "Zur Ebene zoomen",
|
||||
"Zoom to layer · fly to where this layer has data": "Zur Ebene zoomen · dorthin fliegen, wo diese Ebene Daten hat",
|
||||
"Remove from Data Layers": "Aus Datenebenen entfernen",
|
||||
"Remove from Data Layers · add it again from the library": "Aus Datenebenen entfernen · über die Bibliothek erneut hinzufügen",
|
||||
"Coverage outline": "Abdeckungsumriss",
|
||||
"DRAPED": "DRAPIERT",
|
||||
"Below 250 m": "Unter 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m und mehr",
|
||||
"Exit cockpit to fly to a layer": "Cockpit verlassen, um zu einer Ebene zu fliegen",
|
||||
"Library": "Bibliothek",
|
||||
"Environment": "Umwelt",
|
||||
"Imagery": "Bildmaterial",
|
||||
"Demographics": "Demografie",
|
||||
"DATASET": "DATENSATZ",
|
||||
"TILES": "KACHELN",
|
||||
"PACK": "PAKET",
|
||||
"INCUBATING": "IN ERPROBUNG",
|
||||
"COMMUNITY": "COMMUNITY",
|
||||
"VERIFIED": "GEPRÜFT",
|
||||
"ARCHIVED": "ARCHIVIERT",
|
||||
"Datasets": "Datensätze",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} Datensatz",
|
||||
"other": "{count} Datensätze"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} Kachelebene",
|
||||
"other": "{count} Kachelebenen"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} Paket",
|
||||
"other": "{count} Pakete"
|
||||
},
|
||||
"Themes": "Themen",
|
||||
"Added": "Hinzugefügt",
|
||||
"{count} on the map": {
|
||||
"one": "{count} auf der Karte",
|
||||
"other": "{count} auf der Karte"
|
||||
},
|
||||
"none yet": "noch keine",
|
||||
"Map features": "Kartenobjekte",
|
||||
"from {count} datasets": {
|
||||
"one": "aus {count} Datensatz",
|
||||
"other": "aus {count} Datensätzen"
|
||||
},
|
||||
"switch on a dataset to load features": "einen Datensatz einschalten, um Objekte zu laden",
|
||||
"Switched on by this instance · part of the CCTV layer": "Von dieser Instanz eingeschaltet · Teil der CCTV-Ebene",
|
||||
"Operator opt-in: set {env}=1 on the server": "Opt-in durch Betreiber: {env}=1 auf dem Server setzen",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Opt-in durch Betreiber: {id} zu GEV_LIBRARY_ENABLE auf dem Server hinzufügen",
|
||||
"Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet",
|
||||
"Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server",
|
||||
"Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar",
|
||||
"Checking what this instance serves…": "Prüfe, was diese Instanz bereitstellt…",
|
||||
"Regional": "Regional",
|
||||
"Orthophoto": "Orthofoto",
|
||||
"Satellite mosaic": "Satellitenmosaik",
|
||||
"Oblique": "Schrägluftbild",
|
||||
"Historic imagery": "Historische Luftbilder",
|
||||
"True orthophoto": "True Orthofoto",
|
||||
"Type": "Typ",
|
||||
"Resolution": "Auflösung",
|
||||
"Flown": "Befliegung",
|
||||
"Updates": "Aktualisierung",
|
||||
"Ortho quality": "Orthoqualität",
|
||||
"DATA LIBRARY": "DATENBIBLIOTHEK",
|
||||
"Close data library": "Datenbibliothek schließen",
|
||||
"Data Library at a glance": "Datenbibliothek auf einen Blick",
|
||||
"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.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Fügen Sie eine hinzu, und sie erscheint in den Datenebenen unter Bibliothek.",
|
||||
"Name, provider or country": "Name, Anbieter oder Land",
|
||||
"Theme": "Thema",
|
||||
"All themes": "Alle Themen",
|
||||
"Only what this instance serves": "Nur was diese Instanz bereitstellt",
|
||||
"OPT-IN": "OPT-IN",
|
||||
"KEY": "SCHLÜSSEL",
|
||||
"KEYLESS": "OHNE SCHLÜSSEL",
|
||||
"ZOOM": "ZOOMEN",
|
||||
"Zoom to {name}": "Zu {name} zoomen",
|
||||
"WORKING…": "ARBEITET…",
|
||||
"ADD": "HINZUFÜGEN",
|
||||
"Remove {name} from Data Layers": "{name} aus Datenebenen entfernen",
|
||||
"Add {name} to Data Layers": "{name} zu Datenebenen hinzufügen",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "BESTÄTIGEN · {count} ENTFERNEN",
|
||||
"other": "BESTÄTIGEN · ALLE {count} ENTFERNEN"
|
||||
},
|
||||
"REMOVE ALL": "ALLE ENTFERNEN",
|
||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
|
||||
}
|
||||
+104
-1
@@ -3587,5 +3587,108 @@
|
||||
"{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
|
||||
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
+104
-1
@@ -3587,5 +3587,108 @@
|
||||
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
|
||||
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})",
|
||||
"tiles unavailable": "tuiles indisponibles",
|
||||
"zoom in to load this layer": "zoomez pour charger cette couche",
|
||||
"Zoom to layer": "Zoomer sur la couche",
|
||||
"Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · voler là où cette couche a des données",
|
||||
"Remove from Data Layers": "Retirer des Couches de données",
|
||||
"Remove from Data Layers · add it again from the library": "Retirer des Couches de données · ajoutez-la à nouveau depuis la bibliothèque",
|
||||
"Coverage outline": "Contour de couverture",
|
||||
"DRAPED": "DRAPÉ",
|
||||
"Below 250 m": "Moins de 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1 000 m",
|
||||
"1,000–2,000 m": "1 000–2 000 m",
|
||||
"2,000–3,000 m": "2 000–3 000 m",
|
||||
"3,000 m and above": "3 000 m et plus",
|
||||
"Exit cockpit to fly to a layer": "Quittez le cockpit pour voler vers une couche",
|
||||
"Library": "Bibliothèque",
|
||||
"Environment": "Environnement",
|
||||
"Imagery": "Imagerie",
|
||||
"Demographics": "Démographie",
|
||||
"DATASET": "JEU DE DONNÉES",
|
||||
"TILES": "TUILES",
|
||||
"PACK": "PACK",
|
||||
"INCUBATING": "EN INCUBATION",
|
||||
"COMMUNITY": "COMMUNAUTÉ",
|
||||
"VERIFIED": "VÉRIFIÉ",
|
||||
"ARCHIVED": "ARCHIVÉ",
|
||||
"Datasets": "Jeux de données",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} jeu de données",
|
||||
"many": "{count} de jeux de données",
|
||||
"other": "{count} jeux de données"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} couche de tuiles",
|
||||
"many": "{count} de couches de tuiles",
|
||||
"other": "{count} couches de tuiles"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pack",
|
||||
"many": "{count} de packs",
|
||||
"other": "{count} packs"
|
||||
},
|
||||
"Themes": "Thèmes",
|
||||
"Added": "Ajoutées",
|
||||
"{count} on the map": {
|
||||
"one": "{count} sur la carte",
|
||||
"many": "{count} sur la carte",
|
||||
"other": "{count} sur la carte"
|
||||
},
|
||||
"none yet": "aucune pour l'instant",
|
||||
"Map features": "Entités cartographiques",
|
||||
"from {count} datasets": {
|
||||
"one": "issues de {count} jeu de données",
|
||||
"many": "issues de {count} de jeux de données",
|
||||
"other": "issues de {count} jeux de données"
|
||||
},
|
||||
"switch on a dataset to load features": "activez un jeu de données pour charger des entités",
|
||||
"Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur",
|
||||
"Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance",
|
||||
"Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur",
|
||||
"Needs this app's server · not available in a static build": "Nécessite le serveur de cette app · indisponible dans une version statique",
|
||||
"Checking what this instance serves…": "Vérification de ce que propose cette instance…",
|
||||
"Regional": "Régional",
|
||||
"Orthophoto": "Orthophoto",
|
||||
"Satellite mosaic": "Mosaïque satellite",
|
||||
"Oblique": "Oblique",
|
||||
"Historic imagery": "Imagerie historique",
|
||||
"True orthophoto": "Orthophoto vraie",
|
||||
"Type": "Type",
|
||||
"Resolution": "Résolution",
|
||||
"Flown": "Vol",
|
||||
"Updates": "Mise à jour",
|
||||
"Ortho quality": "Qualité ortho",
|
||||
"DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES",
|
||||
"Close data library": "Fermer la bibliothèque de données",
|
||||
"Data Library at a glance": "La bibliothèque de données en un coup d'œil",
|
||||
"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.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette app. Ajoutez-en une et elle apparaît dans Couches de données, sous Bibliothèque.",
|
||||
"Name, provider or country": "Nom, fournisseur ou pays",
|
||||
"Theme": "Thème",
|
||||
"All themes": "Tous les thèmes",
|
||||
"Only what this instance serves": "Seulement ce que propose cette instance",
|
||||
"OPT-IN": "SUR ACTIVATION",
|
||||
"KEY": "CLÉ",
|
||||
"KEYLESS": "SANS CLÉ",
|
||||
"ZOOM": "ZOOMER",
|
||||
"Zoom to {name}": "Zoomer sur {name}",
|
||||
"WORKING…": "EN COURS…",
|
||||
"ADD": "AJOUTER",
|
||||
"Remove {name} from Data Layers": "Retirer {name} des Couches de données",
|
||||
"Add {name} to Data Layers": "Ajouter {name} aux Couches de données",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFIRMER · RETIRER {count}",
|
||||
"many": "CONFIRMER · RETIRER LES {count}",
|
||||
"other": "CONFIRMER · RETIRER LES {count}"
|
||||
},
|
||||
"REMOVE ALL": "TOUT RETIRER",
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer des Couches de données toutes les entrées ajoutées",
|
||||
"Remove every added entry from Data Layers": "Retirer des Couches de données toutes les entrées ajoutées",
|
||||
"Showing {shown} of {total}": "{shown} sur {total} affichés",
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
|
||||
}
|
||||
+104
-1
@@ -3587,5 +3587,108 @@
|
||||
"{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}",
|
||||
"{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים",
|
||||
"No {source} imagery near {place}": "אין צילומים של {source} ליד {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})",
|
||||
"tiles unavailable": "האריחים אינם זמינים",
|
||||
"zoom in to load this layer": "התקרב כדי לטעון את השכבה הזאת",
|
||||
"Zoom to 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": "הסרה משכבות נתונים · אפשר להוסיף אותה שוב מהספרייה",
|
||||
"Coverage outline": "קו מתאר של הכיסוי",
|
||||
"DRAPED": "פרוש על הקרקע",
|
||||
"Below 250 m": "מתחת ל-250 מ׳",
|
||||
"250–500 m": "250–500 מ׳",
|
||||
"500–1,000 m": "500–1,000 מ׳",
|
||||
"1,000–2,000 m": "1,000–2,000 מ׳",
|
||||
"2,000–3,000 m": "2,000–3,000 מ׳",
|
||||
"3,000 m and above": "3,000 מ׳ ומעלה",
|
||||
"Exit cockpit to fly to a layer": "צא מתא הטייס כדי לטוס אל שכבה",
|
||||
"Library": "ספרייה",
|
||||
"Environment": "סביבה",
|
||||
"Imagery": "צילומים",
|
||||
"Demographics": "דמוגרפיה",
|
||||
"DATASET": "מערך נתונים",
|
||||
"TILES": "אריחים",
|
||||
"PACK": "חבילה",
|
||||
"INCUBATING": "בהבשלה",
|
||||
"COMMUNITY": "קהילה",
|
||||
"VERIFIED": "מאומת",
|
||||
"ARCHIVED": "בארכיון",
|
||||
"Datasets": "מערכי נתונים",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} מערך נתונים",
|
||||
"two": "{count} מערכי נתונים",
|
||||
"other": "{count} מערכי נתונים"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} שכבת אריחים",
|
||||
"two": "{count} שכבות אריחים",
|
||||
"other": "{count} שכבות אריחים"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} חבילה",
|
||||
"two": "{count} חבילות",
|
||||
"other": "{count} חבילות"
|
||||
},
|
||||
"Themes": "נושאים",
|
||||
"Added": "נוספו",
|
||||
"{count} on the map": {
|
||||
"one": "{count} על המפה",
|
||||
"two": "{count} על המפה",
|
||||
"other": "{count} על המפה"
|
||||
},
|
||||
"none yet": "אין עדיין",
|
||||
"Map features": "ישויות במפה",
|
||||
"from {count} datasets": {
|
||||
"one": "מ-{count} מערך נתונים",
|
||||
"two": "מ-{count} מערכי נתונים",
|
||||
"other": "מ-{count} מערכי נתונים"
|
||||
},
|
||||
"switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון ישויות",
|
||||
"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": "אזורי",
|
||||
"Orthophoto": "אורתופוטו",
|
||||
"Satellite mosaic": "פסיפס לוויני",
|
||||
"Oblique": "צילום אלכסוני",
|
||||
"Historic imagery": "צילומים היסטוריים",
|
||||
"True orthophoto": "אורתופוטו אמיתי",
|
||||
"Type": "סוג",
|
||||
"Resolution": "רזולוציה",
|
||||
"Flown": "צולם מהאוויר",
|
||||
"Updates": "עדכונים",
|
||||
"Ortho quality": "איכות האורתו",
|
||||
"DATA LIBRARY": "ספריית נתונים",
|
||||
"Close data library": "סגירת ספריית הנתונים",
|
||||
"Data Library at a glance": "ספריית הנתונים במבט חטוף",
|
||||
"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.": "מקורות חיים מעבר לשכבות הליבה. כל מקור נטען מהספק שלו כל עוד הוא מופעל; דבר אינו נשמר באפליקציה. הוסף מקור והוא יופיע בשכבות נתונים תחת ספרייה.",
|
||||
"Name, provider or country": "שם, ספק או מדינה",
|
||||
"Theme": "נושא",
|
||||
"All themes": "כל הנושאים",
|
||||
"Only what this instance serves": "רק מה שהמופע הזה מספק",
|
||||
"OPT-IN": "בהפעלה ידנית",
|
||||
"KEY": "מפתח",
|
||||
"KEYLESS": "ללא מפתח",
|
||||
"ZOOM": "התקרבות",
|
||||
"Zoom to {name}": "התקרבות אל {name}",
|
||||
"WORKING…": "מבצע…",
|
||||
"ADD": "הוספה",
|
||||
"Remove {name} from Data Layers": "הסרת {name} משכבות נתונים",
|
||||
"Add {name} to Data Layers": "הוספת {name} לשכבות נתונים",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "אישור · הסרת {count}",
|
||||
"two": "אישור · הסרת כל {count}",
|
||||
"other": "אישור · הסרת כל {count}"
|
||||
},
|
||||
"REMOVE ALL": "הסרת הכול",
|
||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
|
||||
}
|
||||
+104
-1
@@ -3587,5 +3587,108 @@
|
||||
"{source} imagery could not be opened": "Impossibile aprire le immagini di {source}",
|
||||
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
|
||||
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})",
|
||||
"tiles unavailable": "tile non disponibili",
|
||||
"zoom in to load this layer": "avvicinati per caricare questo livello",
|
||||
"Zoom to layer": "Zoom sul livello",
|
||||
"Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati",
|
||||
"Remove from Data Layers": "Rimuovi da Livelli dati",
|
||||
"Remove from Data Layers · add it again from the library": "Rimuovi da Livelli dati · aggiungilo di nuovo dalla libreria",
|
||||
"Coverage outline": "Contorno di copertura",
|
||||
"DRAPED": "DRAPPEGGIATO",
|
||||
"Below 250 m": "Sotto i 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m e oltre",
|
||||
"Exit cockpit to fly to a layer": "Esci dalla cabina di pilotaggio per volare a un livello",
|
||||
"Library": "Libreria",
|
||||
"Environment": "Ambiente",
|
||||
"Imagery": "Immagini",
|
||||
"Demographics": "Demografia",
|
||||
"DATASET": "DATASET",
|
||||
"TILES": "TILE",
|
||||
"PACK": "PACCHETTO",
|
||||
"INCUBATING": "IN INCUBAZIONE",
|
||||
"COMMUNITY": "COMUNITÀ",
|
||||
"VERIFIED": "VERIFICATO",
|
||||
"ARCHIVED": "ARCHIVIATO",
|
||||
"Datasets": "Dataset",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} dataset",
|
||||
"many": "{count} di dataset",
|
||||
"other": "{count} dataset"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} livello di tile",
|
||||
"many": "{count} di livelli di tile",
|
||||
"other": "{count} livelli di tile"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pacchetto",
|
||||
"many": "{count} di pacchetti",
|
||||
"other": "{count} pacchetti"
|
||||
},
|
||||
"Themes": "Temi",
|
||||
"Added": "Aggiunti",
|
||||
"{count} on the map": {
|
||||
"one": "{count} sulla mappa",
|
||||
"many": "{count} sulla mappa",
|
||||
"other": "{count} sulla mappa"
|
||||
},
|
||||
"none yet": "ancora nessuno",
|
||||
"Map features": "Elementi della mappa",
|
||||
"from {count} datasets": {
|
||||
"one": "da {count} dataset",
|
||||
"many": "da {count} di dataset",
|
||||
"other": "da {count} dataset"
|
||||
},
|
||||
"switch on a dataset to load features": "attiva un dataset per caricare gli elementi",
|
||||
"Switched on by this instance · part of the CCTV layer": "Attivato da questa istanza · parte del livello CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server",
|
||||
"Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza",
|
||||
"Needs a provider key on the server": "Richiede una chiave del fornitore sul server",
|
||||
"Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica",
|
||||
"Checking what this instance serves…": "Verifica di ciò che offre questa istanza…",
|
||||
"Regional": "Regionale",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Mosaico satellitare",
|
||||
"Oblique": "Obliqua",
|
||||
"Historic imagery": "Immagini storiche",
|
||||
"True orthophoto": "Ortofoto vera",
|
||||
"Type": "Tipo",
|
||||
"Resolution": "Risoluzione",
|
||||
"Flown": "Volo",
|
||||
"Updates": "Aggiornamenti",
|
||||
"Ortho quality": "Qualità ortofoto",
|
||||
"DATA LIBRARY": "LIBRERIA DATI",
|
||||
"Close data library": "Chiudi la libreria dati",
|
||||
"Data Library at a glance": "La libreria dati in sintesi",
|
||||
"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.": "Fonti in tempo reale oltre ai livelli principali. Ognuna viene caricata dal suo fornitore finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.",
|
||||
"Name, provider or country": "Nome, fornitore o paese",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Tutti i temi",
|
||||
"Only what this instance serves": "Solo ciò che offre questa istanza",
|
||||
"OPT-IN": "SU ATTIVAZIONE",
|
||||
"KEY": "CHIAVE",
|
||||
"KEYLESS": "SENZA CHIAVE",
|
||||
"ZOOM": "ZOOM",
|
||||
"Zoom to {name}": "Zoom su {name}",
|
||||
"WORKING…": "IN CORSO…",
|
||||
"ADD": "AGGIUNGI",
|
||||
"Remove {name} from Data Layers": "Rimuovi {name} da Livelli dati",
|
||||
"Add {name} to Data Layers": "Aggiungi {name} a Livelli dati",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFERMA · RIMUOVI {count}",
|
||||
"many": "CONFERMA · RIMUOVI TUTTI I {count}",
|
||||
"other": "CONFERMA · RIMUOVI TUTTI I {count}"
|
||||
},
|
||||
"REMOVE ALL": "RIMUOVI TUTTO",
|
||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Showing {shown} of {total}": "{shown} di {total} mostrati",
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
|
||||
}
|
||||
@@ -3425,5 +3425,96 @@
|
||||
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
|
||||
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
|
||||
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})",
|
||||
"tiles unavailable": "タイルを利用できません",
|
||||
"zoom in to load this layer": "ズームインしてこのレイヤーを読み込む",
|
||||
"Zoom to 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": "データレイヤーから削除 · ライブラリから再度追加できます",
|
||||
"Coverage outline": "カバー範囲の輪郭",
|
||||
"DRAPED": "地形に投影",
|
||||
"Below 250 m": "250 m 未満",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m 以上",
|
||||
"Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください",
|
||||
"Library": "ライブラリ",
|
||||
"Environment": "環境",
|
||||
"Imagery": "画像",
|
||||
"Demographics": "人口統計",
|
||||
"DATASET": "データセット",
|
||||
"TILES": "タイル",
|
||||
"PACK": "パック",
|
||||
"INCUBATING": "試験中",
|
||||
"COMMUNITY": "コミュニティ",
|
||||
"VERIFIED": "検証済み",
|
||||
"ARCHIVED": "アーカイブ済み",
|
||||
"Datasets": "データセット",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count} 件のデータセット"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count} 件のタイルレイヤー"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count} 件のパック"
|
||||
},
|
||||
"Themes": "テーマ",
|
||||
"Added": "追加済み",
|
||||
"{count} on the map": {
|
||||
"other": "地図上に {count} 件"
|
||||
},
|
||||
"none yet": "まだありません",
|
||||
"Map features": "地図上の地物",
|
||||
"from {count} datasets": {
|
||||
"other": "{count} 件のデータセットから"
|
||||
},
|
||||
"switch on a dataset to load features": "データセットをオンにすると地物を読み込みます",
|
||||
"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": "運用者による有効化:サーバーの GEV_LIBRARY_ENABLE に {id} を追加",
|
||||
"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": "地域",
|
||||
"Orthophoto": "オルソ画像",
|
||||
"Satellite mosaic": "衛星モザイク",
|
||||
"Oblique": "斜め写真",
|
||||
"Historic imagery": "過去の画像",
|
||||
"True orthophoto": "トゥルーオルソ画像",
|
||||
"Type": "種類",
|
||||
"Resolution": "解像度",
|
||||
"Flown": "撮影",
|
||||
"Updates": "更新",
|
||||
"Ortho quality": "オルソ品質",
|
||||
"DATA LIBRARY": "データライブラリ",
|
||||
"Close data library": "データライブラリを閉じる",
|
||||
"Data Library at a glance": "データライブラリの概要",
|
||||
"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.": "コアレイヤー以外のライブソースです。オンの間は各プロバイダーから取得され、このアプリには何も保存されません。追加すると、データレイヤーの「ライブラリ」に表示されます。",
|
||||
"Name, provider or country": "名前、プロバイダー、国",
|
||||
"Theme": "テーマ",
|
||||
"All themes": "すべてのテーマ",
|
||||
"Only what this instance serves": "このインスタンスが提供するもののみ",
|
||||
"OPT-IN": "要有効化",
|
||||
"KEY": "キー必須",
|
||||
"KEYLESS": "キー不要",
|
||||
"ZOOM": "ズーム",
|
||||
"Zoom to {name}": "{name} にズーム",
|
||||
"WORKING…": "処理中…",
|
||||
"ADD": "追加",
|
||||
"Remove {name} from Data Layers": "{name} をデータレイヤーから削除",
|
||||
"Add {name} to Data Layers": "{name} をデータレイヤーに追加",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "確認 · {count} 件をすべて削除"
|
||||
},
|
||||
"REMOVE ALL": "すべて削除",
|
||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||
"Showing {shown} of {total}": "{total} 件中 {shown} 件を表示",
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る"
|
||||
}
|
||||
+104
-1
@@ -3587,5 +3587,108 @@
|
||||
"{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
|
||||
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})",
|
||||
"tiles unavailable": "blocos indisponíveis",
|
||||
"zoom in to load this layer": "aproxime para carregar esta camada",
|
||||
"Zoom to layer": "Ir para a camada",
|
||||
"Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados",
|
||||
"Remove from Data Layers": "Remover de Camadas de dados",
|
||||
"Remove from Data Layers · add it again from the library": "Remover de Camadas de dados · adicione de novo pela biblioteca",
|
||||
"Coverage outline": "Contorno de cobertura",
|
||||
"DRAPED": "SOBREPOSTO",
|
||||
"Below 250 m": "Abaixo de 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m ou mais",
|
||||
"Exit cockpit to fly to a layer": "Saia da cabine para voar até uma camada",
|
||||
"Library": "Biblioteca",
|
||||
"Environment": "Meio ambiente",
|
||||
"Imagery": "Imagens",
|
||||
"Demographics": "Demografia",
|
||||
"DATASET": "DADOS",
|
||||
"TILES": "BLOCOS",
|
||||
"PACK": "PACOTE",
|
||||
"INCUBATING": "EM INCUBAÇÃO",
|
||||
"COMMUNITY": "COMUNIDADE",
|
||||
"VERIFIED": "VERIFICADO",
|
||||
"ARCHIVED": "ARQUIVADO",
|
||||
"Datasets": "Conjuntos de dados",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} conjunto de dados",
|
||||
"many": "{count} de conjuntos de dados",
|
||||
"other": "{count} conjuntos de dados"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} camada de blocos",
|
||||
"many": "{count} de camadas de blocos",
|
||||
"other": "{count} camadas de blocos"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pacote",
|
||||
"many": "{count} de pacotes",
|
||||
"other": "{count} pacotes"
|
||||
},
|
||||
"Themes": "Temas",
|
||||
"Added": "Adicionadas",
|
||||
"{count} on the map": {
|
||||
"one": "{count} no mapa",
|
||||
"many": "{count} no mapa",
|
||||
"other": "{count} no mapa"
|
||||
},
|
||||
"none yet": "nenhuma ainda",
|
||||
"Map features": "Elementos do mapa",
|
||||
"from {count} datasets": {
|
||||
"one": "de {count} conjunto de dados",
|
||||
"many": "de {count} de conjuntos de dados",
|
||||
"other": "de {count} conjuntos de dados"
|
||||
},
|
||||
"switch on a dataset to load features": "ative um conjunto de dados para carregar elementos",
|
||||
"Switched on by this instance · part of the CCTV layer": "Ativado nesta instância · parte da camada CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Ativação pelo operador: defina {env}=1 no servidor",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Ativação pelo operador: adicione {id} a GEV_LIBRARY_ENABLE no servidor",
|
||||
"Turned off by this instance's operator": "Desativado pelo operador desta instância",
|
||||
"Needs a provider key on the server": "Requer uma chave do provedor no servidor",
|
||||
"Needs this app's server · not available in a static build": "Requer o servidor deste app · indisponível em uma build estática",
|
||||
"Checking what this instance serves…": "Verificando o que esta instância oferece…",
|
||||
"Regional": "Regional",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Mosaico de satélite",
|
||||
"Oblique": "Oblíqua",
|
||||
"Historic imagery": "Imagens históricas",
|
||||
"True orthophoto": "Ortofoto verdadeira",
|
||||
"Type": "Tipo",
|
||||
"Resolution": "Resolução",
|
||||
"Flown": "Voo",
|
||||
"Updates": "Atualização",
|
||||
"Ortho quality": "Qualidade da ortofoto",
|
||||
"DATA LIBRARY": "BIBLIOTECA DE DADOS",
|
||||
"Close data library": "Fechar a biblioteca de dados",
|
||||
"Data Library at a glance": "A biblioteca de dados em resumo",
|
||||
"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.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em Camadas de dados, em Biblioteca.",
|
||||
"Name, provider or country": "Nome, provedor ou país",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Todos os temas",
|
||||
"Only what this instance serves": "Só o que esta instância oferece",
|
||||
"OPT-IN": "OPCIONAL",
|
||||
"KEY": "CHAVE",
|
||||
"KEYLESS": "SEM CHAVE",
|
||||
"ZOOM": "APROXIMAR",
|
||||
"Zoom to {name}": "Ir para {name}",
|
||||
"WORKING…": "PROCESSANDO…",
|
||||
"ADD": "ADICIONAR",
|
||||
"Remove {name} from Data Layers": "Remover {name} de Camadas de dados",
|
||||
"Add {name} to Data Layers": "Adicionar {name} a Camadas de dados",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFIRMAR · REMOVER {count}",
|
||||
"many": "CONFIRMAR · REMOVER TODAS AS {count}",
|
||||
"other": "CONFIRMAR · REMOVER TODAS AS {count}"
|
||||
},
|
||||
"REMOVE ALL": "REMOVER TUDO",
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de Camadas de dados todas as entradas adicionadas",
|
||||
"Remove every added entry from Data Layers": "Remover de Camadas de dados todas as entradas adicionadas",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
|
||||
}
|
||||
+110
-1
@@ -3668,5 +3668,114 @@
|
||||
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
|
||||
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
|
||||
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})",
|
||||
"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.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».",
|
||||
"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 м и выше"
|
||||
}
|
||||
@@ -3506,5 +3506,102 @@
|
||||
"Play speed": "Uppspelningshastighet",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrolla eller nyp för att zooma, dra för att flytta · dubbelklicka för att se allt",
|
||||
"Live": "Live",
|
||||
"Back to live": "Tillbaka till live"
|
||||
"Back to live": "Tillbaka till live",
|
||||
"tiles unavailable": "kartrutor otillgängliga",
|
||||
"zoom in to load this layer": "zooma in för att ladda lagret",
|
||||
"Zoom to layer": "Zooma till lagret",
|
||||
"Zoom to layer · fly to where this layer has data": "Zooma till lagret · flyg dit lagret har data",
|
||||
"Remove from Data Layers": "Ta bort från Datalager",
|
||||
"Remove from Data Layers · add it again from the library": "Ta bort från Datalager · lägg till det igen från biblioteket",
|
||||
"Coverage outline": "Täckningens kontur",
|
||||
"DRAPED": "DRAPERAT",
|
||||
"Below 250 m": "Under 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1 000 m",
|
||||
"1,000–2,000 m": "1 000–2 000 m",
|
||||
"2,000–3,000 m": "2 000–3 000 m",
|
||||
"3,000 m and above": "3 000 m och högre",
|
||||
"Exit cockpit to fly to a layer": "Lämna cockpiten för att flyga till ett lager",
|
||||
"Library": "Bibliotek",
|
||||
"Environment": "Miljö",
|
||||
"Imagery": "Bilder",
|
||||
"Demographics": "Demografi",
|
||||
"DATASET": "DATAMÄNGD",
|
||||
"TILES": "KARTRUTOR",
|
||||
"PACK": "PAKET",
|
||||
"INCUBATING": "INKUBERAS",
|
||||
"COMMUNITY": "GEMENSKAP",
|
||||
"VERIFIED": "VERIFIERAD",
|
||||
"ARCHIVED": "ARKIVERAD",
|
||||
"Datasets": "Datamängder",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} datamängd",
|
||||
"other": "{count} datamängder"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} kartrutelager",
|
||||
"other": "{count} kartrutelager"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} paket",
|
||||
"other": "{count} paket"
|
||||
},
|
||||
"Themes": "Teman",
|
||||
"Added": "Tillagda",
|
||||
"{count} on the map": {
|
||||
"one": "{count} på kartan",
|
||||
"other": "{count} på kartan"
|
||||
},
|
||||
"none yet": "inga ännu",
|
||||
"Map features": "Kartobjekt",
|
||||
"from {count} datasets": {
|
||||
"one": "från {count} datamängd",
|
||||
"other": "från {count} datamängder"
|
||||
},
|
||||
"switch on a dataset to load features": "slå på en datamängd för att ladda objekt",
|
||||
"Switched on by this instance · part of the CCTV layer": "Påslaget av den här instansen · del av CCTV-lagret",
|
||||
"Operator opt-in: set {env}=1 on the server": "Aktiveras av operatören: sätt {env}=1 på servern",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av operatören: lägg till {id} i GEV_LIBRARY_ENABLE på servern",
|
||||
"Turned off by this instance's operator": "Avstängt av instansens operatör",
|
||||
"Needs a provider key on the server": "Kräver en leverantörsnyckel på servern",
|
||||
"Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk build",
|
||||
"Checking what this instance serves…": "Kontrollerar vad instansen tillhandahåller …",
|
||||
"Regional": "Regionalt",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Satellitmosaik",
|
||||
"Oblique": "Snedbild",
|
||||
"Historic imagery": "Historiska bilder",
|
||||
"True orthophoto": "Sant ortofoto",
|
||||
"Type": "Typ",
|
||||
"Resolution": "Upplösning",
|
||||
"Flown": "Flugen",
|
||||
"Updates": "Uppdateras",
|
||||
"Ortho quality": "Ortokvalitet",
|
||||
"DATA LIBRARY": "DATABIBLIOTEK",
|
||||
"Close data library": "Stäng databiblioteket",
|
||||
"Data Library at a glance": "Databiblioteket i korthet",
|
||||
"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.": "Realtidskällor utöver baslagren. Var och en hämtas från sin leverantör medan den är påslagen; inget lagras i appen. Lägg till en så visas den i Datalager under Bibliotek.",
|
||||
"Name, provider or country": "Namn, leverantör eller land",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Alla teman",
|
||||
"Only what this instance serves": "Bara det instansen tillhandahåller",
|
||||
"OPT-IN": "VALFRITT",
|
||||
"KEY": "NYCKEL",
|
||||
"KEYLESS": "UTAN NYCKEL",
|
||||
"ZOOM": "ZOOMA",
|
||||
"Zoom to {name}": "Zooma till {name}",
|
||||
"WORKING…": "ARBETAR …",
|
||||
"ADD": "LÄGG TILL",
|
||||
"Remove {name} from Data Layers": "Ta bort {name} från Datalager",
|
||||
"Add {name} to Data Layers": "Lägg till {name} i Datalager",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "BEKRÄFTA · TA BORT {count}",
|
||||
"other": "BEKRÄFTA · TA BORT ALLA {count}"
|
||||
},
|
||||
"REMOVE ALL": "TA BORT ALLA",
|
||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
|
||||
}
|
||||
+110
-1
@@ -3668,5 +3668,114 @@
|
||||
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
|
||||
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
|
||||
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})"
|
||||
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})",
|
||||
"tiles unavailable": "тайли недоступні",
|
||||
"zoom in to load this layer": "наблизьте, щоб завантажити цей шар",
|
||||
"Zoom to 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": "Вилучити з «Шарів даних» · можна знову додати з бібліотеки",
|
||||
"Coverage outline": "Контур покриття",
|
||||
"DRAPED": "НАКЛАДЕНО",
|
||||
"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 м і вище",
|
||||
"Exit cockpit to fly to a layer": "Вийдіть із кабіни, щоб перелетіти до шару",
|
||||
"Library": "Бібліотека",
|
||||
"Environment": "Довкілля",
|
||||
"Imagery": "Знімки",
|
||||
"Demographics": "Демографія",
|
||||
"DATASET": "НАБІР ДАНИХ",
|
||||
"TILES": "ТАЙЛИ",
|
||||
"PACK": "ПАКЕТ",
|
||||
"INCUBATING": "НА ІНКУБАЦІЇ",
|
||||
"COMMUNITY": "СПІЛЬНОТА",
|
||||
"VERIFIED": "ПЕРЕВІРЕНО",
|
||||
"ARCHIVED": "В АРХІВІ",
|
||||
"Datasets": "Набори даних",
|
||||
"{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} пакета"
|
||||
},
|
||||
"Themes": "Теми",
|
||||
"Added": "Додані",
|
||||
"{count} on the map": {
|
||||
"one": "{count} на карті",
|
||||
"few": "{count} на карті",
|
||||
"many": "{count} на карті",
|
||||
"other": "{count} на карті"
|
||||
},
|
||||
"none yet": "поки жодного",
|
||||
"Map features": "Об’єкти карти",
|
||||
"from {count} datasets": {
|
||||
"one": "з {count} набору даних",
|
||||
"few": "з {count} наборів даних",
|
||||
"many": "з {count} наборів даних",
|
||||
"other": "з {count} набору даних"
|
||||
},
|
||||
"switch on a dataset to load features": "увімкніть набір даних, щоб завантажити об’єкти",
|
||||
"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": "Регіональні",
|
||||
"Orthophoto": "Ортофото",
|
||||
"Satellite mosaic": "Супутникова мозаїка",
|
||||
"Oblique": "Перспективна зйомка",
|
||||
"Historic imagery": "Історичні знімки",
|
||||
"True orthophoto": "Справжнє ортофото",
|
||||
"Type": "Тип",
|
||||
"Resolution": "Роздільність",
|
||||
"Flown": "Зйомка",
|
||||
"Updates": "Оновлення",
|
||||
"Ortho quality": "Якість орто",
|
||||
"DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ",
|
||||
"Close data library": "Закрити бібліотеку даних",
|
||||
"Data Library at a glance": "Бібліотека даних коротко",
|
||||
"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.": "Живі джерела поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно з’явиться в «Шарах даних» у розділі «Бібліотека».",
|
||||
"Name, provider or country": "Назва, постачальник або країна",
|
||||
"Theme": "Тема",
|
||||
"All themes": "Усі теми",
|
||||
"Only what this instance serves": "Лише те, що надає цей екземпляр",
|
||||
"OPT-IN": "ЗА ЗГОДОЮ",
|
||||
"KEY": "КЛЮЧ",
|
||||
"KEYLESS": "БЕЗ КЛЮЧА",
|
||||
"ZOOM": "НАБЛИЗИТИ",
|
||||
"Zoom to {name}": "Наблизити до {name}",
|
||||
"WORKING…": "ОБРОБКА…",
|
||||
"ADD": "ДОДАТИ",
|
||||
"Remove {name} from Data Layers": "Вилучити {name} з «Шарів даних»",
|
||||
"Add {name} to Data Layers": "Додати {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": "Вилучити всі додані записи з «Шарів даних»",
|
||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
|
||||
}
|
||||
@@ -3425,5 +3425,96 @@
|
||||
"{source} imagery could not be opened": "无法打开 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})"
|
||||
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})",
|
||||
"tiles unavailable": "瓦片不可用",
|
||||
"zoom in to load this layer": "放大以加载此图层",
|
||||
"Zoom to 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": "从数据图层中移除 · 可从数据资源库重新添加",
|
||||
"Coverage outline": "覆盖范围轮廓",
|
||||
"DRAPED": "贴地",
|
||||
"Below 250 m": "低于 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m 及以上",
|
||||
"Exit cockpit to fly to a layer": "退出驾驶舱后才能飞往图层",
|
||||
"Library": "资源库",
|
||||
"Environment": "环境",
|
||||
"Imagery": "影像",
|
||||
"Demographics": "人口统计",
|
||||
"DATASET": "数据集",
|
||||
"TILES": "瓦片",
|
||||
"PACK": "数据包",
|
||||
"INCUBATING": "孵化中",
|
||||
"COMMUNITY": "社区",
|
||||
"VERIFIED": "已验证",
|
||||
"ARCHIVED": "已归档",
|
||||
"Datasets": "数据集",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count} 个数据集"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count} 个瓦片图层"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count} 个数据包"
|
||||
},
|
||||
"Themes": "主题",
|
||||
"Added": "已添加",
|
||||
"{count} on the map": {
|
||||
"other": "地图上 {count} 个"
|
||||
},
|
||||
"none yet": "暂无",
|
||||
"Map features": "地图要素",
|
||||
"from {count} datasets": {
|
||||
"other": "来自 {count} 个数据集"
|
||||
},
|
||||
"switch on a dataset to load features": "开启一个数据集以加载要素",
|
||||
"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": "区域",
|
||||
"Orthophoto": "正射影像",
|
||||
"Satellite mosaic": "卫星镶嵌图",
|
||||
"Oblique": "倾斜影像",
|
||||
"Historic imagery": "历史影像",
|
||||
"True orthophoto": "真正射影像",
|
||||
"Type": "类型",
|
||||
"Resolution": "分辨率",
|
||||
"Flown": "航摄时间",
|
||||
"Updates": "更新频率",
|
||||
"Ortho quality": "正射质量",
|
||||
"DATA LIBRARY": "数据资源库",
|
||||
"Close data library": "关闭数据资源库",
|
||||
"Data Library at a glance": "数据资源库概览",
|
||||
"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.": "核心图层之外的实时数据源。每个数据源开启时直接从其提供方获取;本应用不存储任何数据。添加后,它会出现在数据图层的“资源库”分组中。",
|
||||
"Name, provider or country": "名称、提供方或国家",
|
||||
"Theme": "主题",
|
||||
"All themes": "全部主题",
|
||||
"Only what this instance serves": "仅显示此实例提供的内容",
|
||||
"OPT-IN": "需启用",
|
||||
"KEY": "需密钥",
|
||||
"KEYLESS": "免密钥",
|
||||
"ZOOM": "缩放",
|
||||
"Zoom to {name}": "缩放至 {name}",
|
||||
"WORKING…": "处理中…",
|
||||
"ADD": "添加",
|
||||
"Remove {name} from Data Layers": "从数据图层中移除 {name}",
|
||||
"Add {name} to Data Layers": "将 {name} 添加到数据图层",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "确认 · 全部移除 {count} 项"
|
||||
},
|
||||
"REMOVE ALL": "全部移除",
|
||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库"
|
||||
}
|
||||
@@ -3425,5 +3425,96 @@
|
||||
"{source} imagery could not be opened": "無法打開 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})"
|
||||
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})",
|
||||
"tiles unavailable": "圖磚無法使用",
|
||||
"zoom in to load this layer": "放大以載入此圖層",
|
||||
"Zoom to 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": "從數據圖層中移除 · 可從數據資源庫重新加入",
|
||||
"Coverage outline": "涵蓋範圍輪廓",
|
||||
"DRAPED": "貼地",
|
||||
"Below 250 m": "低於 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m 以上",
|
||||
"Exit cockpit to fly to a layer": "離開駕駛艙後才能飛往圖層",
|
||||
"Library": "資源庫",
|
||||
"Environment": "環境",
|
||||
"Imagery": "影像",
|
||||
"Demographics": "人口統計",
|
||||
"DATASET": "資料集",
|
||||
"TILES": "圖磚",
|
||||
"PACK": "資料包",
|
||||
"INCUBATING": "孵化中",
|
||||
"COMMUNITY": "社群",
|
||||
"VERIFIED": "已驗證",
|
||||
"ARCHIVED": "已封存",
|
||||
"Datasets": "資料集",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count} 個資料集"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count} 個圖磚圖層"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count} 個資料包"
|
||||
},
|
||||
"Themes": "主題",
|
||||
"Added": "已加入",
|
||||
"{count} on the map": {
|
||||
"other": "地圖上 {count} 個"
|
||||
},
|
||||
"none yet": "尚無",
|
||||
"Map features": "地圖圖徵",
|
||||
"from {count} datasets": {
|
||||
"other": "來自 {count} 個資料集"
|
||||
},
|
||||
"switch on a dataset to load features": "開啟一個資料集以載入圖徵",
|
||||
"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": "區域",
|
||||
"Orthophoto": "正射影像",
|
||||
"Satellite mosaic": "衛星鑲嵌圖",
|
||||
"Oblique": "傾斜影像",
|
||||
"Historic imagery": "歷史影像",
|
||||
"True orthophoto": "真正射影像",
|
||||
"Type": "類型",
|
||||
"Resolution": "解析度",
|
||||
"Flown": "航拍時間",
|
||||
"Updates": "更新頻率",
|
||||
"Ortho quality": "正射品質",
|
||||
"DATA LIBRARY": "數據資源庫",
|
||||
"Close data library": "關閉數據資源庫",
|
||||
"Data Library at a glance": "數據資源庫概覽",
|
||||
"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.": "核心圖層以外的即時資料來源。每個來源開啟時直接從其提供者取得;本應用程式不儲存任何資料。加入後,它會出現在數據圖層的「資源庫」分組中。",
|
||||
"Name, provider or country": "名稱、提供者或國家",
|
||||
"Theme": "主題",
|
||||
"All themes": "所有主題",
|
||||
"Only what this instance serves": "僅顯示此執行個體提供的內容",
|
||||
"OPT-IN": "需啟用",
|
||||
"KEY": "需金鑰",
|
||||
"KEYLESS": "免金鑰",
|
||||
"ZOOM": "縮放",
|
||||
"Zoom to {name}": "縮放至 {name}",
|
||||
"WORKING…": "處理中…",
|
||||
"ADD": "加入",
|
||||
"Remove {name} from Data Layers": "從數據圖層中移除 {name}",
|
||||
"Add {name} to Data Layers": "將 {name} 加入數據圖層",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "確認 · 全部移除 {count} 項"
|
||||
},
|
||||
"REMOVE ALL": "全部移除",
|
||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已加入項目",
|
||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已加入項目",
|
||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
|
||||
}
|
||||
@@ -244,7 +244,10 @@
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"COMMUNITY",
|
||||
"Regional",
|
||||
"OPT-IN"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
@@ -404,7 +407,9 @@
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"Regional",
|
||||
"{datasets} · {tiles} · {packs}"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
@@ -619,7 +624,12 @@
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
"Photo © {photographer}",
|
||||
"PACK",
|
||||
"{count} packs",
|
||||
"Orthophoto",
|
||||
"Oblique",
|
||||
"Type"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
@@ -822,7 +832,10 @@
|
||||
"SATELLITE",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"DATASET",
|
||||
"{count} datasets",
|
||||
"ZOOM"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -1049,7 +1062,8 @@
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"Regional"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
@@ -1085,7 +1099,8 @@
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"{datasets} · {tiles} · {packs}"
|
||||
],
|
||||
"sv": [
|
||||
"STATION",
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// Station ground heights. A city's stations are all drawn at once at the
|
||||
// fallback height; their ground heights are found afterwards a few
|
||||
// milliseconds at a time, so a city of hundreds of docks never holds the main
|
||||
// thread for the length of hundreds of depth renders. Each case drives the
|
||||
// real rendering part (and the real lifecycle where it tears down) over a
|
||||
// scene whose height reads are counted and timed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
|
||||
import { createTerrainSurface } from '../../services/terrainSurface.js';
|
||||
|
||||
globalThis.document ??= {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
/** Milliseconds one fake depth render holds the thread. */
|
||||
const SAMPLE_COST_MS = 1;
|
||||
/** The ground under a point, as the fake scene and globe both report it. */
|
||||
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
|
||||
|
||||
function busyWait(ms) {
|
||||
const until = performance.now() + ms;
|
||||
while (performance.now() < until);
|
||||
}
|
||||
|
||||
/** `count` stations on a line through Boston. */
|
||||
function stations(count, prefix = 's') {
|
||||
return {
|
||||
data: {
|
||||
stations: Array.from({ length: count }, (_, i) => ({
|
||||
station_id: `${prefix}${i}`,
|
||||
name: `Dock ${i}`,
|
||||
lat: 42.3 + i * 1e-4,
|
||||
lon: -71.1 + i * 1e-4,
|
||||
capacity: 10,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendering part over a scene that counts its height reads.
|
||||
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
|
||||
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
|
||||
* `shared`: the application's terrain surface is supplied;
|
||||
* `unloaded`: latitudes whose terrain tile is not drawn.
|
||||
*/
|
||||
function makeLayer({
|
||||
terrainGlobe = false,
|
||||
shared = false,
|
||||
unloaded = () => false,
|
||||
} = {}) {
|
||||
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
|
||||
/** sampleHeight calls per turn of the event loop. */
|
||||
const perTurn = [0];
|
||||
let ticking = true;
|
||||
const tick = () => {
|
||||
if (!ticking) return;
|
||||
perTurn.push(0);
|
||||
setImmediate(tick);
|
||||
};
|
||||
setImmediate(tick);
|
||||
|
||||
const degrees = (carto) => [
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
];
|
||||
const viewer = {
|
||||
camera: {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
|
||||
changed: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight(carto) {
|
||||
calls.sampleHeight += 1;
|
||||
perTurn[perTurn.length - 1] += 1;
|
||||
busyWait(SAMPLE_COST_MS);
|
||||
return groundAt(...degrees(carto));
|
||||
},
|
||||
globe: {
|
||||
show: terrainGlobe,
|
||||
tilesLoaded: true,
|
||||
getHeight(carto) {
|
||||
calls.getHeight += 1;
|
||||
const [lat, lon] = degrees(carto);
|
||||
return unloaded(lat) ? undefined : groundAt(lat, lon);
|
||||
},
|
||||
},
|
||||
primitives: { add() {}, remove() {} },
|
||||
},
|
||||
};
|
||||
|
||||
const surface = createTerrainSurface();
|
||||
const services = {
|
||||
render: {
|
||||
governorRequestRender() {
|
||||
calls.render += 1;
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
if (shared) {
|
||||
services.ground = {
|
||||
terrainSurface: {
|
||||
...surface,
|
||||
height(...args) {
|
||||
calls.sharedHeight += 1;
|
||||
return surface.height(...args);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source: {} };
|
||||
parts.model = createModel(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.selection = {
|
||||
_installClickHandler() {},
|
||||
_clearSelection() {},
|
||||
_onKeyDown() {},
|
||||
};
|
||||
parts.ingestion = { abortAllInFlight() {} };
|
||||
// Leaves the points alone, so a test of disable() sees what disable() stops.
|
||||
parts.viewport = {
|
||||
onCameraChanged() {},
|
||||
deactivateAllCities() {},
|
||||
runProximityCheck: async () => {},
|
||||
};
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.lifecycle.methods.init(viewer);
|
||||
state._pointCollection = {
|
||||
show: true,
|
||||
add: (options) => ({ ...options }),
|
||||
remove() {},
|
||||
};
|
||||
|
||||
const heightOf = (key) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
state._stationRenderMap.get(key).point.position,
|
||||
).height;
|
||||
|
||||
return {
|
||||
state,
|
||||
parts,
|
||||
viewer,
|
||||
calls,
|
||||
rendering: parts.rendering,
|
||||
lifecycle: parts.lifecycle.methods,
|
||||
heightOf,
|
||||
add(cityId, count) {
|
||||
const info = parts.model.parseStationInformation(stations(count, cityId));
|
||||
parts.rendering.ensureCityPoints(cityId, info);
|
||||
return info;
|
||||
},
|
||||
/** Wait until every queued station has its height, or fail. */
|
||||
async refined() {
|
||||
const deadline = performance.now() + 20_000;
|
||||
while (parts.rendering.pendingHeightCount() > 0) {
|
||||
assert.ok(performance.now() < deadline, 'refinement finished');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
},
|
||||
/** The most sampleHeight calls made without yielding to the loop. */
|
||||
longestBurst() {
|
||||
return Math.max(...perTurn);
|
||||
},
|
||||
done() {
|
||||
ticking = false;
|
||||
parts.rendering.stopHeightRefinement();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('a city of stations is drawn at once without sampling heights', (t) => {
|
||||
const layer = makeLayer();
|
||||
t.after(layer.done);
|
||||
const started = performance.now();
|
||||
layer.add('boston', 300);
|
||||
const spent = performance.now() - started;
|
||||
|
||||
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
|
||||
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
|
||||
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
|
||||
assert.ok(
|
||||
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
|
||||
'drawn at the fallback height',
|
||||
);
|
||||
assert.equal(layer.rendering.pendingHeightCount(), 300);
|
||||
});
|
||||
|
||||
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
|
||||
const layer = makeLayer();
|
||||
t.after(layer.done);
|
||||
const info = layer.add('boston', 120);
|
||||
const rendersBefore = layer.calls.render;
|
||||
await layer.refined();
|
||||
|
||||
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
|
||||
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
|
||||
assert.ok(
|
||||
layer.longestBurst() <= 10,
|
||||
`longest burst was ${layer.longestBurst()} samples`,
|
||||
);
|
||||
for (const station of info.values()) {
|
||||
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < 1e-3,
|
||||
`${station.stationId}: ${actual} vs ${expected}`,
|
||||
);
|
||||
}
|
||||
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
|
||||
});
|
||||
|
||||
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
|
||||
const layer = makeLayer({ terrainGlobe: true, shared: true });
|
||||
t.after(layer.done);
|
||||
const info = layer.add('boston', 200);
|
||||
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
|
||||
await layer.refined();
|
||||
|
||||
assert.equal(layer.calls.sampleHeight, 0);
|
||||
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
|
||||
assert.equal(layer.calls.getHeight, 200);
|
||||
for (const station of info.values()) {
|
||||
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
|
||||
}
|
||||
});
|
||||
|
||||
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
|
||||
const layer = makeLayer({ terrainGlobe: true });
|
||||
t.after(layer.done);
|
||||
layer.add('boston', 50);
|
||||
await layer.refined();
|
||||
assert.equal(layer.calls.sampleHeight, 0);
|
||||
assert.equal(layer.calls.getHeight, 50);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
layer.heightOf('boston:boston7') -
|
||||
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
|
||||
) < 1e-3,
|
||||
);
|
||||
});
|
||||
|
||||
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
|
||||
// Stations 10 and up lie on a tile the globe is not drawing.
|
||||
const layer = makeLayer({
|
||||
terrainGlobe: true,
|
||||
shared: true,
|
||||
unloaded: (lat) => lat > 42.30095,
|
||||
});
|
||||
t.after(layer.done);
|
||||
layer.add('boston', 20);
|
||||
await layer.refined();
|
||||
assert.equal(layer.calls.sampleHeight, 10);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
layer.heightOf('boston:boston15') -
|
||||
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
|
||||
) < 1e-3,
|
||||
);
|
||||
});
|
||||
|
||||
test('removing a city stops its refinement and leaves the other running', async (t) => {
|
||||
const layer = makeLayer();
|
||||
t.after(layer.done);
|
||||
layer.add('boston', 40);
|
||||
layer.add('toronto', 40);
|
||||
layer.rendering.removeCityPoints('boston');
|
||||
assert.equal(layer.rendering.pendingHeightCount(), 40);
|
||||
await layer.refined();
|
||||
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
|
||||
assert.ok(layer.heightOf('toronto:toronto3') > 50);
|
||||
|
||||
layer.add('boston', 40);
|
||||
layer.rendering.removeCityPoints('boston');
|
||||
layer.rendering.removeCityPoints('toronto');
|
||||
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||
await pause(60);
|
||||
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
|
||||
});
|
||||
|
||||
test('disabling the layer stops refinement and leaves no timer', async (t) => {
|
||||
const layer = makeLayer();
|
||||
t.after(layer.done);
|
||||
layer.lifecycle.enable(layer.viewer);
|
||||
layer.add('boston', 200);
|
||||
await pause(30);
|
||||
const partWay = layer.calls.sampleHeight;
|
||||
assert.ok(partWay < 200, 'still refining');
|
||||
layer.lifecycle.disable(layer.viewer);
|
||||
|
||||
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||
await pause(60);
|
||||
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
|
||||
});
|
||||
|
||||
test('a station selected before its height arrives is re-selected at it', async (t) => {
|
||||
const layer = makeLayer();
|
||||
t.after(layer.done);
|
||||
layer.add('boston', 3);
|
||||
const reselected = [];
|
||||
layer.state._selectedKey = 'boston:boston1';
|
||||
// The selection part's select path; here it records the key.
|
||||
layer.parts.selection._selectStation = (key) => reselected.push(key);
|
||||
await layer.refined();
|
||||
assert.deepEqual(reselected, ['boston:boston1']);
|
||||
});
|
||||
@@ -44,7 +44,6 @@ export function createLifecycle({
|
||||
layerState._inFlightInfo = new Map();
|
||||
layerState._inFlightStatus = new Map();
|
||||
layerState._stationRenderMap = new Map();
|
||||
parts.rendering?.stopHeightRefinement?.();
|
||||
layerState._clickHandler = null;
|
||||
layerState._selectedKey = null;
|
||||
layerState._selectedEntity = null;
|
||||
@@ -135,7 +134,6 @@ export function createLifecycle({
|
||||
|
||||
parts.ingestion.abortAllInFlight();
|
||||
parts.viewport.deactivateAllCities();
|
||||
parts.rendering?.stopHeightRefinement?.();
|
||||
layerState._cityRuntime.clear();
|
||||
layerState._pointCollection.show = false;
|
||||
layerState._count = 0;
|
||||
@@ -167,7 +165,6 @@ export function createLifecycle({
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
parts.rendering?.stopHeightRefinement?.();
|
||||
if (layerState._pointCollection) {
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
|
||||
@@ -50,17 +50,6 @@ export const DEFAULT_CAPACITY = 15;
|
||||
|
||||
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
||||
|
||||
/**
|
||||
* Milliseconds one step may spend finding station ground heights. A height
|
||||
* the terrain globe cannot give is a depth render of a millisecond or more.
|
||||
*/
|
||||
|
||||
export const HEIGHT_REFINE_BUDGET_MS = 4;
|
||||
|
||||
/** Milliseconds between those steps, about a frame. */
|
||||
|
||||
export const HEIGHT_REFINE_INTERVAL_MS = 16;
|
||||
|
||||
/** Hard cap on total rendered station points across all cities. */
|
||||
|
||||
export const MAX_TOTAL_POINTS = 8000;
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
MAX_TOTAL_POINTS,
|
||||
COLOR_OUTLINE,
|
||||
HEIGHT_REFINE_BUDGET_MS,
|
||||
HEIGHT_REFINE_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
import {
|
||||
createTerrainSurface,
|
||||
TERRAIN_NEED,
|
||||
} from '../../services/terrainSurface.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -26,14 +20,6 @@ export function createRendering({
|
||||
source,
|
||||
}) {
|
||||
const { governorRequestRender } = services.render;
|
||||
// The terrain globe's loaded surface: the application's shared reader, or
|
||||
// the layer's own when it is built without one.
|
||||
const terrainSurface =
|
||||
services.ground?.terrainSurface || createTerrainSurface();
|
||||
/** Stations drawn at the fallback height, waiting for their ground height. */
|
||||
let heightQueue = [];
|
||||
let heightTimer = null;
|
||||
const heightPoint = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Resolve the effective dock capacity for a station.
|
||||
@@ -101,10 +87,8 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Cartesian3 position for a station at the fallback height. Its
|
||||
* ground height is found afterwards (refineHeights): a city's stations are
|
||||
* drawn at once, and finding hundreds of heights in the same task held the
|
||||
* main thread for most of a second.
|
||||
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
||||
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
||||
* @param {Object} station - Station info object with lat/lon.
|
||||
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
||||
*/
|
||||
@@ -113,78 +97,18 @@ export function createRendering({
|
||||
const lon = Number(station?.lon);
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground height under a station, or undefined when the scene cannot say.
|
||||
* A terrain globe answers from its loaded surface, which is cheap. Where it
|
||||
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
|
||||
* at the point) the scene renders the depth there, at a millisecond or more
|
||||
* a point.
|
||||
*/
|
||||
|
||||
function sampleGroundHeight(lat, lon) {
|
||||
const viewer = layerState._viewer;
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return undefined;
|
||||
// A station is a marker: its one read is not left for a later window.
|
||||
// The step's own time budget bounds what a pass spends.
|
||||
const terrain = terrainSurface.height(
|
||||
viewer,
|
||||
lat,
|
||||
lon,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
if (Number.isFinite(terrain)) return terrain;
|
||||
if (!scene.sampleHeightSupported) return undefined;
|
||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
|
||||
const sampled = scene.sampleHeight(heightPoint);
|
||||
return Number.isFinite(sampled) ? sampled : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move queued stations onto the ground for a few milliseconds, then leave
|
||||
* the rest to the next step. A station the scene has no height for keeps
|
||||
* the fallback height, as it did when heights were read while drawing.
|
||||
*/
|
||||
|
||||
function refineHeights() {
|
||||
heightTimer = null;
|
||||
const started = performance.now();
|
||||
let moved = false;
|
||||
while (
|
||||
heightQueue.length > 0 &&
|
||||
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
|
||||
) {
|
||||
const { record, lat, lon } = heightQueue.pop();
|
||||
const ground = sampleGroundHeight(lat, lon);
|
||||
if (ground === undefined) continue;
|
||||
record.point.position = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
ground + POINT_HEIGHT_OFFSET_M,
|
||||
);
|
||||
moved = true;
|
||||
// The highlight and card of a selected station copy its position.
|
||||
if (layerState._selectedKey === record.key)
|
||||
parts.selection._selectStation(record.key);
|
||||
let height = POINT_HEIGHT_OFFSET_M;
|
||||
// Sample terrain height so points sit on ground rather than at ellipsoid level
|
||||
if (layerState._viewer?.scene?.sampleHeightSupported) {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
||||
if (Number.isFinite(sampled)) {
|
||||
height = sampled + POINT_HEIGHT_OFFSET_M;
|
||||
}
|
||||
}
|
||||
if (moved) governorRequestRender('bikeshare-heights');
|
||||
scheduleHeightRefinement();
|
||||
}
|
||||
|
||||
function scheduleHeightRefinement() {
|
||||
if (heightTimer !== null || heightQueue.length === 0) return;
|
||||
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Drop every queued station and the pending step. */
|
||||
|
||||
function stopHeightRefinement() {
|
||||
heightQueue = [];
|
||||
clearTimeout(heightTimer);
|
||||
heightTimer = null;
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,7 +178,7 @@ export function createRendering({
|
||||
id: key,
|
||||
});
|
||||
|
||||
const record = {
|
||||
layerState._stationRenderMap.set(key, {
|
||||
key,
|
||||
cityId,
|
||||
stationId: station.stationId,
|
||||
@@ -267,19 +191,12 @@ export function createRendering({
|
||||
isInstalled: station.isInstalled,
|
||||
isRenting: station.isRenting,
|
||||
isReturning: station.isReturning,
|
||||
};
|
||||
layerState._stationRenderMap.set(key, record);
|
||||
heightQueue.push({
|
||||
record,
|
||||
lat: Number(station.lat),
|
||||
lon: Number(station.lon),
|
||||
});
|
||||
|
||||
runtime.stationKeys.add(key);
|
||||
}
|
||||
|
||||
layerState._count = layerState._stationRenderMap.size;
|
||||
scheduleHeightRefinement();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,12 +224,6 @@ export function createRendering({
|
||||
layerState._stationRenderMap.delete(key);
|
||||
}
|
||||
|
||||
// Its stations no longer wait for a height.
|
||||
heightQueue = heightQueue.filter(
|
||||
({ record }) => layerState._stationRenderMap.get(record.key) === record,
|
||||
);
|
||||
if (heightQueue.length === 0) stopHeightRefinement();
|
||||
|
||||
layerState._cityRuntime.delete(cityId);
|
||||
layerState._count = layerState._stationRenderMap.size;
|
||||
}
|
||||
@@ -369,10 +280,5 @@ export function createRendering({
|
||||
ensureCityPoints,
|
||||
removeCityPoints,
|
||||
applyStatusToPoints,
|
||||
stopHeightRefinement,
|
||||
/** Stations still waiting for their ground height. */
|
||||
pendingHeightCount: () => heightQueue.length,
|
||||
/** Whether a refinement step is waiting on a timer. */
|
||||
heightRefinementScheduled: () => heightTimer !== null,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
||||
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
||||
import {
|
||||
TERRAIN_NEED,
|
||||
createTerrainSurface,
|
||||
} from '../../services/terrainSurface.js';
|
||||
import {
|
||||
COLD_CELL_FLUSH_MS,
|
||||
COLD_CELL_RETRY_MS,
|
||||
@@ -32,11 +28,6 @@ import {
|
||||
import { placeOnPath } from './pathBinding.js';
|
||||
|
||||
const SPRITE_PX = 64;
|
||||
/**
|
||||
* Terrain reads per degree for a train's height: about 3.7 m apart. At 11 m
|
||||
* a train on fine tiles stood up to a couple of metres off the surface.
|
||||
*/
|
||||
const TERRAIN_GRID = 3e4;
|
||||
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
||||
const FULL_TINT = Cesium.Color.WHITE;
|
||||
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
||||
@@ -110,11 +101,8 @@ export function createRendering({ state, services, parts }) {
|
||||
neighborFloorM = null,
|
||||
coarseFloorCoord = null,
|
||||
} = services.ground;
|
||||
// The terrain globe's loaded surface: the application's shared reader, or
|
||||
// the layer's own when it is built without one.
|
||||
const terrainSurface =
|
||||
services.ground.terrainSurface || createTerrainSurface();
|
||||
const sprites = new Map();
|
||||
const terrainPoint = new Cesium.Cartographic();
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
/** Scratch for the on-line position; place() runs every frame. */
|
||||
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
||||
@@ -391,18 +379,8 @@ export function createRendering({ state, services, parts }) {
|
||||
function floorHeight(lat, lon, entry = null, stop = false) {
|
||||
const globe = state._viewer?.scene?.globe;
|
||||
if (globe?.show === true) {
|
||||
// A moving train is asked about every frame, at a new point each
|
||||
// time. On a ~4 m grid it asks the terrain a few times a second
|
||||
// instead, and a standing train not at all; the drawn height is
|
||||
// smoothed along the track anyway (displayHeightFor). Read as a
|
||||
// marker, never rationed: a train that starts on a fallback floor only
|
||||
// creeps to the terrain at a railway's grade.
|
||||
const rendered = terrainSurface.height(
|
||||
state._viewer,
|
||||
Math.round(lat * TERRAIN_GRID) / TERRAIN_GRID,
|
||||
Math.round(lon * TERRAIN_GRID) / TERRAIN_GRID,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
||||
const rendered = globe.getHeight?.(terrainPoint);
|
||||
if (Number.isFinite(rendered))
|
||||
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
||||
withinViewBounds,
|
||||
} from './policy.js';
|
||||
import {
|
||||
TERRAIN_NEED,
|
||||
createTerrainSurface,
|
||||
} from '../../services/terrainSurface.js';
|
||||
|
||||
/** Surface preparation off the render path, shared by markers and history. */
|
||||
export function createHeight({ state, services, parts }) {
|
||||
@@ -51,39 +47,35 @@ export function createHeight({ state, services, parts }) {
|
||||
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
||||
}
|
||||
|
||||
// Loaded-terrain heights come from the application's shared reader, which
|
||||
// keeps them per point and rations the reads (services/terrainSurface.js).
|
||||
// A layer built without one keeps its own.
|
||||
const sharedSurface = services.ground.terrainSurface;
|
||||
const terrainSurface = sharedSurface || createTerrainSurface();
|
||||
|
||||
/**
|
||||
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {?object} [entry] The vehicle, for a fixture floor.
|
||||
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
|
||||
* @returns {{cell: string, height: number|null, prior: number|null}}
|
||||
*/
|
||||
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
|
||||
const terrainPoint = new Cesium.Cartographic();
|
||||
function requestHeight(lat, lon, entry = null) {
|
||||
const coarse = coarseFloorCoord(lat, lon);
|
||||
const cell = `${coarse.lat},${coarse.lon}`;
|
||||
const fixtureFloor =
|
||||
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
||||
if (Number.isFinite(fixtureFloor))
|
||||
return { cell, height: fixtureFloor, prior: null };
|
||||
// On a terrain globe, use the loaded surface under the marker. This reads
|
||||
// tiles already loaded and does not launch a terrain request.
|
||||
// On a terrain globe, use the loaded surface under the marker. This is
|
||||
// synchronous cache work and does not launch a terrain request.
|
||||
const globe = state._viewer?.scene?.globe;
|
||||
if (globe?.show === true) {
|
||||
if (
|
||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
||||
const rendered = globe.getHeight?.(terrainPoint);
|
||||
const flat =
|
||||
state._viewer?.terrainProvider instanceof
|
||||
Cesium.EllipsoidTerrainProvider
|
||||
)
|
||||
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
|
||||
const rendered = terrainSurface.height(state._viewer, lat, lon, need);
|
||||
if (Number.isFinite(rendered))
|
||||
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
|
||||
Cesium.EllipsoidTerrainProvider;
|
||||
if (Number.isFinite(rendered) || flat)
|
||||
return {
|
||||
cell,
|
||||
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
|
||||
prior: null,
|
||||
};
|
||||
}
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
if (Number.isFinite(floor)) {
|
||||
@@ -115,7 +107,6 @@ export function createHeight({ state, services, parts }) {
|
||||
function rereadFloors() {
|
||||
state._floorTimer = null;
|
||||
if (!state._enabled) return;
|
||||
const waitingBefore = terrainSurface.waitingCount();
|
||||
const onGround = nearGround(),
|
||||
candidates = [];
|
||||
const entries = [...state._vehicles.values()].filter(
|
||||
@@ -129,13 +120,6 @@ export function createHeight({ state, services, parts }) {
|
||||
Number(b.key === state._selectedKey) -
|
||||
Number(a.key === state._selectedKey),
|
||||
);
|
||||
// Every vehicle's own position before any trail: the first trails of a
|
||||
// pass must not spend the budget the last markers are waiting on.
|
||||
for (const entry of entries) {
|
||||
const newest = entry.fixes.at(-1);
|
||||
if (newest)
|
||||
requestHeight(newest.lat, newest.lon, entry, TERRAIN_NEED.MARKER);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const cells = new Map();
|
||||
const collect = (point, answer) => {
|
||||
@@ -157,12 +141,7 @@ export function createHeight({ state, services, parts }) {
|
||||
};
|
||||
parts.trails.prepareEntry(entry, collect);
|
||||
const newest = entry.fixes.at(-1);
|
||||
const answer = requestHeight(
|
||||
newest.lat,
|
||||
newest.lon,
|
||||
entry,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
const answer = requestHeight(newest.lat, newest.lon, entry);
|
||||
entry.heightCell = answer.cell;
|
||||
// The selected vehicle's path also offers its history, newest segment
|
||||
// first; the vehicle's own cell is read where its newest fix is.
|
||||
@@ -248,11 +227,7 @@ export function createHeight({ state, services, parts }) {
|
||||
for (const entry of entries) parts.trails.prepareEntry(entry);
|
||||
parts.rendering.requestVisibility();
|
||||
governorRequestRender('transit-floor');
|
||||
// A pass that ran out of terrain budget before reaching every point
|
||||
// comes back for the rest. Each pass reads its budget, and points stop
|
||||
// falling due once the terrain settles, so this ends.
|
||||
const waiting = terrainSurface.waitingCount() - waitingBefore;
|
||||
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0)
|
||||
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS)
|
||||
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
||||
}
|
||||
|
||||
@@ -283,8 +258,6 @@ export function createHeight({ state, services, parts }) {
|
||||
}
|
||||
state._floorAttempts = 0;
|
||||
state._heightDirty.clear();
|
||||
// The shared reader's heights belong to every layer still showing.
|
||||
if (!sharedSurface) terrainSurface.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -294,7 +267,5 @@ export function createHeight({ state, services, parts }) {
|
||||
anchorFloors,
|
||||
rereadFloors,
|
||||
clear,
|
||||
/** Terrain read counters since the layer was created, for checks. */
|
||||
terrainStats: () => terrainSurface.stats(),
|
||||
};
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createHeight } from './height.js';
|
||||
import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js';
|
||||
import {
|
||||
TERRAIN_HEIGHT_CACHE_MAX,
|
||||
TERRAIN_HEIGHT_FRESH_MS,
|
||||
TERRAIN_NEED,
|
||||
TERRAIN_READ_BUDGET_MS,
|
||||
TERRAIN_READ_WINDOW_MS,
|
||||
TERRAIN_REFRESH_BUDGET_MS,
|
||||
TERRAIN_SETTLE_MS,
|
||||
} from '../../services/terrainSurface.js';
|
||||
|
||||
const LIFT = 1.5;
|
||||
|
||||
/**
|
||||
* A height part over a terrain globe whose every read costs `costMs` of a
|
||||
* clock the test owns. `surface(lat, lon)` is the terrain; `reads` lists the
|
||||
* points actually read.
|
||||
*/
|
||||
function terrain(t, { costMs = 0, surface = () => 100, provider = {} } = {}) {
|
||||
const clock = { now: 1_000_000 };
|
||||
t.mock.method(performance, 'now', () => clock.now);
|
||||
const reads = [];
|
||||
const world = { surface, costMs };
|
||||
const globe = {
|
||||
show: true,
|
||||
getHeight(point) {
|
||||
const lat = Cesium.Math.toDegrees(point.latitude);
|
||||
const lon = Cesium.Math.toDegrees(point.longitude);
|
||||
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
|
||||
clock.now += world.costMs;
|
||||
return world.surface(lat, lon);
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_selectedKey: null,
|
||||
_vehicles: new Map(),
|
||||
_visible: new Set(),
|
||||
_floorCursor: 0,
|
||||
_floorAttempts: 0,
|
||||
_floorTimer: null,
|
||||
_heightDirty: new Set(),
|
||||
_viewer: {
|
||||
camera: { positionCartographic: { height: 500 } },
|
||||
scene: { globe },
|
||||
terrainProvider: provider,
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
trails: { prepareEntry() {} },
|
||||
rendering: { requestVisibility() {} },
|
||||
};
|
||||
const height = createHeight({
|
||||
state,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: {
|
||||
coarseFloorCoord: (lat, lon) => ({
|
||||
lat: Number(lat.toFixed(3)),
|
||||
lon: Number(lon.toFixed(3)),
|
||||
}),
|
||||
cachedGroundFloor: () => null,
|
||||
neighborFloorM: () => null,
|
||||
warmGroundFloor() {},
|
||||
GROUND_FLOOR_LIFT_M: LIFT,
|
||||
},
|
||||
},
|
||||
parts,
|
||||
});
|
||||
t.after(() => height.clear());
|
||||
const nextWindow = () => {
|
||||
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||
};
|
||||
return { height, state, parts, globe, world, clock, reads, nextWindow };
|
||||
}
|
||||
|
||||
test('a point is read from the terrain once and reused until its reuse time passes', (t) => {
|
||||
const { height, reads, clock, world } = terrain(t);
|
||||
const first = height.requestHeight(42.36, -71.06);
|
||||
assert.deepEqual(first, { cell: '42.36,-71.06', height: 100 + LIFT, prior: null });
|
||||
for (let i = 0; i < 500; i++) height.requestHeight(42.36, -71.06);
|
||||
assert.equal(reads.length, 1, '501 requests, one terrain read');
|
||||
|
||||
// A point a metre away is its own point; the same cell does not stand in
|
||||
// for it while the budget allows a read.
|
||||
world.surface = (lat) => (lat > 42.36 ? 140 : 100);
|
||||
assert.equal(height.requestHeight(42.36002, -71.06).height, 140 + LIFT);
|
||||
assert.equal(reads.length, 2);
|
||||
|
||||
// The surface under the first point changed (the globe drew a finer tile).
|
||||
world.surface = () => 120;
|
||||
clock.now += TERRAIN_HEIGHT_FRESH_MS - 1;
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, 100 + LIFT);
|
||||
clock.now += 1;
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, 120 + LIFT);
|
||||
assert.equal(reads.length, 3);
|
||||
assert.deepEqual(height.terrainStats(), {
|
||||
reads: 3,
|
||||
reused: 501,
|
||||
deferred: 0,
|
||||
borrowed: 0,
|
||||
held: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('terrain that is not loaded yet is not remembered as an answer', (t) => {
|
||||
const { height, reads, world } = terrain(t, { surface: () => undefined });
|
||||
assert.deepEqual(height.requestHeight(42.36, -71.06), {
|
||||
cell: '42.36,-71.06',
|
||||
height: null,
|
||||
prior: null,
|
||||
});
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, null);
|
||||
assert.equal(reads.length, 2, 'each request asks again');
|
||||
world.surface = () => 12;
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, 12 + LIFT);
|
||||
assert.equal(height.terrainStats().held, 1);
|
||||
});
|
||||
|
||||
test('a tile that leaves the globe keeps the height it gave', (t) => {
|
||||
const { height, clock, world } = terrain(t, { surface: () => 30 });
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
|
||||
world.surface = () => undefined;
|
||||
clock.now += TERRAIN_HEIGHT_FRESH_MS * 20;
|
||||
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
|
||||
});
|
||||
|
||||
test('new points stop being read once a window has spent its budget', (t) => {
|
||||
const costMs = 5;
|
||||
const { height, reads, nextWindow } = terrain(t, {
|
||||
costMs,
|
||||
surface: (lat, lon) => Math.round(lon * -1000),
|
||||
});
|
||||
const affordable = Math.ceil(TERRAIN_READ_BUDGET_MS / costMs);
|
||||
// One point per floor cell (0.001° apart), more than the window affords.
|
||||
const answers = [];
|
||||
for (let i = 0; i < affordable + 3; i++)
|
||||
answers.push(height.requestHeight(42.36, -71.06 - i * 0.001).height);
|
||||
assert.equal(reads.length, affordable);
|
||||
assert.deepEqual(
|
||||
answers.slice(affordable),
|
||||
[null, null, null],
|
||||
'a point in a cell never read waits',
|
||||
);
|
||||
assert.ok(reads.length * costMs < TERRAIN_READ_BUDGET_MS + costMs, 'the overrun is one read at most');
|
||||
|
||||
// A new point in a cell that was read borrows that cell's height.
|
||||
const borrowed = height.requestHeight(42.36004, -71.06);
|
||||
assert.equal(borrowed.height, 71060 + LIFT);
|
||||
assert.equal(reads.length, affordable, 'borrowing reads nothing');
|
||||
assert.equal(height.terrainStats().borrowed, 1);
|
||||
|
||||
// The next window reads the points that waited, and the borrower its own.
|
||||
nextWindow();
|
||||
for (let i = affordable; i < affordable + 3; i++)
|
||||
assert.equal(
|
||||
height.requestHeight(42.36, -71.06 - i * 0.001).height,
|
||||
Math.round((-71.06 - i * 0.001) * -1000) + LIFT,
|
||||
);
|
||||
assert.equal(reads.length, affordable + 3);
|
||||
});
|
||||
|
||||
test("a vehicle's own position is read even when the window is spent", (t) => {
|
||||
const { height, reads } = terrain(t, { costMs: TERRAIN_READ_BUDGET_MS });
|
||||
height.requestHeight(42.36, -71.06);
|
||||
assert.equal(height.requestHeight(42.37, -71.07).height, null, 'a trail point waits');
|
||||
assert.equal(height.requestHeight(42.38, -71.08, null, TERRAIN_NEED.MARKER).height, 100 + LIFT);
|
||||
assert.equal(reads.length, 2);
|
||||
});
|
||||
|
||||
test('undrawn trail history leaves most of the budget to the paths markers are drawn along', (t) => {
|
||||
const costMs = 1;
|
||||
const { height, reads } = terrain(t, { costMs });
|
||||
// New points, one per floor cell: history first, as a pass offers them.
|
||||
let lon = -71;
|
||||
const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height;
|
||||
const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.BACKGROUND));
|
||||
const read = history.filter((h) => h !== null).length;
|
||||
assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget');
|
||||
const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH));
|
||||
assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest');
|
||||
assert.equal(ask(TERRAIN_NEED.BACKGROUND), null);
|
||||
assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read');
|
||||
assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1);
|
||||
});
|
||||
|
||||
test('held points keep their last height while re-reads are over budget, and the longest-held are re-read first so none starves', (t) => {
|
||||
const costMs = TERRAIN_REFRESH_BUDGET_MS / 2;
|
||||
const { height, reads, clock, world, nextWindow } = terrain(t, { surface: () => 50 });
|
||||
const points = Array.from({ length: 40 }, (_, i) => [42.36, -71.06 - i * 0.001]);
|
||||
for (const [lat, lon] of points) height.requestHeight(lat, lon);
|
||||
assert.equal(reads.length, 40);
|
||||
|
||||
world.costMs = costMs;
|
||||
world.surface = () => 80;
|
||||
clock.now += TERRAIN_HEIGHT_FRESH_MS;
|
||||
reads.length = 0;
|
||||
const pass = () => points.map(([lat, lon]) => height.requestHeight(lat, lon).height);
|
||||
const first = pass();
|
||||
const perWindow = Math.ceil(TERRAIN_REFRESH_BUDGET_MS / costMs);
|
||||
assert.equal(reads.length, perWindow, 'one window re-reads its budget and no more');
|
||||
assert.deepEqual(first.slice(0, perWindow), Array(perWindow).fill(80 + LIFT));
|
||||
assert.deepEqual(first.slice(perWindow), Array(40 - perWindow).fill(50 + LIFT), 'the rest keep their last height');
|
||||
|
||||
// Pass after pass, a window apart: the budget moves down the list instead
|
||||
// of returning to the first points, whose reuse time runs out again long
|
||||
// before the last ones have had a turn (40 points, ${perWindow} a window).
|
||||
const turns = new Map();
|
||||
for (const point of reads) turns.set(point, 1);
|
||||
const cycle = Math.ceil(40 / perWindow);
|
||||
for (let passes = 1; passes < cycle; passes++) {
|
||||
nextWindow();
|
||||
reads.length = 0;
|
||||
pass();
|
||||
for (const point of reads) turns.set(point, (turns.get(point) || 0) + 1);
|
||||
}
|
||||
assert.equal(turns.size, 40, 'every point was re-read in one cycle');
|
||||
assert.ok([...turns.values()].every((count) => count === 1), 'and none twice before the others once');
|
||||
assert.ok(cycle * TERRAIN_READ_WINDOW_MS > TERRAIN_HEIGHT_FRESH_MS, 'the cycle is longer than the reuse time, so the first points were stale again');
|
||||
assert.deepEqual(pass().slice(-1), [80 + LIFT]);
|
||||
});
|
||||
|
||||
test('once the camera and the tiles have settled a height is kept until either changes', (t) => {
|
||||
const { height, state, globe, reads, clock, world } = terrain(t, { surface: () => 10 });
|
||||
const read = () => height.requestHeight(42.36, -71.06).height;
|
||||
assert.equal(read(), 10 + LIFT);
|
||||
// Still settling: the point falls due and is read again.
|
||||
world.surface = () => 20;
|
||||
clock.now += TERRAIN_SETTLE_MS;
|
||||
assert.equal(read(), 20 + LIFT);
|
||||
assert.equal(reads.length, 2);
|
||||
// Read after the settle time: kept, however long the view stays put.
|
||||
world.surface = () => 99;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
clock.now += 60_000;
|
||||
assert.equal(read(), 20 + LIFT);
|
||||
}
|
||||
assert.equal(reads.length, 2);
|
||||
|
||||
// The camera moves: the globe may draw the point from a finer tile.
|
||||
state._viewer.camera.positionCartographic = { longitude: 0, latitude: 0, height: 250 };
|
||||
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||
assert.equal(read(), 99 + LIFT);
|
||||
assert.equal(reads.length, 3);
|
||||
|
||||
// Settled again, then a tile starts loading with the camera still.
|
||||
world.surface = () => 101;
|
||||
clock.now += TERRAIN_SETTLE_MS;
|
||||
assert.equal(read(), 101 + LIFT);
|
||||
clock.now += 60_000;
|
||||
assert.equal(read(), 101 + LIFT);
|
||||
assert.equal(reads.length, 4);
|
||||
world.surface = () => 105;
|
||||
globe.tilesLoaded = false;
|
||||
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||
assert.equal(read(), 105 + LIFT);
|
||||
assert.equal(reads.length, 5);
|
||||
});
|
||||
|
||||
test('a flat globe answers without reading terrain', (t) => {
|
||||
const { height, reads } = terrain(t, {
|
||||
provider: new Cesium.EllipsoidTerrainProvider(),
|
||||
});
|
||||
assert.deepEqual(height.requestHeight(42.36, -71.06), {
|
||||
cell: '42.36,-71.06',
|
||||
height: LIFT,
|
||||
prior: null,
|
||||
});
|
||||
assert.equal(reads.length, 0);
|
||||
});
|
||||
|
||||
test('a hidden globe reads no terrain and clear() forgets what was read', (t) => {
|
||||
const { height, reads, globe } = terrain(t);
|
||||
height.requestHeight(42.36, -71.06);
|
||||
height.clear();
|
||||
assert.equal(height.terrainStats().held, 0);
|
||||
height.requestHeight(42.36, -71.06);
|
||||
assert.equal(reads.length, 2, 'a cleared layer reads again');
|
||||
globe.show = false;
|
||||
assert.equal(height.requestHeight(42.5, -71.5).height, null);
|
||||
assert.equal(reads.length, 2);
|
||||
});
|
||||
|
||||
test('the points kept stay bounded', (t) => {
|
||||
const { height } = terrain(t);
|
||||
const total = TERRAIN_HEIGHT_CACHE_MAX + 5_000;
|
||||
for (let i = 0; i < total; i++)
|
||||
height.requestHeight(10 + Math.floor(i / 1000) * 0.001, 20 + (i % 1000) * 0.0001);
|
||||
const { held, reads } = height.terrainStats();
|
||||
assert.equal(reads, total);
|
||||
assert.ok(held <= TERRAIN_HEIGHT_CACHE_MAX, `${held} held`);
|
||||
assert.ok(held >= TERRAIN_HEIGHT_CACHE_MAX * 0.7, `${held} held`);
|
||||
});
|
||||
|
||||
test('a fleet rereading every trail fix reads each point once, not once per request', (t) => {
|
||||
const { height, state, parts, reads } = terrain(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
// 600 vehicles with 40 fixes each, 25 m apart along a street.
|
||||
const fleet = Array.from({ length: 600 }, (_, v) => ({
|
||||
key: `bus-${v}`,
|
||||
fixes: Array.from({ length: 40 }, (_, i) => ({
|
||||
lat: 42 + v * 0.002,
|
||||
lon: -71 + i * 0.0003,
|
||||
})),
|
||||
}));
|
||||
for (const entry of fleet) {
|
||||
state._vehicles.set(entry.key, entry);
|
||||
state._visible.add(entry);
|
||||
}
|
||||
let requests = 0;
|
||||
parts.trails.prepareEntry = (entry, collect) => {
|
||||
for (const fix of entry.fixes) {
|
||||
requests++;
|
||||
const answer = height.requestHeight(fix.lat, fix.lon, entry);
|
||||
if (collect) collect(fix, answer);
|
||||
}
|
||||
};
|
||||
height.anchorFloors();
|
||||
for (let i = 0; i < FLOOR_REREAD_ATTEMPTS; i++) t.mock.timers.tick(FLOOR_REREAD_MS);
|
||||
assert.equal(requests, 600 * 40 * 2 * (FLOOR_REREAD_ATTEMPTS + 1));
|
||||
assert.equal(new Set(reads).size, 600 * 40);
|
||||
assert.equal(reads.length, 600 * 40, `${requests} requests, ${reads.length} terrain reads`);
|
||||
assert.ok(fleet.every((entry) => entry.heightM === 100 + LIFT && entry.heightResolved));
|
||||
});
|
||||
|
||||
test('a reread cycle outlasts its attempts while trail points still wait for a read, then stops', (t) => {
|
||||
const costMs = 4;
|
||||
const { height, state, parts, reads, clock } = terrain(t, { costMs });
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const entry = {
|
||||
key: 'bus',
|
||||
// One fix per floor cell, so nothing can be borrowed.
|
||||
fixes: Array.from({ length: 60 }, (_, i) => ({ lat: 42, lon: -71 + i * 0.001 })),
|
||||
};
|
||||
state._vehicles.set(entry.key, entry);
|
||||
state._visible.add(entry);
|
||||
parts.trails.prepareEntry = (e) => {
|
||||
for (const fix of e.fixes) height.requestHeight(fix.lat, fix.lon, e);
|
||||
};
|
||||
height.anchorFloors();
|
||||
let passes = 1;
|
||||
while (state._floorTimer && passes < 200) {
|
||||
clock.now += FLOOR_REREAD_MS;
|
||||
t.mock.timers.tick(FLOOR_REREAD_MS);
|
||||
passes++;
|
||||
}
|
||||
assert.equal(new Set(reads).size, 60, 'every fix was read in the end');
|
||||
assert.ok(passes > FLOOR_REREAD_ATTEMPTS + 1, `${passes} passes: the cycle kept going for the unread fixes`);
|
||||
assert.ok(passes < 120, `${passes} passes: and stopped once none waited`);
|
||||
assert.equal(state._floorTimer, null);
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
transitVehicleKey,
|
||||
vehicleReportTimeMs,
|
||||
} from './policy.js';
|
||||
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||
import {
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
@@ -273,12 +272,7 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
// DEM one, or a change of map regime, moves a parked vehicle too. A
|
||||
// cell still cold takes the lowest neighbour as a prior, as before.
|
||||
const priorHeightM = entry.heightM;
|
||||
const height = parts.height.requestHeight(
|
||||
record.lat,
|
||||
record.lon,
|
||||
null,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
||||
if (height.height !== null) {
|
||||
entry.heightCell = height.cell;
|
||||
entry.heightM = height.height;
|
||||
@@ -315,12 +309,7 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const height = parts.height.requestHeight(
|
||||
record.lat,
|
||||
record.lon,
|
||||
null,
|
||||
TERRAIN_NEED.MARKER,
|
||||
);
|
||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
||||
// Keep the local prior separate from resolved historical floors.
|
||||
// A neighbouring cell is not a guaranteed lower bound.
|
||||
const prior = height.height === null ? height.prior : height.height;
|
||||
|
||||
@@ -77,6 +77,7 @@ export const FLOOR_WARM_PER_POLL = 300;
|
||||
export const FLOOR_REREAD_MS = 900;
|
||||
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
||||
export const FLOOR_REREAD_ATTEMPTS = 6;
|
||||
|
||||
// --- Rendering ---
|
||||
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
||||
export const MARKER_PIXEL_SIZE = 20;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../../data/contactPlayback.js';
|
||||
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
||||
import { fixDistanceM } from './movement.js';
|
||||
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
||||
|
||||
const MODE_COLOR = {
|
||||
@@ -73,13 +72,8 @@ export function createTrails({ state, services, parts, source }) {
|
||||
b = {};
|
||||
let vertices = 0,
|
||||
signature = '';
|
||||
const floor = (point, need = TERRAIN_NEED.PATH) => {
|
||||
const answer = parts.height.requestHeight(
|
||||
point.lat,
|
||||
point.lon,
|
||||
entry,
|
||||
need,
|
||||
);
|
||||
const floor = (point) => {
|
||||
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
|
||||
if (collect) collect(point, answer);
|
||||
return answer.height;
|
||||
};
|
||||
@@ -105,6 +99,12 @@ export function createTrails({ state, services, parts, source }) {
|
||||
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < entry.track.count; i++) {
|
||||
readFix(entry.track, i, a);
|
||||
const h = floor(a);
|
||||
if (h !== null) correctHeight(entry.track, a.seq, h);
|
||||
}
|
||||
const order = [];
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -112,18 +112,6 @@ export function createTrails({ state, services, parts, source }) {
|
||||
entry.sample.fromSeq - entry.track.baseSeq,
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < entry.track.count; i++) {
|
||||
readFix(entry.track, i, a);
|
||||
// Fixes behind an unselected vehicle are kept but not drawn.
|
||||
const h = floor(
|
||||
a,
|
||||
entry === selected || i >= activeIndex
|
||||
? TERRAIN_NEED.PATH
|
||||
: TERRAIN_NEED.BACKGROUND,
|
||||
);
|
||||
if (h !== null) correctHeight(entry.track, a.seq, h);
|
||||
}
|
||||
const order = [];
|
||||
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
||||
if (entry === selected)
|
||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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 amsterdamTrafficCameras from './entries/amsterdam-traffic-cameras.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 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 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 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 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 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 orStatewideImagery from './entries/or-statewide-imagery.json' with { type: 'json' };
|
||||
import paStatewideImagery from './entries/pa-statewide-imagery.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 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,
|
||||
amsterdamTrafficCameras,
|
||||
arStatewideImagery,
|
||||
azStatewideImagery,
|
||||
bayernDop40,
|
||||
bismarckMandanNd,
|
||||
bkgTopplusOpen,
|
||||
cctvFloridaFl511,
|
||||
cctvLithuania,
|
||||
cctvNycDot,
|
||||
ctStatewideImagery,
|
||||
dakotaCountyMn,
|
||||
dcStatewideImagery,
|
||||
deStatewideImagery,
|
||||
eeMaaAmetOrtofoto,
|
||||
eiaPowerPlants,
|
||||
epaSuperfundSites,
|
||||
epaToxicReleaseFacilities,
|
||||
epcEnglandWales,
|
||||
femaUsaStructures,
|
||||
gbifOccurrenceDensity,
|
||||
gdacsDisasterAlerts,
|
||||
grandForksNd,
|
||||
gsiJapanMap,
|
||||
gsiJapanPhoto,
|
||||
gvpHoloceneVolcanoes,
|
||||
iaStatewideImagery,
|
||||
idStatewideImagery,
|
||||
ignOrthophotosFrance,
|
||||
ilStatewideImagery,
|
||||
imfPortwatchChokepoints,
|
||||
imfPortwatchDisruptions,
|
||||
inStatewideImagery,
|
||||
jrcSurfaceWater,
|
||||
kartverketAdminUnits,
|
||||
kartverketCadastre,
|
||||
kartverketDepth,
|
||||
kartverketHillshade,
|
||||
kartverketNauticalCharts,
|
||||
kartverketTerrainRelief,
|
||||
kartverketTopoGrey,
|
||||
kartverketTopo,
|
||||
kartverketToporaster,
|
||||
ksStatewideImagery,
|
||||
kyStatewideImagery,
|
||||
lakeCountyIn,
|
||||
lawrenceKs,
|
||||
leeCountyFl,
|
||||
loudounCountyVa,
|
||||
louisvilleLojicKy,
|
||||
maStatewideImagery,
|
||||
macrostratGeology,
|
||||
mapterhornTerrain,
|
||||
marisMs,
|
||||
mdStatewideImagery,
|
||||
meStatewideImagery,
|
||||
miStatewideImagery,
|
||||
mountainVillageCo,
|
||||
msStatewideImagery,
|
||||
mtStatewideImagery,
|
||||
naip,
|
||||
nasaBlackMarble,
|
||||
nasaEonetEvents,
|
||||
ncStatewideImagery,
|
||||
nctcogTx,
|
||||
ndStatewideImagery,
|
||||
newOrleansRpcLa,
|
||||
nhStatewideImagery,
|
||||
njStatewideImagery,
|
||||
noaaTideStations,
|
||||
nrwDop,
|
||||
nswImagery,
|
||||
ntadNationalBridges,
|
||||
nwaBentonWashingtonAr,
|
||||
nwsActiveAlerts,
|
||||
nyStatewideImagery,
|
||||
oaklandCountyMi,
|
||||
ohStatewideImagery,
|
||||
orStatewideImagery,
|
||||
paStatewideImagery,
|
||||
plOrtofotomapa,
|
||||
pnoaMa,
|
||||
portlandMetroOr,
|
||||
ptDgtOrtos,
|
||||
riStatewideImagery,
|
||||
swisstopoSwissimage,
|
||||
txStatewideImagery,
|
||||
unionCountyOh,
|
||||
usgsHydrography,
|
||||
usgsTopo,
|
||||
vaStatewideImagery,
|
||||
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])),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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<boolean>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<number>} 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<boolean>} 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "amsterdam-traffic-cameras",
|
||||
"kind": "dataset",
|
||||
"family": "cameras",
|
||||
"title": "Amsterdam traffic and ANPR camera positions",
|
||||
"provider": "Gemeente Amsterdam",
|
||||
"description": "Where the City of Amsterdam's traffic and ANPR cameras stand (about 500), from its traffic information systems register. Positions and camera type only: the city publishes no pictures.",
|
||||
"icon": "◉",
|
||||
"maintainer": "github:Maher-Reven",
|
||||
"coverage": { "countries": ["NL"], "bbox": [4.72, 52.27, 5.08, 52.44] },
|
||||
"source": {
|
||||
"format": "geojson",
|
||||
"url": "https://api.data.amsterdam.nl/v1/verkeersinformatiesystemen/verkeersinformatiesystemen/",
|
||||
"query": {
|
||||
"objectSoort": "Camera",
|
||||
"_format": "geojson",
|
||||
"_pageSize": "1000"
|
||||
},
|
||||
"fetch": "browser-direct",
|
||||
"cacheSeconds": 86400,
|
||||
"maxFeatures": 1000
|
||||
},
|
||||
"display": {
|
||||
"color": "#e53e3e",
|
||||
"label": "standplaats",
|
||||
"fields": ["typeGedetailleerd", "objectnummer"]
|
||||
},
|
||||
"licence": {
|
||||
"name": "Gemeente Amsterdam open data: public unless stated otherwise; no named licence",
|
||||
"url": "https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset",
|
||||
"operatorOptIn": true
|
||||
},
|
||||
"attribution": {
|
||||
"text": "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)",
|
||||
"url": "https://api.data.amsterdam.nl/v1/docs/"
|
||||
},
|
||||
"status": "community",
|
||||
"reviewed": "2026-11-07"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user