Add the Data Library: live regional datasets, tiles and packs

DATA LAYERS keeps its curated core layers; regional and niche sources
live in the Data Library, opened from Browse data library at the foot
of the panel. Each entry is a JSON manifest in src/library/entries/
pointing at a live upstream (query API, provider-hosted file or tile
endpoint), so nothing is bundled and a self-hosted instance hosts no
data. Sources without CORS go through one allowlisted /api/library
route. Kinds: dataset (GeoJSON, CSV, JSON records, ArcGIS, partitioned
GeoParquet), tiles (XYZ, WMS/ArcGIS bbox export, terrarium relief) and
pack. Selections persist and travel in share links as lib=, separate
from the core layer tokens. Library rows get Zoom and Remove icon
chips that work while the layer is off, rendered through RailChips.
Strings are translated in every locale.
This commit is contained in:
Said Turksever committed 2026-11-12 12:26:45 +00:00
1 parent 10a788db78
commit db80d82b8d
182 files changed
+15805 -44

No files matched your search

+200
View File
@@ -384,6 +384,206 @@ provider registration rejects compositions that cannot fit rather than truncatin
identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply.
## Data Library — October 21, 2026
DATA LAYERS keeps its curated core layers; everything else lives in the Data
Library, opened from **Browse data library** at the foot of the panel. An entry
is a manifest in `src/library/entries/` that points at a live upstream its
provider hosts — a query API, a provider-hosted static file or a tile endpoint.
Nothing an entry describes is bundled, so a self-hosted instance serves every
entry without hosting data. Manifests are validated strictly when
`src/library/catalog.js` loads (unknown fields are errors), so an invalid entry
fails the unit suite. Authoring guide: [DATA-LIBRARY.md](DATA-LIBRARY.md).
Three kinds ship. A `dataset` (GeoJSON, GeoJSON Lines, CSV with lat/lon
columns, a JSON record list read from `source.records` with named or guessed
lat/lon fields, or a paged ArcGIS FeatureServer query) renders through the same
GeoJSON layer as Datacenters and Dams, with the manifest's label, up to two
card fields and an optional `rank` field that orders a capped set and feeds the
layer's level-of-detail priority (0–600, below the named/unnamed gap). A capped
dataset reads PARTIAL. A dataset with `source.view` is view-bounded,
for layers too large to load whole (FEMA USA Structures: ~135 million
footprints): below `view.maxAltitudeM` it queries ArcGIS for what intersects a
window around the point the camera looks at (half the camera height each way,
330 m – 5.5 km, clipped to the visible rectangle), one page per view after the
camera settles, replacing the previous set only when the new one is ready and
aborting a superseded request; above it the row reads "zoom in to load this
layer" and nothing is fetched. Footprints are ground-clamped polygons (draped on
Google 3D tiles too), without outlines. Zoom to layer lands straight above the
manifest's `view.sample` area at 60 % of the altitude limit. View-bounded
entries are ArcGIS or GeoParquet, and browser-direct, in this build.
`geoparquet` datasets are read in the browser with `hyparquet` (with `fzstd`
for ZSTD), imported lazily on first use; only the columns an entry uses are
decoded, and WKB geometry arrives as GeoJSON. A partitioned set
(`source.partitions`: an S3 ListObjectsV2 `listUrl` and a `keyBase`, both on the
source host) must be view-bounded. Its index — each file's GeoParquet footer
bbox, read with one range request per file — comes from `/api/library/index/<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, GIBS GPW, Macrostrat's ICS period colours); EMODnet publishes
no machine-readable scale and has none. Aerial photo layers (PDOK, IGN, GSI
photos, swisstopo SWISSIMAGE) set `display.outline`: their covered countries'
borders draw as a thin white ground-clamped line, so the imagery's edge is
plain, with a "Coverage outline" legend item. Borders come from Natural
Earth's 1:50m admin-0 countries (public domain, fetched live once per page and
credited); `countries` draws every part (Japan's islands, Liechtenstein beside
Switzerland), `coverage` only the parts inside `coverage.bbox` (the European
Netherlands, metropolitan France). A failed border fetch costs only the
outline. The same layers set `display.aerial`: they drape above every other
imagery layer and raise themselves again whenever another drape is added, so a
basemap or weather overlay switched on later goes underneath. Each is
`source.bounded` (tiles requested inside `coverage.bbox` only) and names its
provider's no-imagery fill in `display.noDataColor`, drawn transparent through
Cesium's colour-to-alpha (max channel distance 0.02): measured fills are PDOK
and swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey
(#343434). Without both, PDOK's white fill covered the whole globe. A 4xx tile
answer counts as "no imagery here", never as a tile error. IGN's overseas
departments are outside its single bbox and are not shown. The aerial set now holds 68
entries: 64 imported from the Open Imagery Register (national programmes in
Austria, Belgium, Germany, Estonia, Spain, Luxembourg, Poland, Portugal,
Geneva, Australia's NSW, and 51 US services: NAIP plus state, county and
city programmes) plus PDOK, IGN, GSI and SWISSIMAGE, each with an `imagery`
block (type, published resolution, true ortho, years flown, refresh) that
Browse shows as a spec list. Tiles take `xyz` (with `{reverseY}` for TMS) or
`bbox` templates (`{bbox-epsg-3857}`: WMS GetMap and ArcGIS export, filled
by Cesium in the browser and by the proxy from z/x/y). Sub-national entries
draw a `bbox` rectangle outline without fetching the country borders. 35
entries whose licence the register gives only as "see publisher terms" are
operator opt-in. Norway comes from Kartverket's open
services (CC BY 4.0, "© Kartverket"; its zoom 12–20 Geovekst data may be shown
in live services but not copied): the topographic, greyscale, raster-topo and
nautical-chart caches (`xyz`), and WMS hillshade, DTM colour relief, sea depths
(from zoom 11, Kartverket's 16-class depth legend), county and municipality
boundaries, and the cadastre (from zoom 14). Norge i bilder needs
authentication and is not included. `mapterhorn-terrain` is the first
`source.encoding: terrarium` entry: Mapterhorn's global 512 px terrain-RGB
tiles are decoded per tile in the browser (`src/library/relief.js`) into
shaded relief with a six-class elevation legend in metres. A `tiles` entry (`{z}/{x}/{y}` template) drapes on the
active imagery host — the globe, or the Google 3D tileset — and follows map
stack switches; with no host it shows the existing "choose a globe map"
guidance, and its row reads DRAPED rather than a count. A `pack` describes an
existing host-layer source pack (today the opt-in CCTV packs): it is
informational in Browse, names the host's own switch (`CCTV_NYC_ENABLED=1` and
so on) and offers no Add. The `provider` kind from the RFC is refused by the
validator until it ships.
Entries register their layer only when a viewer adds them, after the core
catalog is sealed: `LayerLifecycle.registerLibraryLayer` accepts only ids in
the `lib-<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 and OpenSeaMap seamarks (tiles), and the NYC,
Lithuania and FL511 CCTV packs. Added from the Ask dataset registry (its live,
mappable sources that are not core layers): the WRI Global Power Plant Database
(provider-hosted CSV, the 10,000 largest of ~35,000 by capacity, so it reads
PARTIAL), NWS active alerts that carry their own polygon (zone-wide alerts have
no geometry and are dropped), NOAA CO-OPS tide stations (JSON records), and IMF
PortWatch chokepoints and port disruptions (ArcGIS, operator opt-in until the
reuse terms are confirmed, as Ask holds them in hosted mode). FEMA/ORNL USA
Structures (CC BY 4.0) is the first view-bounded entry; its service refuses a
query page smaller than its own 2,000 records, so the probe asks for a full
page. Addresscloud's EPC ratings for England and Wales (Source Cooperative,
read from `s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop`, 344
local-authority GeoParquet files, OGL v3.0) are the first partitioned
GeoParquet entry, coloured by band A–G with the official EPC colours. From an
open/freemium registry review (commercial sources excluded): GDACS disaster
alerts; the National Bridge Inventory (view-bounded, coloured Good/Fair/Poor);
EPA Superfund sites and Toxics Release Inventory facilities (view-bounded); and
tile overlays for Macrostrat geology, GBIF biodiversity density, USGS
topographic and hydrography maps, PDOK (Netherlands) and IGN (France) aerial
photos, BKG TopPlusOpen, GSI Japan map and photos, EMODnet bathymetry, SEDAC
GPW 2020 population density (legend from the GIBS colour map) and JRC surface
water occurrence. `npm run library:check` validates manifests,
the ledger (append-only with `--base-ref`, run in CI on pull requests) and the
generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream
once; `npm run qa:data-library` is the browser gate (fixture-backed).
## Utilities panel — October 29, 2026
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
+183
View File
@@ -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.