mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge current upstream and resolve PR 581 conflicts
This commit is contained in:
commit
d866f9b01c
211 files changed
+28120
-1988
No files matched your search
@@ -183,3 +183,6 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# 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: comma-separated Overpass URLs; use only instances you run or pay for.
|
||||
OVERPASS_UPSTREAMS=
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -48,6 +49,10 @@ jobs:
|
||||
- name: Check package boundaries
|
||||
run: npm run check:boundaries
|
||||
|
||||
- name: Check published layer-token allocations
|
||||
if: github.event_name == 'pull_request'
|
||||
run: npm run layer-token:check -- --base-ref origin/main
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -1,6 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
- Bound the remaining upstream body reads (FIRMS, CelesTrak, adsb.lol, TomTom, OpenSky, adsbdb) with streaming size caps and shared deadlines; the adsb.lol military and OpenSky `states/all` polls gain deadlines of their own.
|
||||
- Public Overpass instances are no longer used by default. Street Traffic
|
||||
roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen
|
||||
on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a
|
||||
TomTom key the default is Hybrid: TomTom roads with live flow, plus
|
||||
OpenFreeMap roads TomTom does not cover, simulated. Without a key every
|
||||
choice draws OpenFreeMap roads. TomTom mode shows only roads with flow; OSM
|
||||
mode matches TomTom flow onto OpenFreeMap roads for congestion colors,
|
||||
speeds and closures, and unmatched roads stay simulated. Road failures name
|
||||
OpenFreeMap and retain HTTP status or timeout reasons separately from TomTom.
|
||||
Flow coverage counts
|
||||
rendered dots on roads with flow; mapped military areas come from OpenFreeMap and
|
||||
follow settled camera changes with cached tiles and retained site entities.
|
||||
Superseded requests preserve the next view’s pending load.
|
||||
Tile and API transports can be injected independently; the optional Overpass
|
||||
ALPR adapter remains available.
|
||||
OSM-derived layers share one attribution entry and a persistent inline map credit,
|
||||
including routing, Warendorf cameras, datacenters, dams and the Nepal locator.
|
||||
Military areas use bundled Overture/OSM names; wide views show bounded,
|
||||
decluttered named points that follow camera motion while Contacts holds a subject,
|
||||
using the shared world-overlay host for wide-point and polygon titles. Polygons appear while names load; cancelled views
|
||||
cannot publish late names, and stalled name downloads can retry. Merged groups
|
||||
keep their identity and parent name when a pan hides the largest member. Late
|
||||
names reconcile ids across zooms while retaining selection and marker ownership. Wide
|
||||
named points remain visible until matching close polygons take over. Installation
|
||||
titles share datacenter/dam card arbitration and keep incumbents while panning.
|
||||
Selecting an installation replaces its label with one card; deselection restores it.
|
||||
Selected footprints receive a translucent fill draped on the active map surface.
|
||||
ALPR cameras come from an hourly OpenStreetMap extract (US and Canada). Features
|
||||
without a replacement say they are unavailable; area and footprint
|
||||
annotations check once whether an Overpass instance is configured and skip
|
||||
the query when none is. Area annotations outline countries, states and provinces
|
||||
(Natural Earth, including UK constituent countries) and US counties (US Census Bureau) from bundled data, with no
|
||||
lookup. County names are disambiguated across countries by aliases, qualifiers
|
||||
and geography. `OVERPASS_UPSTREAMS` sets an
|
||||
Overpass instance you run or pay for. The cockpit regional brief resolves
|
||||
regions from bundled Natural Earth data instead of Nominatim. Traffic starts on enable and paints
|
||||
locally grounded roads incrementally, shares concurrent tile requests and
|
||||
reuses validated heights across pans. Camera moves and arriving tiles retain
|
||||
existing traffic dots on their roads; budget changes fade in/out incrementally,
|
||||
height refinements ease vertically, and live flow changes color and speed in
|
||||
place. The traffic footprint follows the reticle square at oblique angles, with
|
||||
cached detailed near tiles and coarse distant roads. Failed detail tiles retain
|
||||
coarse roads and retry at most three times. Road-pass deadlines also cancel
|
||||
queued tile paints and terrain callbacks;
|
||||
off-screen height corrections cannot hold loading open, and visible corrections
|
||||
stay pixel-limited at canvas and camera-plane boundaries. Completed road snapshots replace
|
||||
streamed previews without duplicating roads. Unresolved previews no longer delay or suppress detailed street roads.
|
||||
Road admission excludes private, parking, walking and unknown traffic categories;
|
||||
OpenStreetMap service ways and tunnels no longer receive surface vehicles.
|
||||
Detail tiles load alongside previews, arrival bypasses the movement debounce,
|
||||
and geographically validated rendered depth avoids repeated mesh redraws.
|
||||
Traffic releases surface-frame listeners on disable/destroy and reuses unchanged
|
||||
surface identities without frame-by-frame allocations.
|
||||
Grounded dots respect building occlusion instead of drawing through roofs,
|
||||
and the traffic status chip reports completion without a delayed text animation.
|
||||
Short road chunks and local surface elevation keep the budget on visible
|
||||
streets in elevated cities. Congestion and closures respect travel
|
||||
direction. ALPR distinguishes unsupported coverage from empty results and
|
||||
asks for zoom-in before exceeding its tile budget. Installation footprints
|
||||
retain visible fragments without joining separate parcels across zooms or
|
||||
across quantization gaps between different mapped identities. Keyless
|
||||
terrain tiles retry HTTP 429 and transient gateway failures with bounded,
|
||||
shared backoff. Operator-configured Overpass area and footprint queries
|
||||
retain relation member geometry so their outlines remain available.
|
||||
Contacts filters mapped installations to a true 100 km surface radius around
|
||||
the current tracked subject,
|
||||
including in Cockpit, instead of "?" or nothing. Search arrivals frame against the resolved
|
||||
ground and lift the eye above the rendered surface, so Camp Mabry and Denver
|
||||
no longer land underground (Milan Khanal, #118). Arrival corrections yield to
|
||||
new camera owners and are cancelled on layer/app teardown. ALPR floor preparation
|
||||
and marker placement share terrain requests, cancelled when the layer is disabled.
|
||||
Map-source changes reposition every ALPR marker without replacing entities. ALPR loads
|
||||
whole-city views from z9-z12 tiles, drops native ground wedges and clamping,
|
||||
keeps frame time with ALPR on within a few percent of ALPR off, seats nearby
|
||||
badges on the rendered surface and says when no loaded camera is on screen.
|
||||
|
||||
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
||||
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
|
||||
@@ -55,6 +130,19 @@
|
||||
`src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and
|
||||
the `renderable`-not-`enabled` choice (KnottyDyes, #705).
|
||||
|
||||
- Make share-link layer tokens durable allocations instead of ad hoc picks.
|
||||
Existing one-character mappings are pinned permanently, while new layers use
|
||||
the next free single-character digit, then two-character base-36 tokens.
|
||||
Authors record each token in the permanent JSON ledger after running
|
||||
`npm run layer-token:next -- <layer-id>` on a rebased branch.
|
||||
`npm run layer-token:check -- --base-ref origin/main` guards published
|
||||
assignments and allocation order in
|
||||
pull-request CI. Existing v2 links keep their exact meaning.
|
||||
Malformed enabled-layer lists now reject the whole layer payload instead of
|
||||
restoring a partial list. A QA-only browser fixture exercises the future
|
||||
two-character path after a fixture-only exhausted-digit scenario, without
|
||||
reserving `00` in production.
|
||||
|
||||
- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over
|
||||
the Alps") work again in the dev server: the bundled Natural Earth and
|
||||
neighborhood packs are fetched as JSON in the browser
|
||||
@@ -513,6 +601,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
|
||||
|
||||
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
|
||||
|
||||
@@ -70,6 +70,39 @@ gate for the feature you changed.
|
||||
> and a browser. Include the applicable local run in your PR's validation
|
||||
> evidence.
|
||||
|
||||
## Share-link layer tokens
|
||||
|
||||
Every shareable layer owns an immutable token in `LAYER_STATE_REGISTRY` and
|
||||
`src/data/layerStateTokenReservations.json`. Never change or reuse an existing
|
||||
token: published v2 links are authored state, and the reservation remains even
|
||||
if its layer is later removed.
|
||||
|
||||
New layers first use the remaining unreserved single-character digits (`0`
|
||||
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
|
||||
current published ledger, `1` and `2` are already owned, so the next choices
|
||||
are `0`, `3` through `9`, then `00`. After rebasing onto the latest `main`, run
|
||||
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
|
||||
JSON reservation ledger and registry entry, and keep the exact registered-layer
|
||||
count assertion up to date. The token is assigned against the merge-time base, not
|
||||
claimed when a PR opens, so another merged layer may require rerunning the
|
||||
command after a rebase. Never repurpose a published or retired token.
|
||||
Run `npm run layer-token:check -- --base-ref origin/main` after the rebase;
|
||||
this checks that every published assignment remains reserved and that new
|
||||
assignments consume free digits before the two-character tokens, in order.
|
||||
Pull-request CI runs the same check against `main`.
|
||||
|
||||
The helper reads the local ledger; it does not run during a rebase or edit files.
|
||||
If another PR publishes your provisional token first, keep every published
|
||||
registry entry and ledger reservation from the refreshed `main`. Temporarily
|
||||
remove only your still-unpublished layer's registry entry and ledger row so the
|
||||
local registry is valid again, then rerun `npm run layer-token:next -- <layer-id>`.
|
||||
For example, if PR A publishes `0` while PR B still provisionally uses `0`,
|
||||
PR B removes only its own provisional entry, reruns the helper, and receives
|
||||
`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count
|
||||
and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`.
|
||||
An invalid duplicate ledger can fail during module import before the helper
|
||||
prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
## Good first contributions
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
+29
-37
@@ -20,7 +20,7 @@ How to read this:
|
||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
|
||||
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
@@ -34,11 +34,10 @@ How to read this:
|
||||
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
|
||||
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" |
|
||||
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page, and last-resort forward place search via `/api/geocode` when Google and Photon do not answer | ODbL 1.0 + [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/) (maximum 1 request/second, results cached, identifying User-Agent) | "© OpenStreetMap contributors" |
|
||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" |
|
||||
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
|
||||
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
@@ -46,7 +45,7 @@ How to read this:
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
|
||||
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **DelDOT (video.deldot.gov)** | CCTV camera catalog (`tmc.deldot.gov/json/videocamera.json`, cached for 15 minutes) + live HTTPS HLS video, Delaware. Keyless | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) | "DelDOT — Delaware Department of Transportation" (courtesy) |
|
||||
@@ -65,7 +64,7 @@ How to read this:
|
||||
| **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
|
||||
@@ -96,9 +95,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
|
||||
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
|
||||
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` shares the cockpit's Nominatim queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
|
||||
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
||||
|
||||
---
|
||||
@@ -111,11 +110,14 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
|
||||
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
||||
|
||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||
@@ -128,33 +130,23 @@ The richer structured dataset is licensed separately/commercially by TeleGeograp
|
||||
|
||||
### ALPR camera mapping
|
||||
|
||||
The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance`
|
||||
and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values)
|
||||
through the shared Overpass proxy. Source/tag reference:
|
||||
[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR).
|
||||
No DeFlock software, branded assets or separate database is bundled. The layer
|
||||
shows contributor-supplied locations and tags, not plate records, camera video,
|
||||
current operating status, or exhaustive coverage. All manufacturers share the
|
||||
ALPR category and marker color. Manufacturer, operator and camera type are shown
|
||||
only when tagged, not inferred or independently verified. Every selected card
|
||||
names OpenStreetMap in a smaller, muted source line beneath those details.
|
||||
The optional ALPR layer reads OpenStreetMap `surveillance:type=ALPR` nodes
|
||||
from a community-hosted hourly US/Canada extract, using detail vector tiles.
|
||||
It shows mapped locations and available tags, not plate records, camera video,
|
||||
current operating status or exhaustive coverage. OSM edit timestamps are not
|
||||
presented as field-verification dates. Other regions show no ALPR data.
|
||||
|
||||
Queries are limited to a city-scale viewport (at most 3° per axis), at most
|
||||
1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited
|
||||
coverage notices. A linked © OpenStreetMap credit appears for five
|
||||
seconds when camera locations first display after enabling, then collapses;
|
||||
Data attribution retains the full source/license entry, following the
|
||||
[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines).
|
||||
OSM data remains separately attributed under ODbL; querying
|
||||
at runtime does not replace the license obligations. ODbL permits commercial
|
||||
use and does not license the application code. If publicly using a derivative
|
||||
database, follow its share-alike and access requirements; keep independent
|
||||
datasets separate rather than treating the whole collection as MIT data.
|
||||
Overpass hosting capacity and usage policies are separate from the data license.
|
||||
Reads are viewport-bounded, with at most 16 tiles per country, four concurrent
|
||||
reads per country, 1,500 displayed records and an hour-long decoded-tile cache.
|
||||
Displayed OSM-derived layers retain a short inline OpenStreetMap credit, plus
|
||||
OpenMapTiles while OpenFreeMap tiles are used; Data attribution retains the full credits. ODbL permits
|
||||
commercial use with attribution and applicable database share-alike/access
|
||||
obligations; it does not license the application code. Trimmed test-only tile
|
||||
fixtures retain their provenance in `src/data/fixtures/README.md`.
|
||||
|
||||
### ODbL share-alike (datacenters, dams)
|
||||
### Bundled OpenStreetMap data
|
||||
|
||||
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a _modified_ version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
|
||||
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
|
||||
|
||||
### NASA FIRMS acknowledgement
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ non-commercial use, or a **Google Maps key** for the direct, metered route and
|
||||
Google place search. Provider terms and quotas apply. Add keys through the
|
||||
app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
|
||||
|
||||
> **Already installed?** Update to the latest version. Older versions query
|
||||
> public OpenStreetMap Overpass servers, which now refuse them, so Traffic,
|
||||
> Mapped Installations and ALPR stay empty until you update.
|
||||
|
||||
### Path 1 — One click, no terminal
|
||||
|
||||
1. Install or update [Pinokio](https://desktop.pinokio.co/) to **8.2 or later**.
|
||||
|
||||
@@ -17,6 +17,8 @@ until deliberately adopted.
|
||||
Generated output, local configuration, browser evidence and bundled datasets are
|
||||
excluded. The formatter validates every entry before writing any file.
|
||||
|
||||
New world text labels must use the world-overlay host; Cesium text labels are forbidden.
|
||||
|
||||
## Current component ownership
|
||||
|
||||
Package imports use `gods-eye-view`; `package.json` is the authoritative
|
||||
@@ -668,8 +670,8 @@ An empty search array is a definitive miss. HTTP failures, malformed results
|
||||
and oversized responses remain retryable. Attribution identifies OpenStreetMap /
|
||||
Nominatim.
|
||||
|
||||
Overpass currently supplies traffic road geometry, ALPR camera records, military
|
||||
installation footprints, and annotation geometry (administrative boundaries,
|
||||
Vector tile sources supply traffic roads, ALPR records and keyless military
|
||||
areas. Operator-configured Overpass supplies named installations and annotation geometry (administrative boundaries,
|
||||
neighborhoods, streets, building/grounds outlines and monument candidates).
|
||||
Nominatim forward/reverse lookup does not replace those queries. Layer source
|
||||
interfaces select traffic, camera and installation ingestion separately;
|
||||
@@ -711,7 +713,9 @@ query-language strings or decoding backend tags and relation members.
|
||||
|
||||
Points use `{lat, lon}`. Options carry `signal`. A successful array, including an
|
||||
empty array, is definitive; `null` means a transient failure;
|
||||
`{rateLimited: true, retryAfterMs}` retains a provider's retry delay. Candidates
|
||||
`{rateLimited: true, retryAfterMs}` retains a provider's retry delay.
|
||||
`{unavailable:true, code:'OVERPASS_NOT_CONFIGURED', retryable:false}` is a
|
||||
capability miss that stops retries while preserving pins and offline geometry. Candidates
|
||||
carry `id`, `names` (`primary`, `english`, `official`, `alternate`, `short`),
|
||||
`coordinates` (objects with `lat` and `lon`), `building` and optional `heightM`,
|
||||
`center` and `point`. Administrative candidates use `category: 'administrative'`
|
||||
|
||||
+479
-43
@@ -224,6 +224,22 @@ Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-
|
||||
|
||||
Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records (default 2,000 per new layer, core satellite rows before dense extras); omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available.
|
||||
|
||||
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
|
||||
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
||||
proxy in this repository sees those requests (`/api/terrain/heights` is the
|
||||
separate point-height endpoint), and the upstream edge answers zoom-out bursts
|
||||
with HTTP 429 under load. `src/maps/terrain.js` hands Cesium a `Resource`
|
||||
whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch
|
||||
inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms
|
||||
doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
||||
plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the
|
||||
upstream exposes it. One console warning is logged per cooldown window. Other
|
||||
failures keep Cesium's handling and the flat `EllipsoidTerrainProvider`
|
||||
fallback. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless
|
||||
dev server, counts real throttles with their headers as evidence, synthesises
|
||||
throttles with `--inject N` when the upstream is not throttling, and fails if
|
||||
a throttled tile never loads.
|
||||
|
||||
AIS encodes speed over ground in 0.1-knot units and course over ground in
|
||||
0.1-degree units, reserving the top code of each field for "not available", so
|
||||
those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown,
|
||||
@@ -303,7 +319,7 @@ are protected from fallback writes. Version 6 is the export format, with
|
||||
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
|
||||
see [Director](DIRECTOR.md) for camera, pack, action and sharing support.
|
||||
|
||||
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged.
|
||||
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass.
|
||||
|
||||
Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
|
||||
|
||||
@@ -426,12 +442,30 @@ selection, route/direct-line honesty and tool schemas are preserved. Node
|
||||
Google, OSRM and Nominatim adapters accept trusted construction-time configuration;
|
||||
request parameters cannot choose arbitrary upstream URLs. See APPLICATION.md.
|
||||
|
||||
Every ALPR native marker is repositioned on map-source changes, including canvas-owned saved appearances, while retaining its entity identity.
|
||||
|
||||
ALPR floor preparation and clamped markers share a per-cell queue, with at most one 64-cell terrain batch active per layer. Disabling or destroying the layer cancels its terrain consumer and discards queued cells. Repeated moves inside an outstanding query retain the displayed markers until the new snapshot is ready.
|
||||
|
||||
The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations,
|
||||
not camera footage or plate records. City-scale queries use the existing Overpass
|
||||
provider, with capped results, retry, cached-data and incomplete-coverage notices.
|
||||
not camera footage or plate records. City-scale acquisition uses an hourly
|
||||
OpenStreetMap US/Canada extract, with available operator/brand/bearing
|
||||
attributes. The extract carries full attributes from z9 (z0-8 are geometry-only
|
||||
heat tiles); each view reads the finest zoom from z12 down to z9 that fits 16
|
||||
tiles, so a 30 km whole-city view loads about 1,000 Austin cameras and a
|
||||
neighbourhood reads z12 (about 2 m position precision). Each camera keeps the
|
||||
finest position seen, so zooming out never nudges a marker. The fetched tiles'
|
||||
bounds are the accepted coverage: later views inside it (and precise enough for
|
||||
it) reuse the records at once, without a request or debounce, and a still camera
|
||||
sees one marker update per move. Missing verification dates remain unknown; OSM edit dates do not
|
||||
stand in for verification. Outside coverage the row says "No ALPR data for this
|
||||
area". Reads cap tiles at 16 per country, concurrency at four per country,
|
||||
responses at 4 MiB, decoded cache at 64 entries/24 MiB per country with a one-hour
|
||||
TTL, and accepted records at 6,000; at most 1,500 cameras in view render,
|
||||
nearest the view centre first, and more than that reads "Coverage limited".
|
||||
Partial coverage remains explicit.
|
||||
Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source
|
||||
adapter. Sources return normalized records, stale/coverage flags and their own
|
||||
attribution; rendering does not parse Overpass payloads. The standalone
|
||||
attribution; rendering does not parse tile payloads. The standalone
|
||||
registration supplies selection, picking, terrain and
|
||||
render services. Selected cards and Data attribution identify OpenStreetMap.
|
||||
Layer state and the existing voice layer tools include `alpr-cameras`.
|
||||
@@ -440,8 +474,28 @@ screen. Cyan camera badges use Manjunath's camera artwork; selection switches to
|
||||
larger coral badge with corner brackets and an animated tactical card. Numeric
|
||||
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
|
||||
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
|
||||
selected camera; farther cameras retain native Cesium badges and ground-clamped
|
||||
wedges. Unknown bearings never acquire a wedge. Asset URLs resolve relative to
|
||||
selected camera; farther cameras keep native Cesium badges without wedges.
|
||||
Native ground-clamped wedges (a ground polyline plus a classification polygon per
|
||||
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
|
||||
and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
|
||||
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a
|
||||
tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
||||
Overlay anchors start on the same floor and write marker properties only on
|
||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
||||
sink on slopes; wedge corners follow their own floor cells instead of staying
|
||||
level with the apex. Unknown bearings never acquire a wedge. The row reads
|
||||
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
||||
"None on screen — nearby cameras are outside the view" instead of looking
|
||||
unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off,
|
||||
then on (cold and warm), and asserts time to cameras, marker stability while
|
||||
still, and frame time within 10% of ALPR off. It also checks rendered frames:
|
||||
marker pixels in the recorded clip against the ALPR-off pass, and, at the end
|
||||
of every hold, the screen against the same scene after forcing frames, so a
|
||||
state change without a render request fails (`--self-test-drop-renders` proves
|
||||
it does). Asset URLs resolve relative to
|
||||
the reusable layer, and the caller supplies overlay and map-stack services.
|
||||
The ALPR row uses the same header layout as other data layers. **SHOW NEAREST**
|
||||
frames and selects the nearest loaded camera; it is
|
||||
@@ -452,8 +506,8 @@ Coverage follows a bounded neighborhood around the screen-center ground point,
|
||||
with a radius of twice the camera-to-ground range (at least 1 km), rather than
|
||||
extending to the horizon. Sky, dateline-crossing and wider-than-city views do not
|
||||
query. Movement within an accepted or pending query reuses it. Markers retain
|
||||
their Cesium identity and ground clamping while their geometry is unchanged;
|
||||
refreshes preserve selection without replaying a click event.
|
||||
their Cesium identity while their geometry is unchanged; refreshes preserve
|
||||
selection without replaying a click event.
|
||||
|
||||
Data Centers, Dams and Submarine Cables release their built Cesium data sources
|
||||
and record references when disabled. Parsed datasets remain cached for the layer
|
||||
@@ -470,6 +524,8 @@ Each layer owns its state and visibility listener; destruction cancels source
|
||||
reads and pending initialization. Malformed health responses retain prior health.
|
||||
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
|
||||
|
||||
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
|
||||
|
||||
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||
`./layers/bikeshare`. Traffic separates road requests, ingestion, animation,
|
||||
flow matching, styling, viewport lifecycle and development timing. Each source
|
||||
@@ -693,6 +749,18 @@ and result presentation. Existing search providers and camera handoff policy are
|
||||
preserved. Replacing or closing a POI row cancels its pending expansion frame;
|
||||
destruction releases listeners, pending searches and the orbit indicator.
|
||||
|
||||
Contacts and mapped-site search feedback filter subject-window installation matches by surface distance from the current subject position, within the stated 100 km radius; the retained tile-fetch square is only a loading window.
|
||||
|
||||
Search arrivals (location bar and voice) end above the rendered surface.
|
||||
Precise places without a detailed outline used to frame a 250 m landmark shot
|
||||
from a sea-level target, which left Camp Mabry about 1 m above the 171 m mesh
|
||||
and a Denver coordinate underground. Search now resolves the application's
|
||||
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
|
||||
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
|
||||
surface under the target and the camera once tiles stream in and lifts the eye
|
||||
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry
|
||||
and a Denver coordinate and requires at least 60 m clearance.
|
||||
|
||||
## Layer panel ownership
|
||||
|
||||
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
|
||||
@@ -829,8 +897,9 @@ stalled downloads are cancelled. Development and preview use the same handler.
|
||||
|
||||
## Upstream body bounds
|
||||
|
||||
Every proxied fetch outside CCTV media now reads its body through the shared
|
||||
streaming readers in `src/sources/httpBody.js`, so a declared or observed body
|
||||
FIRMS CSV, CelesTrak TLE groups, adsb.lol military responses, TomTom flow
|
||||
tiles, OpenSky states/all and adsbdb lookups now read their bodies through the
|
||||
shared streaming readers in `src/sources/httpBody.js`, so a declared or observed body
|
||||
over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than
|
||||
buffered. `readResponseBytesCapped` accepts the same optional abort signal as
|
||||
the text reader and cancels a body still streaming when it fires; TomTom flow
|
||||
@@ -846,7 +915,6 @@ body is an upstream miss and follows each route's existing error and
|
||||
cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV
|
||||
media keep their current handling.
|
||||
|
||||
|
||||
## Remaining local service modules
|
||||
|
||||
Overpass query validation, geometry simplification, disk caching and upstream
|
||||
@@ -1141,8 +1209,34 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
||||
|
||||
## Installations and map-source guidance
|
||||
|
||||
- On an uncached Overpass failure, mapped installations keep their existing
|
||||
30–240 second retry backoff. The top status and Contacts row explain the
|
||||
- Keyless mapped installations use OpenFreeMap `landuse` military polygons,
|
||||
merge touching parcels and tile fragments into one "Military area" marker;
|
||||
tiles carry no site names. The marker uses an area-weighted centroid, moved
|
||||
inside a footprint when necessary. Source-feature aliases retain ids across
|
||||
pans (4096-entry cap); grouping admits at most 2048 fragments per view and
|
||||
reports saturation if that cap is reached. Tile-buffer geometry is clipped to each core and
|
||||
artificial clipping edges never become outlines. Fills, boundary polylines
|
||||
and markers clamp to the visible globe or photoreal mesh. Wide views start
|
||||
at z10, local views at z12, stepping down no further than z9 to fit the
|
||||
16-tile view cap: OpenMapTiles carries `landuse=military` only from z9, so a
|
||||
view too wide for 16 z9 tiles is a "Zoom in" state, never an empty area.
|
||||
Camera changes debounce for 180 ms, with a final moveEnd check; identical
|
||||
windows reuse the active load. Disabling or returning Contacts to passive mode
|
||||
clears its anchor; passive refreshes cannot reclaim it. Background repaints
|
||||
refresh context records without publishing a new selection.
|
||||
Installation tiles decode only the `landuse` layer. Reloads keep the Cesium
|
||||
entities of sites whose geometry is unchanged, so outlines and fills do not
|
||||
blink. While Contacts has a subject, the layer loads a window around it
|
||||
instead of the camera view (100 km half-width at mid latitudes, shrinking so
|
||||
it fits 16 z9 tiles), moves it only after the subject travels 20 km, and
|
||||
ignores camera `moveEnd`; clearing the subject returns to the viewport. The
|
||||
row status names what was found ("3 mapped sites within 100 km of the
|
||||
contact", "No mapped sites in view") and tile failures read "Map tiles
|
||||
temporarily unavailable". Google Places
|
||||
search/classification stays separate and unchanged. Configured Overpass can
|
||||
still supply named sites; a not-configured miss switches to tiles once.
|
||||
Retryable tile/configured-upstream failures retain the existing 30–240 second
|
||||
backoff, extended when Retry-After requires it. The top status and Contacts row explain the
|
||||
outage and scheduled countdown; an active retry says "Retrying mapped
|
||||
sites" and successful recovery clears the previous error. Known upstream
|
||||
rate limits, timeouts, and query failures are distinguished without exposing
|
||||
@@ -1327,7 +1421,7 @@ This is the current runtime/source-of-truth snapshot for the project.
|
||||
> Without the predicate that window prints an all-clear `0`.
|
||||
> - **Mapped installations never reaches that window.**
|
||||
> `militaryInstallations.enable()` is synchronous and the manager awaits
|
||||
> `update()`, which owns the first Overpass fetch, so the lifecycle stays
|
||||
> `update()`, which owns the first mapped-data fetch, so the lifecycle stays
|
||||
> `enabling` for the whole fetch and the pre-existing `enabling` branch
|
||||
> covers it. Confirmed live on :4272 across a held 17 s first fetch (34
|
||||
> samples, `enabling` throughout, panel non-numeric) and across a failing
|
||||
@@ -2104,7 +2198,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its local 90/min limiter. Cache and single-flight responses
|
||||
> therefore do not spend quota; upstream-bound misses retain the existing
|
||||
> limiter, mirror, stale, and sanitization behavior.
|
||||
> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error.
|
||||
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
||||
> activates it and routes the camera flight through the panel FOCUS policy.
|
||||
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
||||
@@ -2772,11 +2866,11 @@ its criteria cannot be silently ignored.
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
@@ -3385,10 +3479,11 @@ silently demoting every later lookup for the session.
|
||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
||||
- Context selection transfers camera ownership by subject type: selecting a civilian or military flight keeps that layer's moving follow camera, while selecting an AIS vessel or mapped installation first releases any prior aircraft tracker and performs only the source layer's one-time framing. The camera therefore remains user-controlled after non-aircraft selection instead of continuing to move with the previously selected plane.
|
||||
- Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations use the tracked-readout aesthetic. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards.
|
||||
- Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards.
|
||||
- Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout.
|
||||
- While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact.
|
||||
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
|
||||
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
|
||||
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
|
||||
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Scenes controls. These compact-state widths do not constrain expanded panel or child-content widths.
|
||||
|
||||
@@ -3503,13 +3598,97 @@ silently demoting every later lookup for the session.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
### Overpass proxy mirror rotation (September 2026)
|
||||
### Optional Overpass configuration
|
||||
|
||||
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache.
|
||||
- HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data.
|
||||
- A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`).
|
||||
- Every mirror is asked with a User-Agent that names the application, its version and the project address (`OVERPASS_USER_AGENT` in `server/providers/overpass/constants.js`), which is what the OSM API usage policy asks for. Mirrors may refuse a client they cannot identify; such a refusal is never treated as data and costs the fan-out that mirror, so the header is what keeps the list at full strength.
|
||||
- When the fan-out has nothing left to offer, the traffic row says which upstream declined and how: `Overpass rate-limited`, `Overpass timed out`, or `Overpass refused the road query (HTTP 406)`; the proxy's own 502 (every mirror unreachable) and 503 (local limiter busy) read `Overpass mirrors unreachable` and `Overpass temporarily unavailable`. Street Traffic draws on two upstreams — OpenStreetMap for geometry, TomTom for flow — so a row that only said "road data unavailable" sent readers to check a TomTom key that was never the problem (#661). `roadRequestError` in `src/layers/traffic/source.js` owns the mapping and tags the error with its status. A refusal whose code is unreadable — the layer takes its source by injection, so an adapter may not supply one — reports `Overpass temporarily unavailable` rather than printing `HTTP undefined`, and anything the layer did not classify at all (a dropped connection, a malformed snapshot) still reads `Road data temporarily unavailable`, because a platform exception is not a sentence to put on a panel.
|
||||
Public Overpass instances are not used by default. `OVERPASS_UPSTREAMS` is a
|
||||
server-only comma-separated list of HTTP(S) instances the operator runs or pays
|
||||
for, resolved lazily after env loading. It replaces the empty default list;
|
||||
there is no built-in fallback. Local/private instances are allowed. The stable
|
||||
`gods-eye-view/…` User-Agent is unchanged. URLs never appear in response headers
|
||||
or upstream errors, including old cached endpoint metadata.
|
||||
|
||||
The shared transport guards `/api/overpass` and `/api/military-installations`.
|
||||
With no configured upstream, each route serves existing cache data (including
|
||||
expired entries, marked stale with the original cache/retrieval date) without
|
||||
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
|
||||
`retryable:false`; installations return that capability as HTTP 200 and select
|
||||
the working vector-tile source without a failed-network console entry.
|
||||
`GET /api/overpass/status` answers `{configured}`; the browser asks once per
|
||||
page and, when nothing is configured, returns the unavailable result for
|
||||
annotation and location-feature queries without sending them (so no 503 is
|
||||
logged). An older server without the probe is queried as before. The probe
|
||||
times out after 3 s; a failed or timed-out probe is retried after 30 s, and
|
||||
queries in between go to the server. Each query stops waiting on the probe
|
||||
when its own signal aborts.
|
||||
Configured endpoints have per-instance cooldowns for 406/429, honoring
|
||||
Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
||||
`elements` is a valid answer. Existing byte, query, concurrency and cache caps
|
||||
remain. Consumer capability misses never schedule automatic retries.
|
||||
|
||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||
Member geometry is printed only inside a box around the point (6 km for
|
||||
neighbourhoods, 3 km for street areas, 1.5 km for focus footprints); a
|
||||
relation cut by the box does not close and falls back to its pin.
|
||||
Street Traffic and ALPR never use Overpass. Installations switch once to vector
|
||||
tiles after a capability miss. Annotation and location-feature sources retain
|
||||
a distinct unavailable result and stop querying for that source lifetime.
|
||||
`scripts/qa-overpass-offload.mjs <dev-url>` checks Austin road dots, ALPR,
|
||||
Camp Mabry markers/outlines, at most 12 Fort Cavazos installation markers,
|
||||
an area annotation with zero console errors (and zero `/api/overpass` queries
|
||||
when unconfigured), and zero browser requests to external Overpass/Nominatim hosts. It waits for
|
||||
visible photoreal tilesets, dismisses first launch and measures at least 150
|
||||
street-view dots within -3/+25 m of sampled mesh height, for both keyed and
|
||||
keyless OpenFreeMap roads (an isolated page overrides only TomTom key availability).
|
||||
Keyed Austin defaults to Hybrid (`roadSource: TomTom + OpenStreetMap`) with positive flow coverage.
|
||||
It records milliseconds from enabling Street Traffic to first rendered dots at
|
||||
2 km on a fresh page for each mode, plus warmed street-view timings and orbit screenshots;
|
||||
`src/overpassOffload.test.mjs` separately proves server-handler zero egress.
|
||||
All QA modes record per-view request counts, decoded response-body bytes,
|
||||
transferred bytes and browser cache hits for OpenFreeMap TileJSON/tiles, ALPR
|
||||
extract metadata/tiles and local `/api/tomtom/*` responses. `--tour` measures
|
||||
Austin, London, Dubai, San Diego, Tokyo and São Paulo with all three layers on,
|
||||
at 450 m and -35° pitch (São Paulo: 1250 m), then revisits Austin to measure
|
||||
cache reuse. The tour disables camera collision adjustment and asserts the actual
|
||||
latitude, longitude, altitude and pitch so remote terrain cannot shift a revisit.
|
||||
`--compare` captures TomTom, OpenStreetMap and Hybrid at identical close tilted
|
||||
photoreal views: Twin Peaks, Lombard Street, Loop 360, Dubai and Shibuya. The
|
||||
mode order rotates per view so each mode meets cold caches somewhere. It records
|
||||
first-dot latency (null where TomTom has no roads), projected dots,
|
||||
surface-aligned dots (-3/+25 m), floaters, sinkers and missing samples, saving
|
||||
`qa-shots/compare-<view>-<mode>.png` and `overpass-offload-compare-result.json`.
|
||||
The comparison also clicks a real row chip and verifies its stored preference.
|
||||
`--pan` runs 20 settled Austin moves: ten 150–300 m steps, five 1–2 km steps,
|
||||
and five exact returns. It records OFM requests/bytes, flow tile requests, all
|
||||
local API calls and dots per move, rejects repeat XYZ downloads and any new
|
||||
OFM request on a return, and checks small-pan tiles against admitted bounds.
|
||||
Its TomTom session ceiling is 32 browser flow requests (0.53% of the default
|
||||
6,000/day upstream budget); cached server responses also count toward this
|
||||
conservative session bound. `--profile` captures cold/warm Austin at 450 m,
|
||||
-35 degrees with settled mesh. Development `roads:*` phase measures include
|
||||
status, TileJSON, per-tile fetch/decode, parsing and surface sample counts/CPU
|
||||
time, slice maxima and cache hits; no obsolete zero-valued parse-time sampling
|
||||
aggregate is emitted. The six-city tour records first-dot and on-mesh counts
|
||||
and asserts the 4 s cold / 2.5 s cached-return deadlines.
|
||||
Reports go to `qa-shots/overpass-offload-result.json` and separate
|
||||
acceptance/tour/pan/profile result files, with a console summary table. These are browser
|
||||
session measurements; TomTom upstream usage still depends on the server cache
|
||||
and budget. MB uses decimal bytes; transfer totals include response headers.
|
||||
|
||||
Configured Overpass responses still use `overpassPayloadIsData()` for cache
|
||||
admission and stale fallback: refusals and body-level errors are never data.
|
||||
Identical requests share a configured endpoint sequence and last-good fallback;
|
||||
all configured endpoints retain the stable application User-Agent. HTTP refusals
|
||||
rotate only among those configured endpoints, honoring their cooldowns.
|
||||
|
||||
Street Traffic reports OpenFreeMap road failures separately from TomTom flow:
|
||||
`OpenFreeMap tiles rate-limited`, `OpenFreeMap tiles timed out`, or
|
||||
`OpenFreeMap tiles unavailable (HTTP 406)`. `RoadRequestError` retains a numeric
|
||||
`status` (null when absent); malformed snapshots and network failures read
|
||||
`OpenFreeMap tiles unavailable`, never `HTTP undefined`. Detailed-pass failures
|
||||
retain the major roads and display the same upstream reason with reduced coverage.
|
||||
The TomTom /api proxy still reports key unavailability (503), daily budget (429),
|
||||
and unreachable upstream (502/504) separately from road geometry.
|
||||
|
||||
### Share-link v2 layer state (August 2026)
|
||||
|
||||
@@ -3518,11 +3697,33 @@ silently demoting every later lookup for the session.
|
||||
with compact fields for enabled layers, allowlisted layer options, panel state,
|
||||
and the active preset's allowlisted shader controls. An absent layer field uses
|
||||
deterministic defaults; an explicit empty field means no enabled layers.
|
||||
- The registry seals only after all 16 production layers register, and every
|
||||
layer has an explicit serialization disposition. Unknown enabled-layer tokens
|
||||
reject the layer payload; unknown option tokens are ignored. Restoration
|
||||
- The registry seals only after every production layer registers, and every
|
||||
layer has an explicit serialization disposition. Unknown, empty, or repeated
|
||||
enabled-layer tokens reject the whole layer payload (`l=` alone is the valid
|
||||
explicit-empty form); unknown option tokens are ignored. Restoration
|
||||
settles independently per layer so one failed or unavailable source cannot
|
||||
block its siblings.
|
||||
- Layer tokens are permanent public compatibility identifiers. The reservation
|
||||
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
|
||||
one-character assignments (all letters plus `1` and
|
||||
`2` on current `main`) even if a layer is later removed. New layers allocate
|
||||
the remaining unreserved single-character digits first (`0`, then `3`
|
||||
through `9` on current `main`), then the next unreserved two-character
|
||||
base-36 token (`00` through `zz`) after rebasing onto the merge-time `main`;
|
||||
the dot-delimited v2 codec accepts both widths without changing existing
|
||||
links or the schema version. The contributor
|
||||
check reads the complete published JSON ledger from `origin/main`, including
|
||||
retired reservations, so later assignments cannot silently replace published
|
||||
ones. The pre-ledger base is accepted only at its known source blob; unknown
|
||||
historical shapes fail closed.
|
||||
Pull-request CI runs this check against `origin/main`.
|
||||
- `scripts/qa-layer-token-twochar.mjs` simulates exhaustion of the earlier
|
||||
digit slots and injects a no-network `00` layer only into isolated dev-browser
|
||||
responses. It proves encoding, a cold-recipient reload, restoration, explicit
|
||||
OFF, and legacy-token compatibility. This is synthetic
|
||||
end-to-end evidence, not production allocation: `00` remains unreserved until
|
||||
the first eligible real layer receives it after the free digits through the
|
||||
normal allocation process and passes its own application share/reload check.
|
||||
- Stable visible options are limited to aircraft 3D mode, selected civilian and
|
||||
military flight IDs, Satellite catalog and selection, CCTV coverage/projection/
|
||||
auto-hop, and Radio filter/volume. Playback and tuning, live-data health,
|
||||
@@ -3736,7 +3937,36 @@ are omitted rather than framing the wrong part of the globe.
|
||||
|
||||
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
|
||||
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
|
||||
- Resolver contract: `src/annotations/annotationResolver.js` converts names/coords/screen pixels into world anchors and optional geometry. The resolver is type-aware: Google Geocode/Places gives a centroid + scope, OSM/Overpass supplies admin/place/footprint/street/enclosing-area geometry, route requests use `/api/route`, and ambiguous/far results are rejected or recovered near the current view instead of drawing misleading blobs. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
|
||||
- Resolver contract: `src/annotations/annotationResolver.js` converts names/coords/screen pixels into world anchors and optional geometry. The resolver is type-aware: Google Geocode/Places gives a centroid + scope, operator-configured OSM/Overpass supplies admin/place/footprint/street/enclosing-area geometry, route requests use `/api/route`, and ambiguous/far results are rejected or recovered near the current view instead of drawing misleading blobs. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
|
||||
- With no detailed-feature capability, annotations retain their pins and say
|
||||
"Detailed outline unavailable"; location focus reports the same result without
|
||||
retrying. Missing capability never synthesizes an outline from a radius.
|
||||
Manual geometry, bundled neighborhoods, Natural Earth regions and bundled
|
||||
state/province/county outlines remain.
|
||||
- County queries rank eligible US counties and international admin-1 units together, retaining exact aliases and country/state qualifiers.
|
||||
- Bundled administrative outlines (`src/data/adminBoundaries.js`): states and
|
||||
provinces worldwide (Natural Earth admin-1,
|
||||
`src/data/local_data/natural_earth/states_provinces.json`) and US counties
|
||||
and county equivalents (US Census Bureau,
|
||||
`src/data/local_data/us_census_counties/counties.json`), both public domain
|
||||
and rebuilt by `scripts/build-admin-packs.mjs`. An area ask whose words name
|
||||
a unit ("Texas", "TX", "State of Texas", "Bavaria", "Travis County, Texas",
|
||||
"Orleans Parish") is drawn from the pack before any geocode: no network
|
||||
request, no proximity gate, no outline pending. Same-named units go to the
|
||||
state/country qualifier, then the one under or nearest the camera
|
||||
("Washington County"); every qualifier must place the unit ("Travis County
|
||||
Courthouse, Texas" is not the county). A bare name answers alone only for a
|
||||
prominent unit (a US state, a Canadian province, Bavaria) or a city-state
|
||||
(Berlin, Vienna), and never when it is also a country or a city
|
||||
("Georgia", "New York", "Santa Fe", "Victoria") or a generic word
|
||||
("North"); those, and smaller units ("Kent", "Long Island"), are left to
|
||||
the geocoder. When the geocoder types the place as a state or county, the
|
||||
unit carrying the name and containing the geocoded point is drawn from the
|
||||
pack before any Overpass query (a US county first, so "New York County" is
|
||||
Manhattan). Outlines keep every part and hole (`anno.polygons`: Hawaii's islands,
|
||||
Berlin inside Brandenburg). Each pack loads on the first ask that needs it,
|
||||
never at start-up; the analyst region lookup uses the same packs. Gate:
|
||||
`scripts/qa-admin-outlines.mjs`.
|
||||
- Renderer contract: `src/annotations/hybridAnnotationRenderer.js` routes draped `area`/`route` geometry to world-space Cesium rendering and reticles/pins/arrows/callouts to the screen-space SVG renderer. Area labels are screen-space callouts so all captions share one visual language; progressive outline upgrades convert the existing screen group in place when the anchor snaps to the resolved centroid.
|
||||
- Tooling: voice has `annotate_map` and `clear_annotations` tools. Annotations accumulate and persist by default; clearing is explicit only. Partial failures, approximate synthesized zones, and route fallbacks are returned as structured tool results so the voice layer can be honest.
|
||||
- Console/dev API: `window.__gevAnnotations.tour()`, `.demo()`, `.annotate()`, `.clear()`, `.count()`, and `.list()` are the deterministic no-mic test surface.
|
||||
@@ -3859,8 +4089,8 @@ are omitted rather than framing the wrong part of the globe.
|
||||
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache.
|
||||
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
|
||||
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, mirror fallback, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation/traffic capacity.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||
- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
||||
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
||||
@@ -3945,12 +4175,12 @@ are omitted rather than framing the wrong part of the globe.
|
||||
be enabled, and a civilian or military aircraft must be tracked. The visible
|
||||
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
||||
flight-layer selection cannot enter a context-dependent cockpit.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Scenes and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and local place/current-weather context from OpenStreetMap Nominatim and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Scenes and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over the inherited map preset, `CRT`, `NVG`, `FLIR`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. The inherited entry is named directly, such as `NOIR`, and retains that map shader. There is no empty `NONE` entry. CRT, NVG, FLIR, and NOIR temporarily activate the existing Cesium post-process stages, while returning to the inherited entry or exiting Cockpit restores the pre-entry visual style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Nominatim, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||
- **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap.
|
||||
- **Manual-first cockpit briefing:** the right-side Live Signals / Regional News / Local Info carousel does not advance automatically on page load. Previous, Next, and direct page controls remain available; the visible `CYCLE OFF` / `CYCLE ON` toggle explicitly starts or stops the nine-second page cycle, which still pauses on hover/focus and while collapsed, hidden, or outside cockpit mode. Live signal data continues refreshing in either state.
|
||||
@@ -4097,7 +4327,7 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
`SIMULATED — traffic service unreachable`; a total flow-fetch failure in live
|
||||
mode sets `error` (DEGRADED · `SIMULATED — <reason>`) and zeroes the stale
|
||||
coverage number. `stats.loading` covers outstanding flow work as well as the
|
||||
road fetch, so a failure landing after the 250 ms paint race still ends the
|
||||
road fetch, so a flow failure landing after the first paint still ends the
|
||||
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
||||
never on `mode === 'live'` alone.
|
||||
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
||||
@@ -4105,14 +4335,168 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
||||
(`.gev-cache/tomtom/`, 120 s TTL, `TOMTOM_DAILY_TILE_BUDGET` default 6k/day,
|
||||
sized so a 31-day month stays inside TomTom's 200K/month free allowance),
|
||||
decoded client-side (`flowTiles.js`), matched onto Overpass roads
|
||||
(`flowMatch.js`), and rendered as green/amber/red dot color + speed/density
|
||||
scaling (`trafficFlowStyle.js`); closures spawn no dots; unmatched roads stay
|
||||
white. Road fetch bounds center on the camera look-at point (`trafficBounds.js`).
|
||||
decoded client-side for green/amber/red dot color and speed/density scaling
|
||||
(`trafficFlowStyle.js`).
|
||||
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
|
||||
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
|
||||
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
|
||||
overrides restored state until the user picks a chip. With no stored choice
|
||||
the default is Hybrid with a TomTom key and OSM without one; share links
|
||||
without a road-source choice (including older ones) likewise use the
|
||||
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
|
||||
every choice draws OSM roads and the status says the choice needs a key.
|
||||
TomTom draws only flow-tile lines with their own flow; roads without flow
|
||||
are not drawn and the status says so. A `full`-coverage flow line carries
|
||||
both travel directions, a `one_side` line the direction it is drawn in.
|
||||
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
||||
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
||||
runs within 35 m in the same travel direction (30 degrees), tested every
|
||||
10 m; only uncovered stretches of at least 40 m are kept, and they are
|
||||
simulated. When exactly one side is a motorway/trunk mainline (TomTom
|
||||
`Motorway`/`International road`; OSM ramps count as ordinary roads) the
|
||||
radius is 15 m, so public frontage roads beside a freeway stay. Flow values are ignored for this test, so an uncertain overlap
|
||||
drops the OpenFreeMap copy, never the TomTom line; opposite carriageways
|
||||
stay. OpenFreeMap tiles stream first and are drawn plain until flow
|
||||
arrives, then one replacing snapshot applies the TomTom lines and dedupe.
|
||||
Status examples: `LIVE · Roads: TomTom · Roads without flow hidden`,
|
||||
`LIVE · Roads: TomTom + OpenStreetMap · Flow 81%`; Hybrid with no TomTom
|
||||
line in view names OpenStreetMap alone. TomTom mode skips the z14 detail
|
||||
pass and never requests OpenFreeMap unless it falls back without a key.
|
||||
Matching reuses `flowMatch.js`: seven road samples, a 35 m nearest-segment
|
||||
radius, 30 degree travel-bearing tolerance, and a bounded
|
||||
3x3 probe in a 100 m spatial grid. At least half the samples must match;
|
||||
the median flow level supplies speed/color and any matched closure stops
|
||||
dots in that travel direction. Two-way roads have independent forward/reverse
|
||||
records; one-way direction is enforced. Equal-distance candidates with
|
||||
contradictory flow/closure are left simulated. Matching runs only on load/refresh, never per frame.
|
||||
Flow stays at z12 with the same 16-tile cap, 120-second cache and server
|
||||
daily budget. Lines are clipped to tile cores before caching and to the
|
||||
look-at viewport fetch box before road conversion or matching. Unmatched
|
||||
roads retain white simulated dots at free-flow speeds (unless the explicit
|
||||
uncovered-roads hide option is enabled). Cached OSM road snapshots are
|
||||
rematched on every load; TomTom and Hybrid views are recomposed from the
|
||||
bounded tile caches each load because their roads carry flow. A snapshot
|
||||
is cached only when the load drew plain OpenStreetMap roads, and the cache
|
||||
is not read before the TomTom status probe settles (except for an explicit
|
||||
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
|
||||
refreshes clear old matches and report simulation. `flowCoveragePct` is the
|
||||
percentage of shown dots on roads with flow, including all unmatched dots in
|
||||
the denominator and excluding dots hidden by closures. The OSM keyed status
|
||||
reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch
|
||||
coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
|
||||
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
|
||||
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
|
||||
revisits without requests, and settled coverage replaces the box-overlap gate.
|
||||
- OSM geometry (OSM mode and Hybrid fill) comes from OpenFreeMap's immutable versioned tiles:
|
||||
successful TileJSON metadata prefetched at enable and cached for the source lifetime, z12 wide pass and
|
||||
z14 detail below 4.5 km. The nearest 16 intersecting detail tiles cover the
|
||||
near field; coarse roads remain outside successfully loaded detail cores.
|
||||
Partial coverage retains coarse roads under missing tiles and retries at most
|
||||
three times with bounded backoff, reusing successful tiles. A failed detail pass retains major roads and separately
|
||||
reports "Detailed roads unavailable". OpenMapTiles admits motorway, trunk,
|
||||
primary, secondary, tertiary and minor roads, excluding access restrictions,
|
||||
path subclasses, indoor ways and tunnels. Bridges and ramps inherit the motor
|
||||
road's eligibility; the ramp flag alone never admits a path. Service ways,
|
||||
including untyped ones, parking aisles, driveways, drive-throughs and alleys,
|
||||
are excluded: their tiles do not establish public through traffic. TomTom
|
||||
admits only its eight named motor-road types, rejecting Non public road,
|
||||
Parking road, Walkway road and unknown categories for geometry, flow matching
|
||||
and Hybrid deduplication. Eligibility follows provider classification; omitted
|
||||
or incorrect upstream restrictions remain a source limitation.
|
||||
Only eligible road geometry receives dots;
|
||||
reverse one-way lines are reversed. The shared tile source caps each view at
|
||||
16 tiles per pass (32 OpenFreeMap tiles total), four concurrent requests and 4 MiB per response. OpenFreeMap retains
|
||||
up to 192 decoded tiles/64 MiB (four times body bytes as a storage estimate);
|
||||
other tile sources retain the 64-tile/24 MiB defaults. Identical source-version/XYZ
|
||||
requests share one download/decode, with independent caller cancellation. Parsed road views have a separate 24 MiB/64-entry cap; incomplete
|
||||
views are not retained as complete snapshots. Buffer geometry is clipped and sub-12 m line slivers dropped; tile
|
||||
road fragments are not stitched. Roads preserve bends and insert
|
||||
waypoints at most 150 m apart, splitting paths at 80 vertices. A cancellable
|
||||
preparation pass paints decoded tiles independently. Explicit enable starts
|
||||
acquisition immediately; camera gestures retain the 320 ms settle debounce,
|
||||
but moveEnd starts the final view immediately. Detail acquisition runs alongside
|
||||
the major preview, using the same bounded tile cache. Once dots exist they stay
|
||||
alive until the detail snapshot replaces them, avoiding repeated preview picks.
|
||||
Status/flow and roads start
|
||||
concurrently; neither TileJSON repeat reads nor flow impose a paint barrier.
|
||||
Only dot-budget-admitted roads crossing the canvas (100 px margin), within
|
||||
the bounded reticle range, receive surface work. Near and upper-screen roads
|
||||
share the height-station budget, with fewer dots on distant roads.
|
||||
Roads split into deterministic chunks no longer than 600 m, preserving every
|
||||
bend while keeping dot budgets on nearby street sections. Height stations
|
||||
are at most 150 m apart along the road, with heights
|
||||
interpolated over the preserved intervening bends. On photoreal maps, surface
|
||||
preparation first reuses rendered depth. Up to four height/projection iterations
|
||||
must converge within 2 m of the road coordinate; foreground hits, invalid heights,
|
||||
off-canvas points and translucent-depth picking fall back to the existing mesh
|
||||
sampler. Depth and settled-height certification require a completed render of
|
||||
the current camera and surface. This avoids redrawing a pick frustum for every visible station. Preparation yields after a 12 ms slice (an individual synchronous Cesium pick
|
||||
cannot be preempted). Validated coordinate heights use a per-scene 40,000-point
|
||||
LRU across loads. The LRU belongs to the surface it sampled (the terrain
|
||||
provider, or the set of visible 3D tilesets), so a map-provider switch
|
||||
drops it. Each height records its detail band (log2 of the camera-to-point
|
||||
distance in 100 m steps); a point seen from a closer band is re-sampled
|
||||
against the finer mesh, and a failed finer sample keeps the coarser one.
|
||||
Revisits at the same or a coarser band sample nothing. Provisional shared-floor estimates remain hidden until a
|
||||
local rendered-mesh/terrain sample resolves; a globally loading tileset does
|
||||
not block streets whose mesh is already present. Non-finite/out-of-band
|
||||
(+/-9000 m) samples defer their road and show a local-surface status, never
|
||||
an OpenFreeMap failure. Samples floor on cached ground/visible terrain.
|
||||
Samples acquired during refinement are revalidated when the visible mesh settles.
|
||||
Shared floor cells are read only; raw mesh samples never enter their cache.
|
||||
Roads retain their dot populations by source, geometry and travel direction.
|
||||
Partial tiles add roads without clearing the point collection; full source
|
||||
snapshots replace the accumulated graph in every road mode; completed
|
||||
snapshots retire only roads that disappeared. Density changes apply at most
|
||||
768 additions/removals per animation frame, with 180 ms growth/fades. The
|
||||
total cap stays 6,000 at street level and scales by (1,000 / altitude)^2
|
||||
above 1 km (about 667 at 3 km, minimum 400). Surface admission shares that
|
||||
budget, plus a deterministic 1,000-unique-height-station ceiling, nearest roads
|
||||
first (`surfaceBudgetOmitted` reports roads outside that ceiling). Below 4.5 km the wide pass prepares a 32-road preview before the
|
||||
complete detail pass; previews never wait/retry missing mesh or retire existing
|
||||
roads. Full coarse coverage remains available above that band.
|
||||
Flow matching is memoized for admitted roads within each camera load. Surviving
|
||||
dots keep their identity, road, direction and progress. Flow updates change
|
||||
color, speed and closures in place. Dots use normal depth testing, including
|
||||
jammed traffic, so buildings occlude roads behind them. Surface preparation is reused within a
|
||||
camera load. A local height estimate selects the provisional viewport in
|
||||
elevated cities; measured preview heights and rendered center depth refresh
|
||||
it before detail selection. It never admits unmeasured dots. Spacing uses
|
||||
height above that local surface, retaining street density at high elevations; later heights ease into separate retained waypoint arrays,
|
||||
bounded to 12 projected pixels per waypoint per frame near the canvas with reused
|
||||
scratch objects. Visible waypoints keep that limit even when a trial step leaves
|
||||
the canvas; unsafe near-plane steps shrink before publication. Off-screen corrections use bounded world-space easing and do not
|
||||
hold loading open. Road acquisition and body reads have cancellable deadlines. Queued tile paints
|
||||
and terrain callbacks share pass cancellation; expired passes cannot publish,
|
||||
while completed coarse roads remain available. The traffic status chip polls
|
||||
busy work every 100 ms and completes without a split-flap delay; idle and global
|
||||
status reads keep their 500 ms cadence. Refinement is one bounded pass, never a
|
||||
publish loop. That pass replaces early cached mesh heights even in the same
|
||||
distance band: tilesets can briefly report loaded before finer destination
|
||||
tiles begin streaming. Ordinary revisits still reuse validated cached heights.
|
||||
At a road endpoint the departing dot fades before a new simulated vehicle
|
||||
identity enters at the other end, preserving one-way travel. Detection uses
|
||||
those stable identities rather than collection slots. `visitMotionDots`
|
||||
exposes identity, road identity, primitive and source classification for the motion gate.
|
||||
`scripts/qa-traffic-motion.mjs` records timestamped CDP frames and checks
|
||||
cold/warm still-camera holds in all three road modes; `--baseline` also
|
||||
measures rebuild churn in older runtimes using primitive object identities.
|
||||
`--journey` adds the South Congress/downtown/Camp Mabry route, layer toggles,
|
||||
Capitol, greenbelt, commercial parking and corridor holds, with a 1.5-second
|
||||
steady-state limit including loading and population changes. It checks every
|
||||
shown dot against the allowed source classes and records phase timings.
|
||||
Positions and segment distances are precomputed, with no new animation-loop
|
||||
allocation. Visible-globe roads use cached terrain without offscreen mesh picks. The road-source label names the geometry being drawn, with partial/unavailable states shown plainly.
|
||||
- TileJSON caches successful metadata. Transient failures retry after a
|
||||
five-second cooldown; invalid metadata/origins stay unavailable until the
|
||||
source is cleared. Clear resets metadata and cancels pending requests.
|
||||
Every failed read cancels its body and aborts its owned request, including
|
||||
declared and streaming byte-cap failures.
|
||||
- Development captures opened with `?trafficDebug=1` mint an interaction anchor
|
||||
from the exact `camera.changed` event that arms each debounced load, then emit
|
||||
scheduling-correlated User Timing entries for production `response.json`, road
|
||||
parse, flow-race, dot construction, heat-line rebuild, and next-post-render
|
||||
parse, dot construction, heat-line rebuild, and next-post-render
|
||||
boundaries. Every trace is paired with the exact camera-change that scheduled
|
||||
its load; mismatches are counted drops. Cesium's `moveEnd` remains a diagnostic
|
||||
mark only: it arrives about 500 ms after stillness, typically after fetch has
|
||||
@@ -4184,11 +4568,11 @@ executable-path lookup before testing or passing the path to Chrome. Supported N
|
||||
|
||||
### Traffic city navigation
|
||||
|
||||
Traffic checks the final camera view on arrival and retries a failed road request
|
||||
while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road
|
||||
requests report an unavailable source. Leaving the traffic altitude range or
|
||||
disabling the layer cancels pending work; superseded road and flow requests cannot
|
||||
release the current request or keep its loading indicator active.
|
||||
Traffic checks the final camera view on arrival. Retryable road failures get
|
||||
at most three parked retries with 1.5–30 second backoff; permanent capability
|
||||
misses stop both the retry timer and enable-time kick. New views can try again.
|
||||
Leaving the traffic altitude range or disabling cancels outstanding work.
|
||||
Superseded road/flow responses cannot publish or refill cleared caches.
|
||||
|
||||
### Optional frame-rate readout
|
||||
|
||||
@@ -4420,3 +4804,55 @@ Desktop wind now allows 7,200 baked native GPU paths (narrow viewports remain at
|
||||
Temperature uses stronger fixed −40..50°C colors; the underlying 1° forecast and
|
||||
numeric inspection values are unchanged. No volumetric cloud height or local rain
|
||||
arrival prediction is claimed. NOAA source limits are documented in DATA_SOURCES.
|
||||
|
||||
|
||||
Traffic source refinements: ALPR admits z9-z12 detail only when the view fits 16 tiles,
|
||||
returning explicit zoom guidance before I/O and allowing the next smaller view to
|
||||
load normally. Outside the US/Canada extracts the row reads “No ALPR data for this
|
||||
area — US and Canada only” and suppresses the nearby count. Military viewport
|
||||
membership checks every merged footprint. Different source identities require
|
||||
topological contact; identity aliases are zoom-scoped so coarse associations cannot join separated
|
||||
detailed parcels.
|
||||
|
||||
Country outlines use the lazy Natural Earth admin-0 pack. UK constituent
|
||||
countries use their own map-unit geometry; parent-country qualifiers stay separate
|
||||
from target aliases. The pack includes substantial
|
||||
islands. Names and aliases resolve offline; ambiguous Georgia follows the
|
||||
camera or an explicit country qualifier. Geocoded country boundaries require
|
||||
both a matching name and containment of the geocoded point.
|
||||
|
||||
Installation camera-load keys belong to their scheduled view; an older aborted
|
||||
request cannot clear a newer view’s pending key.
|
||||
|
||||
Name acquisition has a ten-second deadline and retry cooldown. Close views publish
|
||||
polygons immediately and enrich only the current view when names arrive.
|
||||
Military names load lazily from the separately licensed Overture/OSM pack when
|
||||
installation context needs them. Typed OSM ids join polygon fragments before
|
||||
merging; canonical group identity survives partial views independently of the
|
||||
largest named member supplying the title. Enrichment reconciles canonical ids
|
||||
to the named parent across zooms, migrating selection, context and marker ownership
|
||||
through explicit aliases. Geometry-union evidence stays zoom-scoped. Parent names and other member names
|
||||
remain available across same-zoom pans. Wide views use a viewport-culled, area-ranked grid of at most 512
|
||||
point primitives and 24 decluttered titles. Wide-point and polygon titles share
|
||||
one world-overlay source using the same ambient-card entries, paint lane and persistent
|
||||
label arbitration as datacenters and dams, with a 24-title source ceiling.
|
||||
Camera changes reproject existing markers and titles without source loads or
|
||||
per-frame allocation. Selection promotes the record’s own entry to a selected card;
|
||||
clicking empty ground restores its ambient title without a second readout. Selected
|
||||
footprints receive a 22% outline-colored classification fill on Google 3D tiles or
|
||||
the active terrain, removed on deselect/disable and rebound on map-source changes. Labels follow the host’s Clean-UI behavior; disable/destroy
|
||||
clears the source and removes marker listeners. Named point ids carry into polygon
|
||||
views; wide named points remain until their close polygons publish. Unnamed
|
||||
polygons remain “Military area”.
|
||||
|
||||
Tile sources accept a separate `tileFetchImpl` for public vector tiles; API
|
||||
requests retain `fetchImpl`. The exported Overpass ALPR source uses `/api/overpass`;
|
||||
the standalone layer defaults to the hourly tile source.
|
||||
|
||||
OSM-derived layers share one OpenStreetMap data attribution entry. Displayed
|
||||
OSM data retains a short inline OpenStreetMap credit, plus OpenMapTiles while
|
||||
OpenFreeMap tiles are used; its display follows the existing map-credit rules. Routing, Warendorf cameras,
|
||||
datacenters, dams and the Nepal locator retain that shared credit while displayed.
|
||||
|
||||
Military fragments stitch by source identity. Different mapped parcels merge only
|
||||
when their footprints touch or contain one another, never across a seam tolerance gap.
|
||||
@@ -35,6 +35,8 @@
|
||||
"doctor": "node scripts/setup-doctor.mjs",
|
||||
"format": "node scripts/format.mjs --write",
|
||||
"format:check": "node scripts/format.mjs --check",
|
||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||
"dev": "vite",
|
||||
"dev:secure": "./scripts/dev-secure.sh",
|
||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the bundled administrative-boundary packs read by
|
||||
* `src/data/adminBoundaries.js`:
|
||||
*
|
||||
* src/data/local_data/natural_earth/states_provinces.json
|
||||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||||
* src/data/local_data/us_census_counties/counties.json
|
||||
* US Census Bureau cartographic boundary counties (public domain).
|
||||
*
|
||||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||||
* byte for byte. Downloads are cached outside the repository (default: the
|
||||
* system temp directory) and are never committed.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||||
*
|
||||
* The provenance READMEs beside each pack record these parameters.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
import { normalizeAdminName } from '../src/data/adminBoundaries.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const NE_COMMIT = 'ca96624a56bd078437bca8184e78163e5039ad19';
|
||||
const NE_BASE = `https://raw.githubusercontent.com/nvkelso/natural-earth-vector/${NE_COMMIT}/geojson`;
|
||||
|
||||
export const SOURCES = Object.freeze({
|
||||
countries: {
|
||||
url: `${NE_BASE}/ne_10m_admin_0_countries.geojson`,
|
||||
sha256: '239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255',
|
||||
},
|
||||
mapUnits: {
|
||||
url: `${NE_BASE}/ne_10m_admin_0_map_units.geojson`,
|
||||
sha256: '57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862',
|
||||
},
|
||||
admin1: {
|
||||
url: `${NE_BASE}/ne_10m_admin_1_states_provinces.geojson`,
|
||||
sha256: '22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5',
|
||||
},
|
||||
places: {
|
||||
url: `${NE_BASE}/ne_10m_populated_places_simple.geojson`,
|
||||
sha256: 'fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f',
|
||||
},
|
||||
counties: {
|
||||
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip',
|
||||
sha256: 'faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pack parameters. The Douglas-Peucker tolerance scales with the unit's size
|
||||
* (`toleranceFactor` × √area, clamped to `[minToleranceDeg, maxToleranceDeg]`)
|
||||
* because a unit is viewed at a zoom that fits it: Texas keeps ~2 km detail,
|
||||
* Bavaria ~1 km, Travis County ~150 m. Parts smaller than `minPartKm2` or than
|
||||
* `minPartShare` of the largest part are dropped (the largest part always
|
||||
* stays); holes under `minHoleKm2` are dropped.
|
||||
*/
|
||||
export const PARAMS = Object.freeze({
|
||||
countries: {
|
||||
toleranceFactor: 0.004,
|
||||
minToleranceDeg: 0.005,
|
||||
maxToleranceDeg: 0.02,
|
||||
decimals: 3,
|
||||
minPartKm2: 20,
|
||||
minPartShare: 0.00003,
|
||||
minHoleKm2: 20,
|
||||
},
|
||||
admin1: {
|
||||
toleranceFactor: 0.004,
|
||||
minToleranceDeg: 0.005,
|
||||
maxToleranceDeg: 0.02,
|
||||
decimals: 3,
|
||||
minPartKm2: 20,
|
||||
minPartShare: 0.0003,
|
||||
minHoleKm2: 20,
|
||||
},
|
||||
counties: {
|
||||
toleranceFactor: 0.003,
|
||||
minToleranceDeg: 0.0005,
|
||||
maxToleranceDeg: 0.01,
|
||||
decimals: 4,
|
||||
minPartKm2: 1,
|
||||
minPartShare: 0.001,
|
||||
minHoleKm2: 1,
|
||||
},
|
||||
// A bare admin-1 name is marked ambiguous — the resolver then lets a
|
||||
// geocoder decide — when it is also a country, the name of a city of at
|
||||
// least `insideMinPopulation` inside the unit's box ("New York", "Madrid"),
|
||||
// or the name of a city of at least `elsewhereMinPopulation` outside it
|
||||
// ("Santa Fe", "Victoria"). A unit no larger than `cityStateMaxKm2` whose
|
||||
// own city lies inside is the city itself (Berlin, Vienna, Paris): it is
|
||||
// marked `cityState` and may be named alone however small.
|
||||
ambiguity: {
|
||||
insideMinPopulation: 200_000,
|
||||
elsewhereMinPopulation: 50_000,
|
||||
cityStateMaxKm2: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cacheDir = path.resolve(
|
||||
args.includes('--cache')
|
||||
? args[args.indexOf('--cache') + 1]
|
||||
: path.join(os.tmpdir(), 'gev-admin-packs'),
|
||||
);
|
||||
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
||||
|
||||
async function fetchPinned(name) {
|
||||
const { url, sha256 } = SOURCES[name];
|
||||
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
||||
let bytes = await readFile(file).catch(() => null);
|
||||
if (!bytes) {
|
||||
console.log(`Downloading ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
bytes = Buffer.from(await response.arrayBuffer());
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(file, bytes);
|
||||
}
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
if (digest !== sha256)
|
||||
throw new Error(
|
||||
`${name}: SHA-256 ${digest} does not match the pinned ${sha256}`,
|
||||
);
|
||||
return { bytes, digest };
|
||||
}
|
||||
|
||||
// ── geometry ────────────────────────────────────────────────────────────
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
|
||||
function signedArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
|
||||
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
function segDist(p, a, b) {
|
||||
let [x, y] = p;
|
||||
const [x1, y1] = a;
|
||||
const [x2, y2] = b;
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
||||
if (t > 1) {
|
||||
x -= x2;
|
||||
y -= y2;
|
||||
return Math.hypot(x, y);
|
||||
}
|
||||
if (t > 0) {
|
||||
x -= x1 + dx * t;
|
||||
y -= y1 + dy * t;
|
||||
return Math.hypot(x, y);
|
||||
}
|
||||
}
|
||||
return Math.hypot(x - x1, y - y1);
|
||||
}
|
||||
|
||||
/** Iterative Douglas-Peucker over an open point list. */
|
||||
function douglasPeucker(points, tolerance) {
|
||||
if (points.length <= 2) return points.slice();
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop();
|
||||
let maxDist = 0;
|
||||
let index = -1;
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDist(points[i], points[first], points[last]);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (maxDist > tolerance && index !== -1) {
|
||||
keep[index] = 1;
|
||||
stack.push([first, index], [index, last]);
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
|
||||
* farthest vertex from the start so Douglas-Peucker has two anchored halves
|
||||
* (a closed ring has no chord to measure against). Returns null when fewer
|
||||
* than four distinct vertices survive.
|
||||
*/
|
||||
function simplifyRing(ring, tolerance, decimals) {
|
||||
let open = ring.slice();
|
||||
const first = open[0];
|
||||
const last = open[open.length - 1];
|
||||
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
|
||||
open = open.slice(0, -1);
|
||||
if (open.length < 4) return null;
|
||||
let far = 1;
|
||||
let farDist = -1;
|
||||
for (let i = 1; i < open.length; i++) {
|
||||
const d = Math.hypot(open[i][0] - first[0], open[i][1] - first[1]);
|
||||
if (d > farDist) {
|
||||
farDist = d;
|
||||
far = i;
|
||||
}
|
||||
}
|
||||
const a = douglasPeucker(open.slice(0, far + 1), tolerance);
|
||||
const b = douglasPeucker([...open.slice(far), first], tolerance);
|
||||
const simplified = [...a, ...b.slice(1, -1)];
|
||||
const factor = 10 ** decimals;
|
||||
const out = [];
|
||||
for (const [lon, lat] of simplified) {
|
||||
const p = [
|
||||
Math.round(lon * factor) / factor,
|
||||
Math.round(lat * factor) / factor,
|
||||
];
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && prev[0] === p[0] && prev[1] === p[1]) continue;
|
||||
out.push(p);
|
||||
}
|
||||
while (
|
||||
out.length > 1 &&
|
||||
out[0][0] === out[out.length - 1][0] &&
|
||||
out[0][1] === out[out.length - 1][1]
|
||||
)
|
||||
out.pop();
|
||||
return out.length >= 4 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplify polygons ([outer, ...holes][]): drop parts and holes under the
|
||||
* area floors (the largest part always stays), orient outers
|
||||
* counter-clockwise and holes clockwise, sort parts by area descending.
|
||||
*/
|
||||
function simplifyPolygons(polygons, params, tolerance, decimals) {
|
||||
const parts = [];
|
||||
for (const poly of polygons) {
|
||||
const outer = simplifyRing(poly[0], tolerance, decimals);
|
||||
if (!outer) continue;
|
||||
const area = ringAreaKm2(outer);
|
||||
if (signedArea(outer) < 0) outer.reverse();
|
||||
const holes = [];
|
||||
for (const hole of poly.slice(1)) {
|
||||
const ring = simplifyRing(hole, tolerance, decimals);
|
||||
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
|
||||
if (signedArea(ring) > 0) ring.reverse();
|
||||
holes.push(ring);
|
||||
}
|
||||
parts.push({ rings: [outer, ...holes], area });
|
||||
}
|
||||
parts.sort((x, y) => y.area - x.area);
|
||||
const floor = Math.max(
|
||||
params.minPartKm2,
|
||||
(parts[0]?.area || 0) * params.minPartShare,
|
||||
);
|
||||
return parts
|
||||
.filter((part, index) => index === 0 || part.area >= floor)
|
||||
.map((part) => part.rings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplify one unit at its size-scaled tolerance. A unit too small to
|
||||
* survive the pack precision (a Maldives atoll) is kept at one more decimal
|
||||
* and a fifth of the minimum tolerance, or failing that unsimplified at two
|
||||
* more decimals; the caller records `d`.
|
||||
*/
|
||||
function simplifyUnit(polygons, params) {
|
||||
const sourceArea = polygons.reduce(
|
||||
(sum, poly) => sum + ringAreaKm2(poly[0]),
|
||||
0,
|
||||
);
|
||||
const tolerance = Math.min(
|
||||
params.maxToleranceDeg,
|
||||
Math.max(
|
||||
params.minToleranceDeg,
|
||||
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
|
||||
),
|
||||
);
|
||||
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
|
||||
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
|
||||
const fine = simplifyPolygons(
|
||||
polygons,
|
||||
params,
|
||||
params.minToleranceDeg / 5,
|
||||
params.decimals + 1,
|
||||
);
|
||||
if (fine.length) return { polygons: fine, decimals: params.decimals + 1 };
|
||||
// Smaller still (Vatican City): the source ring as drawn.
|
||||
const exact = simplifyPolygons(polygons, params, 0, params.decimals + 2);
|
||||
return { polygons: exact, decimals: params.decimals + 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a rounded open ring as integers in units of 10^-decimals degrees:
|
||||
* the first vertex absolute, every later vertex as a delta from the previous
|
||||
* one — [lon0, lat0, dLon1, dLat1, …]. Decoded by `decodeRing` in
|
||||
* src/data/adminBoundaries.js.
|
||||
*/
|
||||
function encodeRing(ring, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const out = [];
|
||||
let px = 0;
|
||||
let py = 0;
|
||||
for (const [lon, lat] of ring) {
|
||||
const x = Math.round(lon * factor);
|
||||
const y = Math.round(lat * factor);
|
||||
out.push(x - px, y - py);
|
||||
px = x;
|
||||
py = y;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeUnit({ polygons, decimals }) {
|
||||
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
|
||||
}
|
||||
|
||||
function geojsonPolygons(geometry) {
|
||||
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||||
if (geometry?.type === 'MultiPolygon') return geometry.coordinates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function bboxOf(polygons) {
|
||||
let w = Infinity;
|
||||
let s = Infinity;
|
||||
let e = -Infinity;
|
||||
let n = -Infinity;
|
||||
for (const poly of polygons)
|
||||
for (const [lon, lat] of poly[0]) {
|
||||
if (lon < w) w = lon;
|
||||
if (lon > e) e = lon;
|
||||
if (lat < s) s = lat;
|
||||
if (lat > n) n = lat;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
// ── zip / shapefile / dbf ───────────────────────────────────────────────
|
||||
|
||||
/** Read named entries from a zip archive (stored or deflated). */
|
||||
function unzip(buffer) {
|
||||
let eocd = buffer.length - 22;
|
||||
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
|
||||
if (eocd < 0) throw new Error('zip: end of central directory not found');
|
||||
const count = buffer.readUInt16LE(eocd + 10);
|
||||
let offset = buffer.readUInt32LE(eocd + 16);
|
||||
const files = new Map();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buffer.readUInt32LE(offset) !== 0x02014b50)
|
||||
throw new Error('zip: bad central directory entry');
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressed = buffer.readUInt32LE(offset + 20);
|
||||
const nameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
const local = buffer.readUInt32LE(offset + 42);
|
||||
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
|
||||
const dataStart =
|
||||
local +
|
||||
30 +
|
||||
buffer.readUInt16LE(local + 26) +
|
||||
buffer.readUInt16LE(local + 28);
|
||||
const data = buffer.subarray(dataStart, dataStart + compressed);
|
||||
if (method === 0) files.set(name, data);
|
||||
else if (method === 8) files.set(name, inflateRawSync(data));
|
||||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
|
||||
function readShp(buffer) {
|
||||
const shapes = [];
|
||||
let offset = 100;
|
||||
while (offset < buffer.length) {
|
||||
const length = buffer.readInt32BE(offset + 4) * 2;
|
||||
const content = offset + 8;
|
||||
const type = buffer.readInt32LE(content);
|
||||
const rings = [];
|
||||
if (type === 5) {
|
||||
const numParts = buffer.readInt32LE(content + 36);
|
||||
const numPoints = buffer.readInt32LE(content + 40);
|
||||
const partsAt = content + 44;
|
||||
const pointsAt = partsAt + numParts * 4;
|
||||
for (let p = 0; p < numParts; p++) {
|
||||
const start = buffer.readInt32LE(partsAt + p * 4);
|
||||
const end =
|
||||
p + 1 < numParts
|
||||
? buffer.readInt32LE(partsAt + (p + 1) * 4)
|
||||
: numPoints;
|
||||
const ring = [];
|
||||
for (let i = start; i < end; i++)
|
||||
ring.push([
|
||||
buffer.readDoubleLE(pointsAt + i * 16),
|
||||
buffer.readDoubleLE(pointsAt + i * 16 + 8),
|
||||
]);
|
||||
rings.push(ring);
|
||||
}
|
||||
} else if (type !== 0) {
|
||||
throw new Error(`shp: unsupported shape type ${type}`);
|
||||
}
|
||||
shapes.push(rings);
|
||||
offset = content + length;
|
||||
}
|
||||
return shapes;
|
||||
}
|
||||
|
||||
/** dBASE records as plain objects (character and numeric fields). */
|
||||
function readDbf(buffer) {
|
||||
const count = buffer.readUInt32LE(4);
|
||||
const headerLength = buffer.readUInt16LE(8);
|
||||
const recordLength = buffer.readUInt16LE(10);
|
||||
const fields = [];
|
||||
for (let at = 32; buffer[at] !== 0x0d; at += 32) {
|
||||
fields.push({
|
||||
name: buffer.toString('latin1', at, at + 11).replace(/\0.*$/, ''),
|
||||
length: buffer[at + 16],
|
||||
});
|
||||
}
|
||||
const records = [];
|
||||
for (let r = 0; r < count; r++) {
|
||||
let at = headerLength + r * recordLength + 1;
|
||||
const record = {};
|
||||
for (const field of fields) {
|
||||
record[field.name] = buffer
|
||||
.toString('utf8', at, at + field.length)
|
||||
.trim();
|
||||
at += field.length;
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
|
||||
function shpPolygons(rings) {
|
||||
const outers = [];
|
||||
const holes = [];
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 4) continue;
|
||||
(signedArea(ring) < 0 ? outers : holes).push(ring);
|
||||
}
|
||||
const polygons = outers.map((outer) => [outer]);
|
||||
for (const hole of holes) {
|
||||
const [x, y] = hole[0];
|
||||
const owner = polygons.find(([outer]) => pointInRing(outer, x, y));
|
||||
if (owner) owner.push(hole);
|
||||
else polygons.push([hole.slice().reverse()]);
|
||||
}
|
||||
return polygons;
|
||||
}
|
||||
|
||||
function pointInRing(ring, x, y) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// ── packs ────────────────────────────────────────────────────────────────
|
||||
|
||||
const LATIN = /^[\p{Script=Latin}\p{N}\s.,'’()\-/&]+$/u;
|
||||
|
||||
function uniqueNames(values, exclude) {
|
||||
const seen = new Set(exclude.map(normalizeAdminName));
|
||||
const out = [];
|
||||
for (const value of values) {
|
||||
const text = String(value || '').trim();
|
||||
const key = normalizeAdminName(text);
|
||||
if (!text || !key || seen.has(key) || !LATIN.test(text)) continue;
|
||||
seen.add(key);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildCountries() {
|
||||
const source = await fetchPinned('countries');
|
||||
const mapUnits = await fetchPinned('mapUnits');
|
||||
const params = PARAMS.countries;
|
||||
const features = [];
|
||||
for (const { properties: p, geometry } of JSON.parse(source.bytes).features) {
|
||||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||||
if (!unit.polygons.length) continue;
|
||||
const name = p.ADMIN || p.NAME_EN || p.NAME;
|
||||
features.push({
|
||||
name,
|
||||
alt: uniqueNames(
|
||||
[p.NAME, p.NAME_LONG, p.NAME_EN, p.FORMAL_EN, p.BRK_NAME].filter(
|
||||
Boolean,
|
||||
),
|
||||
[name],
|
||||
),
|
||||
country: name,
|
||||
iso: p.ADM0_A3,
|
||||
iso2: p.ISO_A2_EH || p.ISO_A2,
|
||||
type: 'Country',
|
||||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||||
polygons: encodeUnit(unit),
|
||||
});
|
||||
}
|
||||
for (const { properties: p, geometry } of JSON.parse(mapUnits.bytes)
|
||||
.features) {
|
||||
if (p.ADM0_A3 !== 'GBR') continue;
|
||||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||||
features.push({
|
||||
name: p.GEOUNIT,
|
||||
country: p.ADMIN,
|
||||
iso: p.GU_A3,
|
||||
iso2: 'GB',
|
||||
type: 'Constituent country',
|
||||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||||
polygons: encodeUnit(unit),
|
||||
});
|
||||
}
|
||||
features.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth admin-0 countries',
|
||||
mapUnits: SOURCES.mapUnits,
|
||||
source: 'ne_10m_admin_0_countries',
|
||||
url: SOURCES.countries.url,
|
||||
sha256: source.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAdmin1() {
|
||||
const params = PARAMS.admin1;
|
||||
const source = await fetchPinned('admin1');
|
||||
const places = await fetchPinned('places');
|
||||
const collection = JSON.parse(source.bytes.toString('utf8'));
|
||||
const { ambiguity } = PARAMS;
|
||||
const cities = JSON.parse(places.bytes.toString('utf8'))
|
||||
.features.map((f) => f.properties)
|
||||
.filter((p) => p.pop_max >= ambiguity.elsewhereMinPopulation)
|
||||
.map((p) => ({
|
||||
keys: new Set([p.name, p.nameascii].map(normalizeAdminName)),
|
||||
lon: p.longitude,
|
||||
lat: p.latitude,
|
||||
population: p.pop_max,
|
||||
country: p.adm0name,
|
||||
}));
|
||||
const countryNames = new Set();
|
||||
for (const { properties: p } of collection.features)
|
||||
for (const name of [p.admin, p.geonunit])
|
||||
if (name) countryNames.add(normalizeAdminName(name));
|
||||
|
||||
const features = [];
|
||||
for (const { properties: p, geometry } of collection.features) {
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) continue;
|
||||
const unit = simplifyUnit(geojsonPolygons(geometry), params);
|
||||
if (!unit.polygons.length) continue;
|
||||
const nameEn =
|
||||
p.name_en && normalizeAdminName(p.name_en) !== normalizeAdminName(name)
|
||||
? String(p.name_en).trim()
|
||||
: null;
|
||||
const alt = uniqueNames(
|
||||
String(p.name_alt || '').split('|'),
|
||||
[name, nameEn].filter(Boolean),
|
||||
);
|
||||
const bbox = bboxOf(unit.polygons);
|
||||
const areaKm2 = unit.polygons.reduce(
|
||||
(sum, poly) => sum + ringAreaKm2(poly[0]),
|
||||
0,
|
||||
);
|
||||
const keys = new Set(
|
||||
[name, nameEn, ...alt].filter(Boolean).map(normalizeAdminName),
|
||||
);
|
||||
const amb = [];
|
||||
const ambAbroad = [];
|
||||
let cityState = false;
|
||||
const inBox = (c) =>
|
||||
c.lon >= bbox[0] &&
|
||||
c.lon <= bbox[2] &&
|
||||
c.lat >= bbox[1] &&
|
||||
c.lat <= bbox[3];
|
||||
for (const key of keys) {
|
||||
const namesakes = cities.filter((c) => c.keys.has(key));
|
||||
const ownCity = namesakes.some(
|
||||
(c) => inBox(c) && c.population >= ambiguity.insideMinPopulation,
|
||||
);
|
||||
const cityInside = ownCity && areaKm2 > ambiguity.cityStateMaxKm2;
|
||||
if (ownCity && !cityInside) cityState = true;
|
||||
const elsewhere = namesakes.filter((c) => !inBox(c));
|
||||
if (
|
||||
countryNames.has(key) ||
|
||||
cityInside ||
|
||||
elsewhere.some((c) => c.country === p.admin)
|
||||
)
|
||||
amb.push(key);
|
||||
// Only abroad: "Victoria, Australia" settles it; "Victoria" alone not.
|
||||
else if (elsewhere.length) ambAbroad.push(key);
|
||||
}
|
||||
const postal = String(p.postal || '').trim();
|
||||
features.push({
|
||||
name,
|
||||
...(nameEn ? { nameEn } : {}),
|
||||
...(alt.length ? { alt } : {}),
|
||||
...(/^[A-Z]{2}$/.test(postal) ? { postal } : {}),
|
||||
type: p.type_en || p.type || null,
|
||||
...(Number.isFinite(p.min_label) ? { rank: p.min_label } : {}),
|
||||
country: p.admin,
|
||||
iso2: p.iso_a2 && p.iso_a2 !== '-99' ? p.iso_a2 : null,
|
||||
iso: p.iso_3166_2 && !p.iso_3166_2.includes('~') ? p.iso_3166_2 : null,
|
||||
...(amb.length ? { amb: amb.sort() } : {}),
|
||||
...(ambAbroad.length ? { ambAbroad: ambAbroad.sort() } : {}),
|
||||
...(cityState ? { cityState } : {}),
|
||||
...(Number.isFinite(p.longitude) && Number.isFinite(p.latitude)
|
||||
? {
|
||||
label: [
|
||||
Math.round(p.longitude * 1000) / 1000,
|
||||
Math.round(p.latitude * 1000) / 1000,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||||
polygons: encodeUnit(unit),
|
||||
});
|
||||
}
|
||||
features.sort(
|
||||
(a, b) =>
|
||||
a.country.localeCompare(b.country) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
(a.iso || '').localeCompare(b.iso || ''),
|
||||
);
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth admin-1 states and provinces',
|
||||
source: 'ne_10m_admin_1_states_provinces',
|
||||
url: SOURCES.admin1.url,
|
||||
sha256: source.digest,
|
||||
ambiguitySource: SOURCES.places.url,
|
||||
ambiguitySha256: places.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
schema:
|
||||
'features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
|
||||
decimals: params.decimals,
|
||||
curation: { ...params, ambiguity },
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCounties() {
|
||||
const params = PARAMS.counties;
|
||||
const source = await fetchPinned('counties');
|
||||
const files = unzip(source.bytes);
|
||||
const base = path.basename(new URL(SOURCES.counties.url).pathname, '.zip');
|
||||
const shapes = readShp(files.get(`${base}.shp`));
|
||||
const records = readDbf(files.get(`${base}.dbf`));
|
||||
if (shapes.length !== records.length)
|
||||
throw new Error('counties: shp/dbf record counts differ');
|
||||
const features = [];
|
||||
records.forEach((record, index) => {
|
||||
const unit = simplifyUnit(shpPolygons(shapes[index]), params);
|
||||
if (!unit.polygons.length) return;
|
||||
const lsad = record.NAMELSAD.startsWith(record.NAME)
|
||||
? record.NAMELSAD.slice(record.NAME.length).trim()
|
||||
: '';
|
||||
features.push({
|
||||
name: record.NAME,
|
||||
full: record.NAMELSAD,
|
||||
...(lsad ? { lsad } : {}),
|
||||
state: record.STATE_NAME,
|
||||
st: record.STUSPS,
|
||||
geoid: record.GEOID,
|
||||
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
|
||||
polygons: encodeUnit(unit),
|
||||
});
|
||||
});
|
||||
features.sort((a, b) => a.geoid.localeCompare(b.geoid));
|
||||
return {
|
||||
meta: {
|
||||
title: 'US Census Bureau cartographic boundary counties',
|
||||
source: base,
|
||||
url: SOURCES.counties.url,
|
||||
sha256: source.digest,
|
||||
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
|
||||
schema:
|
||||
'features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
async function writePack(relative, pack) {
|
||||
const file = path.join(ROOT, relative);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const text = JSON.stringify(pack) + '\n';
|
||||
await writeFile(file, text);
|
||||
const parts = pack.features.reduce((n, f) => n + f.polygons.length, 0);
|
||||
const vertices = pack.features.reduce(
|
||||
(n, f) => n + f.polygons.flat().reduce((m, ring) => m + ring.length / 2, 0),
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`${relative}: ${pack.features.length} features, ${parts} parts, ${vertices} vertices, ${Buffer.byteLength(text)} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!only || only === 'countries')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/countries.json',
|
||||
await buildCountries(),
|
||||
);
|
||||
if (!only || only === 'admin1')
|
||||
await writePack(
|
||||
'src/data/local_data/natural_earth/states_provinces.json',
|
||||
await buildAdmin1(),
|
||||
);
|
||||
if (!only || only === 'counties')
|
||||
await writePack(
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintainer-only Overture military-name pack build (DuckDB 1.5.5)."""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RELEASE = '2026-09-23.1'
|
||||
CLASSES = ['military_land', 'airfield', 'naval_base', 'range', 'barracks', 'base', 'training_area']
|
||||
PACK = ROOT / 'src/data/local_data/osm_military_names'
|
||||
|
||||
|
||||
def assemble(rows):
|
||||
records = []
|
||||
snapshots = set()
|
||||
for row in rows:
|
||||
source = next(s for s in row['sources'] if s['dataset'] == 'OpenStreetMap')
|
||||
match = re.fullmatch(r'([nwr])(\d+)@\d+', source['record_id'])
|
||||
if not match:
|
||||
raise ValueError('Invalid typed OSM identity')
|
||||
ident = match[1] + match[2]
|
||||
bbox = [row['bbox'][k] for k in ['xmin', 'ymin', 'xmax', 'ymax']]
|
||||
lon, lat, area = row['lon'], row['lat'], row['area']
|
||||
if not all(math.isfinite(v) for v in [lon, lat, area, *bbox]):
|
||||
raise ValueError('Invalid geometry measurement')
|
||||
if not bbox[0] - 1e-7 <= lon <= bbox[2] + 1e-7 or not bbox[1] - 1e-7 <= lat <= bbox[3] + 1e-7:
|
||||
raise ValueError('Label outside source bounds')
|
||||
kind = row['military'] if row['military'] in CLASSES else 'military_land'
|
||||
records.append([ident, row['name'], round(lon, 5), round(lat, 5),
|
||||
*[round(v, 4) for v in bbox], CLASSES.index(kind), max(1, round(abs(area)))])
|
||||
snapshots.add(source['version'][:10])
|
||||
records.sort(key=lambda r: (r[0][0], int(r[0][1:])))
|
||||
if len(set(r[0] for r in records)) != len(records):
|
||||
raise ValueError('Duplicate OSM identity')
|
||||
return {'release': RELEASE, 'snapshots': sorted(snapshots), 'classes': CLASSES, 'records': records}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--extensions', type=Path)
|
||||
parser.add_argument('--verify', action='store_true')
|
||||
args = parser.parse_args()
|
||||
args.cache.mkdir(parents=True, exist_ok=True)
|
||||
cached = args.cache / ('military-names-' + RELEASE + '.json')
|
||||
if not cached.exists():
|
||||
import duckdb
|
||||
if duckdb.__version__ != '1.5.5':
|
||||
raise RuntimeError('Use duckdb==1.5.5')
|
||||
config = {'threads': '8', 'memory_limit': '3GB', 'temp_directory': str(args.cache / 'temp')}
|
||||
if args.extensions:
|
||||
config['extension_directory'] = str(args.extensions)
|
||||
connection = duckdb.connect(config=config)
|
||||
if not args.extensions:
|
||||
connection.execute('INSTALL httpfs; INSTALL spatial;')
|
||||
connection.execute('LOAD httpfs; LOAD spatial; SET geometry_always_xy=true; SET enable_progress_bar=false;')
|
||||
files = json.loads((ROOT / 'scripts/military-names-files.json').read_text())
|
||||
if not all('/' + RELEASE + '/' in url for url in files):
|
||||
raise ValueError('Unexpected source release')
|
||||
cursor = connection.execute((ROOT / 'scripts/military-names.sql').read_text(), [files])
|
||||
fields = [d[0] for d in cursor.description]
|
||||
rows = [dict(zip(fields, row)) for row in cursor.fetchall()]
|
||||
cached.write_text(json.dumps(rows, ensure_ascii=False, separators=(',', ':')))
|
||||
pack = assemble(json.loads(cached.read_text()))
|
||||
raw = json.dumps(pack, ensure_ascii=False, separators=(',', ':')).encode()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
if args.verify:
|
||||
expected = (PACK / 'names.sha256').read_text().split()[0]
|
||||
if digest != expected:
|
||||
raise ValueError(f'Output SHA-256 mismatch: {digest}')
|
||||
else:
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
(PACK / 'names.json').write_bytes(raw)
|
||||
(PACK / 'names.sha256').write_text(digest + ' names.json\n')
|
||||
print(json.dumps({'records': len(pack['records']), 'rawBytes': len(raw),
|
||||
'gzipBytes': len(gzip.compress(raw, compresslevel=9, mtime=0)), 'sha256': digest}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
parseLayerStateTokenReservations,
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from '../src/data/layerState.js';
|
||||
|
||||
// The pre-ledger main revision had exactly the 28 pinned v2 registry entries.
|
||||
// Accept only that source blob as the one-time bootstrap; any other published
|
||||
// source without a ledger must fail closed rather than risk losing a token.
|
||||
const PRE_LEDGER_LAYER_STATE_BLOB = 'e559a4c40191512dcefb83134958a3bfd4a98a69';
|
||||
// This snapshot belongs to the verified published blob above, not to the PR's
|
||||
// editable legacy map. Otherwise a candidate could redefine both its ledger
|
||||
// and its comparison baseline before the first ledger reaches main.
|
||||
export const PRE_LEDGER_LAYER_STATE_TOKENS = Object.freeze({
|
||||
'ais-live-vessels': 'a',
|
||||
'alpr-cameras': 'p',
|
||||
'bhote-koshi-2026': 'h',
|
||||
'bhote-koshi-locator': 'z',
|
||||
bikeshare: 'b',
|
||||
cctv: 'c',
|
||||
directions: 'n',
|
||||
earthquakes: 'e',
|
||||
'fire-perimeters': '2',
|
||||
flights: 'f',
|
||||
'local-dams': 'q',
|
||||
'local-datacenters': 'd',
|
||||
'local-firms': 'w',
|
||||
military: 'm',
|
||||
'military-awareness': 'g',
|
||||
'military-installations': 'i',
|
||||
radio: 'r',
|
||||
'recent-imagery': '1',
|
||||
'rocket-launches': 'x',
|
||||
satellites: 's',
|
||||
'telegeography-submarine-cables': 'u',
|
||||
traffic: 't',
|
||||
transit: 'j',
|
||||
'weather-cyclones': 'y',
|
||||
'weather-lightning': 'l',
|
||||
'weather-radar': 'v',
|
||||
'weather-satellite': 'o',
|
||||
wind: 'k',
|
||||
});
|
||||
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
|
||||
const SOURCE_PATH = 'src/data/layerState.js';
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/** Read every permanent assignment from a published Git revision. */
|
||||
export function readPublishedLayerStateReservations(
|
||||
baseRef,
|
||||
cwd = process.cwd(),
|
||||
) {
|
||||
const commit = git(
|
||||
['rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`],
|
||||
cwd,
|
||||
);
|
||||
const ledgerSpec = `${commit}:${LEDGER_PATH}`;
|
||||
let hasLedger = true;
|
||||
try {
|
||||
git(['cat-file', '-e', ledgerSpec], cwd);
|
||||
} catch {
|
||||
hasLedger = false;
|
||||
}
|
||||
if (!hasLedger) {
|
||||
const sourceBlob = git(['rev-parse', `${commit}:${SOURCE_PATH}`], cwd);
|
||||
if (sourceBlob !== PRE_LEDGER_LAYER_STATE_BLOB) {
|
||||
throw new Error(
|
||||
'Published base has no recognizable layer-state token ledger',
|
||||
);
|
||||
}
|
||||
return PRE_LEDGER_LAYER_STATE_TOKENS;
|
||||
}
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(git(['show', ledgerSpec], cwd));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read published layer-state token ledger: ${error.message}`,
|
||||
);
|
||||
}
|
||||
return parseLayerStateTokenReservations(rows);
|
||||
}
|
||||
|
||||
export function checkLayerStateTokens(baseRef, cwd = process.cwd()) {
|
||||
const base = readPublishedLayerStateReservations(baseRef, cwd);
|
||||
validateLayerStateRegistry();
|
||||
validateLayerStateAllocations(base, LAYER_STATE_TOKEN_RESERVATIONS);
|
||||
return {
|
||||
published: Object.keys(base).length,
|
||||
added:
|
||||
Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length -
|
||||
Object.keys(base).length,
|
||||
};
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: '';
|
||||
if (import.meta.url === invokedPath) {
|
||||
const args = process.argv.slice(2);
|
||||
const baseRef =
|
||||
args[0] === '--base-ref' && args.length === 2 ? args[1] : null;
|
||||
if (!baseRef || baseRef.startsWith('-')) {
|
||||
console.error('Usage: npm run layer-token:check -- --base-ref origin/main');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
try {
|
||||
const result = checkLayerStateTokens(baseRef);
|
||||
console.log(
|
||||
`Layer tokens valid against ${baseRef}: ${result.published} published, ` +
|
||||
`${result.added} new.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Layer token check failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
"scripts/format-scope.json",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
"scripts/check-layer-state-tokens.mjs",
|
||||
"scripts/qa-layer-token-twochar.mjs",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/layerStateTokenLedger.test.mjs",
|
||||
"scripts/check-package-boundaries.mjs",
|
||||
"scripts/precompute-cctv-heights.mjs",
|
||||
"src/tooling/precomputeCctvHeights.test.mjs",
|
||||
@@ -21,11 +26,17 @@
|
||||
"src/annotations/drawMode.test.mjs",
|
||||
"src/annotations/drawTool.js",
|
||||
"src/annotations/drawTool.test.mjs",
|
||||
"src/annotations/adminOutline.test.mjs",
|
||||
"src/annotations/regionRing.test.mjs",
|
||||
"src/data/adminBoundaries.test.mjs",
|
||||
"scripts/build-admin-packs.mjs",
|
||||
"scripts/qa-admin-outlines.mjs",
|
||||
"src/data/inputOwnership.test.mjs",
|
||||
"docs/CODE-BOUNDARIES.md",
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
"scripts/qa-application.mjs",
|
||||
"scripts/qa-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
@@ -576,6 +587,8 @@
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/maps/terrainRetry.test.mjs",
|
||||
"src/mapStackController.js",
|
||||
"src/mapStartup.js",
|
||||
"src/mapStackChips.test.mjs",
|
||||
@@ -840,5 +853,17 @@
|
||||
"src/ui/imageryBoxTool.test.mjs",
|
||||
"src/ui/recentImagery.test.mjs",
|
||||
"src/layers/recentImagery/testDoubles.mjs",
|
||||
"scripts/qa-recent-imagery.mjs"
|
||||
"scripts/qa-recent-imagery.mjs",
|
||||
"scripts/qa-overpass-offload.mjs",
|
||||
"src/overpassOffload.test.mjs",
|
||||
"src/sources/openFreeMap.test.mjs",
|
||||
"src/sources/vectorTiles.test.mjs",
|
||||
"src/sources/militaryTileGeometry.test.mjs",
|
||||
"src/layers/traffic/surface.test.mjs",
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs"
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
[
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00000-18dcb239-f29e-5e57-b36f-4bd46c12fb86-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00001-4a0ec645-f2e2-5fd1-aeaf-a2210290fe76-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00002-0bfb9ec4-5947-583a-9b20-5b39a6635e7d-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00003-0aa711cc-b475-5914-9670-7295640f184f-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00004-9a1af7ec-89dd-5aa1-a419-d6325719c330-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00005-e85508c1-3f80-5883-8539-35302da710f3-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00006-184886ec-67b4-5a65-852f-f26f757aaeea-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00007-adbd870a-4116-50a2-9554-57064af19a18-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00008-6362555a-8b0c-5589-a6fe-b71ddca82083-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00009-19bb41f3-6c3b-579a-adae-8f69030c37c7-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00010-0e372a9c-a60d-5b06-89e2-866989d612dd-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00011-2ae04990-7b00-5def-bf72-12b2fdc13b48-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00012-dcfe8859-26a4-544b-ad61-dfbc7e7f272a-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00013-a369ed98-5e3b-5bda-94c9-460f06e8746b-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00014-d03d3cde-6ae3-5938-abe9-b2018f7ab0e3-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00015-57a736a1-7d58-5851-877a-2fca7de484ba-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00016-141b8ad5-f6d4-5f5e-8353-450a53188e27-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00017-14133e77-297b-57f3-b028-c5133bb56578-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00018-65485e6e-8939-51ab-8566-47dd3887b6c3-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00019-64fb52b8-5ba4-5f00-860f-4fe71e1c9f3a-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00020-0feea924-6adb-5118-9a2b-e0a0d3ef6963-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00021-a9115b0c-a202-5468-bfae-0a1e6f953be3-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00022-440ee3b6-f6ae-5bf9-b5d1-cc81fcb0a430-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00023-53a4d462-62b9-589c-9650-f7c9652354e7-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00024-54089af7-32f4-5ad8-8970-cea7350cbb20-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00025-59cc63a7-e7da-58e2-8d06-1df15f3a0636-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00026-ba0e8b28-756c-5b5e-aa01-0939a15e45c8-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00027-8237ebb3-2fcb-50d7-884b-81c8e81bda1e-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00028-6fb209d3-9fbe-5155-9983-896b1bb9e84e-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00029-e86227ec-f5f7-51c6-b537-a10334796cc4-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00030-a2779b0d-003a-5f5d-812c-02eb4a5db71f-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00031-554320d6-a62d-5be7-80b0-d32fb67d87ca-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00032-a49d2756-469a-571f-86ef-be60690cfaea-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00033-0b36146b-6963-5133-8a67-ced0bbb37d6a-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00034-b676aef2-1e90-5966-8531-40f42c5d01d6-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00035-c9b04728-fc59-50f2-bfa8-5f8692ea731e-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00036-22a0fbd7-fd24-5832-ac6c-85e82b56ced6-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00037-d157ddb7-8962-515b-9ec3-d54333fcf836-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00038-39d77c44-55f2-5792-83e1-cac6416d0630-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00039-f6871cf2-753b-53f1-aacf-645ea035826d-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00040-3d142e7a-4189-5e50-9f68-0f77e9a51047-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00041-9e6d1cdd-f130-5089-9d0f-703626552100-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00042-4cefe8fe-4966-5e1e-9cf5-19fd90384db8-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00043-65360671-af63-58dd-9784-bceb07abe219-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00044-b423bff1-dd2b-52c4-90f6-cd1605e4d83f-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00045-1f4d97be-99fc-50de-b9cb-f3624c9a1817-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00046-32a9756a-e3a0-5980-a8d9-88dd475f25b9-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00047-cf80d668-e9f1-5f69-8e21-0a73f1732e5f-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00048-8d932283-4f3f-533d-895b-684faaa9cc34-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00049-996168f3-0c3a-5b58-8444-168c7d859a04-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00050-f9c3c526-3ea5-5dab-a46d-05c623c3e905-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00051-72b4d98d-4b3b-5b3b-a565-62c206d4fa29-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00052-7385fa90-2fa9-53c8-9ed4-96d5703f3a37-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00053-96804709-94fa-56d2-bafd-69b2cbce3881-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00054-520c3c50-82f8-5f3d-97b5-fa8f5f4e65e7-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00055-8742f073-69cd-579e-bf00-b16b6e6a7669-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00056-58083e97-7020-5522-ac19-d8a5b4dcbc98-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00057-bf201c73-1fb3-5dbc-8cec-d344d999820e-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00058-9e1561c3-ba15-528b-8db3-62ce04b1570b-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00059-dd8cd7ca-410f-54f8-9b50-66beb004d640-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00060-203c7177-ec4a-55c2-8003-d828b144a6d6-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00061-0da76969-7fb1-56b9-bb83-4e40f3acea8d-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00062-13344b87-db6c-5415-a48e-afd95a928a85-c000.zstd.parquet",
|
||||
"https://overturemaps-us-west-2.s3.us-west-2.amazonaws.com/release/2026-09-23.1/theme=base/type=land_use/part-00063-a61ab643-210f-518d-b6b8-894f72e5c094-c000.zstd.parquet"
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
SELECT names.primary AS name, bbox, sources,
|
||||
source_tags['military'] AS military,
|
||||
ST_X(ST_PointOnSurface(geometry)) AS lon,
|
||||
ST_Y(ST_PointOnSurface(geometry)) AS lat,
|
||||
ST_Area_Spheroid(geometry) AS area
|
||||
FROM read_parquet(?)
|
||||
WHERE subtype = 'military'
|
||||
AND nullif(trim(names.primary), '') IS NOT NULL
|
||||
AND (source_tags['landuse'] = 'military'
|
||||
OR source_tags['military'] IN
|
||||
('airfield', 'naval_base', 'range', 'barracks', 'base', 'training_area'))
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
nextLayerStateToken,
|
||||
} from '../src/data/layerState.js';
|
||||
|
||||
const layerId = String(process.argv[2] || '').trim();
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(layerId)) {
|
||||
console.error('Usage: npm run layer-token:next -- <layer-id>');
|
||||
process.exitCode = 1;
|
||||
} else if (Object.hasOwn(LAYER_STATE_TOKEN_RESERVATIONS, layerId)) {
|
||||
console.error(
|
||||
`${layerId} already owns token ${LAYER_STATE_TOKEN_RESERVATIONS[layerId]}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`${layerId}: ${nextLayerStateToken()}`);
|
||||
console.log('Order: free digits 0-9, then two-character base-36 00-zz.');
|
||||
console.log('Re-run after rebasing onto the latest main before merge.');
|
||||
}
|
||||
+153
-21
@@ -9,7 +9,8 @@
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -259,7 +260,12 @@
|
||||
"src/data/regionalModel.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
"src/sources/rateLimit.js",
|
||||
"src/data/local_data/natural_earth/marine.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -517,6 +523,7 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keySetupCore.mjs",
|
||||
@@ -624,7 +631,8 @@
|
||||
"src/ui/shellFacade.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/ui/localSdrPresentation.js"
|
||||
"src/ui/localSdrPresentation.js",
|
||||
"src/data/cameraArrival.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -728,7 +736,8 @@
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js"
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/data/cameraArrival.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -756,6 +765,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -797,6 +807,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -833,6 +844,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -969,6 +981,7 @@
|
||||
"installations-layer": {
|
||||
"exports": ["./layers/installations"],
|
||||
"modules": [
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -982,9 +995,23 @@
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
@@ -1028,18 +1055,29 @@
|
||||
"src/layers/traffic/flow.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/footprint.js",
|
||||
"src/layers/traffic/index.js",
|
||||
"src/layers/traffic/ingestion.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/layers/traffic/lifecycle.js",
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/policy.js",
|
||||
"src/layers/traffic/rendering.js",
|
||||
"src/layers/traffic/retention.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/traffic/surface.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/traffic/roadModes.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -1117,15 +1155,23 @@
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/layers/alpr/records.js"
|
||||
"src/layers/alpr/records.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/alpr/floors.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
"radio-layer": {
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
@@ -1213,6 +1259,7 @@
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
@@ -1327,6 +1374,7 @@
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/cameraVerbs.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
"src/cctvFocusRequest.js",
|
||||
@@ -1395,6 +1443,7 @@
|
||||
"src/data/issPass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/localGeojson.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
@@ -1414,6 +1463,10 @@
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/local_data/natural_earth/states_provinces.json",
|
||||
"src/data/local_data/natural_earth/countries.json",
|
||||
"src/data/local_data/us_census_counties/counties.json",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/radio.js",
|
||||
@@ -1639,15 +1692,19 @@
|
||||
"src/layers/traffic/flow.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/footprint.js",
|
||||
"src/layers/traffic/index.js",
|
||||
"src/layers/traffic/ingestion.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/layers/traffic/lifecycle.js",
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/policy.js",
|
||||
"src/layers/traffic/rendering.js",
|
||||
"src/layers/traffic/retention.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/traffic/surface.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
@@ -1679,6 +1736,7 @@
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/orbit.js",
|
||||
"src/overlays/worldOverlay.js",
|
||||
@@ -1965,7 +2023,21 @@
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/ui/recentImagery.js",
|
||||
"src/ui/imageryBoxTool.js"
|
||||
"src/ui/imageryBoxTool.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/traffic/roadModes.js",
|
||||
"src/data/cameraArrival.js",
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2059,6 +2131,7 @@
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/localGeojson.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
@@ -2070,6 +2143,10 @@
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/local_data/natural_earth/states_provinces.json",
|
||||
"src/data/local_data/natural_earth/countries.json",
|
||||
"src/data/local_data/us_census_counties/counties.json",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/radioCountry.js",
|
||||
@@ -2278,15 +2355,19 @@
|
||||
"src/layers/traffic/flow.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/footprint.js",
|
||||
"src/layers/traffic/index.js",
|
||||
"src/layers/traffic/ingestion.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/layers/traffic/lifecycle.js",
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/policy.js",
|
||||
"src/layers/traffic/rendering.js",
|
||||
"src/layers/traffic/retention.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/traffic/surface.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
@@ -2329,6 +2410,7 @@
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/worldAnnotationRenderer.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/cameraVerbs.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/data/routeSteps.js",
|
||||
@@ -2425,7 +2507,21 @@
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
"src/maps/imageryHost.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/traffic/roadModes.js",
|
||||
"src/data/cameraArrival.js",
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2452,6 +2548,10 @@
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/local_data/natural_earth/states_provinces.json",
|
||||
"src/data/local_data/natural_earth/countries.json",
|
||||
"src/data/local_data/us_census_counties/counties.json",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
@@ -2459,6 +2559,7 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/locations.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/search/defaults.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/google.js",
|
||||
@@ -2481,7 +2582,9 @@
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/capability.js",
|
||||
"src/data/cameraArrival.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
@@ -2494,7 +2597,7 @@
|
||||
},
|
||||
"layer-lifecycle": {
|
||||
"exports": ["./data/lifecycle"],
|
||||
"modules": ["src/data/lifecycle.js"],
|
||||
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"],
|
||||
"external": []
|
||||
},
|
||||
"ui-navigation": {
|
||||
@@ -2502,7 +2605,8 @@
|
||||
"modules": [
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/navigationController.js",
|
||||
"src/app/stateChannel.js"
|
||||
"src/app/stateChannel.js",
|
||||
"src/data/cameraArrival.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2511,6 +2615,7 @@
|
||||
"modules": [
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/loadingFeedback.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
@@ -2663,9 +2768,14 @@
|
||||
"modules": [
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/source.js"
|
||||
"src/layers/alpr/source.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js"
|
||||
],
|
||||
"external": []
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
"bikeshare-source": {
|
||||
"exports": ["./layers/bikeshare/source"],
|
||||
@@ -2694,9 +2804,21 @@
|
||||
"exports": ["./layers/installations/source"],
|
||||
"modules": [
|
||||
"src/layers/installations/source.js",
|
||||
"src/data/militaryInstallationData.js"
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/data/militaryNames.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/data/local_data/osm_military_names/names.json",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
"external": []
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
"launches-source": {
|
||||
"exports": ["./layers/launches/source"],
|
||||
@@ -2719,8 +2841,16 @@
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/footprint.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/openFreeMap.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/traffic/roadModes.js",
|
||||
"src/data/flowMatch.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -2857,7 +2987,8 @@
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/capability.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -2926,7 +3057,8 @@
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/capability.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a
|
||||
* user would drive them.
|
||||
*
|
||||
* Run: node scripts/qa-admin-outlines.mjs http://localhost:4173 [--headful] [--no-video]
|
||||
*
|
||||
* Journey: frame Texas at state scale and outline "Texas"; pan and zoom toward
|
||||
* Austin in smooth 2–3 s moves; outline "Travis County, Texas"; pan north and
|
||||
* outline the neighbouring "Williamson County, Texas"; zoom out to see both;
|
||||
* then outline "Bavaria" (framed, as a voice request for a distant place is),
|
||||
* and finally Brandenburg (Berlin is a hole) and Hawaii (eight islands).
|
||||
*
|
||||
* Asserts for every outline: it resolves from the bundled packs (no geocode,
|
||||
* boundary or Overpass request), is drawn as a draped outline within the
|
||||
* budget, and holds still — no rebuilt primitives and no new entities while
|
||||
* the camera is still. The whole journey must log zero console/page errors and
|
||||
* send zero requests to any Overpass host or nominatim.openstreetmap.org.
|
||||
*
|
||||
* Evidence in qa-shots/ (gitignored): admin-outlines-<step>.png screenshots,
|
||||
* admin-outlines-journey.mp4 (CDP screencast at ~24 fps, frames re-timed from
|
||||
* their capture timestamps), and admin-outlines-result.json with per-step
|
||||
* timings, frame-time p50/p95 and still-camera frame-difference statistics.
|
||||
* Exits nonzero on any failed assertion.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-admin-outlines.mjs <dev-server-url> [--headful] [--no-video]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const url = args.find((arg) => !arg.startsWith('--'));
|
||||
if (!url || !['http:', 'https:'].includes(new URL(url).protocol))
|
||||
throw new Error('Supply a running dev server URL; see --help');
|
||||
const recordVideo = !args.includes('--no-video');
|
||||
const shots = path.resolve('qa-shots');
|
||||
const framesDir = path.join(shots, 'admin-outlines-frames');
|
||||
await fs.mkdir(shots, { recursive: true });
|
||||
await fs.rm(framesDir, { recursive: true, force: true });
|
||||
await fs.mkdir(framesDir, { recursive: true });
|
||||
|
||||
const OUTLINE_BUDGET_MS = 1500;
|
||||
const STILL_MS = 3000;
|
||||
const FRAME_INTERVAL_S = 1 / 24;
|
||||
|
||||
const result = {
|
||||
url,
|
||||
steps: [],
|
||||
forbiddenRequests: [],
|
||||
lookups: [],
|
||||
packRequests: [],
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
screenshots: [],
|
||||
video: null,
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !args.includes('--headful'),
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
|
||||
let page;
|
||||
const frames = [];
|
||||
let lastKept = -Infinity;
|
||||
let recording = false;
|
||||
let marker = 'startup';
|
||||
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
client.on('Network.requestWillBeSent', ({ request }) => {
|
||||
const target = new URL(request.url);
|
||||
const where = `${request.method} ${target.origin}${target.pathname}`;
|
||||
if (
|
||||
target.hostname.includes('overpass') ||
|
||||
target.hostname === 'nominatim.openstreetmap.org'
|
||||
)
|
||||
result.forbiddenRequests.push({ step: marker, request: where });
|
||||
// Lookups an outline could make: a forward geocode, a place search, an
|
||||
// Overpass boundary query. The HUD's reverse geocode of the camera centre
|
||||
// (latlng=) is not one of them.
|
||||
const local = target.origin === new URL(url).origin;
|
||||
const reverse = target.searchParams.has('latlng');
|
||||
const lookup =
|
||||
!reverse &&
|
||||
((local &&
|
||||
/^\/api\/(overpass|geocode|google\/text-search|nominatim|photon)/.test(
|
||||
target.pathname,
|
||||
)) ||
|
||||
(target.hostname === 'maps.googleapis.com' &&
|
||||
target.pathname.includes('/geocode/')) ||
|
||||
target.hostname === 'photon.komoot.io');
|
||||
if (lookup) result.lookups.push({ step: marker, request: where });
|
||||
if (/\/(states_provinces|countries|counties)\.json$/.test(target.pathname))
|
||||
result.packRequests.push({ step: marker, pack: target.pathname });
|
||||
});
|
||||
page.on('pageerror', (error) =>
|
||||
result.pageErrors.push({ step: marker, message: error.message }),
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error')
|
||||
result.consoleErrors.push({ step: marker, message: message.text() });
|
||||
});
|
||||
|
||||
client.on('Page.screencastFrame', async ({ data, metadata, sessionId }) => {
|
||||
client.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
||||
if (!recording) return;
|
||||
const t = metadata.timestamp;
|
||||
if (t - lastKept < FRAME_INTERVAL_S * 0.9) return;
|
||||
lastKept = t;
|
||||
const file = path.join(
|
||||
framesDir,
|
||||
`${String(frames.length).padStart(6, '0')}.jpg`,
|
||||
);
|
||||
frames.push({ file, t, step: marker });
|
||||
await fs.writeFile(file, Buffer.from(data, 'base64'));
|
||||
});
|
||||
|
||||
console.log('Loading viewer...');
|
||||
const navigationUrl = new URL(url);
|
||||
navigationUrl.searchParams.set('welcome', '0');
|
||||
await page.goto(navigationUrl.href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations,
|
||||
{ timeout: 90_000, polling: 500 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000, polling: 250 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('#first-run-launcher:not([hidden])'),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
result.renderer = await page.evaluate(() => {
|
||||
const gl = window.__godsEyeView.viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return debug
|
||||
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
console.log('Renderer:', result.renderer);
|
||||
|
||||
// In-page helpers: camera moves, frame timing and annotation state.
|
||||
await page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cesium = window.__CESIUM__;
|
||||
window.__qaOutlines = {
|
||||
fly(view, duration) {
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
view.lon,
|
||||
view.lat,
|
||||
view.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(view.heading ?? 0),
|
||||
pitch: Cesium.Math.toRadians(view.pitch ?? -70),
|
||||
roll: 0,
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
});
|
||||
});
|
||||
},
|
||||
frameTimes(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const deltas = [];
|
||||
let last = performance.now();
|
||||
const end = last + ms;
|
||||
const step = (now) => {
|
||||
deltas.push(now - last);
|
||||
last = now;
|
||||
if (now < end) requestAnimationFrame(step);
|
||||
else resolve(deltas.slice(1));
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
},
|
||||
annotationSource() {
|
||||
for (let i = 0; i < viewer.dataSources.length; i++) {
|
||||
const source = viewer.dataSources.get(i);
|
||||
if (source.name === 'gev-annotations') return source;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
groundPrimitives() {
|
||||
const list = [];
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (item?.length !== undefined && typeof item.get === 'function')
|
||||
walk(item);
|
||||
else list.push(item);
|
||||
}
|
||||
};
|
||||
walk(viewer.scene.groundPrimitives);
|
||||
return list;
|
||||
},
|
||||
snapshot() {
|
||||
const source = this.annotationSource();
|
||||
const entities = source ? source.entities.values : [];
|
||||
const primitives = this.groundPrimitives();
|
||||
window.__qaPrimitiveIds ??= new WeakMap();
|
||||
let next = window.__qaPrimitiveNext ?? 1;
|
||||
const ids = primitives.map((primitive) => {
|
||||
if (!window.__qaPrimitiveIds.has(primitive))
|
||||
window.__qaPrimitiveIds.set(primitive, next++);
|
||||
return window.__qaPrimitiveIds.get(primitive);
|
||||
});
|
||||
window.__qaPrimitiveNext = next;
|
||||
return {
|
||||
entities: entities.length,
|
||||
polygons: entities.filter((e) => e.polygon).length,
|
||||
polylines: entities.filter((e) => e.polyline).length,
|
||||
draped:
|
||||
entities.every(
|
||||
(e) =>
|
||||
!e.polygon ||
|
||||
e.polygon.classificationType?.getValue() ===
|
||||
Cesium.ClassificationType.BOTH,
|
||||
) &&
|
||||
entities.every(
|
||||
(e) => !e.polyline || e.polyline.clampToGround?.getValue(),
|
||||
),
|
||||
primitiveIds: ids,
|
||||
ready: viewer.dataSourceDisplay.ready,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const settle = () =>
|
||||
page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (p?.show && typeof p.tilesLoaded === 'boolean' && !p.tilesLoaded)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60_000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
async function shot(name) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const filename = `admin-outlines-${name}.png`;
|
||||
await page.screenshot({ path: path.join(shots, filename) });
|
||||
result.screenshots.push(filename);
|
||||
}
|
||||
|
||||
async function move(name, view, duration = 2.5) {
|
||||
marker = `move:${name}`;
|
||||
const [, times] = await Promise.all([
|
||||
page.evaluate((v, d) => window.__qaOutlines.fly(v, d), view, duration),
|
||||
page.evaluate((ms) => window.__qaOutlines.frameTimes(ms), duration * 900),
|
||||
]);
|
||||
result.steps.push({ step: `move:${name}`, frameTimes: stats(times) });
|
||||
}
|
||||
|
||||
/** Annotate like the voice tool does, then watch the mark while still. */
|
||||
async function outline(name, target, { flyTo = false } = {}) {
|
||||
marker = `outline:${name}`;
|
||||
const lookupsBefore = result.lookups.length;
|
||||
const errorsBefore = result.consoleErrors.length + result.pageErrors.length;
|
||||
const timing = await page.evaluate(
|
||||
async (target, flyTo) => {
|
||||
const started = performance.now();
|
||||
const response = await window.__godsEyeView.annotations.annotate(
|
||||
[{ type: 'area', target, label: target, footprint: true }],
|
||||
{ flyTo },
|
||||
);
|
||||
const resolvedMs = performance.now() - started;
|
||||
// "Drawn" = the first rendered frame after the entity visualizers have
|
||||
// built the mark's primitives (each tick updates them before render).
|
||||
const { viewer } = window.__godsEyeView;
|
||||
await new Promise((resolve) => {
|
||||
const off = viewer.scene.postRender.addEventListener(() => {
|
||||
if (!viewer.dataSourceDisplay.ready) return;
|
||||
off();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const drawnMs = performance.now() - started;
|
||||
const mark = window.__godsEyeView.annotations
|
||||
.list()
|
||||
.find((m) => m.id === response.ids?.[0]);
|
||||
return {
|
||||
response: response.results?.[0] ?? null,
|
||||
resolvedMs,
|
||||
drawnMs,
|
||||
parts: mark?.polygons?.length ?? (mark?.ring ? 1 : 0),
|
||||
holes: (mark?.polygons ?? []).reduce(
|
||||
(n, part) => n + part.length - 1,
|
||||
0,
|
||||
),
|
||||
ringVertices: mark?.ring?.length ?? 0,
|
||||
pending: Boolean(mark?.pendingOutline),
|
||||
};
|
||||
},
|
||||
target,
|
||||
flyTo,
|
||||
);
|
||||
if (flyTo) await new Promise((r) => setTimeout(r, 2200));
|
||||
await settle();
|
||||
// Hold still: the drawn mark must not rebuild or grow.
|
||||
marker = `still:${name}`;
|
||||
const before = await page.evaluate(() => window.__qaOutlines.snapshot());
|
||||
const stillStart = frames.length;
|
||||
const times = await page.evaluate(
|
||||
(ms) => window.__qaOutlines.frameTimes(ms),
|
||||
STILL_MS,
|
||||
);
|
||||
const after = await page.evaluate(() => window.__qaOutlines.snapshot());
|
||||
const step = {
|
||||
step: `outline:${name}`,
|
||||
target,
|
||||
...timing,
|
||||
entities: after.entities,
|
||||
polygons: after.polygons,
|
||||
polylines: after.polylines,
|
||||
draped: after.draped,
|
||||
stableWhileStill:
|
||||
before.entities === after.entities &&
|
||||
JSON.stringify(before.primitiveIds) ===
|
||||
JSON.stringify(after.primitiveIds),
|
||||
stillFrameTimes: stats(times),
|
||||
stillFrames: [stillStart, frames.length],
|
||||
lookups: result.lookups.slice(lookupsBefore),
|
||||
errors:
|
||||
result.consoleErrors.length + result.pageErrors.length - errorsBefore,
|
||||
};
|
||||
result.steps.push(step);
|
||||
console.log(
|
||||
`${name}: resolved ${timing.resolvedMs.toFixed(0)} ms, drawn ${timing.drawnMs.toFixed(0)} ms, ` +
|
||||
`${timing.parts} part(s), ${timing.holes} hole(s), via ${timing.response?.resolvedVia}; ` +
|
||||
`still p50/p95 ${step.stillFrameTimes.p50}/${step.stillFrameTimes.p95} ms; ` +
|
||||
`stable=${step.stableWhileStill}`,
|
||||
);
|
||||
assert.equal(timing.response?.ok, true, `${name}: annotation drawn`);
|
||||
assert.equal(timing.response.outline, true, `${name}: outline, not a pin`);
|
||||
assert.equal(timing.response.resolvedVia, 'bundled', `${name}: bundled`);
|
||||
assert.equal(timing.pending, false, `${name}: nothing pending`);
|
||||
assert.ok(
|
||||
timing.drawnMs < OUTLINE_BUDGET_MS,
|
||||
`${name}: drawn in ${timing.drawnMs} ms`,
|
||||
);
|
||||
assert.equal(step.draped, true, `${name}: draped on terrain/tiles`);
|
||||
assert.equal(step.stableWhileStill, true, `${name}: no redraw when still`);
|
||||
assert.deepEqual(step.lookups, [], `${name}: no network lookups`);
|
||||
assert.equal(step.errors, 0, `${name}: no console errors`);
|
||||
return step;
|
||||
}
|
||||
|
||||
// Start from a clean board and the default layers, as a user would.
|
||||
await page.evaluate(() => window.__godsEyeView.annotations.clear?.());
|
||||
await page.evaluate(() =>
|
||||
window.__qaOutlines.fly(
|
||||
{ lat: 25.5, lon: -99.6, height: 2_600_000, pitch: -80 },
|
||||
0,
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
if (recordVideo) {
|
||||
await client.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality: 72,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 800,
|
||||
everyNthFrame: 1,
|
||||
});
|
||||
recording = true;
|
||||
}
|
||||
|
||||
await outline('texas', 'Texas');
|
||||
await shot('texas');
|
||||
await move('pan-west', {
|
||||
lat: 25.2,
|
||||
lon: -101.5,
|
||||
height: 2_300_000,
|
||||
pitch: -75,
|
||||
});
|
||||
await move('zoom-central-texas', {
|
||||
lat: 28.8,
|
||||
lon: -98.2,
|
||||
height: 700_000,
|
||||
pitch: -65,
|
||||
});
|
||||
await move('zoom-austin', {
|
||||
lat: 29.75,
|
||||
lon: -97.8,
|
||||
height: 120_000,
|
||||
pitch: -55,
|
||||
});
|
||||
await settle();
|
||||
await outline('travis', 'Travis County, Texas');
|
||||
await shot('travis');
|
||||
await move('pan-williamson', {
|
||||
lat: 30.05,
|
||||
lon: -97.65,
|
||||
height: 110_000,
|
||||
pitch: -55,
|
||||
});
|
||||
await settle();
|
||||
await outline('williamson', 'Williamson County, Texas');
|
||||
// Close over the shared Travis/Williamson line near Pflugerville: the
|
||||
// outline must lie on the photoreal mesh, not float above or sink into it.
|
||||
const border = await page.evaluate(() => {
|
||||
const travis = window.__godsEyeView.annotations
|
||||
.list()
|
||||
.find((m) => m.label === 'Travis County, Texas');
|
||||
let best = null;
|
||||
for (const [lon, lat] of travis.ring) {
|
||||
const d = (lat - 30.46) ** 2 + (lon + 97.62) ** 2;
|
||||
if (!best || d < best.d) best = { lon, lat, d };
|
||||
}
|
||||
return best;
|
||||
});
|
||||
await move(
|
||||
'drape-close',
|
||||
{ lat: border.lat - 0.035, lon: border.lon, height: 2500, pitch: -40 },
|
||||
3,
|
||||
);
|
||||
await settle();
|
||||
await page.evaluate((ms) => new Promise((r) => setTimeout(r, ms)), 2500);
|
||||
await shot('drape-close');
|
||||
await move('zoom-out-both', {
|
||||
lat: 29.55,
|
||||
lon: -97.7,
|
||||
height: 260_000,
|
||||
pitch: -60,
|
||||
});
|
||||
await settle();
|
||||
await page.evaluate((ms) => new Promise((r) => setTimeout(r, ms)), 1500);
|
||||
await shot('both-counties');
|
||||
await outline('bavaria', 'Bavaria', { flyTo: true });
|
||||
await shot('bavaria');
|
||||
await move(
|
||||
'orbit-bavaria',
|
||||
{ lat: 46.6, lon: 11.9, height: 650_000, heading: 20, pitch: -55 },
|
||||
3,
|
||||
);
|
||||
await settle();
|
||||
await shot('bavaria-orbit');
|
||||
// A hole (Berlin inside Brandenburg) and islands (Hawaii) draw as such.
|
||||
const brandenburg = await outline('brandenburg', 'Brandenburg', {
|
||||
flyTo: true,
|
||||
});
|
||||
assert.ok(brandenburg.holes >= 1, 'Brandenburg keeps the Berlin hole');
|
||||
await shot('brandenburg');
|
||||
const hawaii = await outline('hawaii', 'Hawaii', { flyTo: true });
|
||||
assert.ok(hawaii.parts >= 7, 'Hawaii keeps its islands');
|
||||
await shot('hawaii');
|
||||
marker = 'end';
|
||||
|
||||
for (const [name, target, lat, lon, height] of [
|
||||
['scotland', 'Scotland', 57, -4, 800000],
|
||||
['england', 'England', 52.5, -1.5, 800000],
|
||||
['wales', 'Wales', 52.3, -3.7, 420000],
|
||||
['northern-ireland', 'Northern Ireland', 54.7, -6.7, 300000],
|
||||
['switzerland', 'Switzerland', 46.8, 8.2, 500000],
|
||||
['iran', 'Iran', 32, 54, 2200000],
|
||||
['france', 'France', 46.6, 2.2, 1600000],
|
||||
['japan', 'Japan', 37, 138, 2200000],
|
||||
['georgia-state', 'Georgia', 32.6, -83.4, 650000],
|
||||
['georgia-country', 'Georgia', 42, 43.6, 700000],
|
||||
['georgia-qualified', 'the country of Georgia', 42, 43.6, 700000],
|
||||
]) {
|
||||
await page.evaluate(() => window.__godsEyeView.annotations.clear());
|
||||
await move(name, { lat, lon, height, pitch: -90 }, 1);
|
||||
await outline(name, target);
|
||||
await shot(name);
|
||||
const rendered = await page.evaluate(() => window.__qaOutlines.snapshot());
|
||||
assert.ok(rendered.polygons > 0, `${target} polygon renders`);
|
||||
}
|
||||
|
||||
if (recordVideo) {
|
||||
recording = false;
|
||||
await client.send('Page.stopScreencast');
|
||||
}
|
||||
result.totals = {
|
||||
forbiddenRequests: result.forbiddenRequests.length,
|
||||
consoleErrors: result.consoleErrors.length,
|
||||
pageErrors: result.pageErrors.length,
|
||||
};
|
||||
assert.deepEqual(
|
||||
result.forbiddenRequests,
|
||||
[],
|
||||
'no Overpass or nominatim.openstreetmap.org requests',
|
||||
);
|
||||
// The packs load on the first outline that needs them, never at start-up.
|
||||
assert.deepEqual(
|
||||
result.packRequests.map((r) => [r.step, path.basename(r.pack)]),
|
||||
[
|
||||
['outline:texas', 'states_provinces.json'],
|
||||
['outline:travis', 'counties.json'],
|
||||
['outline:scotland', 'countries.json'],
|
||||
],
|
||||
'packs load lazily, once each',
|
||||
);
|
||||
assert.deepEqual(result.pageErrors, [], 'no page errors');
|
||||
assert.deepEqual(result.consoleErrors, [], 'no console errors');
|
||||
} catch (error) {
|
||||
result.failure = error.message;
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
if (page)
|
||||
await page
|
||||
.screenshot({ path: path.join(shots, 'admin-outlines-failure.png') })
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (recordVideo && frames.length > 1) {
|
||||
// Frame-to-frame change while the camera is still: steady pulses read as
|
||||
// small, even values; a flicker (a part vanishing and returning) as a spike.
|
||||
for (const step of result.steps.filter((s) => s.stillFrames)) {
|
||||
const [start, end] = step.stillFrames;
|
||||
const diffs = [];
|
||||
let previous = null;
|
||||
for (const frame of frames.slice(start, end)) {
|
||||
const pixels = await sharp(frame.file)
|
||||
.resize(160, 100, { fit: 'fill' })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
if (previous) {
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
const d = Math.abs(pixels[i] - previous[i]);
|
||||
sum += d;
|
||||
if (d > max) max = d;
|
||||
}
|
||||
diffs.push(sum / pixels.length);
|
||||
}
|
||||
previous = pixels;
|
||||
}
|
||||
step.stillFrameDiff = {
|
||||
frames: end - start,
|
||||
meanAbsDiff: stats(diffs, 3),
|
||||
};
|
||||
}
|
||||
const list = [];
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const duration =
|
||||
i + 1 < frames.length ? frames[i + 1].t - frames[i].t : FRAME_INTERVAL_S;
|
||||
list.push(`file '${frames[i].file}'`, `duration ${duration.toFixed(4)}`);
|
||||
}
|
||||
list.push(`file '${frames.at(-1).file}'`);
|
||||
const listFile = path.join(framesDir, 'frames.txt');
|
||||
await fs.writeFile(listFile, list.join('\n') + '\n');
|
||||
const video = path.join(shots, 'admin-outlines-journey.mp4');
|
||||
execFileSync('ffmpeg', [
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
listFile,
|
||||
'-vf',
|
||||
'fps=24,format=yuv420p',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-crf',
|
||||
'23',
|
||||
video,
|
||||
]);
|
||||
const span = frames.at(-1).t - frames[0].t;
|
||||
result.video = {
|
||||
file: path.relative(process.cwd(), video),
|
||||
frames: frames.length,
|
||||
seconds: Number(span.toFixed(1)),
|
||||
capturedFps: Number(((frames.length - 1) / span).toFixed(1)),
|
||||
};
|
||||
console.log('Video:', result.video);
|
||||
await fs.rm(framesDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(shots, 'admin-outlines-result.json'),
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
console.table(
|
||||
result.steps.map((s) => ({
|
||||
step: s.step,
|
||||
resolvedMs: s.resolvedMs?.toFixed(0),
|
||||
drawnMs: s.drawnMs?.toFixed(0),
|
||||
parts: s.parts,
|
||||
stable: s.stableWhileStill,
|
||||
p50: (s.stillFrameTimes || s.frameTimes)?.p50,
|
||||
p95: (s.stillFrameTimes || s.frameTimes)?.p95,
|
||||
stillDiffP95: s.stillFrameDiff?.meanAbsDiff.p95,
|
||||
stillDiffMax: s.stillFrameDiff?.meanAbsDiff.max,
|
||||
})),
|
||||
);
|
||||
console.log(process.exitCode ? 'FAILED' : 'PASSED');
|
||||
|
||||
function stats(values, digits = 1) {
|
||||
if (!values.length) return { n: 0 };
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const at = (q) =>
|
||||
Number(
|
||||
sorted[
|
||||
Math.min(sorted.length - 1, Math.floor(q * sorted.length))
|
||||
].toFixed(digits),
|
||||
);
|
||||
return { n: values.length, p50: at(0.5), p95: at(0.95), max: at(1) };
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
||||
*
|
||||
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
|
||||
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||
*
|
||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
||||
* Mabry (~2 km across), two 600 m pans, a small zoom out and back, downtown
|
||||
* (~6 km), the whole city (~30 km) and back to the neighbourhood. The first
|
||||
* pass runs with Mapped ALPR Cameras off (it also warms the photoreal tiles),
|
||||
* then ALPR is switched on mid-journey from its layer row and the path repeats
|
||||
* cold and warm.
|
||||
*
|
||||
* Per stop it records: time from the camera stopping to cameras shown (or an
|
||||
* explicit "zoom in" state), the camera count and row status, rAF interval and
|
||||
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* marker stability while the camera is still (camera-set changes after the stop
|
||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
||||
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
|
||||
* zoom-in prompt and never ends a hold still loading; at most one visible
|
||||
* marker update per stop and none after 3 s while still; warm-pass frame p50/p95 within 10% of the ALPR-off pass (plus a
|
||||
* 2 ms floor for timer noise); zero console errors.
|
||||
*
|
||||
* Rendered-state checks read the recorded frames, not entity state: ALPR-cyan
|
||||
* pixels per frame during each hold, minus the ALPR-off pass at the same stop,
|
||||
* give when markers actually reached the screen. Cameras projected on screen
|
||||
* must be painted, no later than 700 ms after the layer state settled (a
|
||||
* missing render request fails here), within 2 s of the stop on the warm pass;
|
||||
* a view whose loaded cameras are all off screen must say "None on screen".
|
||||
* At the end of each hold the screen is compared with the same scene after
|
||||
* forcing four frames; any marker difference is a missing render request.
|
||||
* --self-test-drop-renders disables explicit render requests during the ALPR
|
||||
* passes to prove those checks fail.
|
||||
*
|
||||
* Writes <out>/result.json, <out>/clip.mp4 (CDP screencast with real frame
|
||||
* timing) and per-stop screenshots. Needs ffmpeg on PATH.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
createScreencast,
|
||||
frameStats,
|
||||
installFrameProbe,
|
||||
moveCamera,
|
||||
pageNow,
|
||||
readFrames,
|
||||
} from './qa-journey-recorder.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-alpr-journey.mjs <dev-server-url> [--headful] [--passes off,on,on] [--out dir] [--no-assert] [--self-test-drop-renders]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const option = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const appUrl =
|
||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
||||
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||
const passes = option('--passes', 'off,on,on').split(',');
|
||||
const assertOn = !argv.includes('--no-assert');
|
||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const cyanCount = async (file) => {
|
||||
const { data, info } = await sharp(file)
|
||||
.resize({ width: 640 })
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
if (r < 140 && g > 160 && b > 205 && b - r > 80) n += 1;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Cyan pixels (at 640 px width) that mean at least one badge is painted. */
|
||||
const MIN_MARKER_PIXELS = 12;
|
||||
|
||||
// Camp Mabry neighbourhood (Tarrytown / Bryker Woods), downtown, whole city.
|
||||
const N = {
|
||||
lat: 30.2985,
|
||||
lon: -97.7655,
|
||||
height: 1500,
|
||||
heading: 20,
|
||||
pitch: -55,
|
||||
};
|
||||
const east = (view, m) => ({
|
||||
...view,
|
||||
lon: view.lon + m / (111_320 * Math.cos((view.lat * Math.PI) / 180)),
|
||||
});
|
||||
const north = (view, m) => ({ ...view, lat: view.lat + m / 111_320 });
|
||||
const STOPS = [
|
||||
{ name: 'neighbourhood', view: N, move: 0, hold: 6, scale: 'neighbourhood' },
|
||||
{
|
||||
name: 'pan-east-600m',
|
||||
view: east(N, 600),
|
||||
move: 2.5,
|
||||
hold: 5,
|
||||
scale: 'neighbourhood',
|
||||
},
|
||||
{
|
||||
name: 'pan-north-600m',
|
||||
view: north(east(N, 600), 600),
|
||||
move: 2.5,
|
||||
hold: 5,
|
||||
scale: 'neighbourhood',
|
||||
},
|
||||
{
|
||||
name: 'zoom-out-3km',
|
||||
view: { ...north(east(N, 600), 600), height: 3000 },
|
||||
move: 2.5,
|
||||
hold: 5,
|
||||
scale: 'neighbourhood',
|
||||
},
|
||||
{
|
||||
name: 'zoom-back-in',
|
||||
view: north(east(N, 600), 600),
|
||||
move: 2.5,
|
||||
hold: 5,
|
||||
scale: 'neighbourhood',
|
||||
},
|
||||
{
|
||||
name: 'downtown-6km',
|
||||
view: { lat: 30.262, lon: -97.7431, height: 4500, heading: 0, pitch: -60 },
|
||||
move: 3,
|
||||
hold: 7,
|
||||
scale: 'downtown',
|
||||
},
|
||||
{
|
||||
name: 'city-30km',
|
||||
view: { lat: 30.2, lon: -97.75, height: 24000, heading: 0, pitch: -65 },
|
||||
move: 3,
|
||||
hold: 8,
|
||||
scale: 'city',
|
||||
},
|
||||
{
|
||||
name: 'back-to-neighbourhood',
|
||||
view: N,
|
||||
move: 3,
|
||||
hold: 6,
|
||||
scale: 'neighbourhood',
|
||||
},
|
||||
];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !argv.includes('--headful'),
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
],
|
||||
});
|
||||
const result = {
|
||||
url: appUrl,
|
||||
startedAt: new Date().toISOString(),
|
||||
passes: [],
|
||||
consoleErrors: [],
|
||||
failures: [],
|
||||
};
|
||||
const fail = (message) => {
|
||||
result.failures.push(message);
|
||||
console.log(`[FAIL] ${message}`);
|
||||
};
|
||||
let page;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await installFrameProbe(page);
|
||||
// ALPR watcher: row state every 100 ms and the rendered camera set (ids and
|
||||
// visibility) as a cheap hash, so structural churn while still is visible.
|
||||
await page.evaluate(() => {
|
||||
const samples = [];
|
||||
window.__gevAlprSamples = samples;
|
||||
setInterval(() => {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
|
||||
const stats = entry?.module?.getStats?.() || {};
|
||||
// Layers that publish a render revision are measured by it; otherwise
|
||||
// hash the rendered entity ids and their visibility.
|
||||
let hash = 0;
|
||||
let shown = 0;
|
||||
if (Number.isFinite(stats.renderRevision)) {
|
||||
hash = stats.renderRevision;
|
||||
shown = stats.shown ?? stats.count ?? 0;
|
||||
} else {
|
||||
const entities =
|
||||
entry?.module && entry.enabled
|
||||
? window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'alpr-cameras',
|
||||
)[0]?.entities.values || []
|
||||
: [];
|
||||
for (const entity of entities) {
|
||||
if (entity.show === false) continue;
|
||||
shown += 1;
|
||||
const id = String(entity.id);
|
||||
for (let i = 0; i < id.length; i++)
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
}
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
enabled: Boolean(entry?.enabled),
|
||||
count: stats.count ?? 0,
|
||||
shown,
|
||||
hash,
|
||||
loading: Boolean(stats.loading),
|
||||
status: stats.status || null,
|
||||
label: stats.loadingLabel || '',
|
||||
onScreen: stats.onScreen ?? null,
|
||||
});
|
||||
if (samples.length > 20_000) samples.splice(0, 5_000);
|
||||
}, 100);
|
||||
});
|
||||
const recorder = await createScreencast(page, outDir);
|
||||
await recorder.start();
|
||||
|
||||
async function waitTiles(maxMs = 20_000) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (p.show && p.tilesLoaded === false) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: maxMs, polling: 250 },
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
for (const [passIndex, mode] of passes.entries()) {
|
||||
const alpr = mode === 'on';
|
||||
const passName = `${passIndex + 1}-${mode}${alpr && passes.slice(0, passIndex).includes('on') ? '-warm' : alpr ? '-cold' : ''}`;
|
||||
console.log(`Pass ${passName}`);
|
||||
await moveCamera(page, STOPS[0].view, 0);
|
||||
await waitTiles();
|
||||
if (
|
||||
alpr !==
|
||||
(await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('alpr-cameras'),
|
||||
))
|
||||
) {
|
||||
// The user's switch: the layer row, not a private API.
|
||||
await page.evaluate(
|
||||
(enabled) =>
|
||||
window.__godsEyeView.dataManager.setEnabled('alpr-cameras', enabled, {
|
||||
origin: 'user',
|
||||
}),
|
||||
alpr,
|
||||
);
|
||||
}
|
||||
if (alpr && dropRenders)
|
||||
// Self-test: drop explicit render requests so markers reach the screen
|
||||
// only with the next camera move. The rendered-pixel checks must fail.
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.scene.requestRender = () => {};
|
||||
});
|
||||
const pass = { name: passName, alpr, stops: [] };
|
||||
for (const stop of STOPS) {
|
||||
const moveStart = await pageNow(page);
|
||||
if (stop.move) await moveCamera(page, stop.view, stop.move);
|
||||
else await moveCamera(page, stop.view, 0);
|
||||
const holdStart = await pageNow(page);
|
||||
const holdStartEpoch = await page.evaluate(
|
||||
() => performance.timeOrigin + performance.now(),
|
||||
);
|
||||
await sleep(stop.hold * 1000);
|
||||
const holdEnd = await pageNow(page);
|
||||
// Render truth: what the screen shows at the end of the hold versus the
|
||||
// same scene after forcing a few frames. A difference means the layer
|
||||
// changed state without asking for a render.
|
||||
let staleFrame = null;
|
||||
if (alpr) {
|
||||
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
await page.evaluate(async () => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const mode = scene.requestRenderMode;
|
||||
scene.requestRenderMode = false;
|
||||
for (let i = 0; i < 4; i++)
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scene.requestRenderMode = mode;
|
||||
});
|
||||
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
staleFrame = {
|
||||
shown,
|
||||
forced,
|
||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
||||
};
|
||||
}
|
||||
const moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||
const samples = await page.evaluate(
|
||||
(a, b) => window.__gevAlprSamples.filter((s) => s.t >= a && s.t < b),
|
||||
holdStart,
|
||||
holdEnd,
|
||||
);
|
||||
// Time to cameras: the last visible change (or the end of loading) after
|
||||
// the camera stopped. More than one visible change, or any change after
|
||||
// 3 s, is flicker: a still camera must see one clean update at most.
|
||||
let visualChanges = 0;
|
||||
const changeLog = [];
|
||||
let lastChangeAt = null;
|
||||
let loadingEndAt = null;
|
||||
let wasLoading = false;
|
||||
let previous = null;
|
||||
for (const s of samples) {
|
||||
if (
|
||||
previous &&
|
||||
(s.hash !== previous.hash || s.shown !== previous.shown)
|
||||
) {
|
||||
visualChanges += 1;
|
||||
lastChangeAt = s.t;
|
||||
changeLog.push({
|
||||
atMs: Math.round(s.t - holdStart),
|
||||
shown: s.shown,
|
||||
loading: s.loading,
|
||||
});
|
||||
}
|
||||
if (s.loading) wasLoading = true;
|
||||
else if (wasLoading && loadingEndAt === null) loadingEndAt = s.t;
|
||||
previous = s;
|
||||
}
|
||||
const first = samples[0] || {};
|
||||
const settledAt = Math.max(
|
||||
lastChangeAt ?? holdStart,
|
||||
loadingEndAt ?? (first.loading ? holdEnd : holdStart),
|
||||
);
|
||||
const end = samples.at(-1) || {};
|
||||
const row = {
|
||||
stop: stop.name,
|
||||
scale: stop.scale,
|
||||
holdEpochMs: [holdStartEpoch, holdStartEpoch + (holdEnd - holdStart)],
|
||||
onScreen: end.onScreen ?? null,
|
||||
staleFrame,
|
||||
timeToCamerasMs: alpr ? Math.round(settledAt - holdStart) : null,
|
||||
cameras: end.count ?? 0,
|
||||
shown: end.shown ?? 0,
|
||||
status: end.status,
|
||||
label: end.label,
|
||||
loadingAtHoldEnd: Boolean(end.loading),
|
||||
visualChanges,
|
||||
changeLog,
|
||||
lateChanges: samples.filter(
|
||||
(sample, i) =>
|
||||
i > 0 &&
|
||||
sample.t > holdStart + 3000 &&
|
||||
(sample.hash !== samples[i - 1].hash ||
|
||||
sample.shown !== samples[i - 1].shown),
|
||||
).length,
|
||||
moving,
|
||||
holding,
|
||||
};
|
||||
pass.stops.push(row);
|
||||
console.log(
|
||||
` ${stop.name.padEnd(22)} cams ${String(row.cameras).padStart(5)} t ${String(row.timeToCamerasMs ?? '-').padStart(5)} ms frame p50/p95 ${holding.frameP50}/${holding.frameP95} render p50/p95 ${holding.renderP50}/${holding.renderP95} changes ${visualChanges}/${row.lateChanges} ${row.status || ''} ${row.label || ''}`,
|
||||
);
|
||||
if (passIndex === passes.length - 1) {
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, `${passName}-${stop.name}.png`),
|
||||
});
|
||||
}
|
||||
}
|
||||
result.passes.push(pass);
|
||||
}
|
||||
const clip = await recorder.stop();
|
||||
result.clip = clip;
|
||||
// Clip-relative hold windows, so a reviewer can jump to each stop.
|
||||
const clipStart = recorder.frames[0]?.timestamp * 1000;
|
||||
for (const pass of result.passes)
|
||||
for (const stop of pass.stops)
|
||||
stop.clipSeconds = stop.holdEpochMs.map(
|
||||
(ms) => Math.round((ms - clipStart) / 100) / 10,
|
||||
);
|
||||
console.log('clip', clip);
|
||||
|
||||
// Rendered-state check from the recorded frames themselves: count ALPR-cyan
|
||||
// pixels per frame during each hold, subtract the ALPR-off pass at the same
|
||||
// stop, and find when the markers actually became visible on screen. Entity
|
||||
// state can be ready while nothing is painted (a missing render request),
|
||||
// which this catches and the in-page samples cannot.
|
||||
const framesIn = ([a, b]) =>
|
||||
recorder.frames.filter(
|
||||
(frame) => frame.timestamp * 1000 >= a && frame.timestamp * 1000 < b,
|
||||
);
|
||||
const median = (list) =>
|
||||
list.length ? [...list].sort((x, y) => x - y)[list.length >> 1] : 0;
|
||||
const offPass = result.passes.find((p) => !p.alpr);
|
||||
for (const pass of result.passes) {
|
||||
for (const [index, stop] of pass.stops.entries()) {
|
||||
const frames = framesIn(stop.holdEpochMs);
|
||||
const counts = [];
|
||||
for (const frame of frames)
|
||||
counts.push({
|
||||
atMs: Math.round(frame.timestamp * 1000 - stop.holdEpochMs[0]),
|
||||
cyan: await cyanCount(frame.file),
|
||||
});
|
||||
stop.pixelTimeline = counts;
|
||||
if (!pass.alpr) {
|
||||
stop.cyanBaseline = median(counts.map((c) => c.cyan));
|
||||
continue;
|
||||
}
|
||||
const baseline = offPass?.stops[index]?.cyanBaseline ?? 0;
|
||||
const tail = counts.filter(
|
||||
(c) => c.atMs >= stop.holdEpochMs[1] - stop.holdEpochMs[0] - 1000,
|
||||
);
|
||||
const final = median(tail.map((c) => c.cyan)) - baseline;
|
||||
stop.renderedMarkerPixels = final;
|
||||
let visibleAt = null;
|
||||
if (final >= MIN_MARKER_PIXELS) {
|
||||
for (let i = counts.length - 1; i >= 0; i--) {
|
||||
if (counts[i].cyan - baseline >= final * 0.8)
|
||||
visibleAt = counts[i].atMs;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
stop.renderedVisibleAtMs = visibleAt;
|
||||
console.log(
|
||||
` ${pass.name} ${stop.stop.padEnd(22)} on screen ${stop.onScreen ?? '-'} marker px ${final} visible at ${visibleAt ?? '-'} ms (state ${stop.timeToCamerasMs} ms) end-of-hold px ${stop.staleFrame?.shown} / forced ${stop.staleFrame?.forced}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate and assert.
|
||||
const aggregate = (pass, phase) => {
|
||||
const frames = pass.stops.map((s) => s[phase]);
|
||||
const weighted = (key) => {
|
||||
const total = frames.reduce((n, f) => n + (f.frames || 0), 0);
|
||||
return (
|
||||
Math.round(
|
||||
(frames.reduce((n, f) => n + (f[key] || 0) * (f.frames || 0), 0) /
|
||||
Math.max(1, total)) *
|
||||
10,
|
||||
) / 10
|
||||
);
|
||||
};
|
||||
return {
|
||||
frameP50: weighted('frameP50'),
|
||||
frameP95: weighted('frameP95'),
|
||||
renderP50: weighted('renderP50'),
|
||||
renderP95: weighted('renderP95'),
|
||||
longFrames: frames.reduce((n, f) => n + f.longFrames, 0),
|
||||
};
|
||||
};
|
||||
result.summary = result.passes.map((pass) => ({
|
||||
pass: pass.name,
|
||||
moving: aggregate(pass, 'moving'),
|
||||
holding: aggregate(pass, 'holding'),
|
||||
}));
|
||||
console.table(
|
||||
result.summary.map((s) => ({
|
||||
pass: s.pass,
|
||||
'move p50/p95': `${s.moving.frameP50}/${s.moving.frameP95}`,
|
||||
'hold p50/p95': `${s.holding.frameP50}/${s.holding.frameP95}`,
|
||||
'render hold p50/p95': `${s.holding.renderP50}/${s.holding.renderP95}`,
|
||||
'long frames': s.moving.longFrames + s.holding.longFrames,
|
||||
})),
|
||||
);
|
||||
if (assertOn) {
|
||||
const off = result.summary.find((s) => s.pass.includes('off'));
|
||||
const warm = result.summary.find((s) => s.pass.endsWith('warm'));
|
||||
const warmPass = result.passes.find((p) => p.name.endsWith('warm'));
|
||||
const onPasses = result.passes.filter((p) => p.alpr);
|
||||
if (off && warm) {
|
||||
for (const phase of ['moving', 'holding'])
|
||||
for (const key of ['frameP50', 'frameP95'])
|
||||
if (warm[phase][key] > off[phase][key] * 1.1 + 2)
|
||||
fail(
|
||||
`${phase} ${key} ${warm[phase][key]} ms with ALPR vs ${off[phase][key]} ms without`,
|
||||
);
|
||||
}
|
||||
for (const stop of warmPass?.stops || []) {
|
||||
if (
|
||||
stop.scale !== 'city' &&
|
||||
!(
|
||||
stop.timeToCamerasMs !== null &&
|
||||
stop.timeToCamerasMs <= 2000 &&
|
||||
stop.cameras > 0
|
||||
)
|
||||
)
|
||||
fail(
|
||||
`${stop.stop}: cameras after ${stop.timeToCamerasMs} ms (${stop.cameras}) on the warm pass`,
|
||||
);
|
||||
}
|
||||
for (const pass of onPasses)
|
||||
for (const stop of pass.stops) {
|
||||
if (stop.staleFrame?.stale)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: screen showed ${stop.staleFrame.shown} marker px, ${stop.staleFrame.forced} after forcing a render`,
|
||||
);
|
||||
// Cameras on screen must be painted, and painted when state says so.
|
||||
if (stop.onScreen > 0 && stop.renderedMarkerPixels < MIN_MARKER_PIXELS)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: ${stop.onScreen} cameras on screen but no markers rendered`,
|
||||
);
|
||||
if (
|
||||
stop.renderedVisibleAtMs !== null &&
|
||||
stop.renderedVisibleAtMs > stop.timeToCamerasMs + 700
|
||||
)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: markers rendered ${stop.renderedVisibleAtMs} ms after the stop, ${stop.timeToCamerasMs} ms after state`,
|
||||
);
|
||||
if (
|
||||
pass.name.endsWith('warm') &&
|
||||
stop.scale !== 'city' &&
|
||||
stop.renderedVisibleAtMs !== null &&
|
||||
stop.renderedVisibleAtMs > 2000
|
||||
)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: markers visible only after ${stop.renderedVisibleAtMs} ms`,
|
||||
);
|
||||
if (
|
||||
stop.cameras > 0 &&
|
||||
stop.onScreen === 0 &&
|
||||
!/none on screen/i.test(stop.label)
|
||||
)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: no camera on screen and no explicit state`,
|
||||
);
|
||||
if (stop.loadingAtHoldEnd)
|
||||
fail(`${pass.name} ${stop.stop}: still loading when the hold ended`);
|
||||
if (
|
||||
stop.scale === 'city' &&
|
||||
!(stop.cameras > 0 || /zoom in/i.test(stop.label))
|
||||
)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: neither cameras nor a zoom-in prompt`,
|
||||
);
|
||||
if (stop.visualChanges > 1 || stop.lateChanges > 0)
|
||||
fail(
|
||||
`${pass.name} ${stop.stop}: ${stop.visualChanges} marker changes (${stop.lateChanges} after 3 s) while the camera was still`,
|
||||
);
|
||||
}
|
||||
const errors = result.consoleErrors.filter(
|
||||
(text) => !/favicon/i.test(text),
|
||||
);
|
||||
if (errors.length)
|
||||
fail(`console errors: ${errors.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error.stack || error.message);
|
||||
} finally {
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'result.json'),
|
||||
JSON.stringify(result, null, 1),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
console.log(
|
||||
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
||||
);
|
||||
process.exit(result.failures.length ? 1 : 0);
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-context-arrivals.mjs — search arrivals above the surface, and Contacts
|
||||
* listing mapped installations around a moving tracked aircraft.
|
||||
*
|
||||
* Run: node scripts/qa-context-arrivals.mjs http://localhost:4173 [--headful]
|
||||
* [--out qa-shots/context-arrivals] [--only arrivals|contacts]
|
||||
*
|
||||
* Arrivals: types "Camp Mabry" and "39.7392, -104.9903" (downtown Denver)
|
||||
* into the location search, waits for the flight and the photoreal tiles,
|
||||
* orbits a little like a user looking around, and asserts the camera sits at
|
||||
* least 60 m above the rendered surface under it and under the view centre.
|
||||
*
|
||||
* Contacts: serves two synthetic aircraft through the page's own
|
||||
* /api/opensky response (one flying north over Camp Mabry at 2,500 m, one
|
||||
* near Fort Cavazos) and an empty military feed, starts from a wide Texas
|
||||
* view, and presses CONTACTS as a user would. Asserts the Mapped
|
||||
* installations row lists at least one site with a distance within 15 s,
|
||||
* never falls back to "?" while the aircraft moves (30 s), keeps its site
|
||||
* entities (no rebuilds while the view is still), still lists sites in
|
||||
* Cockpit, and that SEARCH NEARBY SITES reports what it found. Then re-enters
|
||||
* Contacts on the Fort Cavazos aircraft and asserts a site within 30 km.
|
||||
* Requires zero console errors.
|
||||
*
|
||||
* Writes <out>/result.json, screenshots and <out>/clip.mp4 (CDP screencast
|
||||
* with real frame timing; needs ffmpeg).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-context-arrivals.mjs <dev-server-url> [--headful] [--out dir] [--only arrivals|contacts]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const option = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const appUrl =
|
||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
||||
const outDir = path.resolve(option('--out', 'qa-shots/context-arrivals'));
|
||||
const only = option('--only', null);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const result = {
|
||||
url: appUrl,
|
||||
arrivals: [],
|
||||
contacts: {},
|
||||
consoleErrors: [],
|
||||
failures: [],
|
||||
};
|
||||
const check = (name, ok, detail = '') => {
|
||||
console.log(
|
||||
`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !argv.includes('--headful'),
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
],
|
||||
});
|
||||
|
||||
async function openPage({ aircraft = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400) return;
|
||||
const url = new URL(response.url());
|
||||
// Track/enrichment lookups for the synthetic ICAOs have no upstream record.
|
||||
const synthetic = /aaa05\d/i.test(url.search + url.pathname);
|
||||
(synthetic
|
||||
? (result.expectedMisses ??= [])
|
||||
: (result.httpErrors ??= [])
|
||||
).push(`${response.status()} ${url.pathname}${url.search}`);
|
||||
});
|
||||
await page.evaluateOnNewDocument((aircraft) => {
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
if (!aircraft) return;
|
||||
// Synthetic traffic through the app's own feed endpoints: one aircraft
|
||||
// flying north over Camp Mabry, one loitering near Fort Cavazos.
|
||||
const started = Date.now();
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const raw =
|
||||
typeof input === 'string' || input instanceof URL
|
||||
? String(input)
|
||||
: input?.url;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw, location.href);
|
||||
} catch {
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (url.origin === location.origin && url.pathname === '/api/opensky') {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const seconds = (Date.now() - started) / 1000;
|
||||
const lat = 30.29 + (seconds * 120) / 111_320;
|
||||
const json = {
|
||||
time: now,
|
||||
states: [
|
||||
[
|
||||
'aaa051',
|
||||
'QA051',
|
||||
'Synthetic',
|
||||
now,
|
||||
now,
|
||||
-97.765,
|
||||
lat,
|
||||
2500,
|
||||
false,
|
||||
120,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
2500,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
[
|
||||
'aaa052',
|
||||
'QA052',
|
||||
'Synthetic',
|
||||
now,
|
||||
now,
|
||||
-97.72,
|
||||
31.12,
|
||||
3000,
|
||||
false,
|
||||
90,
|
||||
270,
|
||||
0,
|
||||
null,
|
||||
3000,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
],
|
||||
};
|
||||
return Promise.resolve(Response.json(json));
|
||||
}
|
||||
if (url.origin === location.origin && url.pathname === '/api/adsblol/mil')
|
||||
return Promise.resolve(
|
||||
Response.json({ ac: [], now: Date.now(), total: 0 }),
|
||||
);
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, aircraft);
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
return page;
|
||||
}
|
||||
|
||||
async function waitTiles(page, maxMs = 30_000) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (p.show && p.tilesLoaded === false) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: maxMs, polling: 250 },
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Camera height above the rendered surface under it and under the view centre. */
|
||||
function clearance(page) {
|
||||
return page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const { scene, camera } = viewer;
|
||||
const carto = camera.positionCartographic.clone();
|
||||
const sample = (c) => {
|
||||
try {
|
||||
const h = scene.sampleHeight(c);
|
||||
return Number.isFinite(h) ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const under = sample(carto);
|
||||
let centre = null;
|
||||
const canvas = scene.canvas;
|
||||
const hit = scene.pickPosition?.({
|
||||
x: canvas.clientWidth / 2,
|
||||
y: canvas.clientHeight / 2,
|
||||
});
|
||||
if (hit) centre = scene.globe.ellipsoid.cartesianToCartographic(hit);
|
||||
const centreSurface = centre ? sample(centre) : null;
|
||||
const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity);
|
||||
return {
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
cameraM: Math.round(carto.height),
|
||||
surfaceUnderM: under === null ? null : Math.round(under),
|
||||
surfaceCentreM: centreSurface === null ? null : Math.round(centreSurface),
|
||||
clearanceM: Number.isFinite(surface)
|
||||
? Math.round(carto.height - surface)
|
||||
: null,
|
||||
pitchDeg: Math.round((camera.pitch * 180) / Math.PI),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function arrivals() {
|
||||
const page = await openPage();
|
||||
const dir = path.join(outDir, 'arrivals');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const recorder = await createScreencast(page, dir);
|
||||
await recorder.start();
|
||||
for (const query of ['Camp Mabry', '39.7392, -104.9903']) {
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('search-toggle')?.click(),
|
||||
);
|
||||
await page.focus('#location-search');
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('location-search').value = '';
|
||||
});
|
||||
await page.type('#location-search', query, { delay: 40 });
|
||||
await page.keyboard.press('Enter');
|
||||
// Wait for the flight to land: the camera still for 1.5 s.
|
||||
const landed = Date.now();
|
||||
let last = null;
|
||||
let stillSince = null;
|
||||
while (Date.now() - landed < 30_000) {
|
||||
const now = await page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionWC;
|
||||
return [c.x, c.y, c.z];
|
||||
});
|
||||
const moved =
|
||||
!last ||
|
||||
Math.hypot(now[0] - last[0], now[1] - last[1], now[2] - last[2]) > 0.5;
|
||||
last = now;
|
||||
if (moved) stillSince = null;
|
||||
else stillSince ??= Date.now();
|
||||
if (
|
||||
Date.now() - landed > 4000 &&
|
||||
stillSince &&
|
||||
Date.now() - stillSince > 1500
|
||||
)
|
||||
break;
|
||||
await sleep(250);
|
||||
}
|
||||
await waitTiles(page);
|
||||
await sleep(2500); // the ground guard measures after tiles stream in
|
||||
const arrived = await clearance(page);
|
||||
const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) });
|
||||
// Look around: a gentle orbit, then check the eye still clears the ground.
|
||||
const view = await page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera;
|
||||
return {
|
||||
lat: (c.positionCartographic.latitude * 180) / Math.PI,
|
||||
lon: (c.positionCartographic.longitude * 180) / Math.PI,
|
||||
height: c.positionCartographic.height,
|
||||
heading: (c.heading * 180) / Math.PI,
|
||||
pitch: (c.pitch * 180) / Math.PI,
|
||||
};
|
||||
});
|
||||
await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5);
|
||||
await sleep(1500);
|
||||
const lookedAround = await clearance(page);
|
||||
result.arrivals.push({ query, arrived, lookedAround });
|
||||
check(
|
||||
`${query}: arrival clears the rendered surface`,
|
||||
arrived.clearanceM !== null && arrived.clearanceM >= 60,
|
||||
JSON.stringify(arrived),
|
||||
);
|
||||
}
|
||||
result.arrivalClip = await recorder.stop();
|
||||
await page.close();
|
||||
}
|
||||
|
||||
async function contacts() {
|
||||
const page = await openPage({ aircraft: true });
|
||||
const dir = path.join(outDir, 'contacts');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('flights', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
// A wide Texas view: installation viewports are unbounded here.
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 },
|
||||
0,
|
||||
);
|
||||
await sleep(3000);
|
||||
// In-page probe: row text and installation entity churn every 250 ms.
|
||||
await page.evaluate(() => {
|
||||
const samples = [];
|
||||
window.__gevContextSamples = samples;
|
||||
let created = 0;
|
||||
const seen = new WeakSet();
|
||||
setInterval(() => {
|
||||
const row = [
|
||||
...document.querySelectorAll(
|
||||
'#military-awareness-panel .military-awareness-row',
|
||||
),
|
||||
].find(
|
||||
(candidate) =>
|
||||
candidate.querySelector('strong')?.textContent?.trim() ===
|
||||
'Mapped installations',
|
||||
);
|
||||
const ds = window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'military-installations',
|
||||
)[0];
|
||||
for (const entity of ds?.entities.values || [])
|
||||
if (!seen.has(entity)) {
|
||||
seen.add(entity);
|
||||
created += 1;
|
||||
}
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
count: row?.querySelector('b')?.textContent?.trim() ?? null,
|
||||
text: row?.innerText?.replace(/\s+/g, ' ').trim() ?? null,
|
||||
installations: [
|
||||
...(row?.querySelectorAll('button[data-awareness-id]') || []),
|
||||
].map((button) => ({
|
||||
id: button.dataset.awarenessId,
|
||||
name: button.childNodes[0]?.textContent?.trim(),
|
||||
distance: button.querySelector('span')?.textContent?.trim(),
|
||||
})),
|
||||
entities: ds?.entities.values.length ?? 0,
|
||||
created,
|
||||
tracked:
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
});
|
||||
if (samples.length > 5000) samples.splice(0, 1000);
|
||||
}, 250);
|
||||
});
|
||||
const recorder = await createScreencast(page, dir);
|
||||
await recorder.start();
|
||||
const t0 = await page.evaluate(() => performance.now());
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('global-context-flights-btn').click(),
|
||||
);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
|
||||
'flights:aaa051',
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const readSamples = (from) =>
|
||||
page.evaluate(
|
||||
(a) => window.__gevContextSamples.filter((s) => s.t >= a),
|
||||
from,
|
||||
);
|
||||
const numeric = (s) => /^\d+/.test(String(s.count ?? ''));
|
||||
// Up to 15 s for the first answer, then watch 30 s of flight.
|
||||
const deadline = Date.now() + 15_000;
|
||||
let first = null;
|
||||
while (Date.now() < deadline && !first) {
|
||||
first = (await readSamples(t0)).find(
|
||||
(s) =>
|
||||
numeric(s) &&
|
||||
Number.parseInt(s.count, 10) >= 1 &&
|
||||
/\d+(\.\d+)? k?m\b/.test(s.text),
|
||||
);
|
||||
if (!first) await sleep(500);
|
||||
}
|
||||
check(
|
||||
'Contacts lists a mapped installation with a distance',
|
||||
Boolean(first),
|
||||
first
|
||||
? `${Math.round(first.t - t0)} ms: ${first.text}`
|
||||
: 'row never answered',
|
||||
);
|
||||
await page.screenshot({ path: path.join(dir, 'contacts-camp-mabry.png') });
|
||||
await sleep(30_000);
|
||||
const flight = (await readSamples(first?.t ?? t0)).filter(
|
||||
(s) => s.tracked === 'flights:aaa051',
|
||||
);
|
||||
const reverted = flight.filter((s) => !numeric(s));
|
||||
const createdDuring = flight.length
|
||||
? flight.at(-1).created - flight[0].created
|
||||
: 0;
|
||||
result.contacts.mabry = {
|
||||
firstAnswerMs: first ? Math.round(first.t - t0) : null,
|
||||
firstText: first?.text ?? null,
|
||||
lastText: flight.at(-1)?.text ?? null,
|
||||
samples: flight.length,
|
||||
revertedSamples: reverted.length,
|
||||
entities: flight.at(-1)?.entities ?? 0,
|
||||
entitiesCreatedWhileTracking: createdDuring,
|
||||
};
|
||||
check(
|
||||
'the row never falls back to "?" while the aircraft moves',
|
||||
reverted.length === 0,
|
||||
`${reverted.length}/${flight.length}`,
|
||||
);
|
||||
check(
|
||||
'site entities are not rebuilt while tracking',
|
||||
createdDuring === 0,
|
||||
`${createdDuring} created`,
|
||||
);
|
||||
|
||||
// Cockpit: the same context must hold with a horizon view.
|
||||
const cockpit = await page.evaluate(() => {
|
||||
const view = window.__godsEyeView.styleManager.cockpitView;
|
||||
view.syncEntry();
|
||||
return view.enter();
|
||||
});
|
||||
await sleep(12_000);
|
||||
const inCockpit = (
|
||||
await readSamples((await page.evaluate(() => performance.now())) - 8000)
|
||||
).filter((s) => s.cockpit);
|
||||
const scope = await page.evaluate(
|
||||
() =>
|
||||
document.getElementById('cockpit-context-subject')?.textContent || null,
|
||||
);
|
||||
await page.screenshot({ path: path.join(dir, 'cockpit-camp-mabry.png') });
|
||||
result.contacts.cockpit = {
|
||||
entered: cockpit,
|
||||
samples: inCockpit.length,
|
||||
last: inCockpit.at(-1)?.text ?? null,
|
||||
scope,
|
||||
};
|
||||
check(
|
||||
'Cockpit scope names the installation window',
|
||||
/INSTALLATIONS WITHIN \d+ KM/.test(scope || ''),
|
||||
scope || 'no scope',
|
||||
);
|
||||
check(
|
||||
'Cockpit keeps mapped installations listed',
|
||||
cockpit !== false &&
|
||||
inCockpit.length > 0 &&
|
||||
inCockpit.every((s) => numeric(s) && Number.parseInt(s.count, 10) >= 1),
|
||||
inCockpit.at(-1)?.text || 'no cockpit samples',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.cockpitView.exit?.(),
|
||||
);
|
||||
await sleep(2000);
|
||||
|
||||
// SEARCH NEARBY SITES, as the user presses it.
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('toast').textContent = '';
|
||||
document.getElementById('installations-search-btn').click();
|
||||
});
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.getElementById('toast').textContent.trim().length > 0,
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const toast = await page.evaluate(() =>
|
||||
document.getElementById('toast').textContent.trim(),
|
||||
);
|
||||
await sleep(1500);
|
||||
const afterSearch = (
|
||||
await readSamples((await page.evaluate(() => performance.now())) - 1000)
|
||||
).at(-1);
|
||||
result.contacts.search = { toast, row: afterSearch?.text ?? null };
|
||||
check(
|
||||
'SEARCH NEARBY SITES reports what it found and the row still lists sites',
|
||||
/mapped sites? within \d+ km/i.test(toast) && numeric(afterSearch || {}),
|
||||
`toast "${toast}" · ${afterSearch?.text}`,
|
||||
);
|
||||
|
||||
// Fort Cavazos: leave Contacts, track the other aircraft, re-enter.
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('global-context-flights-btn').click(),
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('flights')
|
||||
.module.trackById('aaa052'),
|
||||
);
|
||||
await sleep(3000);
|
||||
const t1 = await page.evaluate(() => performance.now());
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('global-context-flights-btn').click(),
|
||||
);
|
||||
let cavazos = null;
|
||||
const cavazosDeadline = Date.now() + 20_000;
|
||||
while (Date.now() < cavazosDeadline) {
|
||||
const latest = (await readSamples(t1))
|
||||
.filter((s) => s.tracked === 'flights:aaa052' && numeric(s))
|
||||
.at(-1);
|
||||
const site = latest?.installations.find(
|
||||
(record) => record.name === 'Fort Hood',
|
||||
);
|
||||
const nearest = site?.distance?.match(/^(\d+(?:\.\d+)?) (k?m)$/);
|
||||
const km = nearest
|
||||
? Number(nearest[1]) / (nearest[2] === 'm' ? 1000 : 1)
|
||||
: null;
|
||||
if (km !== null && km <= 30) {
|
||||
cavazos = {
|
||||
text: latest.text,
|
||||
nearestKm: km,
|
||||
site,
|
||||
afterMs: Math.round(latest.t - t1),
|
||||
};
|
||||
break;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
await sleep(2000);
|
||||
await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') });
|
||||
result.contacts.cavazos = cavazos;
|
||||
check(
|
||||
'Fort Cavazos contact lists Fort Hood within 30 km',
|
||||
Boolean(cavazos),
|
||||
JSON.stringify(cavazos),
|
||||
);
|
||||
result.contactsClip = await recorder.stop();
|
||||
await page.close();
|
||||
}
|
||||
|
||||
try {
|
||||
if (only !== 'contacts') await arrivals();
|
||||
if (only !== 'arrivals') await contacts();
|
||||
// A 404 for a synthetic aircraft's track logs "Failed to load resource";
|
||||
// count those only when every failed response was such an expected miss.
|
||||
const resourceErrors = result.consoleErrors.filter((text) =>
|
||||
/Failed to load resource/.test(text),
|
||||
).length;
|
||||
const errors = result.consoleErrors.filter(
|
||||
(text) =>
|
||||
!/favicon/i.test(text) &&
|
||||
!(
|
||||
/Failed to load resource/.test(text) &&
|
||||
!(result.httpErrors || []).length &&
|
||||
resourceErrors <= (result.expectedMisses || []).length
|
||||
),
|
||||
);
|
||||
check(
|
||||
'zero console errors',
|
||||
errors.length === 0,
|
||||
errors.slice(0, 3).join(' | '),
|
||||
);
|
||||
check(
|
||||
'zero failed app responses',
|
||||
!(result.httpErrors || []).length,
|
||||
(result.httpErrors || []).slice(0, 3).join(' | '),
|
||||
);
|
||||
} catch (error) {
|
||||
check('harness completed', false, error.stack || error.message);
|
||||
} finally {
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'result.json'),
|
||||
JSON.stringify(result, null, 1),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
console.log(
|
||||
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
||||
);
|
||||
process.exit(result.failures.length ? 1 : 0);
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/** Persistent inline attribution through actual OSM layer display lifetimes. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { moveCamera } from './qa-journey-recorder.mjs';
|
||||
const url = process.argv.find((a) => /^https?:/.test(a));
|
||||
if (!url) throw new Error('Supply the running server URL');
|
||||
const dir = process.env.QA_POLISH_DIR || 'qa-shots/final-polish';
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const result = { states: [], errors: [] };
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-angle=metal'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('pageerror', (e) => result.errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') result.errors.push(m.text());
|
||||
});
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
for (const [id] of g.dataManager.layers)
|
||||
await g.dataManager.setEnabled(id, false);
|
||||
g.styleManager.setDetection({ enabled: false });
|
||||
});
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lon: -97.765, lat: 30.3125, height: 3500, pitch: -80 },
|
||||
0,
|
||||
);
|
||||
const toggle = (id, value) =>
|
||||
page.evaluate(
|
||||
(id, value) => window.__godsEyeView.dataManager.setEnabled(id, value),
|
||||
id,
|
||||
value,
|
||||
);
|
||||
const text = () => page.$eval('#cesium-credits', (e) => e.innerText);
|
||||
const shot = async (name, osm, tiles) => {
|
||||
await sleep(300);
|
||||
const state = await page.$eval('#cesium-credits', (e) => {
|
||||
const nodes = [
|
||||
...e.querySelectorAll('a,img,.cesium-credit-expand-link'),
|
||||
].filter((n) =>
|
||||
n.checkVisibility({ visibilityProperty: true, opacityProperty: true }),
|
||||
);
|
||||
return {
|
||||
text: e.innerText,
|
||||
rects: nodes
|
||||
.map((n) => {
|
||||
const b = n.getBoundingClientRect();
|
||||
return {
|
||||
text: n.textContent || n.alt || 'image',
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
reachable: n.contains(
|
||||
document.elementFromPoint(
|
||||
b.x + b.width / 2,
|
||||
b.y + b.height / 2,
|
||||
),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((b) => b.width > 0 && b.height > 0),
|
||||
};
|
||||
});
|
||||
assert.equal(state.text.includes('© OpenStreetMap'), osm);
|
||||
assert.equal(state.text.includes('© OpenMapTiles'), tiles);
|
||||
assert.equal(
|
||||
(state.text.match(/© OpenStreetMap/g) || []).length,
|
||||
osm ? 1 : 0,
|
||||
);
|
||||
assert.ok(state.rects.length >= 3);
|
||||
const centers = state.rects.map((b) => b.y + b.height / 2);
|
||||
assert.ok(
|
||||
Math.max(...centers) - Math.min(...centers) < 12,
|
||||
`${name}: credits stay on one desktop row`,
|
||||
);
|
||||
assert.ok(
|
||||
state.rects.every(
|
||||
(b) => b.x >= 0 && b.x + b.width <= page.viewport().width,
|
||||
),
|
||||
'no credit clipped',
|
||||
);
|
||||
assert.ok(
|
||||
state.rects.every((b) => b.reachable),
|
||||
'all credits and the attribution link remain clear of controls',
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/after-${name}.png` });
|
||||
await page.screenshot({
|
||||
path: `${dir}/after-${name}-row.png`,
|
||||
clip: { x: 16, y: 750, width: 1150, height: 150 },
|
||||
});
|
||||
result.states.push({ name, ...state });
|
||||
};
|
||||
await sleep(1600);
|
||||
await shot('nothing', false, false);
|
||||
await toggle('alpr-cameras', true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('#cesium-credits')
|
||||
.innerText.includes('© OpenStreetMap'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await shot('alpr', true, false);
|
||||
await sleep(6000);
|
||||
await shot('alpr-persistent', true, false);
|
||||
await toggle('traffic', true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('#cesium-credits')
|
||||
.innerText.includes('© OpenMapTiles'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await shot('alpr-and-traffic', true, true);
|
||||
await toggle('alpr-cameras', false);
|
||||
await shot('traffic', true, true);
|
||||
for (const width of [1280, 1920]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await sleep(500);
|
||||
await shot(`traffic-${width}`, true, true);
|
||||
}
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setCleanView(true),
|
||||
);
|
||||
await shot('traffic-clean-ui', true, true);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setCleanView(false),
|
||||
);
|
||||
await toggle('traffic', false);
|
||||
await shot('nothing-restored', false, false);
|
||||
assert.deepEqual(result.errors, []);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await fs.writeFile(`${dir}/credits.json`, JSON.stringify(result, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env node
|
||||
/** Mapped installations: isolated camera navigation, cached revisits and Contacts release. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { moveCamera } from './qa-journey-recorder.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-installation-navigation.mjs <url> [--baseline]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const baseline = args.includes('--baseline');
|
||||
const result = { views: [], errors: [], forbidden: [], failures: [] };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-angle=metal', '--disable-background-timer-throttling'],
|
||||
protocolTimeout: 120000,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const tiles = [];
|
||||
page.on('request', (r) => {
|
||||
const u = new URL(r.url());
|
||||
if (
|
||||
u.hostname === 'tiles.openfreemap.org' &&
|
||||
/\/\d+\/\d+\/\d+(\.pbf)?$/.test(u.pathname)
|
||||
)
|
||||
tiles.push(u.pathname);
|
||||
if (
|
||||
/overpass/i.test(u.hostname) ||
|
||||
u.hostname === 'nominatim.openstreetmap.org'
|
||||
)
|
||||
result.forbidden.push(u.hostname);
|
||||
});
|
||||
page.on('pageerror', (e) => result.errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') result.errors.push(m.text());
|
||||
});
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
await page.goto(
|
||||
args.find((a) => /^https?:/.test(a)),
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
for (const [id] of g.dataManager.layers)
|
||||
await g.dataManager.setEnabled(id, false);
|
||||
g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
|
||||
window.installationEvents = { changed: 0, end: 0 };
|
||||
window.installationEntityRefs = new Map();
|
||||
g.viewer.camera.changed.addEventListener(
|
||||
() => window.installationEvents.changed++,
|
||||
);
|
||||
g.viewer.camera.moveEnd.addEventListener(
|
||||
() => window.installationEvents.end++,
|
||||
);
|
||||
});
|
||||
let low = 60000,
|
||||
high = 400000;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const height = (low + high) / 2;
|
||||
await moveCamera(page, { lat: 32.72, lon: -117.16, height, pitch: -90 }, 0);
|
||||
const fits = await page.evaluate(async () => {
|
||||
const { installationTileZoom } =
|
||||
await import('/src/layers/installations/source.js');
|
||||
const r = window.__godsEyeView.viewer.camera.computeViewRectangle();
|
||||
const d = 180 / Math.PI;
|
||||
return (
|
||||
r &&
|
||||
installationTileZoom({
|
||||
west: r.west * d,
|
||||
east: r.east * d,
|
||||
south: r.south * d,
|
||||
north: r.north * d,
|
||||
}) !== null
|
||||
);
|
||||
});
|
||||
if (fits) low = height;
|
||||
else high = height;
|
||||
}
|
||||
const widestHeight = Math.floor(low);
|
||||
result.widestHeight = widestHeight;
|
||||
result.firstTooWideHeight = Math.ceil(high);
|
||||
let enabled = false;
|
||||
const places = [
|
||||
['san-diego', 32.72, -117.16],
|
||||
['pendleton', 33.33, -117.4],
|
||||
['miramar', 32.87, -117.14],
|
||||
['san-diego-return', 32.72, -117.16],
|
||||
];
|
||||
for (const height of [
|
||||
5000,
|
||||
20000,
|
||||
60000,
|
||||
150000,
|
||||
widestHeight,
|
||||
400000,
|
||||
5000,
|
||||
]) {
|
||||
for (const [name, lat, lon] of height >= 150000
|
||||
? places.slice(0, 1)
|
||||
: places) {
|
||||
const before = tiles.length;
|
||||
const previous = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats().lastUpdate,
|
||||
);
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat, lon, height, pitch: -90 },
|
||||
enabled ? 0.6 : 0,
|
||||
);
|
||||
const stop = Date.now();
|
||||
if (!enabled) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'military-installations',
|
||||
true,
|
||||
),
|
||||
);
|
||||
enabled = true;
|
||||
}
|
||||
await page
|
||||
.waitForFunction(
|
||||
(prev, wide) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats();
|
||||
return (
|
||||
!s.loading &&
|
||||
(s.lastUpdate > prev || (wide && s.status === 'zoom-in'))
|
||||
);
|
||||
},
|
||||
{ timeout: 6000 },
|
||||
previous,
|
||||
height === 400000,
|
||||
)
|
||||
.catch(() => {});
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
const ds = g.viewer.dataSources.getByName('military-installations')[0];
|
||||
const points = ds.entities.values.filter((e) => e.point);
|
||||
const stable = points.every(
|
||||
(e) =>
|
||||
!window.installationEntityRefs.has(e.id) ||
|
||||
window.installationEntityRefs.get(e.id) === e,
|
||||
);
|
||||
window.installationEntityRefs = new Map(points.map((e) => [e.id, e]));
|
||||
return {
|
||||
...g.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
stable,
|
||||
ids: points.map((e) => e.id),
|
||||
events: { ...window.installationEvents },
|
||||
};
|
||||
});
|
||||
const row = {
|
||||
name,
|
||||
height,
|
||||
ms: Date.now() - stop,
|
||||
tiles: tiles.length - before,
|
||||
updated: snapshot.lastUpdate > previous,
|
||||
...snapshot,
|
||||
};
|
||||
if (
|
||||
(!row.stable && result.views.at(-1)?.height === height) ||
|
||||
new Set(row.ids).size !== row.ids.length
|
||||
)
|
||||
result.failures.push(
|
||||
`${name}/${height}: unstable or duplicate site entities`,
|
||||
);
|
||||
if (name.endsWith('return')) {
|
||||
const first = result.views.find(
|
||||
(v) => v.name === 'san-diego' && v.height === height,
|
||||
);
|
||||
if (
|
||||
first &&
|
||||
JSON.stringify([...first.ids].sort()) !==
|
||||
JSON.stringify([...row.ids].sort())
|
||||
)
|
||||
result.failures.push(
|
||||
`${name}/${height}: changed site ids on revisit`,
|
||||
);
|
||||
}
|
||||
result.views.push(row);
|
||||
console.log(JSON.stringify(row));
|
||||
if (!row.updated && row.status !== 'zoom-in')
|
||||
result.failures.push(`${name}/${height}: no reload`);
|
||||
if (row.ms > 1500 && row.status !== 'zoom-in')
|
||||
result.failures.push(`${name}/${height}: ${row.ms} ms`);
|
||||
if (name.endsWith('return') && row.tiles)
|
||||
result.failures.push(`${name}/${height}: revisit fetched ${row.tiles}`);
|
||||
}
|
||||
}
|
||||
for (const gesture of [
|
||||
'wheel',
|
||||
'keyboard',
|
||||
'contacts-close',
|
||||
'contacts-clear',
|
||||
]) {
|
||||
let previous = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats().lastUpdate,
|
||||
);
|
||||
if (gesture === 'wheel') {
|
||||
await page.mouse.move(640, 400);
|
||||
await page.mouse.wheel({ deltaY: -350 });
|
||||
} else if (gesture === 'keyboard') {
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('search-toggle').click(),
|
||||
);
|
||||
await page.focus('#location-search');
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('location-search').value = '';
|
||||
});
|
||||
await page.type('#location-search', '33.33, -117.40');
|
||||
await page.keyboard.press('Enter');
|
||||
await sleep(4500);
|
||||
} else {
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat: 32.87, lon: -117.14, height: 5000, pitch: -90 },
|
||||
0.6,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.dataSources
|
||||
.getByName('military-installations')[0]
|
||||
.entities.values.some((e) => e.point),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const button = document.getElementById('global-context-flights-btn');
|
||||
if (button.getAttribute('aria-selected') !== 'true') button.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.__godsEyeView;
|
||||
return (
|
||||
g.dataManager.isEffectivelyEnabled('military-awareness') &&
|
||||
!g.dataManager.layers.get('military-awareness').module.getParams()
|
||||
.passive
|
||||
);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await sleep(1000);
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
const ds = g.viewer.dataSources.getByName('military-installations')[0];
|
||||
const id = ds.entities.values.find((e) => e.point).id;
|
||||
window.installationSubjectId = id;
|
||||
await g.dataManager.layers
|
||||
.get('military-awareness')
|
||||
.module.focusTarget('military-installations', id);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const a =
|
||||
window.__godsEyeView.dataManager.layers.get(
|
||||
'military-awareness',
|
||||
).module;
|
||||
const subject = a.getContextSnapshot()?.subject;
|
||||
return (
|
||||
subject?.layerId === 'military-installations' &&
|
||||
subject.id === window.installationSubjectId &&
|
||||
!a._getAwarenessNavigationStateForTest().pendingSelectionKey
|
||||
);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats().coverage.kind === 'subject',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('global-context-flights-btn')
|
||||
.getAttribute('aria-busy') === 'false',
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const anchored = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
);
|
||||
if (anchored.coverage.kind !== 'subject')
|
||||
result.failures.push(`${gesture}: Contacts did not acquire subject`);
|
||||
result.contextClear = await page.evaluate(async (clear) => {
|
||||
if (clear) {
|
||||
const { clearSelectedEntityContextForLayer, getContextStore } =
|
||||
await import('/src/data/contextStore.js');
|
||||
const store = getContextStore();
|
||||
const before = {
|
||||
selectedId: store.selectedEntityId,
|
||||
record: store.entities.get(store.selectedEntityId)?.id,
|
||||
subject: window.__godsEyeView.dataManager.layers
|
||||
.get('military-awareness')
|
||||
.module.getContextSnapshot()?.subject,
|
||||
};
|
||||
clearSelectedEntityContextForLayer('military-installations');
|
||||
return before;
|
||||
} else document.getElementById('global-context-flights-btn').click();
|
||||
}, gesture === 'contacts-clear');
|
||||
if (gesture === 'contacts-clear') {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.dataManager.layers
|
||||
.get('military-awareness')
|
||||
.module.getContextSnapshot(),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const button = document.getElementById(
|
||||
'global-context-flights-btn',
|
||||
);
|
||||
return (
|
||||
button.getAttribute('aria-selected') === 'false' &&
|
||||
button.getAttribute('aria-busy') === 'false'
|
||||
);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await sleep(500);
|
||||
previous = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats().lastUpdate,
|
||||
);
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat: 33.33, lon: -117.4, height: 5000, pitch: -90 },
|
||||
0.6,
|
||||
);
|
||||
}
|
||||
await page
|
||||
.waitForFunction(
|
||||
(prev) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats();
|
||||
return (
|
||||
!s.loading && s.lastUpdate > prev && s.coverage.kind === 'viewport'
|
||||
);
|
||||
},
|
||||
{ timeout: 6000 },
|
||||
previous,
|
||||
)
|
||||
.catch(() => result.failures.push(`${gesture}: no viewport reload`));
|
||||
const row = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
);
|
||||
if (gesture.startsWith('contacts') && row.count !== 1)
|
||||
result.failures.push(
|
||||
`${gesture}: destination count ${row.count}, expected 1`,
|
||||
);
|
||||
result.views.push({ name: gesture, ...row });
|
||||
console.log(JSON.stringify({ name: gesture, ...row }));
|
||||
}
|
||||
await page.screenshot({ path: 'qa-shots/installations-navigation.png' });
|
||||
} finally {
|
||||
fs.mkdirSync('qa-shots', { recursive: true });
|
||||
fs.writeFileSync(
|
||||
`qa-shots/installations-navigation-${baseline ? 'before' : 'after'}.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
if (
|
||||
!baseline &&
|
||||
(result.failures.length || result.errors.length || result.forbidden.length)
|
||||
)
|
||||
process.exitCode = 1;
|
||||
@@ -0,0 +1,404 @@
|
||||
/** Shared browser assertions for installation label persistence and selection. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import { moveCamera, createScreencast } from './qa-journey-recorder.mjs';
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function checkInstallationPan(page, dir) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('local-datacenters', true),
|
||||
);
|
||||
await sleep(3000);
|
||||
await page.evaluate(async () => {
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const g = window.__godsEyeView;
|
||||
const installations = g.dataManager.layers.get(
|
||||
'military-installations',
|
||||
).module;
|
||||
const centerIds = [];
|
||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
|
||||
window.__polishPanFrames = [];
|
||||
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
||||
const sites = [],
|
||||
centers = [];
|
||||
installations.visitNamedMarkers((id) => {
|
||||
if (getOverlayPaintRect('military-installations', id)) sites.push(id);
|
||||
});
|
||||
for (const id of centerIds)
|
||||
if (getOverlayPaintRect('local-datacenters', id)) centers.push(id);
|
||||
window.__polishPanFrames.push({ t: performance.now(), sites, centers });
|
||||
});
|
||||
});
|
||||
await moveCamera(page, { lon: -99, lat: 39, height: 6500000, pitch: -90 }, 8);
|
||||
const frames = await page.evaluate(() => {
|
||||
window.__stopPolishPan();
|
||||
return window.__polishPanFrames;
|
||||
});
|
||||
const count = (key) => {
|
||||
let appeared = 0,
|
||||
disappeared = 0,
|
||||
reappeared = 0;
|
||||
const seen = new Set(frames[0][key]);
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const previous = new Set(frames[i - 1][key]),
|
||||
next = new Set(frames[i][key]);
|
||||
frames[i][`${key}Appeared`] = [...next].filter((id) => !previous.has(id));
|
||||
frames[i][`${key}Disappeared`] = [...previous].filter(
|
||||
(id) => !next.has(id),
|
||||
);
|
||||
for (const id of frames[i][`${key}Appeared`]) {
|
||||
appeared++;
|
||||
if (seen.has(id)) reappeared++;
|
||||
seen.add(id);
|
||||
}
|
||||
disappeared += frames[i][`${key}Disappeared`].length;
|
||||
}
|
||||
const mean =
|
||||
frames.reduce((sum, f) => sum + f[key].length, 0) / frames.length;
|
||||
return {
|
||||
appeared,
|
||||
disappeared,
|
||||
reappeared,
|
||||
mean,
|
||||
eventsPerLabel: (appeared + disappeared) / mean,
|
||||
};
|
||||
};
|
||||
const result = {
|
||||
frames: frames.length,
|
||||
installations: count('sites'),
|
||||
datacenters: count('centers'),
|
||||
};
|
||||
await fs.writeFile(
|
||||
`${dir}/slow-pan-frames.json`,
|
||||
JSON.stringify(frames, null, 2),
|
||||
);
|
||||
await fs.writeFile(`${dir}/slow-pan.json`, JSON.stringify(result, null, 2));
|
||||
assert.ok(frames.length > 100);
|
||||
assert.ok(
|
||||
result.installations.mean >= 5 && result.datacenters.mean >= 5,
|
||||
'both real layers contribute visible labels on the same pan',
|
||||
);
|
||||
assert.ok(
|
||||
frames.every((f) => f.sites.length <= 24),
|
||||
'shared-domain budget cannot exceed the installation title cap',
|
||||
);
|
||||
assert.ok(
|
||||
result.installations.eventsPerLabel <=
|
||||
result.datacenters.eventsPerLabel + 0.1,
|
||||
'installation label transitions per label match Data Centers on the same slow pan',
|
||||
);
|
||||
assert.ok(
|
||||
result.installations.reappeared <= result.datacenters.reappeared,
|
||||
'installation labels do not flicker off and back on more than Data Centers',
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/slow-pan-side-by-side.png` });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('local-datacenters', false),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function checkInstallationSelection(
|
||||
page,
|
||||
dir,
|
||||
{ video = true } = {},
|
||||
) {
|
||||
const result = [];
|
||||
const recorder = video
|
||||
? await createScreencast(page, `${dir}/selection-orbit`, { fps: 24 })
|
||||
: null;
|
||||
await recorder?.start();
|
||||
try {
|
||||
for (const site of [
|
||||
{
|
||||
key: 'mabry',
|
||||
name: 'Camp Mabry',
|
||||
lon: -97.765,
|
||||
lat: 30.3125,
|
||||
height: 3500,
|
||||
},
|
||||
{
|
||||
key: 'naval',
|
||||
name: 'Naval Base San Diego',
|
||||
lon: -117.119,
|
||||
lat: 32.684,
|
||||
height: 6500,
|
||||
},
|
||||
]) {
|
||||
await moveCamera(page, { ...site, pitch: -80 }, 1.5);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats();
|
||||
return !s.loading && s.count > 0;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
const picked = await page.evaluate(async (site) => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const g = window.__godsEyeView,
|
||||
layer = g.dataManager.layers.get('military-installations').module;
|
||||
const record = layer
|
||||
.getNearby(C.Cartesian3.fromDegrees(site.lon, site.lat), 100000, 1000)
|
||||
.find((r) => r.name === site.name && r.footprints?.length);
|
||||
if (!record) throw new Error(`No polygon for ${site.name}`);
|
||||
let point;
|
||||
layer.visitNamedMarkers((id, p) => {
|
||||
if (id === record.id) point = p.position;
|
||||
});
|
||||
const screen = C.SceneTransforms.worldToWindowCoordinates(
|
||||
g.viewer.scene,
|
||||
point,
|
||||
);
|
||||
window.__polishSelectedId = record.id;
|
||||
window.__polishTitlePaints = [];
|
||||
const canvas = document.getElementById('world-overlay-canvas'),
|
||||
ctx = canvas.getContext('2d');
|
||||
const original = ctx.fillText;
|
||||
const matches = (text) =>
|
||||
String(text).toLowerCase() === site.name.toLowerCase();
|
||||
ctx.fillText = function (text, ...args) {
|
||||
if (matches(text))
|
||||
window.__polishTitlePaints.push({ t: performance.now(), text });
|
||||
return original.call(this, text, ...args);
|
||||
};
|
||||
window.__restorePolishPaint = () => {
|
||||
ctx.fillText = original;
|
||||
};
|
||||
window.__polishSelectionFrames = [];
|
||||
const { getSelectedEntityContext } =
|
||||
await import('/src/data/contextStore.js');
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const knownFills = new Set();
|
||||
const fillEntities = () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (e.id.startsWith(`${record.id}:selected-fill:`)) list.push(e);
|
||||
for (const e of list) knownFills.add(e);
|
||||
return list;
|
||||
};
|
||||
// Geometry-instance ids prove the ground primitive actually built, not just an entity declaration.
|
||||
const primitives = () => {
|
||||
let count = 0;
|
||||
const walk = (collection) => {
|
||||
if (collection?._collectionsArray) {
|
||||
for (const child of collection._collectionsArray) walk(child);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < (collection?.length || 0); i++) {
|
||||
const p = collection.get(i);
|
||||
if (
|
||||
p.ready &&
|
||||
typeof p.getGeometryInstanceAttributes === 'function'
|
||||
) {
|
||||
for (const e of knownFills) {
|
||||
try {
|
||||
const a = p.getGeometryInstanceAttributes(e);
|
||||
if (a && (!a.show || a.show[0])) count++;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (typeof p.get === 'function' || p._collectionsArray) walk(p);
|
||||
}
|
||||
};
|
||||
walk(g.viewer.scene.groundPrimitives);
|
||||
return count;
|
||||
};
|
||||
window.__polishFillProbe = () => ({
|
||||
entities: fillEntities().length,
|
||||
primitives: primitives(),
|
||||
classification: fillEntities().map((e) =>
|
||||
e.polygon.classificationType.getValue(),
|
||||
),
|
||||
selected: getSelectedEntityContext()?.id || null,
|
||||
});
|
||||
window.__stopPolishSelection =
|
||||
g.viewer.scene.postRender.addEventListener(() => {
|
||||
window.__polishSelectionFrames.push({
|
||||
paints: window.__polishTitlePaints.splice(0).length,
|
||||
own: Boolean(
|
||||
getOverlayPaintRect('military-installations', record.id),
|
||||
),
|
||||
tracked: Boolean(
|
||||
getOverlayPaintRect('tracked', `installations:${record.id}`),
|
||||
),
|
||||
selected: getSelectedEntityContext()?.id === record.id,
|
||||
total:
|
||||
getWorldOverlayDiagnostics().paintedBySource[
|
||||
'military-installations'
|
||||
] || 0,
|
||||
});
|
||||
});
|
||||
return {
|
||||
id: record.id,
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
parts: record.footprints.length,
|
||||
};
|
||||
}, site);
|
||||
await page.screenshot({
|
||||
path: `${dir}/after-${site.key}-unselected.png`,
|
||||
});
|
||||
await page.mouse.click(picked.x, picked.y);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const p = window.__polishFillProbe();
|
||||
return p.selected === window.__polishSelectedId && p.primitives > 0;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
await page.screenshot({ path: `${dir}/selection-failure.png` });
|
||||
const diagnostic = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
const describe = (p) => ({
|
||||
type: p.constructor.name,
|
||||
ready: p.ready,
|
||||
length: p.length,
|
||||
keys: Object.keys(p).filter((k) =>
|
||||
/primitive|instance|collection/i.test(k),
|
||||
),
|
||||
ids: p._instanceIds?.map((id) => id.id || String(id)),
|
||||
children:
|
||||
typeof p.get === 'function'
|
||||
? Array.from({ length: p.length }, (_, i) =>
|
||||
describe(p.get(i)),
|
||||
)
|
||||
: p._collectionsArray?.map(describe),
|
||||
});
|
||||
return {
|
||||
probe: window.__polishFillProbe(),
|
||||
stats: g.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
primitives: describe(g.viewer.scene.groundPrimitives),
|
||||
frames: window.__polishSelectionFrames.slice(-5),
|
||||
};
|
||||
});
|
||||
await fs.writeFile(
|
||||
`${dir}/selection-failure.json`,
|
||||
JSON.stringify({ picked, ...diagnostic }, null, 2),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
await sleep(500);
|
||||
const selected = await page.evaluate(() => window.__polishFillProbe());
|
||||
await page.screenshot({ path: `${dir}/after-selection-${site.key}.png` });
|
||||
// Close, oblique angles expose surface clipping and floating fills.
|
||||
for (const heading of [30, 120]) {
|
||||
await page.evaluate(
|
||||
async (site, heading) => {
|
||||
const C =
|
||||
await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const g = window.__godsEyeView;
|
||||
const target = C.Cartesian3.fromDegrees(
|
||||
site.lon,
|
||||
site.lat,
|
||||
site.key === 'mabry' ? 180 : 10,
|
||||
);
|
||||
g.viewer.camera.lookAt(
|
||||
target,
|
||||
new C.HeadingPitchRange(
|
||||
C.Math.toRadians(heading),
|
||||
C.Math.toRadians(-30),
|
||||
800,
|
||||
),
|
||||
);
|
||||
g.viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
|
||||
},
|
||||
site,
|
||||
heading,
|
||||
);
|
||||
await sleep(1700);
|
||||
assert.ok(
|
||||
(await page.evaluate(() => window.__polishFillProbe())).selected,
|
||||
'orbit must preserve installation selection',
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${dir}/after-${site.key}-orbit-${heading}.png`,
|
||||
});
|
||||
}
|
||||
await moveCamera(page, { ...site, pitch: -80 }, 1);
|
||||
await sleep(900);
|
||||
const empty = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
for (let y = 250; y < 650; y += 75)
|
||||
for (let x = 400; x < 850; x += 75) {
|
||||
const pick = g.viewer.scene.pick({ x, y });
|
||||
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
|
||||
return { x, y };
|
||||
}
|
||||
throw new Error('No empty map point');
|
||||
});
|
||||
await page.mouse.click(empty.x, empty.y);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__polishFillProbe().selected === null &&
|
||||
window.__polishFillProbe().entities === 0,
|
||||
);
|
||||
await sleep(600);
|
||||
const deselected = await page.evaluate(() => {
|
||||
window.__stopPolishSelection();
|
||||
window.__restorePolishPaint();
|
||||
return {
|
||||
...window.__polishFillProbe(),
|
||||
frames: window.__polishSelectionFrames,
|
||||
};
|
||||
});
|
||||
assert.equal(selected.entities, picked.parts);
|
||||
assert.equal(selected.primitives, picked.parts);
|
||||
assert.ok(
|
||||
selected.classification.every((c) => c === 1),
|
||||
'Google 3D tile classification',
|
||||
);
|
||||
assert.equal(deselected.entities, 0);
|
||||
assert.equal(deselected.primitives, 0);
|
||||
const selectedFrames = deselected.frames.filter(
|
||||
(f) => f.selected && f.own,
|
||||
);
|
||||
assert.ok(selectedFrames.length > 10);
|
||||
assert.ok(
|
||||
selectedFrames.every((f) => !f.tracked && f.paints === 1),
|
||||
'selected record has exactly one painted overlay element',
|
||||
);
|
||||
assert.ok(
|
||||
deselected.frames
|
||||
.slice(-5)
|
||||
.every((f) => !f.selected && f.own && !f.tracked && f.paints === 1),
|
||||
'deselect restores the one ambient entry',
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${dir}/after-${site.key}-deselected.png`,
|
||||
});
|
||||
result.push({ site, picked, selected, deselected });
|
||||
}
|
||||
} finally {
|
||||
await fs.writeFile(
|
||||
`${dir}/selection-checks.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
if (recorder)
|
||||
await fs.writeFile(
|
||||
`${dir}/selection-video.json`,
|
||||
JSON.stringify(await recorder.stop(), null, 2),
|
||||
);
|
||||
}
|
||||
return result.map(({ site, picked, selected, deselected }) => ({
|
||||
site: site.name,
|
||||
id: picked.id,
|
||||
selected,
|
||||
deselected: {
|
||||
entities: deselected.entities,
|
||||
primitives: deselected.primitives,
|
||||
},
|
||||
frames: deselected.frames.length,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* qa-journey-recorder.mjs — shared support for journey QA gates (not a gate).
|
||||
*
|
||||
* - `createScreencast(page, dir)` records CDP `Page.startScreencast` frames
|
||||
* (every compositor frame) with their real timestamps and builds
|
||||
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
||||
* durations, so held frames last as long as they did on screen.
|
||||
* - `installFrameProbe(page)` measures every animation frame in the page:
|
||||
* the requestAnimationFrame interval and, for frames Cesium rendered, the
|
||||
* CPU time between `scene.preUpdate` and `scene.postRender`.
|
||||
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
||||
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
||||
* zoom rather than teleporting it.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
||||
export function percentile(values, p) {
|
||||
if (!values.length) return Number.NaN;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)
|
||||
];
|
||||
}
|
||||
|
||||
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
|
||||
export function frameStats(frames) {
|
||||
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
||||
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
|
||||
const round = (v) => (Number.isFinite(v) ? Math.round(v * 10) / 10 : null);
|
||||
const mean = (list) =>
|
||||
list.length ? list.reduce((a, b) => a + b, 0) / list.length : Number.NaN;
|
||||
return {
|
||||
frames: intervals.length,
|
||||
frameMean: round(mean(intervals)),
|
||||
frameP50: round(percentile(intervals, 50)),
|
||||
frameP95: round(percentile(intervals, 95)),
|
||||
longFrames: intervals.filter((v) => v > 50).length,
|
||||
renderedFrames: renders.length,
|
||||
renderMean: round(mean(renders)),
|
||||
renderP50: round(percentile(renders, 50)),
|
||||
renderP95: round(percentile(renders, 95)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Install the in-page frame probe once per document; returns nothing. */
|
||||
export async function installFrameProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.__gevFrameProbe) return;
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const probe = { frames: [], started: 0 };
|
||||
window.__gevFrameProbe = probe;
|
||||
let last = performance.now();
|
||||
let renderStart = null;
|
||||
let pendingRender = null;
|
||||
viewer.scene.preUpdate.addEventListener(() => {
|
||||
renderStart = performance.now();
|
||||
});
|
||||
viewer.scene.postRender.addEventListener(() => {
|
||||
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
||||
renderStart = null;
|
||||
});
|
||||
const tick = (now) => {
|
||||
probe.frames.push({ t: now, dt: now - last, renderMs: pendingRender });
|
||||
if (probe.frames.length > 40_000) probe.frames.splice(0, 10_000);
|
||||
pendingRender = null;
|
||||
last = now;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
/** Read probe frames with t in [t0, t1) (page performance.now() times). */
|
||||
export async function readFrames(page, t0, t1 = Infinity) {
|
||||
return page.evaluate(
|
||||
(a, b) =>
|
||||
window.__gevFrameProbe.frames.filter(
|
||||
(f) => f.t >= a && f.t < (b ?? Infinity),
|
||||
),
|
||||
t0,
|
||||
Number.isFinite(t1) ? t1 : null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Page clock in ms (performance.now()). */
|
||||
export function pageNow(page) {
|
||||
return page.evaluate(() => performance.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease the camera to a view over `seconds`, resolving when it lands.
|
||||
* @param {import('puppeteer').Page} page Page.
|
||||
* @param {{lat:number, lon:number, height:number, heading?:number, pitch?:number}} view
|
||||
* Destination (degrees, metres).
|
||||
* @param {number} seconds Flight duration; 0 jumps.
|
||||
*/
|
||||
export async function moveCamera(page, view, seconds) {
|
||||
await page.evaluate(
|
||||
(v, duration) =>
|
||||
new Promise((resolve) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const C = viewer.camera;
|
||||
C.cancelFlight();
|
||||
C.flyTo({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
latitude: (v.lat * Math.PI) / 180,
|
||||
longitude: (v.lon * Math.PI) / 180,
|
||||
height: v.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: ((v.heading ?? 0) * Math.PI) / 180,
|
||||
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
duration,
|
||||
// Straight, eased motion like a drag or wheel zoom, not an arc.
|
||||
maximumHeight: Math.max(v.height, C.positionCartographic.height),
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
});
|
||||
}),
|
||||
view,
|
||||
seconds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the page with CDP screencast frames and real per-frame timing.
|
||||
* @param {import('puppeteer').Page} page Page to record.
|
||||
* @param {string} dir Output directory (frames + clip.mp4).
|
||||
* @param {{maxWidth?: number, maxHeight?: number, quality?: number, fps?: number|null}} [options]
|
||||
*/
|
||||
export async function createScreencast(page, dir, options = {}) {
|
||||
const {
|
||||
maxWidth = 1280,
|
||||
maxHeight = 800,
|
||||
quality = 70,
|
||||
fps = null,
|
||||
} = options;
|
||||
const framesDir = path.join(dir, 'frames');
|
||||
fs.rmSync(framesDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(framesDir, { recursive: true });
|
||||
const cdp = await page.createCDPSession();
|
||||
const frames = [];
|
||||
let recording = false;
|
||||
cdp.on('Page.screencastFrame', async (frame) => {
|
||||
const file = path.join(
|
||||
framesDir,
|
||||
`f${String(frames.length).padStart(5, '0')}.jpg`,
|
||||
);
|
||||
if (recording) {
|
||||
fs.writeFileSync(file, Buffer.from(frame.data, 'base64'));
|
||||
frames.push({ file, timestamp: frame.metadata.timestamp });
|
||||
}
|
||||
await cdp
|
||||
.send('Page.screencastFrameAck', { sessionId: frame.sessionId })
|
||||
.catch(() => {});
|
||||
});
|
||||
return {
|
||||
frames,
|
||||
async start() {
|
||||
recording = true;
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
everyNthFrame: 1,
|
||||
});
|
||||
},
|
||||
/** Stop, encode with real timing, and return clip facts. */
|
||||
async stop() {
|
||||
recording = false;
|
||||
await cdp.send('Page.stopScreencast').catch(() => {});
|
||||
await cdp.detach().catch(() => {});
|
||||
if (frames.length < 2) return { clip: null, frames: frames.length };
|
||||
const lines = [];
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const next = frames[i + 1]?.timestamp;
|
||||
const duration = next
|
||||
? Math.min(2, Math.max(0.001, next - frames[i].timestamp))
|
||||
: 0.05;
|
||||
lines.push(`file '${path.resolve(frames[i].file)}'`);
|
||||
lines.push(`duration ${duration.toFixed(4)}`);
|
||||
}
|
||||
// The concat demuxer ignores the last duration unless the file repeats.
|
||||
lines.push(`file '${path.resolve(frames.at(-1).file)}'`);
|
||||
const list = path.join(dir, 'frames.txt');
|
||||
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
||||
const clip = path.join(dir, 'clip.mp4');
|
||||
execFileSync('ffmpeg', [
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
list,
|
||||
'-fps_mode',
|
||||
fps ? 'cfr' : 'vfr',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-vf',
|
||||
`scale=trunc(iw/2)*2:trunc(ih/2)*2${fps ? `,fps=${fps}` : ''}`,
|
||||
clip,
|
||||
]);
|
||||
const seconds = frames.at(-1).timestamp - frames[0].timestamp;
|
||||
return {
|
||||
clip,
|
||||
frames: frames.length,
|
||||
seconds: Math.round(seconds * 10) / 10,
|
||||
fps: Math.round((frames.length / Math.max(seconds, 0.001)) * 10) / 10,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* End-to-end share/reload proof for the first two-character token. The QA
|
||||
* layer and an exhausted-digit reservation scenario are injected into
|
||||
* dev-server responses in this isolated browser only. Neither belongs in a
|
||||
* production bundle or ledger.
|
||||
*
|
||||
* Run against a local Vite server: node scripts/qa-layer-token-twochar.mjs
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import {
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
nextLayerStateToken,
|
||||
parseLayerStateTokenReservations,
|
||||
validateLayerStateAllocations,
|
||||
} from '../src/data/layerState.js';
|
||||
|
||||
const base = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
if (!['127.0.0.1', 'localhost'].includes(base.hostname))
|
||||
throw new Error('The QA fixture requires a loopback-only dev server');
|
||||
const origin = base.origin;
|
||||
const fixtureId = 'qa-twochar-fixture';
|
||||
const injections = { codec: 0, catalog: 0 };
|
||||
const errors = [];
|
||||
const reservations = JSON.parse(
|
||||
await readFile(
|
||||
new URL('../src/data/layerStateTokenReservations.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
reservations.some(([, token]) => token === '00'),
|
||||
false,
|
||||
'00 must remain unallocated in the production ledger for this QA fixture',
|
||||
);
|
||||
const productionTokens = new Set(reservations.map(([, token]) => token));
|
||||
const priorDigitRows = [...'0123456789']
|
||||
.filter((digit) => !productionTokens.has(digit))
|
||||
.map((digit) => [`qa-prior-digit-${digit}`, digit]);
|
||||
const priorState = parseLayerStateTokenReservations([
|
||||
...reservations,
|
||||
...priorDigitRows,
|
||||
]);
|
||||
assert.equal(nextLayerStateToken(priorState), '00');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
parseLayerStateTokenReservations([
|
||||
...reservations,
|
||||
...priorDigitRows,
|
||||
[fixtureId, '00'],
|
||||
]),
|
||||
),
|
||||
true,
|
||||
);
|
||||
console.log(
|
||||
'PASS: isolated fixture exhausts prior digits before allocating 00',
|
||||
);
|
||||
for (const source of [
|
||||
'../src/data/layerState.js',
|
||||
'../src/app/constructCatalog.js',
|
||||
]) {
|
||||
assert.equal(
|
||||
(await readFile(new URL(source, import.meta.url), 'utf8')).includes(
|
||||
fixtureId,
|
||||
),
|
||||
false,
|
||||
`${fixtureId} must not exist in production source`,
|
||||
);
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const first = source.indexOf(from);
|
||||
if (first < 0 || source.indexOf(from, first + from.length) >= 0)
|
||||
throw new Error(`QA fixture anchor changed: ${label}`);
|
||||
return source.replace(from, to);
|
||||
}
|
||||
|
||||
function injectCodec(source) {
|
||||
const fixtureRows = JSON.stringify([...priorDigitRows, [fixtureId, '00']]);
|
||||
let result = replaceOnce(
|
||||
source,
|
||||
'parseLayerStateTokenReservations(reservationRows)',
|
||||
`parseLayerStateTokenReservations([...reservationRows, ...${fixtureRows}])`,
|
||||
'reservation',
|
||||
);
|
||||
result = replaceOnce(
|
||||
result,
|
||||
']);\n\nexport const REGISTERED_LAYER_IDS',
|
||||
` Object.freeze({ id: '${fixtureId}', token: '00', disposition: 'enabled-only' }),\n]);\n\nexport const REGISTERED_LAYER_IDS`,
|
||||
'registry',
|
||||
);
|
||||
injections.codec += 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
function injectCatalog(source) {
|
||||
const result = replaceOnce(
|
||||
source,
|
||||
' createBhoteKoshiEventLayer(),',
|
||||
` {
|
||||
id: '${fixtureId}',
|
||||
name: 'Synthetic two-character token',
|
||||
icon: '◆',
|
||||
source: 'QA-only fixture',
|
||||
async init() { return true; },
|
||||
async enable() { return true; },
|
||||
async update() { return true; },
|
||||
async disable() { return true; },
|
||||
async destroy() { return true; },
|
||||
getStats() { return { count: 0 }; },
|
||||
},
|
||||
createBhoteKoshiEventLayer(),`,
|
||||
'catalog',
|
||||
);
|
||||
injections.catalog += 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
|
||||
async function newPage() {
|
||||
const context = await browser.createBrowserContext();
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.setCacheEnabled(false);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__qaRestoreEvents = [];
|
||||
window.addEventListener('gev:initial-share-restore-settled', (event) => {
|
||||
window.__qaRestoreEvents.push(event.detail);
|
||||
});
|
||||
});
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== origin) return await request.abort();
|
||||
const transform =
|
||||
url.pathname === '/src/data/layerState.js'
|
||||
? injectCodec
|
||||
: url.pathname === '/src/app/constructCatalog.js'
|
||||
? injectCatalog
|
||||
: null;
|
||||
if (!transform) return await request.continue();
|
||||
const response = await fetch(request.url());
|
||||
assert.equal(response.status, 200, `dev module ${url.pathname}`);
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: transform(await response.text()),
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(String(error?.stack || error));
|
||||
await request.abort().catch(() => {});
|
||||
}
|
||||
});
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function load(page, url) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView?.dataManager?.layers?.has(id),
|
||||
{ timeout: 60_000 },
|
||||
fixtureId,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadedState(page) {
|
||||
return page.evaluate((id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
return {
|
||||
enabled: manager.isEnabled(id),
|
||||
flights: manager.isEnabled('flights'),
|
||||
localStorage: localStorage.getItem('gev:layer-state:v2'),
|
||||
restoreEvents: window.__qaRestoreEvents,
|
||||
hash: location.hash,
|
||||
};
|
||||
}, fixtureId);
|
||||
}
|
||||
|
||||
async function waitForRestore(page) {
|
||||
await page.waitForFunction(() => window.__qaRestoreEvents.length > 0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return loadedState(page);
|
||||
}
|
||||
|
||||
function withLayers(link, layers) {
|
||||
const url = new URL(link);
|
||||
const params = new URLSearchParams(url.hash.slice(1));
|
||||
params.set('l', layers);
|
||||
url.hash = params.toString();
|
||||
return url.href;
|
||||
}
|
||||
|
||||
const contexts = [];
|
||||
try {
|
||||
const sender = await newPage();
|
||||
contexts.push(sender.context);
|
||||
await load(sender.page, `${origin}/?welcome=0`);
|
||||
const toggled = await sender.page.evaluate(
|
||||
(id) =>
|
||||
window.__godsEyeView.dataManager.setEnabled(id, true, { origin: 'user' }),
|
||||
fixtureId,
|
||||
);
|
||||
assert.equal(toggled, true, 'synthetic layer enables through manager');
|
||||
await sender.page.waitForFunction(
|
||||
() => new URLSearchParams(location.hash.slice(1)).get('l') === '00',
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const sharedUrl = sender.page.url();
|
||||
const authored = new URLSearchParams(new URL(sharedUrl).hash.slice(1));
|
||||
assert.equal(authored.get('v'), '2');
|
||||
assert.equal(authored.get('l'), '00');
|
||||
assert.ok(authored.has('lat') && authored.has('lon'));
|
||||
console.log('PASS: real user-origin enable encoded 00 in a full share URL');
|
||||
|
||||
const recipient = await newPage();
|
||||
contexts.push(recipient.context);
|
||||
await load(recipient.page, sharedUrl);
|
||||
const restored = await waitForRestore(recipient.page);
|
||||
assert.equal(restored.enabled, true);
|
||||
assert.equal(restored.localStorage, null);
|
||||
assert.equal(restored.restoreEvents.at(-1)?.status, 'settled');
|
||||
assert.ok(
|
||||
restored.restoreEvents
|
||||
.at(-1)
|
||||
?.layers?.some(
|
||||
(layer) =>
|
||||
layer.layerId === fixtureId &&
|
||||
layer.targetEnabled === true &&
|
||||
layer.succeeded === true,
|
||||
),
|
||||
'share coordinator restored the synthetic layer',
|
||||
);
|
||||
console.log(
|
||||
'PASS: cold recipient reload restored the 00 layer without writing local preferences',
|
||||
);
|
||||
|
||||
await recipient.page.reload({
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await recipient.page.waitForFunction(
|
||||
(id) => window.__godsEyeView?.dataManager?.layers?.has(id),
|
||||
{ timeout: 60_000 },
|
||||
fixtureId,
|
||||
);
|
||||
const reloaded = await waitForRestore(recipient.page);
|
||||
assert.equal(reloaded.enabled, true);
|
||||
assert.equal(reloaded.localStorage, null);
|
||||
assert.equal(new URLSearchParams(reloaded.hash.slice(1)).get('l'), '00');
|
||||
console.log('PASS: a full recipient reload restores the same 00 layer state');
|
||||
|
||||
const disabled = await recipient.page.evaluate(
|
||||
(id) =>
|
||||
window.__godsEyeView.dataManager.setEnabled(id, false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
fixtureId,
|
||||
);
|
||||
assert.equal(disabled, true);
|
||||
await recipient.page.waitForFunction(
|
||||
() => new URLSearchParams(location.hash.slice(1)).get('l') === '',
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
assert.equal((await loadedState(recipient.page)).enabled, false);
|
||||
console.log('PASS: explicit OFF removes 00 from the authored share URL');
|
||||
|
||||
const legacy = await newPage();
|
||||
contexts.push(legacy.context);
|
||||
await load(legacy.page, withLayers(sharedUrl, 'f'));
|
||||
const legacyState = await waitForRestore(legacy.page);
|
||||
assert.equal(legacyState.flights, true);
|
||||
assert.equal(legacyState.enabled, false);
|
||||
assert.equal(legacyState.restoreEvents.at(-1)?.status, 'settled');
|
||||
console.log('PASS: a legacy one-character token still restores');
|
||||
|
||||
const mixed = await newPage();
|
||||
contexts.push(mixed.context);
|
||||
await load(mixed.page, withLayers(sharedUrl, 'f.00'));
|
||||
const mixedState = await waitForRestore(mixed.page);
|
||||
assert.equal(mixedState.flights, true);
|
||||
assert.equal(mixedState.enabled, true);
|
||||
console.log('PASS: mixed legacy and two-character tokens restore together');
|
||||
|
||||
const malformed = await newPage();
|
||||
contexts.push(malformed.context);
|
||||
await load(malformed.page, withLayers(sharedUrl, 'f..00'));
|
||||
const malformedState = await waitForRestore(malformed.page);
|
||||
assert.equal(malformedState.flights, false);
|
||||
assert.equal(malformedState.enabled, false);
|
||||
assert.equal(malformedState.localStorage, null);
|
||||
console.log(
|
||||
'PASS: malformed layer list is rejected without partial restoration',
|
||||
);
|
||||
|
||||
const repeated = await newPage();
|
||||
contexts.push(repeated.context);
|
||||
const repeatedUrl = new URL(withLayers(sharedUrl, 'f'));
|
||||
const repeatedParams = new URLSearchParams(repeatedUrl.hash.slice(1));
|
||||
repeatedParams.append('l', '00');
|
||||
repeatedUrl.hash = repeatedParams.toString();
|
||||
await load(repeated.page, repeatedUrl.href);
|
||||
const repeatedState = await waitForRestore(repeated.page);
|
||||
assert.equal(repeatedState.flights, false);
|
||||
assert.equal(repeatedState.enabled, false);
|
||||
assert.equal(repeatedState.localStorage, null);
|
||||
console.log('PASS: repeated layer fields reject the entire share payload');
|
||||
|
||||
assert.ok(injections.codec >= contexts.length);
|
||||
assert.ok(injections.catalog >= contexts.length);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: QA-only codec and catalog injections reached every isolated session',
|
||||
);
|
||||
console.log(
|
||||
'Synthetic end-to-end evidence only; production 00 allocation remains pending.',
|
||||
);
|
||||
console.log('RESULT: 10 passed, 0 failed, 0 skipped');
|
||||
} finally {
|
||||
await Promise.all(contexts.map((context) => context.close().catch(() => {})));
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
/** Named military areas: continent performance, close identities and a recorded zoom. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import {
|
||||
checkInstallationPan,
|
||||
checkInstallationSelection,
|
||||
} from './qa-installation-polish.mjs';
|
||||
import {
|
||||
createScreencast,
|
||||
installFrameProbe,
|
||||
readFrames,
|
||||
frameStats,
|
||||
pageNow,
|
||||
moveCamera,
|
||||
} from './qa-journey-recorder.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) {
|
||||
console.log('Usage: node scripts/qa-military-names.mjs <url> [--no-video]');
|
||||
process.exit(0);
|
||||
}
|
||||
const url = args.find((a) => /^https?:/.test(a));
|
||||
if (!url) throw new Error('Supply the running server URL');
|
||||
const dir = process.env.QA_NAMES_DIR || 'qa-shots/military-names';
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const result = {
|
||||
views: [],
|
||||
sites: [],
|
||||
errors: [],
|
||||
forbidden: [],
|
||||
packs: [],
|
||||
video: null,
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 180000,
|
||||
args: [
|
||||
'--use-angle=metal',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
let recorder;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (e) => result.errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') result.errors.push(m.text());
|
||||
});
|
||||
page.on('request', (request) => {
|
||||
const u = new URL(request.url());
|
||||
if (
|
||||
/overpass/i.test(u.hostname) ||
|
||||
u.hostname === 'nominatim.openstreetmap.org'
|
||||
)
|
||||
result.forbidden.push(u.origin);
|
||||
if (u.pathname.endsWith('/osm_military_names/names.json'))
|
||||
result.packs.push(u.pathname);
|
||||
});
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
await page.goto(url + (url.includes('?') ? '&' : '?') + 'welcome=0', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
result.page = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
canvas: Boolean(document.querySelector('#cesiumContainer canvas')),
|
||||
errorOverlay: Boolean(document.querySelector('vite-error-overlay')),
|
||||
}));
|
||||
assert.equal(new URL(result.page.url).origin, new URL(url).origin);
|
||||
assert.ok(
|
||||
result.page.title && result.page.canvas && !result.page.errorOverlay,
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
for (const [id] of g.dataManager.layers)
|
||||
await g.dataManager.setEnabled(id, false);
|
||||
g.styleManager.setDetection({ enabled: false });
|
||||
g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
|
||||
});
|
||||
assert.equal(result.packs.length, 0, 'names pack stays unloaded at startup');
|
||||
result.renderer = await page.evaluate(() => {
|
||||
const gl = window.__godsEyeView.viewer.scene.context._gl;
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext
|
||||
? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
assert.doesNotMatch(result.renderer, /swiftshader|software/i);
|
||||
await installFrameProbe(page);
|
||||
const stats = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
);
|
||||
const settle = async () => {
|
||||
await sleep(500);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats();
|
||||
return !s.loading && s.count > 0 && s.status === 'ready';
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1200);
|
||||
};
|
||||
const us = { lon: -100, lat: 39, height: 6500000, pitch: -90 };
|
||||
await moveCamera(page, us, 0);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('military-installations', true),
|
||||
);
|
||||
await settle();
|
||||
result.credit = await page.evaluate(() => {
|
||||
const el = document.querySelector('#cesium-credits');
|
||||
return {
|
||||
text: el?.innerText || '',
|
||||
visible: Boolean(el && getComputedStyle(el).display !== 'none'),
|
||||
};
|
||||
});
|
||||
assert.match(result.credit.text, /© OpenStreetMap/);
|
||||
assert.doesNotMatch(
|
||||
result.credit.text,
|
||||
/© OpenMapTiles/,
|
||||
'wide bundled names use no vector tiles',
|
||||
);
|
||||
assert.ok(result.credit.visible);
|
||||
await page.screenshot({ path: `${dir}/credit.png` });
|
||||
for (const [name, view] of [
|
||||
['us', us],
|
||||
['europe', { lon: 12, lat: 50, height: 4500000, pitch: -90 }],
|
||||
]) {
|
||||
await moveCamera(page, view, 2);
|
||||
await settle();
|
||||
const before = await stats();
|
||||
const overlaps = await page.evaluate(async () => {
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(
|
||||
'military-installations',
|
||||
).module;
|
||||
const rects = [];
|
||||
layer.visitNamedMarkers((id) => {
|
||||
const rect = getOverlayPaintRect('military-installations', id);
|
||||
if (rect) rects.push(rect);
|
||||
});
|
||||
let overlaps = 0;
|
||||
for (let a = 0; a < rects.length; a++)
|
||||
for (let b = a + 1; b < rects.length; b++) {
|
||||
const x = rects[a],
|
||||
y = rects[b];
|
||||
if (
|
||||
x.x < y.x + y.w &&
|
||||
y.x < x.x + x.w &&
|
||||
x.y < y.y + y.h &&
|
||||
y.y < x.y + x.h
|
||||
)
|
||||
overlaps++;
|
||||
}
|
||||
return overlaps;
|
||||
});
|
||||
// Incumbents retain their placements during camera motion, as for Data Centers.
|
||||
assert.ok(
|
||||
before.namedInView > 0 &&
|
||||
before.namedMarkers > 0 &&
|
||||
before.pointsOnScreen > 0,
|
||||
);
|
||||
assert.ok(
|
||||
before.labelsOnScreen > 0 && before.labelsOnScreen <= before.labelCap,
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
// Use the governor's hold so an asynchronous idle transition cannot
|
||||
// overwrite a raw scene flag during the sustained-render measurement.
|
||||
const { holdContinuousRender } = await import('/src/renderGovernor.js');
|
||||
holdContinuousRender('qa-military-names-fps');
|
||||
});
|
||||
const t0 = await pageNow(page);
|
||||
await sleep(3500);
|
||||
const frames = await readFrames(page, t0);
|
||||
await page.evaluate(async () => {
|
||||
const { releaseContinuousRender } =
|
||||
await import('/src/renderGovernor.js');
|
||||
releaseContinuousRender('qa-military-names-fps');
|
||||
});
|
||||
const metrics = frameStats(frames);
|
||||
const seconds = frames.reduce((sum, frame) => sum + frame.dt, 0) / 1000;
|
||||
const fps = metrics.renderedFrames / seconds;
|
||||
const rafFps = metrics.frames / seconds;
|
||||
result.views.push({ name, ...before, overlaps, fps, rafFps, ...metrics });
|
||||
console.log(name, JSON.stringify(result.views.at(-1)));
|
||||
await page.screenshot({ path: `${dir}/${name}.png` });
|
||||
assert.ok(fps >= 55, `${name} fps ${fps.toFixed(1)} < 55`);
|
||||
}
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setCleanView(true),
|
||||
);
|
||||
await sleep(300);
|
||||
result.cleanUi = await page.evaluate(async () => {
|
||||
const host = await import('/src/overlays/worldOverlay.js');
|
||||
const root = document.getElementById('world-overlay-root');
|
||||
return {
|
||||
active: document.body.classList.contains('ui-clean-view'),
|
||||
visible: root.checkVisibility({
|
||||
visibilityProperty: true,
|
||||
opacityProperty: true,
|
||||
}),
|
||||
labels:
|
||||
host.getWorldOverlayDiagnostics().paintedBySource[
|
||||
'military-installations'
|
||||
] || 0,
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
result.cleanUi.active &&
|
||||
result.cleanUi.visible &&
|
||||
result.cleanUi.labels > 0,
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/clean-ui.png` });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setCleanView(false),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'military-installations',
|
||||
false,
|
||||
),
|
||||
);
|
||||
result.disabled = await page.evaluate(async () => {
|
||||
const host = await import('/src/overlays/worldOverlay.js');
|
||||
return host.getWorldOverlayDiagnostics().entriesBySource[
|
||||
'military-installations'
|
||||
];
|
||||
});
|
||||
assert.equal(result.disabled, 0);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('military-installations', true),
|
||||
);
|
||||
await moveCamera(page, us, 1);
|
||||
await settle();
|
||||
result.slowPan = await checkInstallationPan(page, dir);
|
||||
await moveCamera(page, us, 1);
|
||||
await settle();
|
||||
if (!args.includes('--no-video')) {
|
||||
recorder = await createScreencast(page, `${dir}/us-to-san-diego`, {
|
||||
fps: 24,
|
||||
});
|
||||
await recorder.start();
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const g = window.__godsEyeView;
|
||||
const layer = g.dataManager.layers.get('military-installations').module;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'installation-label-probe';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;top:60px;left:360px;z-index:99999;background:#07121cdd;color:#fff;padding:8px 12px;font:13px monospace;pointer-events:none';
|
||||
document.body.append(overlay);
|
||||
const screen = new C.Cartesian2();
|
||||
window.__installationFrames = [];
|
||||
window.__stopInstallationFrames =
|
||||
g.viewer.scene.postRender.addEventListener(() => {
|
||||
const stats = layer.getStats();
|
||||
const markers = [],
|
||||
labels = [],
|
||||
rects = [];
|
||||
layer.visitNamedMarkers((id, point) => {
|
||||
const rect = getOverlayPaintRect('military-installations', id);
|
||||
if (rect) rects.push(rect);
|
||||
const p = C.SceneTransforms.worldToWindowCoordinates(
|
||||
g.viewer.scene,
|
||||
point.position,
|
||||
screen,
|
||||
);
|
||||
if (
|
||||
point.show &&
|
||||
p &&
|
||||
p.x >= 0 &&
|
||||
p.y >= 0 &&
|
||||
p.x < 1280 &&
|
||||
p.y < 800
|
||||
) {
|
||||
markers.push(id);
|
||||
if (rect) labels.push(id);
|
||||
}
|
||||
});
|
||||
let overlaps = 0;
|
||||
for (let a = 0; a < rects.length; a++)
|
||||
for (let b = a + 1; b < rects.length; b++) {
|
||||
const x = rects[a],
|
||||
y = rects[b];
|
||||
if (
|
||||
x.x < y.x + y.w &&
|
||||
y.x < x.x + x.w &&
|
||||
x.y < y.y + y.h &&
|
||||
y.y < x.y + x.h
|
||||
)
|
||||
overlaps++;
|
||||
}
|
||||
const altitude = g.viewer.camera.positionCartographic.height;
|
||||
window.__installationFrames.push({
|
||||
t: performance.now(),
|
||||
altitude,
|
||||
markers,
|
||||
labels,
|
||||
paintedLabels: stats.labelsOnScreen,
|
||||
overlaps,
|
||||
loading: stats.loading,
|
||||
wide: stats.wide,
|
||||
});
|
||||
overlay.textContent = `${(altitude / 1000).toFixed(1)} km | markers ${markers.length} | labels ${labels.length} / 24`;
|
||||
});
|
||||
});
|
||||
const clipStart = await pageNow(page);
|
||||
await sleep(1200);
|
||||
for (const view of [
|
||||
{ lon: -116, lat: 35, height: 1500000, pitch: -85 },
|
||||
{ lon: -117.2, lat: 33, height: 200000, pitch: -80 },
|
||||
{ lon: -117.1, lat: 32.86, height: 18000, pitch: -80 },
|
||||
]) {
|
||||
await moveCamera(page, view, 3);
|
||||
await sleep(800);
|
||||
}
|
||||
await settle();
|
||||
await sleep(1500);
|
||||
result.clipFrames = await readFrames(page, clipStart);
|
||||
result.labelFrames = await page.evaluate(() => {
|
||||
window.__stopInstallationFrames();
|
||||
document.getElementById('installation-label-probe').remove();
|
||||
return window.__installationFrames;
|
||||
});
|
||||
await fs.writeFile(
|
||||
`${dir}/label-frames.json`,
|
||||
JSON.stringify(result.labelFrames, null, 2),
|
||||
);
|
||||
assert.ok(result.labelFrames.length > 100);
|
||||
assert.ok(
|
||||
result.labelFrames.every(
|
||||
(frame) =>
|
||||
frame.markers.length > 0 &&
|
||||
frame.labels.length > 0 &&
|
||||
frame.paintedLabels > 0 &&
|
||||
frame.paintedLabels <= 24,
|
||||
),
|
||||
'zoom never loses all visible named markers or labels',
|
||||
);
|
||||
if (recorder) {
|
||||
result.video = await recorder.stop();
|
||||
recorder = null;
|
||||
}
|
||||
result.selection = await checkInstallationSelection(page, dir, {
|
||||
video: !args.includes('--no-video'),
|
||||
});
|
||||
const sites = [
|
||||
['mabry', 'Camp Mabry', -97.765, 30.3125, 3500],
|
||||
['pendleton', 'Marine Corps Base Camp Pendleton', -117.4, 33.33, 25000],
|
||||
['miramar', 'Marine Corps Air Station Miramar', -117.14, 32.87, 18000],
|
||||
['ramstein', 'Ramstein Air Base', 7.6, 49.44, 7000],
|
||||
['lakenheath', 'RAF Lakenheath', 0.56, 52.41, 7000],
|
||||
['hood', 'Fort Hood', -97.78, 31.135, 30000],
|
||||
];
|
||||
for (const [key, expected, lon, lat, height] of sites) {
|
||||
await moveCamera(page, { lon, lat, height, pitch: -80 }, 1.5);
|
||||
await settle();
|
||||
const snapshot = await page.evaluate(
|
||||
(lat, lon) => {
|
||||
const g = window.__godsEyeView;
|
||||
const layer = g.dataManager.layers.get('military-installations').module;
|
||||
const position = g.viewer.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
{
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
height: 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
stats: layer.getStats(),
|
||||
records: layer.getNearby(position, 100000, 1000).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
memberNames: r.memberNames || [],
|
||||
hasPolygon: Boolean(r.footprints?.length),
|
||||
})),
|
||||
};
|
||||
},
|
||||
lat,
|
||||
lon,
|
||||
);
|
||||
result.sites.push({ key, expected, ...snapshot });
|
||||
assert.equal(
|
||||
new Set(snapshot.records.map((r) => r.id)).size,
|
||||
snapshot.records.length,
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.stats.labelsOnScreen > 0 &&
|
||||
snapshot.stats.labelsOnScreen <= snapshot.stats.labelCap,
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.records.some((r) => r.name === expected && r.hasPolygon),
|
||||
`${key}: missing named polygon ${expected}; got ${snapshot.records.map((r) => r.name).join(', ')}`,
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/${key}.png` });
|
||||
console.log(key, expected);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
if (
|
||||
document
|
||||
.getElementById('global-context-panel')
|
||||
.classList.contains('collapsed')
|
||||
)
|
||||
document
|
||||
.querySelector('[data-collapse-target="global-context-panel"]')
|
||||
.click();
|
||||
});
|
||||
await page.waitForSelector('#global-context-flights-btn', { visible: true });
|
||||
await page.evaluate(() => {
|
||||
const button = document.getElementById('global-context-flights-btn');
|
||||
if (button.getAttribute('aria-selected') !== 'true') button.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const state = window.__godsEyeView.styleManager.getContextModeState();
|
||||
return state.mode === 'flights' && !state.changing;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
result.card = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
return g.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.focusById('osm:military:r13529728');
|
||||
});
|
||||
assert.equal(result.card, true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('military-awareness-panel')
|
||||
?.textContent.includes('Named areas') &&
|
||||
document
|
||||
.getElementById('military-awareness-panel')
|
||||
.checkVisibility({ visibilityProperty: true, opacityProperty: true }),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
result.cardText = await page.$eval(
|
||||
'#military-awareness-panel',
|
||||
(el) => el.textContent,
|
||||
);
|
||||
assert.match(result.cardText, /Fort Hood/);
|
||||
assert.match(result.cardText, /TA-25/);
|
||||
await sleep(1800);
|
||||
await page.$eval(
|
||||
'#military-awareness-panel .military-awareness-names',
|
||||
(el) => {
|
||||
el.open = true;
|
||||
},
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/member-names-card.png` });
|
||||
assert.equal(result.packs.length, 1, 'single lazy asset fetch');
|
||||
assert.deepEqual(result.forbidden, []);
|
||||
assert.deepEqual(result.errors, []);
|
||||
} finally {
|
||||
if (recorder)
|
||||
result.video = await recorder.stop().catch((e) => ({ error: e.message }));
|
||||
await fs.writeFile(`${dir}/result.json`, JSON.stringify(result, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless terrain throttling probe.
|
||||
*
|
||||
* On the keyless map source the browser fetches Re:Earth quantized-mesh tiles
|
||||
* (`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain`) directly.
|
||||
* A zoom-out burst asks for a few dozen shallow tiles at once and the upstream
|
||||
* answers HTTP 429 for most of them. This harness drives that burst, counts the
|
||||
* throttled responses, keeps the first 429's headers as evidence, and then
|
||||
* checks whether every throttled tile eventually loaded — the contract the
|
||||
* client-side retry in `src/maps/terrain.js` provides.
|
||||
*
|
||||
* Run against a keyless dev server (no Google or ion credentials):
|
||||
* env -u GOOGLE_MAPS_API_KEY -u CESIUM_ION_TOKEN npm run dev -- --port 4180
|
||||
* QA_BASE_URL=http://localhost:4180 node scripts/qa-terrain-429.mjs
|
||||
*
|
||||
* Flags: `--mode zoomOut|wheel` (default zoomOut), `--notches N` (default 12),
|
||||
* `--settle MS` (default 25000), `--rounds N` (default 1), `--inject N`
|
||||
* (answer the first N tile requests of each burst with a synthetic 429),
|
||||
* `--headful`.
|
||||
* Exits 1 when a throttled tile never recovered or a terrain tile is left in
|
||||
* Cesium's FAILED state.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name, fallback) => {
|
||||
const index = argv.indexOf(`--${name}`);
|
||||
return index >= 0 && argv[index + 1] !== undefined
|
||||
? argv[index + 1]
|
||||
: fallback;
|
||||
};
|
||||
const mode = flag('mode', 'zoomOut');
|
||||
const notches = Number(flag('notches', 12));
|
||||
const settleMs = Number(flag('settle', 25000));
|
||||
const rounds = Number(flag('rounds', 1));
|
||||
// The upstream throttle is load-dependent and does not fire on demand. With
|
||||
// `--inject N`, the first N tile requests of each burst are answered 429 by an
|
||||
// XMLHttpRequest shim inside the page so the recovery contract can be proven
|
||||
// deterministically; real 429s are still counted and kept as evidence. (CDP
|
||||
// request interception is not used: it stalls Cesium's request scheduler.)
|
||||
const inject = Number(flag('inject', 0));
|
||||
const headful = argv.includes('--headful');
|
||||
const TERRAIN_HOST = 'terrain.reearth.land';
|
||||
const TILE_PATTERN = /\/cesium-mesh\/ellipsoid\/\d+\/\d+\/\d+\.terrain/;
|
||||
|
||||
const executablePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath().catch(() => null));
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Puppeteer Chrome for Testing is unavailable');
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headful ? false : 'new',
|
||||
executablePath,
|
||||
args: [
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--no-sandbox',
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
/** @type {Map<string, {statuses: number[], failures: number}>} per tile URL. */
|
||||
const tiles = new Map();
|
||||
const evidence = [];
|
||||
let layerJsonSeen = false;
|
||||
let recording = false;
|
||||
let requestsInWindow = 0;
|
||||
let throttledInWindow = 0;
|
||||
const tileEntry = (url) => {
|
||||
if (!tiles.has(url)) tiles.set(url, { statuses: [], failures: 0 });
|
||||
return tiles.get(url);
|
||||
};
|
||||
|
||||
if (inject > 0) {
|
||||
// Cesium fetches terrain through XMLHttpRequest and reads status, response
|
||||
// and getAllResponseHeaders() in its onload handler; shadowing those on the
|
||||
// instance is enough to make it see a throttled reply.
|
||||
await page.evaluateOnNewDocument((pattern) => {
|
||||
const tilePattern = new RegExp(pattern);
|
||||
const shim = { remaining: 0, injected: [] };
|
||||
window.__qaTerrainThrottle = shim;
|
||||
const open = XMLHttpRequest.prototype.open;
|
||||
const send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
this.__qaUrl = String(url);
|
||||
return open.call(this, method, url, ...rest);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
if (!(shim.remaining > 0 && tilePattern.test(this.__qaUrl || ''))) {
|
||||
return send.apply(this, args);
|
||||
}
|
||||
shim.remaining -= 1;
|
||||
shim.injected.push(this.__qaUrl);
|
||||
const xhr = this;
|
||||
setTimeout(() => {
|
||||
for (const [key, value] of [
|
||||
['status', 429],
|
||||
['readyState', 4],
|
||||
['response', null],
|
||||
]) {
|
||||
Object.defineProperty(xhr, key, { value, configurable: true });
|
||||
}
|
||||
xhr.getAllResponseHeaders = () => 'retry-after: 1\r\n';
|
||||
xhr.onload?.(new Event('load'));
|
||||
}, 30);
|
||||
return undefined;
|
||||
};
|
||||
}, TILE_PATTERN.source);
|
||||
}
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes(TERRAIN_HOST)) return;
|
||||
if (url.endsWith('layer.json')) layerJsonSeen = true;
|
||||
if (!TILE_PATTERN.test(url)) return;
|
||||
const status = response.status();
|
||||
tileEntry(url).statuses.push(status);
|
||||
if (recording) requestsInWindow += 1;
|
||||
if (status !== 429) return;
|
||||
if (recording) throttledInWindow += 1;
|
||||
if (evidence.length < 3) {
|
||||
let body = '';
|
||||
try {
|
||||
body = (await response.text()).slice(0, 160);
|
||||
} catch {
|
||||
/* body unavailable */
|
||||
}
|
||||
evidence.push({ url, status, headers: response.headers(), body });
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (!TILE_PATTERN.test(url)) return;
|
||||
tileEntry(url).failures += 1;
|
||||
if (recording) requestsInWindow += 1;
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const waitFor = async (predicate, timeoutMs, label) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
};
|
||||
|
||||
const setCamera = (lon, lat, height) =>
|
||||
page.evaluate(
|
||||
({ lon, lat, height }) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
// The app's own startup flight would otherwise keep steering the camera
|
||||
// through the burst and turn the zoom-out into a zoom-in.
|
||||
viewer.camera.cancelFlight();
|
||||
const cartographic =
|
||||
viewer.camera.positionCartographic.constructor.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
);
|
||||
viewer.camera.setView({
|
||||
destination:
|
||||
viewer.scene.globe.ellipsoid.cartographicToCartesian(cartographic),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
window.__godsEyeView.requestRender?.();
|
||||
},
|
||||
{ lon, lat, height },
|
||||
);
|
||||
|
||||
const cameraHeight = () =>
|
||||
page.evaluate(
|
||||
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
||||
);
|
||||
|
||||
const globeState = () =>
|
||||
page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const surface = viewer.scene.globe._surface;
|
||||
let failed = 0;
|
||||
let loaded = 0;
|
||||
const visit = (tile) => {
|
||||
if (!tile) return;
|
||||
const state = tile.data?.terrainState;
|
||||
if (state === 0) failed += 1;
|
||||
else if (state === 6) loaded += 1;
|
||||
for (const child of tile._children || []) visit(child);
|
||||
};
|
||||
for (const tile of surface._levelZeroTiles || []) visit(tile);
|
||||
return {
|
||||
failed,
|
||||
loaded,
|
||||
tilesLoaded: viewer.scene.globe.tilesLoaded,
|
||||
provider: viewer.terrainProvider?.constructor?.name,
|
||||
};
|
||||
});
|
||||
|
||||
const burst = async () => {
|
||||
if (mode === 'zoomOut') {
|
||||
for (let i = 0; i < notches; i += 1) {
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.zoomOut(viewer.camera.positionCartographic.height * 0.6);
|
||||
window.__godsEyeView.requestRender?.();
|
||||
});
|
||||
await sleep(40);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await page.mouse.move(640, 400);
|
||||
for (let i = 0; i < notches; i += 1) {
|
||||
await page.mouse.wheel({ deltaY: 200 });
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender?.());
|
||||
await sleep(40);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView?.viewer), {
|
||||
timeout: 60000,
|
||||
});
|
||||
await waitFor(() => layerJsonSeen, 60000, 'the Re:Earth layer.json fetch');
|
||||
await waitFor(
|
||||
async () => (await globeState()).provider === 'CesiumTerrainProvider',
|
||||
30000,
|
||||
'the keyless terrain provider',
|
||||
);
|
||||
|
||||
const summaries = [];
|
||||
for (let round = 1; round <= rounds; round += 1) {
|
||||
// Re-apply the view until it sticks: a still-running startup flight can
|
||||
// overwrite the first setView on the very next frame.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await setCamera(-97.7431, 30.2672, 40000);
|
||||
await sleep(1500);
|
||||
const height = await cameraHeight();
|
||||
if (Math.abs(height - 40000) < 1000) break;
|
||||
}
|
||||
await waitFor(
|
||||
async () => (await globeState()).tilesLoaded,
|
||||
30000,
|
||||
'the 40 km view to settle',
|
||||
).catch(() => {});
|
||||
await sleep(2000);
|
||||
const settledHeight = await cameraHeight();
|
||||
if (Math.abs(settledHeight - 40000) > 1000) {
|
||||
throw new Error(
|
||||
`camera did not hold the 40 km start view (at ${Math.round(settledHeight)} m)`,
|
||||
);
|
||||
}
|
||||
const before = await globeState();
|
||||
tiles.clear();
|
||||
requestsInWindow = 0;
|
||||
throttledInWindow = 0;
|
||||
if (inject > 0) {
|
||||
await page.evaluate((count) => {
|
||||
window.__qaTerrainThrottle.remaining = count;
|
||||
window.__qaTerrainThrottle.injected = [];
|
||||
}, inject);
|
||||
}
|
||||
recording = true;
|
||||
const startHeight = await cameraHeight();
|
||||
await burst();
|
||||
const endHeight = await cameraHeight();
|
||||
await sleep(settleMs);
|
||||
recording = false;
|
||||
|
||||
// Injected throttles never reach the network, so fold them in from the
|
||||
// page: a later real 200 for the same tile is the retry succeeding.
|
||||
const injected =
|
||||
inject > 0
|
||||
? await page.evaluate(() => window.__qaTerrainThrottle.injected)
|
||||
: [];
|
||||
for (const url of injected) tileEntry(url).statuses.unshift(429);
|
||||
const throttled = [...tiles.entries()].filter(([, entry]) =>
|
||||
entry.statuses.includes(429),
|
||||
);
|
||||
const recovered = throttled.filter(([, entry]) =>
|
||||
entry.statuses.includes(200),
|
||||
);
|
||||
const unrecovered = throttled.filter(
|
||||
([, entry]) => !entry.statuses.includes(200),
|
||||
);
|
||||
const after = await globeState();
|
||||
summaries.push({
|
||||
round,
|
||||
mode,
|
||||
notches,
|
||||
startHeightKm: Math.round(startHeight / 1000),
|
||||
endHeightKm: Math.round(endHeight / 1000),
|
||||
tileResponses: requestsInWindow,
|
||||
throttledResponses: throttledInWindow,
|
||||
injectedThrottles: injected.length,
|
||||
throttledTiles: throttled.length,
|
||||
recoveredTiles: recovered.length,
|
||||
unrecoveredTiles: unrecovered.length,
|
||||
failedTilesBefore: before.failed,
|
||||
failedTilesAfter: after.failed,
|
||||
loadedTilesAfter: after.loaded,
|
||||
});
|
||||
console.log(JSON.stringify(summaries.at(-1)));
|
||||
if (unrecovered.length) {
|
||||
console.log(
|
||||
' unrecovered: ' +
|
||||
unrecovered
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
([url, entry]) =>
|
||||
`${new URL(url).pathname} [${entry.statuses.join(',')}]`,
|
||||
)
|
||||
.join(' · '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (evidence.length) {
|
||||
console.log('first 429 evidence:');
|
||||
for (const item of evidence) {
|
||||
const headers = Object.fromEntries(
|
||||
Object.entries(item.headers).filter(([name]) =>
|
||||
/^(cf-|retry-after|server|content-type|date|x-ratelimit|ratelimit|access-control)/i.test(
|
||||
name,
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
` ${new URL(item.url).pathname} ${JSON.stringify(headers)} body=${JSON.stringify(item.body)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const failures = summaries.filter(
|
||||
(summary) => summary.unrecoveredTiles > 0 || summary.failedTilesAfter > 0,
|
||||
);
|
||||
const terrainErrors = consoleErrors.filter((text) =>
|
||||
/terrain tile/i.test(text),
|
||||
);
|
||||
if (terrainErrors.length) {
|
||||
console.log(`terrain console errors: ${terrainErrors.length}`);
|
||||
console.log(` ${terrainErrors[0].slice(0, 200)}`);
|
||||
}
|
||||
console.log(
|
||||
failures.length
|
||||
? `FAIL — ${failures.length}/${summaries.length} round(s) left throttled terrain tiles unloaded`
|
||||
: `PASS — every throttled terrain tile recovered in ${summaries.length} round(s)`,
|
||||
);
|
||||
process.exitCode = failures.length ? 1 : 0;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Traffic motion acceptance: real-GPU Puppeteer, stable identities, stationary
|
||||
* camera holds and timestamped CDP JPEGs encoded with ffmpeg. No tile mocks.
|
||||
* Run: node scripts/qa-traffic-motion.mjs http://localhost:4186
|
||||
* --baseline records the unmodified pre-fix implementation (object identities
|
||||
* via WeakMap, never collection indices); it reports failures without exiting 1.
|
||||
* --mode=tomtom|osm|hybrid limits a diagnostic run. --tag=name keeps captures.
|
||||
* --journey follows a neighborhood-to-city route with layer toggles, then
|
||||
* checks Capitol/greenbelt/parking/corridor views against a 1.5 s limit.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import puppeteer from 'puppeteer';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-traffic-motion.mjs <url> [--baseline] [--mode=tomtom|osm|hybrid] [--tag=name] [--journey]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const url = args.find((a) => /^https?:/.test(a));
|
||||
if (!url) throw new Error('A running dev server URL is required');
|
||||
const baseline = args.includes('--baseline');
|
||||
const journey = args.includes('--journey');
|
||||
const option = (key) =>
|
||||
args.find((a) => a.startsWith(`--${key}=`))?.split('=')[1];
|
||||
const modes = option('mode') ? [option('mode')] : ['tomtom', 'osm', 'hybrid'];
|
||||
if (modes.some((m) => !['tomtom', 'osm', 'hybrid'].includes(m)))
|
||||
throw new Error('Invalid road mode');
|
||||
const root = path.resolve(
|
||||
'qa-shots/motion',
|
||||
option('tag') || (baseline ? 'before' : '.'),
|
||||
);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const revision = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
'--use-angle=metal',
|
||||
'--window-size=1280,800',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
const results = [];
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
try {
|
||||
for (const mode of modes) {
|
||||
const dir = path.join(root, mode);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [],
|
||||
forbidden = [],
|
||||
logs = [],
|
||||
tileRequests = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') errors.push(m.text());
|
||||
if (m.text().startsWith('[Data:Traffic]')) logs.push(m.text());
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
const u = new URL(r.url());
|
||||
if (
|
||||
u.hostname === 'tiles.openfreemap.org' &&
|
||||
/\/\d+\/\d+\/\d+(?:\.pbf)?$/.test(u.pathname)
|
||||
)
|
||||
tileRequests.push(u.pathname);
|
||||
if (
|
||||
/overpass/i.test(u.hostname) ||
|
||||
u.hostname === 'nominatim.openstreetmap.org'
|
||||
)
|
||||
forbidden.push(u.hostname);
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
const u = new URL(url);
|
||||
u.searchParams.set('trafficRoads', mode);
|
||||
u.searchParams.set('trafficDebug', '1');
|
||||
await page.goto(u.href, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
g.styleManager.setDetection({ enabled: false });
|
||||
for (const [id] of g.dataManager.layers)
|
||||
g.dataManager.setEnabled(id, false);
|
||||
g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
|
||||
});
|
||||
const identity = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
text: document.body.innerText.length,
|
||||
overlay: Boolean(document.querySelector('vite-error-overlay')),
|
||||
}));
|
||||
await page.evaluate((baseline) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const layer = dataManager.layers.get('traffic').module;
|
||||
if (!baseline && typeof layer.visitMotionDots !== 'function')
|
||||
throw new Error('Traffic must expose stable dot identities');
|
||||
const scene = viewer.scene;
|
||||
const weakIds = new WeakMap();
|
||||
let nextId = 0,
|
||||
rebuilds = 0;
|
||||
const collections = [];
|
||||
function discoverCollections() {
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (!Array.isArray(p._pointPrimitives) || collections.includes(p))
|
||||
continue;
|
||||
collections.push(p);
|
||||
const removeAll = p.removeAll;
|
||||
p.removeAll = function () {
|
||||
if (this.length) rebuilds++;
|
||||
return removeAll.call(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
let previous = new Map(),
|
||||
previousN = 0,
|
||||
lastRebuilds = 0,
|
||||
lastTime = 0;
|
||||
const rows = [];
|
||||
window.__motion = {
|
||||
rows,
|
||||
active: false,
|
||||
moveEnds: [],
|
||||
label: 'camera moving',
|
||||
holdStart: null,
|
||||
};
|
||||
const clock = document.createElement('div');
|
||||
clock.style.cssText =
|
||||
'position:fixed;bottom:4px;left:38%;padding:3px 8px;background:#000c;color:#fff;font:12px monospace;z-index:999999;pointer-events:none';
|
||||
document.body.append(clock);
|
||||
viewer.camera.moveEnd.addEventListener(() =>
|
||||
window.__motion.moveEnds.push(performance.now()),
|
||||
);
|
||||
scene.postRender.addEventListener(() => {
|
||||
if (!window.__motion.active) return;
|
||||
discoverCollections();
|
||||
const now = performance.now(),
|
||||
points = new Map();
|
||||
clock.textContent =
|
||||
`QA: ${window.__motion.label}` +
|
||||
(window.__motion.holdStart === null
|
||||
? ''
|
||||
: ` | camera stopped +${((now - window.__motion.holdStart) / 1000).toFixed(1)}s`);
|
||||
const visit = (id, roadId, point) => {
|
||||
if (!point.show) return;
|
||||
const s = scene.cartesianToCanvasCoordinates(point.position);
|
||||
if (s && s.x >= 0 && s.y >= 0 && s.x <= 1280 && s.y <= 800)
|
||||
points.set(id, { x: s.x, y: s.y, roadId });
|
||||
};
|
||||
let n = 0,
|
||||
offRoad = 0;
|
||||
if (!baseline && layer.visitMotionDots) {
|
||||
layer.visitMotionDots((id, roadId, point, road) => {
|
||||
if (point.show) {
|
||||
const p = road?.roadProperties;
|
||||
const allowed = road?.directFlow
|
||||
? [
|
||||
'Motorway',
|
||||
'International road',
|
||||
'Major road',
|
||||
'Secondary road',
|
||||
'Connecting road',
|
||||
'Major local road',
|
||||
'Local road',
|
||||
'Minor local road',
|
||||
].includes(p?.roadType)
|
||||
: [
|
||||
'motorway',
|
||||
'trunk',
|
||||
'primary',
|
||||
'secondary',
|
||||
'tertiary',
|
||||
'minor',
|
||||
].includes(p?.class) &&
|
||||
!p?.service &&
|
||||
!p?.subclass &&
|
||||
!['no', 'private'].includes(p?.access) &&
|
||||
p?.brunnel !== 'tunnel';
|
||||
if (!allowed) offRoad++;
|
||||
}
|
||||
if (point.show) n++;
|
||||
visit(id, roadId, point);
|
||||
});
|
||||
} else {
|
||||
for (const coll of collections)
|
||||
if (coll.show)
|
||||
for (let i = 0; i < coll.length; i++) {
|
||||
const p = coll.get(i);
|
||||
if (!weakIds.has(p)) weakIds.set(p, ++nextId);
|
||||
if (p.show) n++;
|
||||
visit(weakIds.get(p), null, p);
|
||||
}
|
||||
}
|
||||
let tracked = 0,
|
||||
jumps = 0,
|
||||
reassigned = 0,
|
||||
births = 0,
|
||||
deaths = 0;
|
||||
for (const [id, p] of points) {
|
||||
const prev = previous.get(id);
|
||||
if (!prev) {
|
||||
births++;
|
||||
continue;
|
||||
}
|
||||
tracked++;
|
||||
if (Math.hypot(p.x - prev.x, p.y - prev.y) > 30) jumps++;
|
||||
if (p.roadId !== prev.roadId) reassigned++;
|
||||
}
|
||||
for (const id of previous.keys()) if (!points.has(id)) deaths++;
|
||||
rows.push({
|
||||
t: now,
|
||||
loading: layer.getStats().loading,
|
||||
syncing:
|
||||
document
|
||||
.getElementById('traffic-sync-chip')
|
||||
?.classList.contains('visible') &&
|
||||
document.getElementById('traffic-sync-progress')?.textContent ===
|
||||
'...',
|
||||
surfacePending: layer.getStats().surfacePending,
|
||||
offRoad,
|
||||
dt: now - lastTime,
|
||||
n,
|
||||
visible: points.size,
|
||||
tracked,
|
||||
jumps,
|
||||
jumpFrac: tracked ? jumps / tracked : 0,
|
||||
reassigned,
|
||||
births,
|
||||
deaths,
|
||||
sizeChanged: n !== previousN,
|
||||
rebuilds: rebuilds - lastRebuilds,
|
||||
});
|
||||
previous = points;
|
||||
previousN = n;
|
||||
lastRebuilds = rebuilds;
|
||||
lastTime = now;
|
||||
});
|
||||
}, baseline);
|
||||
const fly = (lat, lon, height, pitch, duration, heading = 20) =>
|
||||
page.evaluate(
|
||||
async (v) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
window.__motion.label = 'camera moving';
|
||||
window.__motion.holdStart = null;
|
||||
viewer.camera.cancelFlight();
|
||||
await new Promise((resolve) =>
|
||||
viewer.camera.flyTo({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
{
|
||||
latitude: (v.lat * Math.PI) / 180,
|
||||
longitude: (v.lon * Math.PI) / 180,
|
||||
height: v.height,
|
||||
},
|
||||
),
|
||||
orientation: {
|
||||
heading: (v.heading * Math.PI) / 180,
|
||||
pitch: (v.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
duration: v.duration,
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ lat, lon, height, pitch, duration, heading },
|
||||
);
|
||||
const cdp = await page.createCDPSession();
|
||||
let fi = 0;
|
||||
const frames = [];
|
||||
cdp.on('Page.screencastFrame', (f) => {
|
||||
const name = `f${String(fi++).padStart(5, '0')}.jpg`;
|
||||
fs.writeFileSync(path.join(dir, name), Buffer.from(f.data, 'base64'));
|
||||
frames.push({ name, timestamp: f.metadata.timestamp });
|
||||
cdp
|
||||
.send('Page.screencastFrameAck', { sessionId: f.sessionId })
|
||||
.catch(() => {});
|
||||
});
|
||||
await fly(
|
||||
...(journey
|
||||
? [30.25, -97.7497, 700, -40, 0, 10]
|
||||
: [30.2685, -97.7425, 450, -35, 0]),
|
||||
);
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality: 65,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 800,
|
||||
everyNthFrame: journey ? 1 : 3,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.__motion.active = true;
|
||||
});
|
||||
const holds = [];
|
||||
const hold = async (name, warm = false, holdMs = 10000, empty = false) => {
|
||||
const start = await page.evaluate((label) => {
|
||||
const start = performance.now();
|
||||
window.__motion.label = label;
|
||||
window.__motion.holdStart = start;
|
||||
return start;
|
||||
}, name);
|
||||
await delay(holdMs);
|
||||
const end = await page.evaluate(() => performance.now());
|
||||
holds.push({
|
||||
name,
|
||||
warm,
|
||||
start,
|
||||
end,
|
||||
empty,
|
||||
settleMs: journey ? 1500 : warm ? 1000 : 3000,
|
||||
});
|
||||
await page.screenshot({ path: path.join(dir, `${name}.png`) });
|
||||
console.log(`${mode}: captured ${name}`);
|
||||
};
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
// Match motion-reference: begin the camera-motion scenario from a
|
||||
// settled street view. Initial mesh acquisition is recorded in the clip
|
||||
// and first-dot diagnostics, but is not a stationary motion hold.
|
||||
if (journey) {
|
||||
await delay(4000);
|
||||
await hold('south-congress', true, 6000);
|
||||
const step = async (name, view, ms = 6000, empty = false) => {
|
||||
await fly(...view);
|
||||
await hold(name, false, ms, empty);
|
||||
};
|
||||
await step('pan-north-400m', [30.2536, -97.7497, 700, -40, 2, 10], 4000);
|
||||
await step('pan-east-500m', [30.2536, -97.7445, 700, -40, 2, 10], 4000);
|
||||
await step('zoom-out-2.5km', [30.2536, -97.7445, 2500, -45, 2.5, 10]);
|
||||
await step(
|
||||
'downtown-2.5km',
|
||||
[30.2672, -97.7431, 2500, -45, 2.5, 10],
|
||||
5000,
|
||||
);
|
||||
await step('downtown-500m', [30.2672, -97.7431, 500, -35, 2.5, 20]);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('alpr-cameras', true),
|
||||
);
|
||||
await hold('enable-alpr', true, 6000);
|
||||
await step('pan-west-600m', [30.2672, -97.7494, 500, -35, 2, 20], 5000);
|
||||
await step('city-12km', [30.28, -97.75, 12000, -60, 3, 0], 8000, true);
|
||||
await fly(30.3125, -97.765, 1000, -45, 3, 0);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'military-installations',
|
||||
true,
|
||||
),
|
||||
);
|
||||
await hold('camp-mabry-1km', false, 8000);
|
||||
await step('camp-mabry-pan', [30.3125, -97.76, 1000, -45, 2, 30], 5000);
|
||||
await step('greenbelt', [30.312, -97.775, 1000, -55, 3, 0], 6000);
|
||||
await step('capitol', [30.2747, -97.7404, 500, -70, 3, 0], 6000);
|
||||
await step('commercial-parking', [30.307, -97.735, 600, -60, 3, 0], 6000);
|
||||
await step('corridor', [30.347, -97.799, 1000, -45, 3, 0], 6000);
|
||||
} else {
|
||||
await delay(12000);
|
||||
await page.waitForFunction(
|
||||
() => window.__motion.rows.some((r) => r.n > 0),
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
await hold('street-warm', true);
|
||||
await fly(30.2685, -97.7425, 3000, -45, 1.5);
|
||||
await hold('zoom-out');
|
||||
await fly(30.2685, -97.72, 3000, -45, 1.5);
|
||||
await hold('pan-east');
|
||||
await fly(30.2685, -97.725, 600, -35, 1.5);
|
||||
await hold('zoom-in');
|
||||
await fly(-23.5505, -46.6333, 1250, -35, 1.5);
|
||||
await hold('sao-paulo');
|
||||
await fly(30.2685, -97.7425, 450, -35, 1.5);
|
||||
await hold('austin-revisit', true);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.__motion.active = false;
|
||||
});
|
||||
await cdp.send('Page.stopScreencast');
|
||||
const rows = await page.evaluate(() => window.__motion.rows);
|
||||
const surfacePhases = await page.evaluate(() =>
|
||||
performance
|
||||
.getEntriesByType('measure')
|
||||
.filter(
|
||||
(e) => e.name.startsWith('roads:') || e.name.startsWith('traffic:'),
|
||||
)
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
start: e.startTime,
|
||||
duration: e.duration,
|
||||
detail: e.detail,
|
||||
})),
|
||||
);
|
||||
const stats = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(),
|
||||
);
|
||||
const phases = holds.map((h) => {
|
||||
const all = rows.filter((r) => r.t >= h.start && r.t < h.end);
|
||||
const settled = all.filter((r) => r.t >= h.start + h.settleMs);
|
||||
const n = settled.map((r) => r.n);
|
||||
const lastBusy = all.findLastIndex(
|
||||
(r) => r.loading || r.syncing || r.sizeChanged || r.jumpFrac > 0.02,
|
||||
);
|
||||
// First observed settled frame after the last population/loading event.
|
||||
const steadyMs =
|
||||
lastBusy < 0
|
||||
? 0
|
||||
: Math.round((all[lastBusy + 1]?.t ?? h.end) - h.start);
|
||||
const phase = {
|
||||
mode,
|
||||
phase: h.name,
|
||||
steadyMs,
|
||||
lateLoadingFrames: settled.filter((r) => r.loading).length,
|
||||
offRoadDots: Math.max(0, ...all.map((r) => r.offRoad)),
|
||||
settleMs: h.settleMs,
|
||||
frames: settled.length,
|
||||
rebuilds: all.reduce((s, r) => s + r.rebuilds, 0),
|
||||
lateRebuilds: settled.reduce((s, r) => s + r.rebuilds, 0),
|
||||
jumpFrames: settled.filter((r) => r.jumpFrac > 0.02).length,
|
||||
reassigned: settled.reduce((s, r) => s + r.reassigned, 0),
|
||||
sizeChanges: settled.filter((r) => r.sizeChanged).length,
|
||||
minDots: Math.min(...n),
|
||||
maxDots: Math.max(...n),
|
||||
maxFrameMs: Math.round(Math.max(...settled.map((r) => r.dt))),
|
||||
};
|
||||
phase.passed =
|
||||
phase.frames >= 20 &&
|
||||
phase.rebuilds <= 1 &&
|
||||
phase.lateRebuilds === 0 &&
|
||||
phase.jumpFrames === 0 &&
|
||||
phase.reassigned === 0 &&
|
||||
phase.sizeChanges === 0 &&
|
||||
(h.empty ? phase.maxDots === 0 : phase.minDots > 0) &&
|
||||
(!journey ||
|
||||
(phase.steadyMs <= 1500 &&
|
||||
phase.offRoadDots === 0 &&
|
||||
all.at(-1)?.surfacePending === 0));
|
||||
return phase;
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'frames.ffconcat'),
|
||||
'ffconcat version 1.0\n' +
|
||||
frames
|
||||
.map(
|
||||
(f, i) =>
|
||||
`file '${f.name}'\nduration ${Math.max(0.001, Math.min(2, (frames[i + 1]?.timestamp || f.timestamp + 0.1) - f.timestamp))}\n`,
|
||||
)
|
||||
.join(''),
|
||||
);
|
||||
execFileSync('ffmpeg', [
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-safe',
|
||||
'0',
|
||||
'-f',
|
||||
'concat',
|
||||
'-i',
|
||||
path.join(dir, 'frames.ffconcat'),
|
||||
'-fps_mode',
|
||||
'vfr',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-vf',
|
||||
journey ? 'scale=1280:-2' : 'scale=960:-2',
|
||||
path.join(dir, 'clip.mp4'),
|
||||
]);
|
||||
const result = {
|
||||
mode,
|
||||
revision,
|
||||
baseline,
|
||||
identity,
|
||||
url: u.href,
|
||||
phases,
|
||||
errors,
|
||||
forbidden,
|
||||
tileRequests,
|
||||
duplicateTiles: tileRequests.filter(
|
||||
(p, i) => tileRequests.indexOf(p) !== i,
|
||||
),
|
||||
stats,
|
||||
surfacePhases,
|
||||
logs,
|
||||
holds,
|
||||
rows,
|
||||
moveEnds: await page.evaluate(() => window.__motion.moveEnds),
|
||||
clip: path.join(dir, 'clip.mp4'),
|
||||
frames: frames.length,
|
||||
};
|
||||
result.passed =
|
||||
phases.every((p) => p.passed) &&
|
||||
!errors.length &&
|
||||
!forbidden.length &&
|
||||
result.duplicateTiles.length === 0 &&
|
||||
!identity.overlay &&
|
||||
identity.text > 100;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'metrics.json'),
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
results.push(result);
|
||||
console.table(phases);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'results.json'),
|
||||
JSON.stringify(
|
||||
results.map(({ rows, ...r }) => r),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!baseline && results.some((r) => !r.passed)) process.exitCode = 1;
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reticle coverage on oblique arrivals: source requests, toggle oracle and zoom revisits. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import {
|
||||
moveCamera,
|
||||
createScreencast,
|
||||
installFrameProbe,
|
||||
} from './qa-journey-recorder.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) {
|
||||
console.log(
|
||||
'Usage: node scripts/qa-traffic-oblique.mjs <url> [--baseline] [--mode=hybrid|osm|tomtom]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const baseline = args.includes('--baseline');
|
||||
const modeArg = args.find((a) => a.startsWith('--mode='))?.split('=')[1];
|
||||
const modes = modeArg ? [modeArg] : ['tomtom', 'osm', 'hybrid'];
|
||||
const out = `qa-shots/oblique-${baseline ? 'before' : 'after'}`;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const result = { views: [], errors: [], forbidden: [], failures: [] };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 180000,
|
||||
args: [
|
||||
'--use-angle=metal',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
try {
|
||||
for (const mode of modes) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const counts = { ofm: 0, tomtom: 0 };
|
||||
page.on('request', (r) => {
|
||||
const u = new URL(r.url());
|
||||
if (
|
||||
u.hostname === 'tiles.openfreemap.org' &&
|
||||
/\/\d+\/\d+\/\d+(\.pbf)?$/.test(u.pathname)
|
||||
)
|
||||
counts.ofm++;
|
||||
if (u.pathname.startsWith('/api/tomtom/flow/')) counts.tomtom++;
|
||||
if (
|
||||
/overpass/i.test(u.hostname) ||
|
||||
u.hostname === 'nominatim.openstreetmap.org'
|
||||
)
|
||||
result.forbidden.push(u.hostname);
|
||||
});
|
||||
page.on('pageerror', (e) => result.errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') result.errors.push(m.text());
|
||||
});
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
const url = new URL(args.find((a) => /^https?:/.test(a)));
|
||||
url.searchParams.set('trafficRoads', mode);
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
for (const [id] of g.dataManager.layers)
|
||||
await g.dataManager.setEnabled(id, false);
|
||||
g.styleManager.setDetection({ enabled: false });
|
||||
g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
|
||||
});
|
||||
const snap = () =>
|
||||
page.evaluate(() => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
let upper = 0,
|
||||
lower = 0,
|
||||
near = 0;
|
||||
const camera = viewer.camera.positionWC;
|
||||
dataManager.layers
|
||||
.get('traffic')
|
||||
.module.visitMotionDots((id, key, p) => {
|
||||
if (!p.show) return;
|
||||
const s = viewer.scene.cartesianToCanvasCoordinates(p.position);
|
||||
if (s && s.x >= 240 && s.x <= 1040 && s.y >= 0 && s.y <= 800) {
|
||||
if (s.y < 400) upper++;
|
||||
else lower++;
|
||||
}
|
||||
});
|
||||
return {
|
||||
upper,
|
||||
lower,
|
||||
...dataManager.layers.get('traffic').module.getStats(),
|
||||
};
|
||||
});
|
||||
const settle = async () => {
|
||||
await sleep(700);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('traffic')
|
||||
.module.getStats();
|
||||
return !s.loading;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await sleep(1200);
|
||||
return snap();
|
||||
};
|
||||
for (const [name, lat, lon, height] of [
|
||||
['dubai', 24.964, 55.037, 276],
|
||||
['austin', 30.2672, -97.7431, 300],
|
||||
['london', 51.507, -0.128, 300],
|
||||
]) {
|
||||
const before = { ...counts };
|
||||
await moveCamera(page, { lat, lon, height: 3000, pitch: -45 }, 0);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
await moveCamera(page, { lat, lon, height, pitch: -15 }, 1.4);
|
||||
const settled = await settle();
|
||||
const load = {
|
||||
ofm: counts.ofm - before.ofm,
|
||||
tomtom: counts.tomtom - before.tomtom,
|
||||
};
|
||||
await page.screenshot({ path: `${out}/${name}-${mode}.png` });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', false),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
const toggled = await settle();
|
||||
const oracle =
|
||||
Math.abs(settled.upper - toggled.upper) / Math.max(1, toggled.upper);
|
||||
const preZoom = { ...counts };
|
||||
await moveCamera(page, { lat, lon, height: 3000, pitch: -15 }, 0.7);
|
||||
await settle();
|
||||
const atWide = { ...counts };
|
||||
await moveCamera(page, { lat, lon, height, pitch: -15 }, 0.7);
|
||||
await settle();
|
||||
const revisit = {
|
||||
ofm: counts.ofm - atWide.ofm,
|
||||
tomtom: counts.tomtom - atWide.tomtom,
|
||||
};
|
||||
const row = {
|
||||
mode,
|
||||
name,
|
||||
load,
|
||||
settled,
|
||||
toggled,
|
||||
oracle,
|
||||
revisit,
|
||||
zoomOut: {
|
||||
ofm: atWide.ofm - preZoom.ofm,
|
||||
tomtom: atWide.tomtom - preZoom.tomtom,
|
||||
},
|
||||
};
|
||||
result.views.push(row);
|
||||
console.log(JSON.stringify(row));
|
||||
if (oracle > 0.15)
|
||||
result.failures.push(`${name}/${mode}: upper oracle ${oracle}`);
|
||||
if (revisit.ofm || revisit.tomtom)
|
||||
result.failures.push(`${name}/${mode}: revisit requests`);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', false),
|
||||
);
|
||||
}
|
||||
const locationView = { lat: 24.973, lon: 55.037 };
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat: 24.964, lon: 55.037, height: 3000, pitch: -45 },
|
||||
0,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
const dir = `${out}/dubai-${mode}-journey`;
|
||||
const recording =
|
||||
mode === 'hybrid' ? await createScreencast(page, dir, { fps: 24 }) : null;
|
||||
await installFrameProbe(page);
|
||||
await page.evaluate(() => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
window.__obliqueFrames = [];
|
||||
window.__obliqueMoving = true;
|
||||
viewer.scene.postRender.addEventListener(() => {
|
||||
const s = dataManager.layers.get('traffic').module.getStats();
|
||||
let upper = 0,
|
||||
lower = 0,
|
||||
near = 0;
|
||||
const camera = viewer.camera.positionWC;
|
||||
dataManager.layers
|
||||
.get('traffic')
|
||||
.module.visitMotionDots((id, key, p) => {
|
||||
if (!p.show) return;
|
||||
const q = viewer.scene.cartesianToCanvasCoordinates(p.position);
|
||||
if (q && q.x >= 240 && q.x <= 1040 && q.y >= 0 && q.y <= 800) {
|
||||
if (q.y < 400) upper++;
|
||||
else lower++;
|
||||
const dx = p.position.x - camera.x,
|
||||
dy = p.position.y - camera.y,
|
||||
dz = p.position.z - camera.z;
|
||||
if (dx * dx + dy * dy + dz * dz <= 1500 * 1500) near++;
|
||||
}
|
||||
});
|
||||
window.__obliqueFrames.push({
|
||||
t: performance.now(),
|
||||
moving: window.__obliqueMoving,
|
||||
count: s.count,
|
||||
loading: s.loading,
|
||||
upper,
|
||||
lower,
|
||||
near,
|
||||
publishes: s.motion.publishes,
|
||||
rebuilds: s.motion.rebuilds,
|
||||
});
|
||||
});
|
||||
});
|
||||
await recording?.start();
|
||||
await page.evaluate(async (v) => {
|
||||
const { flyToLandmark } = await import('/src/locations.js');
|
||||
await new Promise((resolve) =>
|
||||
flyToLandmark(window.__godsEyeView.viewer, v.lat, v.lon, {
|
||||
range: 1000,
|
||||
pitch: -15,
|
||||
heading: 0,
|
||||
duration: 1.6,
|
||||
buildingHeight: 0,
|
||||
onComplete: resolve,
|
||||
onCancel: resolve,
|
||||
}),
|
||||
);
|
||||
window.__obliqueMoving = false;
|
||||
}, locationView);
|
||||
const arrived = await settle();
|
||||
await page.evaluate(() => {
|
||||
window.__obliqueMoving = true;
|
||||
});
|
||||
await moveCamera(
|
||||
page,
|
||||
{ lat: 24.964, lon: 55.042, height: 300, pitch: -15 },
|
||||
1.2,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__obliqueMoving = false;
|
||||
});
|
||||
const panned = await settle();
|
||||
await sleep(1800);
|
||||
const frames = await page.evaluate(() => window.__obliqueFrames);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', false),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
||||
);
|
||||
const fresh = await settle();
|
||||
const oracle =
|
||||
Math.abs(panned.upper - fresh.upper) / Math.max(1, fresh.upper);
|
||||
const stops = frames.flatMap((f, i) =>
|
||||
i && !f.moving && frames[i - 1].moving ? [i] : [],
|
||||
);
|
||||
const nearSettles = stops.map((start, index) => {
|
||||
let end = start + 1;
|
||||
while (end < frames.length && !frames[end].moving) end++;
|
||||
const phase = frames.slice(start, end),
|
||||
target = phase.at(-1).near;
|
||||
const reached = phase.find((f) => f.near >= target * 0.85);
|
||||
const ms = reached ? Math.round(reached.t - phase[0].t) : Infinity;
|
||||
if (target && ms > 1100)
|
||||
result.failures.push(
|
||||
`dubai-location-pan/${mode}/${index}: near settle ${ms} ms`,
|
||||
);
|
||||
return {
|
||||
phase: index === 0 ? 'arrival' : 'pan',
|
||||
ms,
|
||||
target,
|
||||
first: phase[0].near,
|
||||
};
|
||||
});
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(`${dir}/metrics.json`, JSON.stringify(frames));
|
||||
const clip = await recording?.stop();
|
||||
result.views.push({
|
||||
mode,
|
||||
name: 'dubai-location-pan',
|
||||
nearSettles,
|
||||
arrived,
|
||||
settled: panned,
|
||||
toggled: fresh,
|
||||
oracle,
|
||||
clip,
|
||||
});
|
||||
if (oracle > 0.15)
|
||||
result.failures.push(
|
||||
`dubai-location-pan/${mode}: upper oracle ${oracle}`,
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
fs.writeFileSync(`${out}/result.json`, JSON.stringify(result, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
if (
|
||||
!baseline &&
|
||||
(result.failures.length || result.errors.length || result.forbidden.length)
|
||||
)
|
||||
process.exitCode = 1;
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
} from './military-installations/constants.js';
|
||||
import { fetchOverpassPayload } from './overpass/transport.js';
|
||||
import {
|
||||
fetchOverpassPayload,
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { resolveOverpassUpstreams } from './overpass/constants.js';
|
||||
import {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
@@ -45,6 +49,7 @@ function militaryInstallationsProxy() {
|
||||
throw Object.assign(
|
||||
new Error('Mapped installation upstream unavailable'),
|
||||
{
|
||||
retryAfterMs: upstream.retryAfterMs,
|
||||
installationReason: upstream.rateLimited
|
||||
? 'rate_limited'
|
||||
: upstream.status === 504
|
||||
@@ -83,14 +88,6 @@ function militaryInstallationsProxy() {
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
@@ -117,6 +114,36 @@ function militaryInstallationsProxy() {
|
||||
: militaryInstallationCacheKey(box);
|
||||
const now = Date.now();
|
||||
const cached = _militaryInstallationCache.get(key);
|
||||
if (!resolveOverpassUpstreams().length) {
|
||||
const stale =
|
||||
cached || (await readMilitaryInstallationDisk(key, Infinity));
|
||||
const disabled = overpassNotConfigured();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': stale ? 'STALE' : 'DISABLED',
|
||||
});
|
||||
res.end(
|
||||
stale
|
||||
? JSON.stringify({
|
||||
...stale.payload,
|
||||
status: 'stale',
|
||||
retrievedAt:
|
||||
stale.payload.retrievedAt ||
|
||||
new Date(stale.cachedAt).toISOString(),
|
||||
})
|
||||
: disabled.body,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const preflight = await resolveMilitaryInstallationTier({
|
||||
cacheKey: key,
|
||||
memoryCache: _militaryInstallationCache,
|
||||
@@ -176,6 +203,9 @@ function militaryInstallationsProxy() {
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, {
|
||||
...(error?.retryAfterMs
|
||||
? { 'Retry-After': String(Math.ceil(error.retryAfterMs / 1000)) }
|
||||
: {}),
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
@@ -183,6 +213,7 @@ function militaryInstallationsProxy() {
|
||||
JSON.stringify({
|
||||
error: 'Mapped installation context is temporarily unavailable',
|
||||
reason: militaryInstallationFailureReason(error),
|
||||
retryAfterMs: error?.retryAfterMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readRequestBodyCapped } from './common/request.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
resolveOverpassUpstreams,
|
||||
} from './overpass/constants.js';
|
||||
import { sanitizeOverpassBody } from './overpass/query.js';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
overpassPayloadIsData,
|
||||
fetchOverpassPayload,
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
|
||||
@@ -41,9 +43,16 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
||||
res.writeHead(payload.status, {
|
||||
'Content-Type': payload.contentType || 'application/json',
|
||||
'Cache-Control': 'public, max-age=15',
|
||||
'Cache-Control': overpassPayloadIsData(payload)
|
||||
? 'public, max-age=15'
|
||||
: 'no-store',
|
||||
'X-Overpass-Cache': cacheStatus,
|
||||
'X-Overpass-Upstream': payload.endpoint || 'unknown',
|
||||
...(Number.isFinite(payload.cachedAt)
|
||||
? { 'X-Overpass-Cached-At': new Date(payload.cachedAt).toISOString() }
|
||||
: {}),
|
||||
...(payload.retryAfterMs
|
||||
? { 'Retry-After': String(Math.ceil(payload.retryAfterMs / 1000)) }
|
||||
: {}),
|
||||
});
|
||||
res.end(payload.body || '');
|
||||
}
|
||||
@@ -52,7 +61,7 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
||||
* Vite plugin: Overpass API proxy with response caching and request coalescing.
|
||||
*
|
||||
* Accepts POST requests at /api/overpass, normalizes the query body for
|
||||
* cache keying, and fans out to multiple Overpass mirrors with per-upstream
|
||||
* cache keying, and uses only operator-configured endpoints with per-upstream
|
||||
* timeout and rate-limit detection. Successful responses are cached for
|
||||
* OVERPASS_CACHE_MS. Concurrent identical queries share a single upstream
|
||||
* request via the in-flight map.
|
||||
@@ -66,6 +75,19 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// (a body-read failure would otherwise hit an out-of-scope reference).
|
||||
let cacheKey = null;
|
||||
try {
|
||||
// Capability probe: lets clients skip queries when nothing is configured.
|
||||
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
configured: resolveOverpassUpstreams().length > 0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
@@ -104,12 +126,21 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
|
||||
// Normalize whitespace so semantically identical Overpass QL queries share cache entries
|
||||
cacheKey = safeBody.replace(/\s+/g, ' ').trim();
|
||||
if (!resolveOverpassUpstreams().length) {
|
||||
const cached = await readStaleOverpass(cacheKey);
|
||||
sendOverpassResponse(
|
||||
res,
|
||||
cached || overpassNotConfigured(),
|
||||
cached ? 'STALE' : 'DISABLED',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const preflight = await resolveOverpassPreflight({
|
||||
cacheKey,
|
||||
memoryCache: _overpassCache,
|
||||
inFlight: _overpassInFlight,
|
||||
// Fresh-enough disk entries survive restarts and skip the public
|
||||
// mirrors; boundary-class queries keep their month-long TTL.
|
||||
// Fresh-enough disk entries survive restarts and skip upstream
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
@@ -194,7 +225,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
console.error('[Overpass Proxy]', e.message);
|
||||
console.error('[Overpass Proxy] request failed');
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy error' }));
|
||||
}
|
||||
|
||||
@@ -3,30 +3,39 @@ import path from 'node:path';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overpass API proxy constants and cache state
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* User-Agent sent to every Overpass mirror.
|
||||
*
|
||||
* The OSM API usage policy asks for a "Valid User-Agent identifying application
|
||||
* and version"; a generic proxy label is not one. A mirror is free to refuse a
|
||||
* client it cannot identify, and `src/overpassProxy.test.mjs` pins what that
|
||||
* costs: a refusal is never data, so the query falls through to whatever
|
||||
* mirrors are left. Keep this honest and stable — if it is ever refused, the
|
||||
* answer is less query volume, not a new name.
|
||||
*/
|
||||
/** Stable application identity for operator-configured Overpass instances. */
|
||||
const OVERPASS_USER_AGENT =
|
||||
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)';
|
||||
|
||||
/** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */
|
||||
const OVERPASS_UPSTREAMS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://lz4.overpass-api.de/api/interpreter',
|
||||
// Community full-planet instance (privateforge nonprofit) — added 2026-07-30
|
||||
// when all three mirrors above refused this IP (likely a dev-traffic rate
|
||||
// ban; refused connections fail in ms, so healthy mirrors above still win).
|
||||
// Verified: planet coverage (Texas query), CORS *, ~5-20 s cold latency.
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
/** Parse only operator-supplied HTTP(S) endpoints; private instances are allowed. */
|
||||
function parseOverpassUpstreams(raw) {
|
||||
const endpoints = [];
|
||||
for (const token of String(raw || '').split(',')) {
|
||||
try {
|
||||
const url = new URL(token.trim());
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!url.hostname ||
|
||||
url.hash
|
||||
)
|
||||
continue;
|
||||
if (!endpoints.includes(url.href)) endpoints.push(url.href);
|
||||
} catch {
|
||||
// Invalid configuration never becomes an upstream or appears in logs.
|
||||
}
|
||||
}
|
||||
return endpoints.slice(0, 8);
|
||||
}
|
||||
|
||||
let upstreamMemo = { raw: null, endpoints: [] };
|
||||
|
||||
/** Resolve after environment loading. Public Overpass instances are not used by default. */
|
||||
function resolveOverpassUpstreams() {
|
||||
const raw = process.env.OVERPASS_UPSTREAMS || '';
|
||||
if (upstreamMemo.raw !== raw)
|
||||
upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) };
|
||||
return [...upstreamMemo.endpoints];
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL for FRESH cached Overpass responses (ms). Road geometry is static for
|
||||
@@ -142,7 +151,8 @@ export {
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
parseOverpassUpstreams,
|
||||
resolveOverpassUpstreams,
|
||||
OVERPASS_USER_AGENT,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
resolveOverpassUpstreams,
|
||||
OVERPASS_USER_AGENT,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
@@ -60,99 +60,147 @@ function overpassPayloadIsData(payload) {
|
||||
);
|
||||
}
|
||||
|
||||
const cooldowns = new Map();
|
||||
|
||||
/** A stable, non-retryable capability response shared by every Overpass route. */
|
||||
export function overpassNotConfigured() {
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: 'Detailed OpenStreetMap queries are not configured',
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Honor Retry-After dates/seconds; absent values use bounded exponential backoff. */
|
||||
function retryDelay(value, failures, now) {
|
||||
const seconds = Number(value);
|
||||
const explicit =
|
||||
value && Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: Date.parse(value) - now;
|
||||
return Number.isFinite(explicit)
|
||||
? Math.max(1000, explicit)
|
||||
: Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
|
||||
}
|
||||
|
||||
function refusal(status, retryAfterMs) {
|
||||
return {
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
rateLimited: status === 429 || status === 406,
|
||||
retryAfterMs,
|
||||
body: JSON.stringify({
|
||||
error: 'Configured Overpass upstream unavailable',
|
||||
code: 'OVERPASS_UNAVAILABLE',
|
||||
retryable: true,
|
||||
retryAfterMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each mirror once, retaining response-size and per-mirror timeout caps.
|
||||
* Refusals and body-level failures rotate; total failure returns the last
|
||||
* rate-limit payload, otherwise the first refusal, or throws a network error.
|
||||
* @param {string} body URL-encoded Overpass QL query body.
|
||||
* @param {number} [maxResponseBytes] Endpoint-specific response cap.
|
||||
* @param {object} [options] Server-only endpoint and I/O overrides for tests.
|
||||
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
|
||||
* Query only the configured chain with capped reads, timeouts and per-endpoint cooldowns.
|
||||
* Explicit endpoint/I/O overrides are server-only test seams. Empty data is valid.
|
||||
*/
|
||||
async function fetchOverpassPayload(
|
||||
body,
|
||||
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
|
||||
{
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
endpoints = resolveOverpassUpstreams(),
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
now = Date.now,
|
||||
} = {},
|
||||
) {
|
||||
let lastError = null;
|
||||
let lastRateLimitPayload = null;
|
||||
let lastRefusalPayload = null;
|
||||
|
||||
if (!endpoints.length) return overpassNotConfigured();
|
||||
let failure = refusal(502, 30_000);
|
||||
for (const endpoint of endpoints) {
|
||||
const previous = cooldowns.get(endpoint);
|
||||
if (previous?.until > now()) {
|
||||
failure = refusal(previous.status, previous.until - now());
|
||||
continue;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const upstream = await fetchImpl(endpoint, {
|
||||
const requestUrl = new URL(endpoint);
|
||||
const authorization =
|
||||
requestUrl.username || requestUrl.password
|
||||
? 'Basic ' +
|
||||
Buffer.from(
|
||||
`${decodeURIComponent(requestUrl.username)}:${decodeURIComponent(requestUrl.password)}`,
|
||||
).toString('base64')
|
||||
: null;
|
||||
requestUrl.username = '';
|
||||
requestUrl.password = '';
|
||||
const upstream = await fetchImpl(requestUrl.href, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': OVERPASS_USER_AGENT,
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/json';
|
||||
const status = upstream.status;
|
||||
const rateLimited =
|
||||
status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const runtimeError = overpassLooksRuntimeError(responseBody);
|
||||
const payload = {
|
||||
status,
|
||||
body: responseBody,
|
||||
contentType,
|
||||
endpoint,
|
||||
rateLimited,
|
||||
runtimeError,
|
||||
};
|
||||
|
||||
if (rateLimited) {
|
||||
lastRateLimitPayload = payload;
|
||||
continue;
|
||||
}
|
||||
// A 200 body carrying a runtime error / timeout is a transient upstream
|
||||
// failure — skip to the next mirror rather than returning or caching it.
|
||||
if (runtimeError) {
|
||||
lastError = new Error(`Overpass runtime error (${endpoint})`);
|
||||
continue;
|
||||
}
|
||||
// Anything but 2xx is this mirror declining, not an answer. Only 5xx used
|
||||
// to rotate, so a 4xx ended the fan-out and was returned — and cached —
|
||||
// as data: a mirror refusing this client answers 406 while the others
|
||||
// answer 200 to the very same request, so every Overpass-backed layer
|
||||
// failed on an error page with healthy mirrors untried. The first
|
||||
// refusal is kept so a genuinely bad query still reports what upstream
|
||||
// said, but only after every mirror has had the chance to answer it.
|
||||
if (status < 200 || status >= 300) {
|
||||
if (!lastRefusalPayload) lastRefusalPayload = payload;
|
||||
lastError = new Error(
|
||||
`Overpass upstream returned ${status} (${endpoint})`,
|
||||
upstream.status === 429 ||
|
||||
upstream.status === 406 ||
|
||||
overpassLooksRateLimited(responseBody);
|
||||
if (
|
||||
rateLimited ||
|
||||
upstream.status < 200 ||
|
||||
upstream.status >= 300 ||
|
||||
overpassLooksRuntimeError(responseBody)
|
||||
) {
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(
|
||||
upstream.headers.get('retry-after'),
|
||||
failures - 1,
|
||||
now(),
|
||||
);
|
||||
const status = rateLimited
|
||||
? upstream.status === 406
|
||||
? 406
|
||||
: 429
|
||||
: 502;
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status });
|
||||
while (cooldowns.size > 64)
|
||||
cooldowns.delete(cooldowns.keys().next().value);
|
||||
failure = refusal(status, delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success: decimate giant boundary geometry before it reaches the cache,
|
||||
// the disk, or the client (what makes the 32 MB read cap safe to hold).
|
||||
payload.body = simplify(payload.body);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (!Array.isArray(parsed?.elements) || parsed.remark)
|
||||
throw new Error('Malformed Overpass response');
|
||||
cooldowns.delete(endpoint);
|
||||
return {
|
||||
status: upstream.status,
|
||||
body: simplify(responseBody),
|
||||
contentType: 'application/json',
|
||||
// Never retain a secret-bearing endpoint in cache or response metadata.
|
||||
endpoint: 'configured',
|
||||
rateLimited: false,
|
||||
};
|
||||
} catch {
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(null, failures - 1, now());
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
|
||||
while (cooldowns.size > 64)
|
||||
cooldowns.delete(cooldowns.keys().next().value);
|
||||
failure = refusal(502, delay);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastRateLimitPayload) return lastRateLimitPayload;
|
||||
if (lastRefusalPayload) return lastRefusalPayload;
|
||||
throw lastError || new Error('All Overpass upstreams failed');
|
||||
return failure;
|
||||
}
|
||||
|
||||
export { overpassPayloadIsData, fetchOverpassPayload };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js';
|
||||
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
|
||||
import {
|
||||
nominatimToGeocodeResult,
|
||||
nominatimViewboxFromBounds,
|
||||
@@ -51,8 +51,8 @@ const NOMINATIM_SEARCH_MAX_QUERY = 200;
|
||||
// The pacer is deliberately module state while everything else here is
|
||||
// per-instance. One request per second is a budget for the whole application,
|
||||
// not for each provider object: two instances each pacing themselves would
|
||||
// send two requests a second between them. Reverse lookups and searches
|
||||
// therefore queue together.
|
||||
// send two requests a second between them. Only last-resort forward searches
|
||||
// use this queue.
|
||||
let _nominatimQueue = Promise.resolve();
|
||||
|
||||
let _nominatimLastRequestAt = 0;
|
||||
@@ -77,9 +77,7 @@ function abandonedError() {
|
||||
* Run one piece of upstream work, never closer than the policy spacing to the
|
||||
* last one.
|
||||
*
|
||||
* `bounded` applies the queue-depth and staleness limits. The cockpit's reverse
|
||||
* lookups are a slow background trickle and stay unbounded; the search route,
|
||||
* which a person can fire as fast as they can type, does not.
|
||||
* `bounded` applies queue-depth and staleness limits to interactive forward searches.
|
||||
*
|
||||
* @param {() => Promise<unknown>} work
|
||||
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
|
||||
@@ -111,30 +109,9 @@ function enqueueNominatim(work, { bounded = false, signal } = {}) {
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Construct the serialized Nominatim reverse adapter with a trusted endpoint. */
|
||||
export function createRegionalPlaceProvider({
|
||||
endpoint = 'https://nominatim.openstreetmap.org/reverse',
|
||||
requestJson = fetchRegionalJson,
|
||||
} = {}) {
|
||||
function fetchRegionalPlace(point) {
|
||||
return enqueueNominatim(async () => {
|
||||
const params = new URLSearchParams({
|
||||
format: 'jsonv2',
|
||||
lat: point.latitude.toFixed(5),
|
||||
lon: point.longitude.toFixed(5),
|
||||
zoom: '10',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await requestJson(`${endpoint}?${params}`, {
|
||||
headers: NOMINATIM_HEADERS,
|
||||
redirect: 'error',
|
||||
});
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
}
|
||||
|
||||
return fetchRegionalPlace;
|
||||
/** Construct the offline regional context provider from bundled Natural Earth polygons. */
|
||||
export function createRegionalPlaceProvider() {
|
||||
return (point) => naturalRegionAtPoint(point.latitude, point.longitude);
|
||||
}
|
||||
|
||||
export const fetchRegionalPlace = createRegionalPlaceProvider();
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Area annotations draw bundled state/province/county outlines with no
|
||||
// network lookup, and multi-part outlines reach the renderer intact.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
|
||||
test('"Texas", "Travis County, Texas" and "Bavaria" outline offline', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [target, label] of [
|
||||
['Texas', 'Texas'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
// Far from the camera, but named outright: no proximity gate applies.
|
||||
['Bavaria', 'Bavaria'],
|
||||
]) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assert.ok(resolved, target);
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.ok(resolved.ring.length >= 9, `${target} ring`);
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1, `${target} parts`);
|
||||
assert.equal(resolved.polygons[0][0].length + 1, resolved.ring.length);
|
||||
assert.ok(resolved.viewport?.low && resolved.viewport?.high);
|
||||
}
|
||||
assert.deepEqual(geocodes, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('duplicate county names go to the one near the camera', async () => {
|
||||
const { resolve } = harness();
|
||||
const nearPortland = await resolve(
|
||||
'Washington County',
|
||||
viewerAt(45.52, -122.68),
|
||||
);
|
||||
assert.ok(nearPortland.lat > 45 && nearPortland.lon < -122, 'Oregon');
|
||||
const nearAustin = await resolve('Washington County', AUSTIN);
|
||||
assert.ok(nearAustin.lat > 29.9 && nearAustin.lat < 30.5, 'Texas');
|
||||
assert.ok(nearAustin.lon > -96.8 && nearAustin.lon < -96, 'Texas');
|
||||
});
|
||||
|
||||
test('multi-part outlines carry every part and hole', async () => {
|
||||
const { resolve } = harness();
|
||||
const hawaii = await resolve('Hawaii', AUSTIN);
|
||||
assert.ok(hawaii.polygons.length >= 7, 'islands');
|
||||
const brandenburg = await resolve('Brandenburg', AUSTIN);
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length === 2),
|
||||
'Berlin hole',
|
||||
);
|
||||
});
|
||||
|
||||
test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [query, viewer, latitude] of [
|
||||
['Georgia', viewerAt(33.75, -84.39, 400_000), 32],
|
||||
['the country of Georgia', AUSTIN, 42],
|
||||
['Georgia', viewerAt(41.7, 44.8), 42],
|
||||
]) {
|
||||
const resolved = await resolve(query, viewer);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(Math.abs(resolved.lat - latitude) < 2);
|
||||
assert.ok(resolved.ring.length > 20);
|
||||
}
|
||||
assert.deepEqual(lookups, []);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
const resolved = await direct.resolve(name, AUSTIN);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(resolved.polygons.length);
|
||||
if (name === 'Japan') assert.ok(resolved.polygons.length > 3);
|
||||
}
|
||||
assert.deepEqual(direct.lookups, []);
|
||||
assert.deepEqual(direct.geocodes, []);
|
||||
const geocoded = harness({
|
||||
lat: 46.95,
|
||||
lng: 7.45,
|
||||
label: 'Switzerland',
|
||||
name: 'Switzerland',
|
||||
types: ['country', 'political'],
|
||||
});
|
||||
const resolved = await geocoded.resolve(
|
||||
'Swiss country outline',
|
||||
viewerAt(46.95, 7.45),
|
||||
);
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.ok(outline.polygons.length);
|
||||
assert.deepEqual(geocoded.lookups, []);
|
||||
});
|
||||
|
||||
test('non-admin asks and entity facts keep the existing path', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
await resolve('Texas Capitol', AUSTIN);
|
||||
await resolve('Texas', AUSTIN, { entityKind: 'building' });
|
||||
await resolve('Zilker Park', AUSTIN);
|
||||
assert.equal(
|
||||
geocodes.filter((q) => q !== 'text-search').length,
|
||||
3,
|
||||
'each ask geocoded',
|
||||
);
|
||||
// Points and explicit coordinates never use the pack.
|
||||
const point = await resolve('Texas', AUSTIN, { footprint: false });
|
||||
assert.notEqual(point?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => {
|
||||
globalThis.requestAnimationFrame ??= () => 1;
|
||||
globalThis.cancelAnimationFrame ??= () => {};
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const polygons = [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
[
|
||||
[0.5, 0.5],
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[3, 3],
|
||||
[4, 3],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
];
|
||||
const ring = [...polygons[0][0], polygons[0][0][0]];
|
||||
const seen = [];
|
||||
const renderer = {
|
||||
add: (anno) => seen.push(['add', anno.polygons]),
|
||||
update: (anno) => seen.push(['update', anno.polygons]),
|
||||
remove() {},
|
||||
sync() {},
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
? { lon: 1, lat: 1, height: 0, ring, polygons, footprintKind: 'area' }
|
||||
: {
|
||||
lon: 1,
|
||||
lat: 1,
|
||||
height: 0,
|
||||
ring: null,
|
||||
resolveOutline: async () => ({
|
||||
ring,
|
||||
polygons,
|
||||
footprintKind: 'area',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
height: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'area', target: 'now' }]);
|
||||
await engine.annotate([{ type: 'area', target: 'later', label: 'later' }]);
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
const marks = engine.list();
|
||||
assert.equal(marks[0].polygons, polygons);
|
||||
assert.ok(
|
||||
marks.some((m) => m.label === 'later' && m.polygons === polygons),
|
||||
'upgrade carries the parts',
|
||||
);
|
||||
assert.ok(
|
||||
seen.some(([kind, parts]) => kind === 'update' && parts === polygons),
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ export async function resolveOutlineWithRetry(
|
||||
} catch {
|
||||
fp = null; // hard failure — definitive, keep the honest point
|
||||
}
|
||||
if (isUnavailableCapability(fp)) return fp;
|
||||
if (isRateLimitedOutcome(fp)) {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
@@ -333,6 +335,7 @@ export function createAnnotationEngine({
|
||||
// refreshes it — usually instantly from the footprint cache.
|
||||
if (anno.pendingOutline && !anno.ring && dup.ring) {
|
||||
anno.ring = dup.ring;
|
||||
anno.polygons = dup.polygons || null;
|
||||
anno.footprintKind = dup.footprintKind || null;
|
||||
anno.buildingHeight = dup.buildingHeight || null;
|
||||
anno.synthesized = Boolean(dup.synthesized);
|
||||
@@ -673,8 +676,11 @@ export function createAnnotationEngine({
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
anno.pendingOutline = false;
|
||||
if (fp) {
|
||||
anno.outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (anno.outlineUnavailable) renderer.update(anno);
|
||||
if (fp && !anno.outlineUnavailable) {
|
||||
anno.ring = fp.ring;
|
||||
anno.polygons = fp.polygons || null;
|
||||
anno.footprintKind = fp.footprintKind || null;
|
||||
anno.buildingHeight = fp.buildingHeight || null;
|
||||
anno.synthesized = Boolean(fp.synthesized);
|
||||
@@ -725,7 +731,14 @@ export function createAnnotationEngine({
|
||||
id: anno.id,
|
||||
label: anno.label || null,
|
||||
target: anno.targetKey || null,
|
||||
status: fp ? 'resolved' : 'failed',
|
||||
status: anno.outlineUnavailable
|
||||
? 'unavailable'
|
||||
: fp
|
||||
? 'resolved'
|
||||
: 'failed',
|
||||
...(anno.outlineUnavailable
|
||||
? { message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
...(fp && anno.synthesized ? { approximate: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
@@ -753,6 +766,9 @@ export function createAnnotationEngine({
|
||||
// layer narrate without waiting out a slow Overpass — and without calling the
|
||||
// missing outline a failure.
|
||||
...(anno.pendingOutline ? { outlinePending: true } : {}),
|
||||
...(anno.outlineUnavailable
|
||||
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
// approximate=true means the area was SYNTHESIZED (a buffered blob around a label
|
||||
// point), not a real OSM boundary — so the voice layer can be honest.
|
||||
...(anno.synthesized ? { approximate: true } : {}),
|
||||
@@ -919,6 +935,9 @@ export function createAnnotationEngine({
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: resolved.height },
|
||||
to: null,
|
||||
ring: resolved.ring || null,
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
@@ -926,6 +945,7 @@ export function createAnnotationEngine({
|
||||
// the upgrade task fills ring/kind in place when it lands. Transient render state
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
outlineUnavailable: Boolean(resolved.outlineUnavailable),
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
// locality qualifiers stripped ("California, United States" ≡ "California"; null for
|
||||
@@ -1047,7 +1067,11 @@ export function createAnnotationEngine({
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
? ringRange(
|
||||
anno.polygons?.length
|
||||
? anno.polygons.flatMap((poly) => poly[0])
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
@@ -1388,7 +1412,11 @@ function ringRange(ring) {
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
// Unwrap around the first vertex so parts cut at the antimeridian (the
|
||||
// Aleutians) measure as neighbours, not as a globe-wide span.
|
||||
const ref = ring[0]?.[0] ?? 0;
|
||||
for (const [rawLon, lat] of ring) {
|
||||
const lon = rawLon - 360 * Math.round((rawLon - ref) / 360);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
|
||||
@@ -254,7 +254,9 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
if (String(url).endsWith('/status')) return httpFailure(404);
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
|
||||
@@ -77,7 +77,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
add(anno);
|
||||
return;
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) return;
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
screen.update(entry.screenProxy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
|
||||
@@ -10,20 +10,33 @@ function featureSource(overrides = {}) {
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
const texasGeocode = {
|
||||
// A state-typed place outside every bundled boundary, so the admin-boundary
|
||||
// feature lookup (not the bundled packs) answers.
|
||||
const offPackGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 31.0,
|
||||
lng: -99.9,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
lat: 0.5,
|
||||
lng: -150.5,
|
||||
label: 'Atlantis',
|
||||
name: 'Atlantis',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function geocoderFor(place) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async geocode(query) {
|
||||
calls.push(query);
|
||||
return { place };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
@@ -50,13 +63,13 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Texas',
|
||||
name: 'Atlantis',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
@@ -77,10 +90,64 @@ test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
|
||||
test('region ring: bundled states and counties resolve without a geocoder', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geocoder = geocoderFor(null);
|
||||
for (const [query, name] of [
|
||||
['Texas', 'Texas'],
|
||||
['Bavaria', 'Bavaria'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('region ring: a geocoder-typed state resolves from the bundled pack', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
// "Georgia" alone is also a country, so the words don't settle it; the
|
||||
// geocoder's state type and point inside the US state do.
|
||||
const geocoder = geocoderFor({
|
||||
lat: 32.16,
|
||||
lng: -82.9,
|
||||
label: 'Georgia, USA',
|
||||
name: 'Georgia',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Georgia',
|
||||
undefined,
|
||||
geocoder,
|
||||
);
|
||||
assert.equal(region?.name, 'Georgia');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(geocoder.calls, ['Georgia']);
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
+157
-13
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { requireFeatureSource } from '../sources/featureSource.js';
|
||||
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||
import {
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
@@ -28,6 +31,14 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -176,6 +187,23 @@ export function createAnnotationResolver({
|
||||
// ("the Texas Capitol" → the state), so geocode types must never grant it.
|
||||
const bypassNearViewGuards = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
|
||||
// Bundled administrative outlines resolve settled names without a lookup.
|
||||
// Georgia uses its qualifier or camera; city/state homonyms defer to geocoding.
|
||||
if (
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
const query = String(target || '').trim();
|
||||
if (query) {
|
||||
@@ -474,8 +502,32 @@ export function createAnnotationResolver({
|
||||
// GROUNDS-like ask must not short-circuit here: the model phrases "the Capitol
|
||||
// grounds" as around_the_thing, but the grounds ARE the thing — the real enclosing
|
||||
// polygon (below) beats a 400 m disc (field test 8's "spherical round one").
|
||||
const availability = await fetchEnclosingArea(
|
||||
lat,
|
||||
lon,
|
||||
signal,
|
||||
matchName,
|
||||
);
|
||||
if (
|
||||
isUnavailableCapability(availability) ||
|
||||
isRateLimitedOutcome(availability)
|
||||
)
|
||||
return availability;
|
||||
if (availability === undefined) return undefined;
|
||||
fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M);
|
||||
} else if (isAdmin) {
|
||||
// A country, state or county the geocoder typed: the bundled unit that carries the
|
||||
// name AND contains the geocoded point (offline; the pack's ambiguity
|
||||
// marks don't apply — the geocoder already chose "Georgia" the state).
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
fp = await fetchAdminArea(lat, lon, matchName, scope, signal);
|
||||
@@ -501,6 +553,7 @@ export function createAnnotationResolver({
|
||||
// A neighborhood whose canonical name carries a city suffix ("Presidio of San
|
||||
// Francisco") can match the CITY admin; treat an oversized admin as a DEFINITIVE
|
||||
// no-neighborhood-polygon so it still falls through to the named-landuse path.
|
||||
if (isUnavailableCapability(adminFp)) return adminFp;
|
||||
if (adminFp && exceedsScopeArea(adminFp, scope)) adminFp = null;
|
||||
if (adminFp) {
|
||||
fp = adminFp;
|
||||
@@ -515,6 +568,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'strict',
|
||||
);
|
||||
if (isUnavailableCapability(footFp)) return footFp;
|
||||
if (footFp) {
|
||||
fp = footFp;
|
||||
} else if (footFp === null) {
|
||||
@@ -531,6 +585,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'loose',
|
||||
);
|
||||
if (isUnavailableCapability(looseFp)) return looseFp;
|
||||
if (looseFp && looseFp.kind !== 'building') fp = looseFp;
|
||||
else if (looseFp === null)
|
||||
fp = synthesizeBufferedArea(lat, lon, NEIGHBORHOOD_RADIUS_M);
|
||||
@@ -580,6 +635,7 @@ export function createAnnotationResolver({
|
||||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||||
const area = await fetchEnclosingArea(lat, lon, signal, matchName);
|
||||
if (isUnavailableCapability(area)) return area;
|
||||
if (area) {
|
||||
fp = area; // real grounds polygon (synthesized:false) — beats both the dome and a disc
|
||||
// It's a grounds/compound feature (already capped at SCOPE_AREA_CAP_M2.compound inside
|
||||
@@ -619,7 +675,7 @@ export function createAnnotationResolver({
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
if (isRateLimitedOutcome(fp)) return fp;
|
||||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||||
const centroid = ringCentroid(fp.ring);
|
||||
@@ -646,10 +702,12 @@ export function createAnnotationResolver({
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
if (fp && !isRateLimitedOutcome(fp)) {
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (fp && !outlineUnavailable && !isRateLimitedOutcome(fp)) {
|
||||
ring = fp.ring;
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
@@ -680,6 +738,7 @@ export function createAnnotationResolver({
|
||||
label,
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -720,6 +779,65 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline patch (resolveOutline's contract) for a bundled administrative unit.
|
||||
* `ring` is the main part, closed; `polygons` carries every part with its
|
||||
* holes (Hawaii's islands, Berlin inside Brandenburg) for renderers that
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
creditAdminSource(viewer, admin);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
polygons: admin.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
`${admin.region ? `, ${admin.region}` : ''} (${admin.source}, ` +
|
||||
`${admin.polygons.length} part(s), ${admin.candidates} candidate(s)) → FINAL source=bundled`,
|
||||
);
|
||||
return {
|
||||
lon: outline.lon,
|
||||
lat: outline.lat,
|
||||
height: outline.height,
|
||||
ring: outline.ring,
|
||||
polygons: outline.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
low: { latitude: south, longitude: west },
|
||||
high: { latitude: north, longitude: east },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize an approximate circular AREA by buffering a label point. Returns a ring
|
||||
* marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered),
|
||||
@@ -1004,7 +1122,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(candidates)) return candidates;
|
||||
if (isUnavailableCapability(candidates) || isRateLimitedOutcome(candidates))
|
||||
return candidates;
|
||||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1067,7 +1186,8 @@ export function createAnnotationResolver({
|
||||
// without first trying the place= polygon (and a named landuse via the caller).
|
||||
if (scope === 'neighborhood' && (!best || bestCoverage < 0.8)) {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1097,7 +1217,8 @@ export function createAnnotationResolver({
|
||||
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||||
signal: signal,
|
||||
});
|
||||
if (isRateLimitedOutcome(relEls)) return relEls;
|
||||
if (isUnavailableCapability(relEls) || isRateLimitedOutcome(relEls))
|
||||
return relEls;
|
||||
if (relEls === null) {
|
||||
transient = true;
|
||||
break;
|
||||
@@ -1126,7 +1247,8 @@ export function createAnnotationResolver({
|
||||
if (exceedsScopeArea(fp, scope)) {
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1149,7 +1271,8 @@ export function createAnnotationResolver({
|
||||
// caller relies on that to gate named-landuse fallback / synthesis.
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1171,7 +1294,7 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(els)) return els;
|
||||
if (isUnavailableCapability(els) || isRateLimitedOutcome(els)) return els;
|
||||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1220,7 +1343,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(areaEls)) return areaEls;
|
||||
if (isUnavailableCapability(areaEls) || isRateLimitedOutcome(areaEls))
|
||||
return areaEls;
|
||||
if (areaEls) {
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1253,7 +1377,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
||||
if (isUnavailableCapability(wayEls) || isRateLimitedOutcome(wayEls))
|
||||
return wayEls;
|
||||
if (wayEls) {
|
||||
const segments = [];
|
||||
for (const el of wayEls) {
|
||||
@@ -1326,7 +1451,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null || signal?.aborted) return undefined;
|
||||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||||
if (fp) {
|
||||
@@ -1374,7 +1500,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1497,7 +1624,11 @@ export function createAnnotationResolver({
|
||||
pending = featureSource
|
||||
.getMonuments({ lat, lon }, { signal: undefined })
|
||||
.then((elements) => {
|
||||
if (elements === null || isRateLimitedOutcome(elements))
|
||||
if (
|
||||
elements === null ||
|
||||
isUnavailableCapability(elements) ||
|
||||
isRateLimitedOutcome(elements)
|
||||
)
|
||||
return null; // transient — NEVER cached (a poisoned bucket
|
||||
// would silently disable the snap for the whole session, field test 7 §1)
|
||||
const feats = [];
|
||||
@@ -1912,6 +2043,10 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
@@ -1935,6 +2070,15 @@ export function createAnnotationResolver({
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -161,7 +161,12 @@ export function createScreenAnnotationRenderer(
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
@@ -174,7 +179,12 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
@@ -203,7 +213,12 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
@@ -242,7 +257,12 @@ export function createScreenAnnotationRenderer(
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
@@ -301,6 +321,10 @@ export function createScreenAnnotationRenderer(
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -10,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
@@ -49,6 +50,7 @@ const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -159,51 +161,74 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
||||
// hole in Brandenburg) draws every part with its holes. All parts share
|
||||
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
||||
// ground primitive per kind instead of one per island.
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
const lineMaterial = anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
||||
rings
|
||||
.slice(1)
|
||||
.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
material: fillMaterial,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const ring of rings) {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 6,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
@@ -228,7 +253,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -248,18 +274,6 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
@@ -284,54 +298,28 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
function pointMarker(anno, base) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
point: {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -348,6 +336,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -452,6 +442,13 @@ FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
||||
function closedRing(ring) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
}
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
|
||||
@@ -150,7 +150,7 @@ export function createApplicationCatalog({
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationTraffic({ source: sources.traffic, surface }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -12,6 +13,7 @@ export function createApplicationAlpr({ surface, source }) {
|
||||
return createAlprCamerasLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
picking,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createCctvLayer } from '../../layers/cctv/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as activation from '../../cctvFocusRequest.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
@@ -13,6 +14,7 @@ export function createApplicationCctv({ surface, source }) {
|
||||
return createCctvLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
sprites,
|
||||
activation,
|
||||
overlays,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -8,6 +10,27 @@ export function createApplicationInstallations({ surface, source }) {
|
||||
const { groundFloor: ground, anchors } = surface;
|
||||
return createInstallationsLayer({
|
||||
source,
|
||||
services: { render, context, ground, anchors, picking },
|
||||
overlayHost,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
ground,
|
||||
anchors,
|
||||
picking,
|
||||
maps: {
|
||||
subscribeMapStack(callback) {
|
||||
globalThis.window?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
return () =>
|
||||
globalThis.window?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
getWorldOverlayDiagnostics,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
export const overlayHost = Object.freeze({
|
||||
clearSource: clearOverlaySource,
|
||||
getDiagnostics: getWorldOverlayDiagnostics,
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createTrafficLayer } from '../../layers/traffic/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as ground from '../../data/groundFloor.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationTraffic({ source }) {
|
||||
export function createApplicationTraffic({ source, surface }) {
|
||||
return createTrafficLayer({
|
||||
source,
|
||||
services: { credits, render },
|
||||
services: { credits, render, ground: surface?.groundFloor || ground },
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -22,4 +23,6 @@ export const localGeoJsonServices = Object.freeze({
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
|
||||
searchAndFlyTo(viewer, query, {
|
||||
...options,
|
||||
features,
|
||||
ground: surface.groundFloor,
|
||||
signal:
|
||||
signal && options.signal
|
||||
? AbortSignal.any([signal, options.signal])
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Camera ground guard: never leave an arrival buried in, or pressed against,
|
||||
* the rendered surface.
|
||||
*
|
||||
* Arrival framing is computed before the destination's tiles exist, so it has
|
||||
* to predict the ground height. `globe.getHeight()` answers nothing while the
|
||||
* globe is hidden under photoreal tiles, curated `groundElevation` presets
|
||||
* cover only a few cities, and the elevation service resolves asynchronously.
|
||||
* A missing prediction used to fall back to sea level, which buried the eye by
|
||||
* roughly the local elevation (Denver ~1,600 m; Austin ~170 m).
|
||||
*
|
||||
* After the flight lands and tiles have streamed in, this module measures the
|
||||
* rendered surface under the framed target and under the camera itself, and
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
const probeScratch = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Highest rendered surface across a few cells, with how many cells answered.
|
||||
* A cell whose tiles are not streamed yet contributes nothing.
|
||||
* @param {Cesium.Scene} scene Live scene.
|
||||
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
|
||||
* @returns {{heightM: number, sampled: number}} Reading.
|
||||
*/
|
||||
function sampleSurfaceM(scene, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
} catch {
|
||||
/* tiles not ready for this cell */
|
||||
}
|
||||
}
|
||||
return { heightM, sampled };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the camera must rise to clear the ground, given a measured surface.
|
||||
* Pure so the policy is testable without a scene.
|
||||
* @param {number} cameraHeightM Eye height above the ellipsoid.
|
||||
* @param {number} groundHeightM Measured surface height above the ellipsoid.
|
||||
* @param {number} [clearanceM] Desired eye height above that surface.
|
||||
* @returns {number} Metres to lift; 0 when the view is already clear.
|
||||
*/
|
||||
export function groundClearanceDeficitM(
|
||||
cameraHeightM,
|
||||
groundHeightM,
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
) {
|
||||
if (!Number.isFinite(cameraHeightM) || !Number.isFinite(groundHeightM))
|
||||
return 0;
|
||||
const deficit = groundHeightM + clearanceM - cameraHeightM;
|
||||
return deficit > GUARD_EPSILON_M ? deficit : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch an arrival and lift the camera once the real surface can be measured.
|
||||
*
|
||||
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
||||
* newer arrival owns the camera.
|
||||
* @param {Cesium.Viewer} viewer Live viewer.
|
||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
|
||||
* @returns {() => void} Cancel function.
|
||||
*/
|
||||
export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
const {
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
attempts = GUARD_ATTEMPTS,
|
||||
intervalMs = GUARD_INTERVAL_MS,
|
||||
isStale = () => false,
|
||||
onLift = null,
|
||||
} = options;
|
||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||
return () => {};
|
||||
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const documentRef = canvas?.ownerDocument;
|
||||
const removers = [];
|
||||
let release = () => {};
|
||||
const stop = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.removeEventListener?.(type, stop);
|
||||
documentRef?.removeEventListener?.('keydown', stop, true);
|
||||
for (const remove of removers) remove();
|
||||
release();
|
||||
};
|
||||
release = ownCameraArrival(viewer, stop);
|
||||
// Direct camera API callers (including duration-zero flights) must yield
|
||||
// before moving the eye. Restore the exact method ownership on completion.
|
||||
for (const name of [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
]) {
|
||||
const camera = viewer.camera;
|
||||
const original = camera[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
|
||||
const wrapped = function (...args) {
|
||||
stop();
|
||||
return original.apply(this, args);
|
||||
};
|
||||
camera[name] = wrapped;
|
||||
removers.push(() => {
|
||||
if (camera[name] !== wrapped) return;
|
||||
if (descriptor) Object.defineProperty(camera, name, descriptor);
|
||||
else delete camera[name];
|
||||
});
|
||||
}
|
||||
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
|
||||
if (removeTracking) removers.push(removeTracking);
|
||||
documentRef?.addEventListener?.('keydown', stop, true);
|
||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.addEventListener?.(type, stop, { once: true, passive: true });
|
||||
|
||||
let remaining = attempts;
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||
remaining -= 1;
|
||||
const camera = viewer.camera;
|
||||
const carto = camera.positionCartographic;
|
||||
// The eye must clear both the framed target and the ground beneath it.
|
||||
const below = {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
const { heightM, sampled } = sampleSurfaceM(viewer.scene, [
|
||||
{ lat: at.lat, lon: at.lon },
|
||||
below,
|
||||
]);
|
||||
if (sampled > 0 && Number.isFinite(heightM)) {
|
||||
const deficit = groundClearanceDeficitM(
|
||||
carto.height,
|
||||
heightM,
|
||||
clearanceM,
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
/* observer only */
|
||||
}
|
||||
}
|
||||
// Both samples answered: the surface is measured, nothing left to wait for.
|
||||
if (sampled === 2 || deficit > 0) return stop();
|
||||
}
|
||||
if (remaining <= 0) return stop();
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
};
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Camera ground-guard policy tests: pure arithmetic plus a fake scene, no network.
|
||||
//
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MIN_EYE_CLEARANCE_M,
|
||||
groundClearanceDeficitM,
|
||||
guardCameraAboveGround,
|
||||
} from './cameraGroundGuard.js';
|
||||
|
||||
test('a buried camera is lifted clear of the surface', () => {
|
||||
// Framed near sea level, arriving over ground at ~1,609 m (Denver).
|
||||
const lift = groundClearanceDeficitM(140, 1609);
|
||||
assert.equal(lift, 1609 + MIN_EYE_CLEARANCE_M - 140);
|
||||
});
|
||||
|
||||
test('a camera resting on the surface is lifted to a usable height', () => {
|
||||
// Camp Mabry field report: eye 1 m above the mesh at 171 m.
|
||||
assert.equal(groundClearanceDeficitM(172, 171), MIN_EYE_CLEARANCE_M - 1);
|
||||
});
|
||||
|
||||
test('a well-framed arrival is left alone', () => {
|
||||
assert.equal(groundClearanceDeficitM(1755, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(2960, 1609), 0);
|
||||
});
|
||||
|
||||
test('sub-metre noise never triggers a nudge', () => {
|
||||
assert.equal(groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M, 1609), 0);
|
||||
assert.equal(
|
||||
groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M - 1, 1609),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unmeasurable surface is never acted on', () => {
|
||||
assert.equal(groundClearanceDeficitM(1000, Number.NaN), 0);
|
||||
assert.equal(groundClearanceDeficitM(Number.NaN, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(1000, undefined), 0);
|
||||
});
|
||||
|
||||
test('the clearance frames a subject without turning into an overflight', () => {
|
||||
assert.ok(MIN_EYE_CLEARANCE_M >= 60);
|
||||
assert.ok(MIN_EYE_CLEARANCE_M <= 250);
|
||||
});
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
sampleHeight: (carto) =>
|
||||
typeof surfaceM === 'function' ? surfaceM(carto) : surfaceM,
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: position,
|
||||
heading: 0.5,
|
||||
pitch: -0.55,
|
||||
roll: 0,
|
||||
flyTo: (options) => flights.push(options),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
const lifts = [];
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 1, onLift: (m) => lifts.push(m) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const lifted = Cesium.Cartographic.fromCartesian(
|
||||
viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||
assert.equal(viewer.flights[0].orientation.heading, 0.5);
|
||||
assert.equal(lifts.length, 1);
|
||||
});
|
||||
|
||||
test('the guard waits for streaming tiles and yields to a newer arrival', async () => {
|
||||
let answer = Number.NaN;
|
||||
let stale = false;
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: () => answer });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 2, attempts: 20, isStale: () => stale },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'no guess while tiles stream in');
|
||||
stale = true;
|
||||
answer = 171;
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'a newer arrival owns the camera');
|
||||
});
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
|
||||
// Handoffs act before the delayed probe, even when the next owner stays low.
|
||||
import { NavigationController } from './ui/navigationController.js';
|
||||
import { SceneDirector } from './scenes/director.js';
|
||||
import { LayerLifecycle } from './data/lifecycle.js';
|
||||
|
||||
function handoffFixture(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
viewer.scene.canvas = new EventTarget();
|
||||
viewer.scene.canvas.ownerDocument = new EventTarget();
|
||||
viewer.trackedEntityChanged = new Cesium.Event();
|
||||
viewer.camera.cancelFlight = () => {};
|
||||
viewer.camera.lookAtTransform = () => {};
|
||||
viewer.camera.flyToBoundingSphere = () => {};
|
||||
viewer.camera.setView = () => {};
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
} else if (handoff === 'keyboard') {
|
||||
viewer.scene.canvas.ownerDocument.dispatchEvent(new Event('keydown'));
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
||||
await lifecycle.setEnabled('test', true);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
navigation.destroy();
|
||||
});
|
||||
}
|
||||
@@ -614,3 +614,9 @@ test('the credit line is never suppressed to make room', () => {
|
||||
}
|
||||
assert.match(css, /body\.ui-clean-view #cesium-credits,\s*\n\s*body\.recording-mode #cesium-credits \{[^}]*bottom: 36px;/);
|
||||
});
|
||||
|
||||
|
||||
test('OSM-derived layers never force the desktop credit row into a stack', () => {
|
||||
assert.doesNotMatch(css, /body:has\([^)]*alpr-cameras[^}]*#cesium-credits/);
|
||||
assert.doesNotMatch(css, /#cesium-credits\s+\.cesium-credit-expand-link\s*\{[^}]*display:\s*block/);
|
||||
});
|
||||
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* Bundled administrative boundaries — countries and states/provinces (Natural
|
||||
* Earth admin-0/admin-1) and US counties (US Census Bureau cartographic boundaries),
|
||||
* so "outline Texas", "outline Bavaria" or "outline Travis County" draws the
|
||||
* real boundary without a network lookup.
|
||||
*
|
||||
* Data: `local_data/natural_earth/{countries,states_provinces}.json` and
|
||||
* `local_data/us_census_counties/counties.json`, public domain and built
|
||||
* by `scripts/build-admin-packs.mjs` (provenance in each folder's README).
|
||||
* Rings are delta-encoded integers; see `decodeRing`.
|
||||
*
|
||||
* PURE data module — no Cesium, node-testable. Each pack is fetched through
|
||||
* `loadBundledJson` on the first lookup that needs it (never at app start),
|
||||
* indexed once, and kept; a failed load is retried later rather than cached
|
||||
* (`createRetryableLoader`). The county pack is only loaded for asks that
|
||||
* name a county (or a county-equivalent city) or that a geocoder typed as one.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `findAdminArea(query, { near })` — name-only. Resolves asks that are
|
||||
* unambiguous from the words alone ("Texas", "TX", "State of Texas",
|
||||
* "Bavaria", "Travis County, Texas", "Orleans Parish"); duplicates are
|
||||
* settled by a state/country qualifier, then by the camera (`near`).
|
||||
* Country/state homonyms such as Georgia use the qualifier or camera;
|
||||
* city/state homonyms such as New York still defer to the geocoder.
|
||||
* - `findAdminAreaAt(names, lat, lon, scope)` — geocoder-confirmed. The
|
||||
* geocoder already typed the place as a country, state or county; the unit must
|
||||
* carry one of the names AND contain the geocoded point.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const FOLD = {
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
æ: 'ae',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
};
|
||||
|
||||
/** Fold a name for matching: case, accents, punctuation, "the", "saint". */
|
||||
export function normalizeAdminName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '')
|
||||
.replace(/\bsaint\b/g, 'st')
|
||||
.replace(/\bsainte\b/g, 'ste');
|
||||
}
|
||||
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/countries.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/states_provinces.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/us_census_counties/counties.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const packLoads = { admin1: 0, counties: 0, countries: 0 };
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²) — same formula as naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one packed ring: integers in units of 10^-decimals degrees, the
|
||||
* first vertex absolute and each later one a delta from the previous.
|
||||
* @param {number[]} encoded
|
||||
* @param {number} decimals
|
||||
* @returns {Array<[number, number]>} open ring of [lon, lat]
|
||||
*/
|
||||
export function decodeRing(encoded, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const ring = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i + 1 < encoded.length; i += 2) {
|
||||
x += encoded[i];
|
||||
y += encoded[i + 1];
|
||||
ring.push([x / factor, y / factor]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons ([outer, ...holes][]) contain the point: inside an outer
|
||||
* ring and not inside any of that part's holes.
|
||||
*/
|
||||
export function polygonsContain(polygons, lat, lon) {
|
||||
for (const [outer, ...holes] of polygons || []) {
|
||||
if (!pointInRing(outer, lat, lon)) continue;
|
||||
if (!holes.some((hole) => pointInRing(hole, lat, lon))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
let areaKm2 = 0;
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
areaKm2 += ringAreaKm2(outer);
|
||||
for (const hole of holes) areaKm2 -= ringAreaKm2(hole);
|
||||
for (const [lon, lat] of outer) {
|
||||
const x = lon - 360 * Math.round((lon - ref) / 360);
|
||||
if (x < west) west = x;
|
||||
if (x > east) east = x;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
polygons,
|
||||
bbox: [west, south, east, north],
|
||||
areaKm2: Math.max(0, areaKm2),
|
||||
label,
|
||||
};
|
||||
return entry.geometry;
|
||||
}
|
||||
|
||||
/** Area-weighted centroid of an open ring (planar; fine at unit scale). */
|
||||
function ringCentroid(ring) {
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [x0, y0] = ring[j];
|
||||
const [x1, y1] = ring[i];
|
||||
const f = x0 * y1 - x1 * y0;
|
||||
a += f;
|
||||
cx += (x0 + x1) * f;
|
||||
cy += (y0 + y1) * f;
|
||||
}
|
||||
if (!a) return { lon: ring[0][0], lat: ring[0][1] };
|
||||
return { lon: cx / (3 * a), lat: cy / (3 * a) };
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
const clampedLon = Math.min(east, Math.max(west, x));
|
||||
const clampedLat = Math.min(north, Math.max(south, lat));
|
||||
return haversineKm(lat, x, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth `min_label` (the zoom at which a unit is labelled) up to which
|
||||
* a unit may be named alone: every US state, Canadian province, Mexican and
|
||||
* German state; not English counties, French départements or small districts.
|
||||
*/
|
||||
const BARE_NAME_MAX_RANK = 7;
|
||||
|
||||
/**
|
||||
* Words that name a unit somewhere (Cameroon's "North", Paraguay's
|
||||
* "Central") but, said alone, name a direction or a feature.
|
||||
*/
|
||||
const GENERIC_NAMES = new Set([
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'northern',
|
||||
'southern',
|
||||
'eastern',
|
||||
'western',
|
||||
'north east',
|
||||
'north west',
|
||||
'south east',
|
||||
'south west',
|
||||
'northeast',
|
||||
'northwest',
|
||||
'southeast',
|
||||
'southwest',
|
||||
'far north',
|
||||
'far west',
|
||||
'central',
|
||||
'centre',
|
||||
'center',
|
||||
'capital',
|
||||
'coast',
|
||||
'island',
|
||||
'islands',
|
||||
'lakes',
|
||||
'highland',
|
||||
'highlands',
|
||||
'interior',
|
||||
'upper',
|
||||
'lower',
|
||||
'valley',
|
||||
'plateau',
|
||||
'plateaux',
|
||||
'littoral',
|
||||
'oriental',
|
||||
'occidental',
|
||||
]);
|
||||
|
||||
/** State codes that, alone, more often mean a city ("LA", "NY"). */
|
||||
const CITY_ABBREVIATIONS = new Set(['la', 'ny']);
|
||||
|
||||
/** Postal abbreviations accepted alone ("TX", "ON"): US states and Canada. */
|
||||
const POSTAL_COUNTRIES = new Set(['US', 'CA']);
|
||||
|
||||
function addKey(index, key, entry, tier) {
|
||||
if (!key) return;
|
||||
entry.keys?.add(key);
|
||||
const list = index.get(key);
|
||||
const item = { entry, tier };
|
||||
if (!list) index.set(key, [item]);
|
||||
else if (!list.some((i) => i.entry === entry)) list.push(item);
|
||||
}
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'state',
|
||||
feature,
|
||||
decimals,
|
||||
amb: new Set(feature.amb || []),
|
||||
ambAbroad: new Set(feature.ambAbroad || []),
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: String(feature.iso2 || '').toLowerCase(),
|
||||
};
|
||||
addKey(index, normalizeAdminName(feature.name), entry, 0);
|
||||
addKey(index, normalizeAdminName(feature.nameEn), entry, 1);
|
||||
for (const alt of feature.alt || [])
|
||||
addKey(index, normalizeAdminName(alt), entry, 2);
|
||||
if (feature.postal && POSTAL_COUNTRIES.has(feature.iso2))
|
||||
addKey(index, `postal:${feature.postal.toLowerCase()}`, entry, 2);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.counties.url,
|
||||
PACKS.counties.importJson,
|
||||
);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
const states = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'county',
|
||||
feature,
|
||||
decimals,
|
||||
state: normalizeAdminName(feature.state),
|
||||
st: String(feature.st || '').toLowerCase(),
|
||||
};
|
||||
addKey(byFull, normalizeAdminName(feature.full), entry, 0);
|
||||
addKey(byName, normalizeAdminName(feature.name), entry, 1);
|
||||
states.set(entry.state, entry.st);
|
||||
}
|
||||
return { byFull, byName, states };
|
||||
});
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.countries.url,
|
||||
PACKS.countries.importJson,
|
||||
);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'country',
|
||||
feature,
|
||||
decimals: pack.meta.decimals,
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: feature.iso2?.toLowerCase(),
|
||||
};
|
||||
for (const name of [feature.name, ...(feature.alt || [])])
|
||||
addKey(index, normalizeAdminName(name), entry, 0);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
async function countryCandidates(parsed) {
|
||||
if (!['any', 'country'].includes(parsed.kind)) return [];
|
||||
const { index } = await loadCountries();
|
||||
const key = COUNTRY_TARGET_ALIASES.get(parsed.name) || parsed.name;
|
||||
return (index.get(key) || []).filter(({ entry }) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(entry, q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Country target aliases never broaden constituent-country requests. */
|
||||
const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['usa', 'united states of america'],
|
||||
['us', 'united states of america'],
|
||||
['u s', 'united states of america'],
|
||||
['u s a', 'united states of america'],
|
||||
['united states', 'united states of america'],
|
||||
['america', 'united states of america'],
|
||||
['uk', 'united kingdom'],
|
||||
['u k', 'united kingdom'],
|
||||
['great britain', 'united kingdom'],
|
||||
['britain', 'united kingdom'],
|
||||
['russian federation', 'russia'],
|
||||
['korea', 'south korea'],
|
||||
['czechia', 'czech republic'],
|
||||
['tanzania', 'united republic of tanzania'],
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
['scotland', 'united kingdom'],
|
||||
['wales', 'united kingdom'],
|
||||
['northern ireland', 'united kingdom'],
|
||||
]);
|
||||
|
||||
/** County-equivalent words, longest first so "city and borough" wins. */
|
||||
const COUNTY_WORDS = [
|
||||
'city and borough',
|
||||
'planning region',
|
||||
'census area',
|
||||
'municipality',
|
||||
'municipio',
|
||||
'borough',
|
||||
'parish',
|
||||
'county',
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
* @param {string} text
|
||||
* @returns {{kind:'country'|'county'|'state'|'any', name:string, qualifiers:string[],
|
||||
* postal:string|null, countyWord:string|null}|null}
|
||||
*/
|
||||
export function parseAdminQuery(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return null;
|
||||
const segments = raw.split(',').map(normalizeAdminName).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
const head = segments[0];
|
||||
const country =
|
||||
/^(?:country|nation) of (.+)$/.exec(head) ||
|
||||
/^(.+) (?:country|nation)$/.exec(head);
|
||||
if (country)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: country[1],
|
||||
qualifiers: segments.slice(1),
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
};
|
||||
const qualifiers = segments.slice(1);
|
||||
const postal = /^[A-Za-z]{2}$/.test(raw) ? raw.toLowerCase() : null;
|
||||
// "TX", "DC", "D.C.": written as an abbreviation, not a short word.
|
||||
const abbreviation = /^[A-Z]{2,3}$/.test(raw.replace(/[.\s]/g, ''));
|
||||
|
||||
// "County Cork", "County of Los Angeles"
|
||||
const countyPrefix = /^county (?:of )?(.+)$/.exec(head);
|
||||
if (countyPrefix)
|
||||
return {
|
||||
kind: 'county',
|
||||
name: countyPrefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: 'county',
|
||||
full: head,
|
||||
};
|
||||
// "Travis County", "Travis County Texas", "Orleans Parish, LA"
|
||||
const county = COUNTY_WORD_RE.exec(head);
|
||||
if (county && county.index > 0) {
|
||||
const name = head.slice(0, county.index).trim();
|
||||
const rest = head.slice(county.index + county[1].length).trim();
|
||||
if (rest) qualifiers.unshift(rest);
|
||||
return {
|
||||
kind: 'county',
|
||||
name,
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: county[1],
|
||||
full: head.slice(0, county.index + county[1].length),
|
||||
};
|
||||
}
|
||||
const prefix = STATE_PREFIX_RE.exec(head);
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: suffix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
name: head,
|
||||
qualifiers,
|
||||
postal,
|
||||
abbreviation,
|
||||
countyWord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
const q = COUNTRY_ALIASES.get(qualifier) || qualifier;
|
||||
return (
|
||||
entry.country === q ||
|
||||
entry.iso2 === q ||
|
||||
entry.keys.has(qualifier) ||
|
||||
entry.keys.has(`postal:${qualifier}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
const contains = hasNear
|
||||
? polygonsContain(geometry.polygons, near.lat, near.lon)
|
||||
: false;
|
||||
const distanceKm = hasNear
|
||||
? contains
|
||||
? 0
|
||||
: boxDistanceKm(geometry.bbox, near.lat, near.lon)
|
||||
: 0;
|
||||
return { entry, tier, contains, distanceKm, areaKm2: geometry.areaKm2 };
|
||||
});
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
Number(b.contains) - Number(a.contains) ||
|
||||
a.tier - b.tier ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
b.areaKm2 - a.areaKm2,
|
||||
);
|
||||
return scored;
|
||||
}
|
||||
|
||||
function toResult(scored, candidateCount) {
|
||||
const { entry } = scored;
|
||||
const { feature } = entry;
|
||||
const geometry = geometryOf(entry);
|
||||
// Parts are stored largest first, so polygons[0][0] is the main outline.
|
||||
const polygons = geometry.polygons;
|
||||
if (entry.kind === 'county') {
|
||||
return {
|
||||
kind: 'county',
|
||||
name: feature.full,
|
||||
region: feature.state,
|
||||
regionCode: feature.st,
|
||||
country: 'United States of America',
|
||||
id: feature.geoid,
|
||||
source: 'us-census',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: entry.kind,
|
||||
name: feature.nameEn || feature.name,
|
||||
localName: feature.name,
|
||||
type: feature.type,
|
||||
region: null,
|
||||
regionCode: feature.postal || null,
|
||||
country: feature.country,
|
||||
id: feature.iso || null,
|
||||
source: 'natural-earth',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function countyCandidates(parsed) {
|
||||
const { byFull, byName, states } = await loadCounties();
|
||||
// An exact full name ("Orleans Parish") wins outright; without one, the
|
||||
// name with ANY county-equivalent word ("Anchorage Borough" finds
|
||||
// Anchorage Municipality). An independent city is only ever its full name
|
||||
// ("Fairfax city", "Carson City").
|
||||
let list = parsed.countyWord
|
||||
? [
|
||||
...(byFull.get(parsed.full || `${parsed.name} ${parsed.countyWord}`) ||
|
||||
byName.get(parsed.name) ||
|
||||
[]),
|
||||
]
|
||||
: [...(byFull.get(parsed.name) || [])];
|
||||
if (!list.length) return [];
|
||||
// Every qualifier must be a state or the US: "Travis County, TX, USA"
|
||||
// qualifies, "Travis County Courthouse, Texas" names something else.
|
||||
const stateByCode = new Map([...states].map(([name, st]) => [st, name]));
|
||||
const stateQualifiers = [];
|
||||
for (const q of parsed.qualifiers) {
|
||||
const state = states.has(q) ? q : stateByCode.get(q);
|
||||
if (state) stateQualifiers.push(state);
|
||||
else if (!isUnitedStates(q)) return [];
|
||||
}
|
||||
if (stateQualifiers.length)
|
||||
list = list.filter((i) => stateQualifiers.includes(i.entry.state));
|
||||
return list;
|
||||
}
|
||||
|
||||
function isUnitedStates(qualifier) {
|
||||
return (
|
||||
(COUNTRY_ALIASES.get(qualifier) || qualifier) === 'united states of america'
|
||||
);
|
||||
}
|
||||
|
||||
async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
const { index } = await loadAdmin1();
|
||||
const keys = [];
|
||||
if (parsed.full) keys.push(parsed.full);
|
||||
keys.push(parsed.name);
|
||||
if (parsed.postal) keys.push(`postal:${parsed.postal}`);
|
||||
const seen = new Set();
|
||||
let list = [];
|
||||
for (const key of keys) {
|
||||
for (const item of index.get(key) || []) {
|
||||
if (seen.has(item.entry)) continue;
|
||||
seen.add(item.entry);
|
||||
list.push({ ...item, key, tier: key === parsed.full ? 0 : item.tier });
|
||||
}
|
||||
}
|
||||
// Every qualifier must place the unit: "Bavaria, Germany", "Washington,
|
||||
// DC" — not "Santa Barbara, California" (a city the pack does not hold).
|
||||
if (parsed.qualifiers.length)
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
// county or a Bahamas district ("Long Island", "Santa Barbara", "Kent" are
|
||||
// likelier the places a geocoder knows). It must not also be a country or
|
||||
// a large city ("Georgia", "New York"), and a short code must be written
|
||||
// as one ("TX", not "tx"). Otherwise a qualifier, admin wording or the
|
||||
// geocoder decides.
|
||||
// A country qualifier places a name without making it a state: "New York,
|
||||
// USA" and "Madrid, Spain" stay the geocoder's; "Washington, DC" names the
|
||||
// unit itself.
|
||||
if (parsed.kind === 'any') {
|
||||
const short = parsed.name.replace(/ /g, '').length <= 3;
|
||||
if (short && (!parsed.abbreviation || CITY_ABBREVIATIONS.has(parsed.name)))
|
||||
return [];
|
||||
const namesUnit = (entry) =>
|
||||
parsed.qualifiers.some(
|
||||
(q) => entry.keys.has(q) || entry.keys.has(`postal:${q}`),
|
||||
);
|
||||
if (!parsed.qualifiers.length) {
|
||||
if (GENERIC_NAMES.has(parsed.name)) return [];
|
||||
return list.filter(
|
||||
(i) =>
|
||||
!i.entry.amb.has(i.key) &&
|
||||
!i.entry.ambAbroad.has(i.key) &&
|
||||
((i.entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
i.entry.feature.cityState),
|
||||
);
|
||||
}
|
||||
// Qualified: a shared name abroad is settled by the country ("Victoria,
|
||||
// Australia"); one shared at home only by the unit's own name.
|
||||
return list.filter((i) => !i.entry.amb.has(i.key) || namesUnit(i.entry));
|
||||
}
|
||||
// "X State" only settles a name that is ambiguous on its own ("New York
|
||||
// State"); otherwise it is as likely a university ("Ohio State") and a
|
||||
// geocoder decides. A unit literally named so ("Free State") matches.
|
||||
if (parsed.form === 'suffix')
|
||||
return list.filter((i) => i.key === parsed.full || i.entry.amb.has(i.key));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an administrative unit from the ask's words alone.
|
||||
*
|
||||
* @param {string} query e.g. "Texas", "TX", "State of Texas", "Bavaria",
|
||||
* "Bavaria, Germany", "Travis County", "Travis County, TX", "Orleans Parish"
|
||||
* @param {{near?: {lat:number, lon:number}|null}} [options] the camera's
|
||||
* view centre — the tie-break between same-named units
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*
|
||||
* @typedef {{kind:'country'|'state'|'county', name:string, region:string|null,
|
||||
* regionCode:string|null, country:string, id:string|null,
|
||||
* source:'natural-earth'|'us-census',
|
||||
* polygons:Array<Array<Array<[number,number]>>>, ring:Array<[number,number]>,
|
||||
* bbox:number[], areaKm2:number, label:{lat:number, lon:number},
|
||||
* candidates:number}} AdminArea
|
||||
*/
|
||||
export async function findAdminArea(query, { near = null } = {}) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed || !parsed.name) return null;
|
||||
let candidates = [];
|
||||
if (parsed.kind === 'country') {
|
||||
candidates = await countryCandidates(parsed);
|
||||
} else if (parsed.kind === 'county') {
|
||||
// County wording also names admin-1 units abroad. Neither pack gets
|
||||
// first refusal: qualifiers exclude ineligible countries before ranking.
|
||||
const [counties, admin1] = await Promise.all([
|
||||
countyCandidates(parsed),
|
||||
stateCandidates({ ...parsed, kind: 'state' }),
|
||||
]);
|
||||
candidates = [...counties, ...admin1];
|
||||
} else {
|
||||
candidates = await stateCandidates(parsed);
|
||||
if (!candidates.length || COUNTRY_TARGET_ALIASES.has(parsed.name)) {
|
||||
const countries = await countryCandidates(parsed);
|
||||
if (countries.length) {
|
||||
const states = await stateCandidates(parsed, { allowAmbiguous: true });
|
||||
if (
|
||||
states.length &&
|
||||
!Number.isFinite(near?.lat) &&
|
||||
!parsed.qualifiers.length
|
||||
)
|
||||
return null;
|
||||
candidates = [...countries, ...states];
|
||||
}
|
||||
}
|
||||
// A county-equivalent city: "Baltimore city", "Fairfax city, Virginia".
|
||||
if (!candidates.length && / city$/.test(parsed.name))
|
||||
candidates = await countyCandidates(parsed);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const ranked = rank(candidates, near);
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
* point. Ambiguity marks do not apply — the geocoder already chose.
|
||||
*
|
||||
* @param {string[]} names the ask and the geocoder's own name for it
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {'country'|'state'|'county'} scope
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*/
|
||||
export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const parsedList = [...new Set((names || []).filter(Boolean))]
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
if (
|
||||
!out.some((c) => c.entry === item.entry) &&
|
||||
polygonsContain(geometryOf(item.entry).polygons, lat, lon)
|
||||
)
|
||||
out.push(item);
|
||||
return out;
|
||||
};
|
||||
if (scope === 'country') {
|
||||
const countries = [];
|
||||
for (const parsed of parsedList)
|
||||
countries.push(
|
||||
...(await countryCandidates({
|
||||
...parsed,
|
||||
kind: 'country',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(countries);
|
||||
return found.length
|
||||
? toResult(rank(found, { lat, lon })[0], found.length)
|
||||
: null;
|
||||
}
|
||||
// The geocoder's scope says what kind of unit this is; the ask's wording
|
||||
// may omit it ("Travis" typed as a county). A US county that matches wins
|
||||
// outright — "New York County" is Manhattan, not the state.
|
||||
if (scope === 'county' && inUnitedStatesBox(lat, lon)) {
|
||||
const counties = [];
|
||||
for (const parsed of parsedList)
|
||||
counties.push(
|
||||
...(await countyCandidates({
|
||||
...parsed,
|
||||
kind: 'county',
|
||||
countyWord: parsed.countyWord || 'county',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(counties);
|
||||
if (found.length)
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
{ allowAmbiguous: true },
|
||||
)),
|
||||
);
|
||||
const found = containing(states);
|
||||
if (!found.length) return null;
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
|
||||
/** Rough box around the US and its territories (skips the county pack elsewhere). */
|
||||
function inUnitedStatesBox(lat, lon) {
|
||||
return (
|
||||
(lat >= 13 && lat <= 72 && (lon <= -64 || lon >= 144)) ||
|
||||
(lat >= -15 && lat <= -10 && lon <= -168)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Bundled state/province and US county boundaries: name matching,
|
||||
// disambiguation, multipolygons and holes, and lazy loading.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
polygonsContain,
|
||||
} from './adminBoundaries.js';
|
||||
|
||||
const ADMIN1 = new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const COUNTIES = new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const PORTLAND = { lat: 45.5152, lon: -122.6784 };
|
||||
|
||||
// Runs first: nothing loads until a lookup needs it, and a state ask never
|
||||
// pulls the county pack.
|
||||
test('packs load lazily, once, and only the pack an ask needs', async () => {
|
||||
assert.deepEqual(packLoads, { admin1: 0, counties: 0, countries: 0 });
|
||||
assert.equal(parseAdminQuery('Texas').kind, 'any');
|
||||
assert.deepEqual(
|
||||
packLoads,
|
||||
{ admin1: 0, counties: 0, countries: 0 },
|
||||
'parsing is free',
|
||||
);
|
||||
await findAdminArea('Texas');
|
||||
await findAdminArea('Bavaria');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 0, countries: 0 });
|
||||
await findAdminArea('Travis County');
|
||||
await findAdminArea('Orleans Parish');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 1, countries: 0 });
|
||||
});
|
||||
|
||||
test('Texas, TX and "State of Texas" name the same unit', async () => {
|
||||
for (const query of [
|
||||
'Texas',
|
||||
'texas',
|
||||
'TX',
|
||||
'State of Texas',
|
||||
'the State of Texas',
|
||||
'Texas, USA',
|
||||
'Texas, United States',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: AUSTIN });
|
||||
assert.equal(unit?.id, 'US-TX', query);
|
||||
assert.equal(unit.name, 'Texas');
|
||||
assert.equal(unit.kind, 'state');
|
||||
assert.equal(unit.source, 'natural-earth');
|
||||
}
|
||||
const texas = await findAdminArea('Texas');
|
||||
assert.ok(
|
||||
texas.areaKm2 > 600_000 && texas.areaKm2 < 750_000,
|
||||
`Texas area ${texas.areaKm2}`,
|
||||
);
|
||||
assert.ok(polygonsContain(texas.polygons, AUSTIN.lat, AUSTIN.lon));
|
||||
});
|
||||
|
||||
test('provinces and states abroad resolve by English and local names', async () => {
|
||||
for (const query of [
|
||||
'Bavaria',
|
||||
'Bayern',
|
||||
'Bavaria, Germany',
|
||||
'Free State of Bavaria',
|
||||
]) {
|
||||
const unit = await findAdminArea(query);
|
||||
assert.equal(unit?.id, 'DE-BY', query);
|
||||
assert.equal(unit.name, 'Bavaria');
|
||||
assert.equal(unit.country, 'Germany');
|
||||
}
|
||||
assert.equal((await findAdminArea('Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('ON'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Province of Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Free State'))?.country, 'South Africa');
|
||||
// Same name in two countries: the qualifier decides.
|
||||
assert.equal((await findAdminArea('Punjab, India'))?.id, 'IN-PB');
|
||||
assert.equal((await findAdminArea('Punjab, Pakistan'))?.id, 'PK-PB');
|
||||
// A qualifier may name the unit itself.
|
||||
assert.equal((await findAdminArea('Washington, DC'))?.id, 'US-DC');
|
||||
});
|
||||
|
||||
test('"Washington" is the state; "Washington County" is a county near the camera', async () => {
|
||||
const state = await findAdminArea('Washington', { near: AUSTIN });
|
||||
assert.equal(state?.id, 'US-WA');
|
||||
const fromAustin = await findAdminArea('Washington County', { near: AUSTIN });
|
||||
assert.equal(fromAustin.kind, 'county');
|
||||
assert.equal(fromAustin.name, 'Washington County');
|
||||
assert.equal(fromAustin.region, 'Texas');
|
||||
assert.ok(fromAustin.candidates > 20, 'dozens of Washington Counties');
|
||||
const fromPortland = await findAdminArea('Washington County', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(fromPortland.region, 'Oregon');
|
||||
const arkansas = await findAdminArea('Washington County, Arkansas', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(arkansas.region, 'Arkansas');
|
||||
assert.equal(arkansas.candidates, 1);
|
||||
assert.equal(
|
||||
(await findAdminArea('Washington County, AR'))?.region,
|
||||
'Arkansas',
|
||||
);
|
||||
});
|
||||
|
||||
test('Travis County with and without its state', async () => {
|
||||
for (const query of [
|
||||
'Travis County',
|
||||
'Travis County, Texas',
|
||||
'Travis County, TX',
|
||||
'Travis County Texas',
|
||||
'travis county, texas, usa',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: PORTLAND });
|
||||
assert.equal(unit?.id, '48453', query);
|
||||
assert.equal(unit.name, 'Travis County');
|
||||
assert.equal(unit.region, 'Texas');
|
||||
assert.equal(unit.source, 'us-census');
|
||||
}
|
||||
assert.equal(await findAdminArea('Travis County, Ohio'), null);
|
||||
assert.equal(await findAdminArea('Travis County Courthouse'), null);
|
||||
// Every qualifier must place the county, not name something in it.
|
||||
assert.equal(await findAdminArea('Travis County Courthouse, Texas'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('Orange County Great Park, California'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('county equivalents: parishes, boroughs, independent cities, and non-US counties', async () => {
|
||||
const orleans = await findAdminArea('Orleans Parish', { near: AUSTIN });
|
||||
assert.equal(orleans?.id, '22071');
|
||||
assert.equal(orleans.region, 'Louisiana');
|
||||
assert.equal((await findAdminArea('Orleans Parish, LA'))?.id, '22071');
|
||||
// The wrong county word still finds the only unit with that name.
|
||||
assert.equal(
|
||||
(await findAdminArea('Anchorage Borough'))?.name,
|
||||
'Anchorage Municipality',
|
||||
);
|
||||
// Outside the US a county is an admin-1 unit.
|
||||
assert.equal((await findAdminArea('County Cork'))?.country, 'Ireland');
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County, England'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County', { near: AUSTIN }))?.region,
|
||||
'Texas',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bare name that is also a country or a large city is left to a geocoder', async () => {
|
||||
for (const query of ['New York', 'Georgia', 'Madrid']) {
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
}
|
||||
// Admin wording settles it.
|
||||
assert.equal((await findAdminArea('New York State'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of New York'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of Mexico'))?.country, 'Mexico');
|
||||
// City-states are the city itself.
|
||||
assert.equal((await findAdminArea('Berlin'))?.id, 'DE-BE');
|
||||
assert.equal((await findAdminArea('Vienna'))?.country, 'Austria');
|
||||
// A country qualifier places a name; it does not make a city a state.
|
||||
assert.equal(await findAdminArea('New York, USA'), null);
|
||||
assert.equal(await findAdminArea('Madrid, Spain'), null);
|
||||
// A namesake city abroad: ambiguous alone, settled by naming the country.
|
||||
const santaFe = { lat: 35.687, lon: -105.938 };
|
||||
assert.equal(await findAdminArea('Santa Fe', { near: santaFe }), null);
|
||||
assert.equal(await findAdminArea('Victoria'), null);
|
||||
assert.equal((await findAdminArea('Victoria, Australia'))?.id, 'AU-VIC');
|
||||
});
|
||||
|
||||
test('bare names: only prominent units, no generic words, codes as codes', async () => {
|
||||
// Places a geocoder knows better than a small admin unit of that name.
|
||||
for (const query of [
|
||||
'Long Island',
|
||||
'Santa Barbara',
|
||||
'Kent',
|
||||
'Hamilton',
|
||||
'Milan',
|
||||
])
|
||||
assert.equal(await findAdminArea(query, { near: AUSTIN }), null, query);
|
||||
for (const query of ['North', 'Central', 'Capital', 'Lakes'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
// Two-letter codes must be written as codes, and not the city ones.
|
||||
assert.equal(await findAdminArea('tx'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('LA', { near: { lat: 34.05, lon: -118.24 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await findAdminArea('NY'), null);
|
||||
assert.equal((await findAdminArea('OK'))?.id, 'US-OK');
|
||||
});
|
||||
|
||||
test('non-admin asks never match', async () => {
|
||||
for (const query of [
|
||||
'Texas Capitol',
|
||||
'the Texas State Capitol',
|
||||
'Zilker Park',
|
||||
'Ohio State',
|
||||
'Texas State',
|
||||
'Alps',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await findAdminArea(query), null, String(query));
|
||||
}
|
||||
});
|
||||
|
||||
test('geocoder-confirmed lookup: the named unit that contains the point', async () => {
|
||||
// Atlanta — the geocoder said "state", so the US state wins over the country.
|
||||
const georgia = await findAdminAreaAt(['Georgia'], 33.749, -84.388, 'state');
|
||||
assert.equal(georgia?.id, 'US-GA');
|
||||
// Tbilisi — Georgia the country has no admin-1 unit of that name.
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
30.33,
|
||||
-97.78,
|
||||
'county',
|
||||
);
|
||||
assert.equal(travis?.id, '48453');
|
||||
// A county the geocoder typed stays a county even where a state shares
|
||||
// its name: Manhattan, the Big Island, Arkansas County.
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['New York County'], 40.783, -73.966, 'county'))?.id,
|
||||
'36061',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Hawaii County'], 19.6, -155.5, 'county'))?.name,
|
||||
'Hawaii County',
|
||||
);
|
||||
// A geocoded point outside the named unit is not that unit.
|
||||
assert.equal(await findAdminAreaAt(['Texas'], 35.0, -106.6, 'state'), null);
|
||||
// Outside the US a geocoder "county" is an admin-1 unit (Kent).
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Kent'], 51.2, 0.7, 'county'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
|
||||
test('multipolygons: every island is a part, and containment follows the parts', async () => {
|
||||
const hawaii = await findAdminArea('Hawaii');
|
||||
assert.ok(
|
||||
hawaii.polygons.length >= 7,
|
||||
`Hawaii parts ${hawaii.polygons.length}`,
|
||||
);
|
||||
assert.ok(polygonsContain(hawaii.polygons, 19.6, -155.5), 'Big Island');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 20.8, -156.3), 'Maui');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 21.45, -158.0), 'Oahu');
|
||||
assert.ok(
|
||||
!polygonsContain(hawaii.polygons, 21.0, -157.3),
|
||||
'the channel between',
|
||||
);
|
||||
// The main ring is the largest part (the Big Island).
|
||||
assert.equal(hawaii.ring, hawaii.polygons[0][0]);
|
||||
|
||||
// Alaska is cut at the antimeridian: its box stays on one side of the globe.
|
||||
const alaska = await findAdminArea('Alaska');
|
||||
const [west, , east] = alaska.bbox;
|
||||
assert.ok(east - west < 60, `Alaska box width ${east - west}°`);
|
||||
assert.ok(alaska.polygons.length > 10, 'Aleutians and the southeast islands');
|
||||
});
|
||||
|
||||
test('holes: Berlin is cut out of Brandenburg', async () => {
|
||||
const brandenburg = await findAdminArea('Brandenburg');
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length > 1),
|
||||
'Brandenburg has a hole',
|
||||
);
|
||||
assert.ok(!polygonsContain(brandenburg.polygons, 52.52, 13.405), 'Berlin');
|
||||
assert.ok(polygonsContain(brandenburg.polygons, 52.39, 13.06), 'Potsdam');
|
||||
const berlin = await findAdminArea('Berlin');
|
||||
assert.ok(polygonsContain(berlin.polygons, 52.52, 13.405));
|
||||
});
|
||||
|
||||
test('holes: an independent city is cut out of its county', async () => {
|
||||
const fairfax = await findAdminArea('Fairfax County, Virginia');
|
||||
assert.ok(fairfax.polygons[0].length > 1, 'Fairfax County has holes');
|
||||
assert.ok(
|
||||
!polygonsContain(fairfax.polygons, 38.846, -77.306),
|
||||
'Fairfax city',
|
||||
);
|
||||
assert.ok(polygonsContain(fairfax.polygons, 38.9, -77.2), 'Fairfax County');
|
||||
const city = await findAdminArea('Fairfax city, Virginia');
|
||||
assert.ok(polygonsContain(city.polygons, 38.846, -77.306));
|
||||
assert.equal((await findAdminArea('Carson City'))?.region, 'Nevada');
|
||||
for (const query of ['New York City', 'Mexico City', 'Quebec City, Canada'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
});
|
||||
|
||||
test('rings decode from deltas and are open', () => {
|
||||
assert.deepEqual(decodeRing([-97743, 30267, 10, -5, -3, 8], 3), [
|
||||
[-97.743, 30.267],
|
||||
[-97.733, 30.262],
|
||||
[-97.736, 30.27],
|
||||
]);
|
||||
});
|
||||
|
||||
test('names normalize across accents, punctuation and "Saint"', () => {
|
||||
assert.equal(normalizeAdminName('Québec'), 'quebec');
|
||||
assert.equal(normalizeAdminName(' The State of TEXAS '), 'state of texas');
|
||||
assert.equal(normalizeAdminName('Saint Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('St. Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('Schleswig-Holstein'), 'schleswig holstein');
|
||||
});
|
||||
|
||||
test('packs: public-domain provenance, schema, and size budgets', () => {
|
||||
const admin1 = JSON.parse(readFileSync(ADMIN1, 'utf8'));
|
||||
const counties = JSON.parse(readFileSync(COUNTIES, 'utf8'));
|
||||
for (const pack of [admin1, counties]) {
|
||||
assert.match(pack.meta.license, /public domain/i);
|
||||
assert.match(pack.meta.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pack.meta.script, 'scripts/build-admin-packs.mjs');
|
||||
for (const feature of pack.features) {
|
||||
assert.ok(feature.name, 'named');
|
||||
assert.ok(feature.polygons.length >= 1, `${feature.name} has a part`);
|
||||
for (const poly of feature.polygons)
|
||||
for (const ring of poly) {
|
||||
assert.equal(ring.length % 2, 0);
|
||||
assert.ok(ring.length >= 8, `${feature.name}: ring has 4+ vertices`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
admin1.features.length > 4500,
|
||||
`${admin1.features.length} states/provinces`,
|
||||
);
|
||||
assert.ok(
|
||||
counties.features.length > 3200,
|
||||
`${counties.features.length} counties`,
|
||||
);
|
||||
assert.ok(statSync(ADMIN1).size <= 4 * 1024 * 1024, 'admin-1 pack ≤ 4 MB');
|
||||
assert.ok(statSync(COUNTIES).size <= 2 * 1024 * 1024, 'county pack ≤ 2 MB');
|
||||
});
|
||||
|
||||
test('county queries compare US and international names before choosing a geography', async () => {
|
||||
const england = { lat: 54.78, lon: -1.58 };
|
||||
const nc = { lat: 36.0, lon: -78.9 };
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham', { near: england }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham'))?.country,
|
||||
'United Kingdom',
|
||||
'exact alias survives parsing',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County', { near: nc }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: nc }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, USA', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County, NC', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
// Durham is also a city: without county wording leave city geocoding intact.
|
||||
assert.equal(await findAdminArea('Durham, NC', { near: england }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Durham, NC'], nc.lat, nc.lon, 'county'))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
});
|
||||
|
||||
test('countries resolve aliases, keep substantial islands and disambiguate Georgia by scope and geography', async () => {
|
||||
for (const names of [
|
||||
['USA', 'United States', 'America'],
|
||||
['UK', 'United Kingdom', 'Britain'],
|
||||
['Iran', 'Islamic Republic of Iran'],
|
||||
['Czechia', 'Czech Republic'],
|
||||
['Ivory Coast', "Côte d'Ivoire"],
|
||||
['South Korea', 'Korea'],
|
||||
['Russia', 'Russian Federation'],
|
||||
['Switzerland'],
|
||||
['France'],
|
||||
]) {
|
||||
const resolved = await Promise.all(
|
||||
names.map((name) => findAdminArea(name, { near: AUSTIN })),
|
||||
);
|
||||
assert.ok(
|
||||
resolved.every((r) => r?.kind === 'country'),
|
||||
names.join('/'),
|
||||
);
|
||||
assert.equal(new Set(resolved.map((r) => r.id)).size, 1);
|
||||
}
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: AUSTIN })).kind,
|
||||
'state',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: { lat: 41.7, lon: 44.8 } })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('the country of Georgia', { near: AUSTIN })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(await findAdminArea('New York', { near: AUSTIN }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Iran'], 35.69, 51.39, 'country')).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
await findAdminAreaAt(['Iran'], 46.9, 7.4, 'country'),
|
||||
null,
|
||||
'name alone cannot override a mismatched geocoded point',
|
||||
);
|
||||
const japan = await findAdminArea('Japan');
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.69],
|
||||
[43.06, 141.35],
|
||||
[33.59, 130.4],
|
||||
[33.56, 133.53],
|
||||
])
|
||||
assert.ok(polygonsContain(japan.polygons, lat, lon));
|
||||
const usa = await findAdminArea('USA');
|
||||
for (const [lat, lon] of [
|
||||
[30.27, -97.74],
|
||||
[61.22, -149.9],
|
||||
[19.7, -155.1],
|
||||
])
|
||||
assert.ok(
|
||||
polygonsContain(usa.polygons, lat, lon),
|
||||
'USA includes mainland, Alaska and Hawaii',
|
||||
);
|
||||
const bytes = statSync(
|
||||
new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
).size;
|
||||
assert.ok(bytes < 1_000_000);
|
||||
});
|
||||
|
||||
test('UK constituent countries retain their own identity and extent from any camera', async () => {
|
||||
const targets = [
|
||||
['Scotland', 'SCT', [55.95, -3.19], [54.6, 61]],
|
||||
['England', 'ENG', [51.5, -0.12], [49.8, 55.9]],
|
||||
['Wales', 'WLS', [51.48, -3.18], [51.3, 53.6]],
|
||||
['Northern Ireland', 'NIR', [54.6, -5.93], [53.9, 55.4]],
|
||||
];
|
||||
for (const [name, id, point, extent] of targets) {
|
||||
const unit = await findAdminArea(name, { near: AUSTIN });
|
||||
assert.equal(unit.id, id);
|
||||
assert.equal(unit.name, name);
|
||||
assert.equal(unit.kind, 'country');
|
||||
assert.ok(
|
||||
unit.bbox[1] >= extent[0] && unit.bbox[3] <= extent[1],
|
||||
`${name}: ${unit.bbox}`,
|
||||
);
|
||||
assert.ok(polygonsContain(unit.polygons, ...point));
|
||||
for (const [other, , otherPoint] of targets)
|
||||
if (other !== name)
|
||||
assert.equal(polygonsContain(unit.polygons, ...otherPoint), false);
|
||||
assert.equal((await findAdminAreaAt([name], ...point, 'country')).id, id);
|
||||
assert.equal(await findAdminAreaAt([name], 30.27, -97.74, 'country'), null);
|
||||
}
|
||||
assert.equal((await findAdminArea('UK', { near: AUSTIN })).id, 'GBR');
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: AUSTIN })).country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
createOverpassAlprSource(),
|
||||
createAlprTileSource(),
|
||||
['fetch'],
|
||||
'ALPR source',
|
||||
{ destroy: () => {}, detailZoom: () => null },
|
||||
);
|
||||
export const configureAlprSource = slot.configure;
|
||||
export default createApplicationAlpr({
|
||||
|
||||
+437
-70
@@ -3,7 +3,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -262,9 +266,45 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
let response = async () => cameraResponse();
|
||||
const requests = [];
|
||||
globalThis.fetch = (...args) => {
|
||||
// Marker floors resolve through the shared terrain service; these tests
|
||||
// count camera requests only.
|
||||
if (String(args[0]).includes('/api/terrain/heights'))
|
||||
return Promise.resolve(Response.json({ results: [] }));
|
||||
requests.push(args);
|
||||
return response(...args);
|
||||
};
|
||||
// Rendering tests use normalized records; tile transport has its own fixture suite.
|
||||
if (layer === alprCamerasLayer)
|
||||
configureAlprSource({
|
||||
...createAlprTileSource(),
|
||||
async fetch(box, signal) {
|
||||
const response = await globalThis.fetch('/test/cameras', {
|
||||
signal,
|
||||
body: `data=${encodeURIComponent(buildOverpassQuery(box.south, box.west, box.north, box.east))}`,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Camera source temporarily unavailable');
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body.elements) || body.remark)
|
||||
throw new Error('Incomplete fixture snapshot');
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
records: [
|
||||
...new Map(
|
||||
body.elements
|
||||
.slice(0, QUERY_LIMIT)
|
||||
.map(normalizeAlprNode)
|
||||
.filter(Boolean)
|
||||
.map((r) => [r.id, r]),
|
||||
).values(),
|
||||
],
|
||||
stale: response.headers.get('x-overpass-cache') === 'STALE',
|
||||
saturated: body.elements.length >= QUERY_LIMIT,
|
||||
noCoverage: body.noCoverage,
|
||||
zoomIn: body.zoomIn,
|
||||
};
|
||||
},
|
||||
});
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
@@ -348,88 +388,28 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
}
|
||||
|
||||
test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => {
|
||||
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'do not use up the introduction while data is still loading',
|
||||
);
|
||||
assert.equal(h.credits.size, 0);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.match(credit.html, /class="gev-alpr-credit">ALPR:/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(credit.html, /© OpenStreetMap<\/a>/);
|
||||
t.mock.timers.tick(4999);
|
||||
assert.match(credit.html, />© OpenStreetMap</);
|
||||
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.credits.size, 0);
|
||||
const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm');
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/ALPR camera locations \(automatic license plate readers\)/,
|
||||
);
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(overflow.html, /ODbL 1\.0/);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'refresh must not keep reintroducing the credit',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(4000);
|
||||
alprCamerasLayer.enable();
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'duplicate enable must not restart the timer',
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
||||
/OpenStreetMap contributors.*ODbL/,
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(h.credits.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer');
|
||||
});
|
||||
|
||||
test('disabling midway through the introduction cancels its timer before a new enable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(3000);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
1,
|
||||
'old deadline must not hide the new presentation',
|
||||
);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(h.credits.size, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -765,7 +745,7 @@ test('empty mapped coverage, stale data and a saturated response remain distinct
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/coverage is incomplete/,
|
||||
/No ALPR data for this area/,
|
||||
);
|
||||
h.expire();
|
||||
h.setFetch(async () =>
|
||||
@@ -928,6 +908,122 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
}
|
||||
});
|
||||
|
||||
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
||||
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
||||
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
||||
const floors = new Map([['30.2672', 150]]);
|
||||
let resolveCalls = 0;
|
||||
let releaseResolve;
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
groundFloor: {
|
||||
cachedGroundFloor: (lat) => floors.get(lat.toFixed(4)) ?? null,
|
||||
resolveGroundFloorCells: async () => {
|
||||
resolveCalls++;
|
||||
await new Promise((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
floors.set('30.2682', 160);
|
||||
},
|
||||
},
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {
|
||||
return null;
|
||||
},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const layer = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({
|
||||
records: [
|
||||
{ id: 'directory:1', latitude: 30.2672, longitude: -97.7431 },
|
||||
{ id: 'directory:2', latitude: 30.2682, longitude: -97.7431 },
|
||||
],
|
||||
stale: false,
|
||||
saturated: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const known = h.source.entities.getById('directory:1');
|
||||
const unknown = h.source.entities.getById('directory:2');
|
||||
assert.equal(
|
||||
known.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
||||
151.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(resolveCalls, 1);
|
||||
releaseResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
||||
161.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
h.source.entities.getById('directory:2'),
|
||||
unknown,
|
||||
'placed in place, not rebuilt',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded cameras outside the view say so instead of looking unloaded', async () => {
|
||||
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
||||
// could hold cameras with none on screen and read as a failed load.
|
||||
const h = cameraHarness();
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
let inView = false;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
||||
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
||||
try {
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
2000,
|
||||
);
|
||||
h.viewer.scene.canvas.clientWidth = 800;
|
||||
h.viewer.scene.canvas.clientHeight = 600;
|
||||
await alprCamerasLayer.update();
|
||||
let stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 0);
|
||||
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
||||
assert.match(stats.loadingLabel, /None on screen/);
|
||||
inView = true;
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 1);
|
||||
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
@@ -943,6 +1039,10 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const position = entity.position,
|
||||
line = entity.polyline;
|
||||
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
||||
// bearings are painted by the overlay for the nearest cameras instead.
|
||||
assert.equal(line, undefined);
|
||||
assert.equal(entity.polygon, undefined);
|
||||
let selections = 0,
|
||||
clears = 0,
|
||||
collectionChanges = 0;
|
||||
@@ -1126,3 +1226,270 @@ test('nearby count and discovery control frame a real loaded camera without fetc
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-retryable capability error does not arm the ALPR retry timer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => {
|
||||
throw Object.assign(new Error('Unavailable'), {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
||||
const requests = h.requests.length;
|
||||
t.mock.timers.tick(300_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(h.requests.length, requests);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported ALPR coverage suppresses the nearby count and names the extract region', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => ({
|
||||
...cameraResponse([]),
|
||||
json: async () => ({ elements: [], noCoverage: true }),
|
||||
}));
|
||||
await alprCamerasLayer.update();
|
||||
const stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.noCoverage, true);
|
||||
assert.equal(stats.countLabel, '');
|
||||
assert.equal(
|
||||
stats.loadingLabel,
|
||||
'No ALPR data for this area — US and Canada only',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
|
||||
test('slow ALPR floors share real terrain requests across preparation, render and pans; disable aborts', async () => {
|
||||
const requests = [];
|
||||
const sourceServices = createApplicationRequestServices({
|
||||
fetchImpl: (url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
assert.match(url, /^\/api\/terrain\/heights\?/);
|
||||
const points = new URL(url, 'http://localhost').searchParams
|
||||
.get('points')
|
||||
.split(';');
|
||||
requests.push({
|
||||
signal,
|
||||
points,
|
||||
release: () =>
|
||||
resolve(
|
||||
Response.json({
|
||||
results: points.map(() => ({ ellipsoid: 160 })),
|
||||
}),
|
||||
),
|
||||
});
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
const terrain = createTerrainHeights({ source: sourceServices.terrain });
|
||||
const ground = createGroundFloor({ terrain });
|
||||
ground.FLOOR_RESOLVE_DEADLINE_MS = 1;
|
||||
const records = Array.from({ length: 130 }, (_, i) => ({
|
||||
id: `slow:${i}`,
|
||||
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
||||
longitude: -97.749 + (i % 15) * 0.001,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: ground,
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'render joins the outstanding preparation batch',
|
||||
);
|
||||
assert.equal(
|
||||
requests[0].points.length,
|
||||
64,
|
||||
'one bounded batch runs at a time',
|
||||
);
|
||||
h.expire();
|
||||
await layer.update();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'further preparation and render reuse cold cell promises',
|
||||
);
|
||||
requests[0].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2);
|
||||
const requested = requests.flatMap((request) => request.points);
|
||||
assert.equal(
|
||||
new Set(requested).size,
|
||||
requested.length,
|
||||
'no duplicate cells over HTTP',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
requests[1].signal.aborted,
|
||||
true,
|
||||
'disable cancels the active HTTP request',
|
||||
);
|
||||
requests[1].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
3,
|
||||
'a fresh consumer can resume unresolved cells',
|
||||
);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
requests[2].signal.aborted,
|
||||
true,
|
||||
'destroy also aborts its consumer',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
||||
let floor = 180;
|
||||
let listener;
|
||||
let removed = 0;
|
||||
const records = Array.from({ length: 70 }, (_, i) => ({
|
||||
id: `surface:${i}`,
|
||||
latitude: 30.2672 + i * 0.00001,
|
||||
longitude: -97.7431,
|
||||
directionDeg: i === 0 ? null : 90,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
subscribeMapStack(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
removed++;
|
||||
};
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
h.viewer.scene.globe.show = next !== 180;
|
||||
listener({ detail: { status: 'ready' } });
|
||||
for (const entity of entities) {
|
||||
assert.equal(h.source.entities.getById(entity.id), entity);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
||||
.height - (next == null ? 0 : next + 1.5),
|
||||
) < 0.01,
|
||||
`${entity.id} refreshes at ${next}`,
|
||||
);
|
||||
assert.equal(
|
||||
entity.billboard.heightReference.getValue(),
|
||||
next == null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(removed, 1, 'map listener is disposed');
|
||||
});
|
||||
|
||||
test('a repeated move inside an outstanding query keeps the displayed marker set until commit', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const previous = h.source.entities.getById('alpr:42');
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
let release;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
||||
const pending = alprCamerasLayer.update();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42'),
|
||||
previous,
|
||||
'do not show an intermediate filtered snapshot while the new query prepares',
|
||||
);
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision);
|
||||
assert.equal(h.requests.length, 2, 'both moves share one new query');
|
||||
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
@@ -1035,6 +1037,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
renderPresentation();
|
||||
viewer.dataSources.add(dataSource);
|
||||
registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
requestRender('bhote-koshi-locator-callout');
|
||||
|
||||
try {
|
||||
@@ -1070,6 +1073,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_enabled = false;
|
||||
_boundaryReady = false;
|
||||
_boundaryResolved = false;
|
||||
|
||||
@@ -27,7 +27,7 @@ function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson) {
|
||||
export async function loadBundledJson(url, importJson, { signal } = {}) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
const response = await fetch(url);
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Arrival corrections belong to one viewer and yield to its next camera owner. */
|
||||
const arrivals = new WeakMap();
|
||||
|
||||
/** Cancel an outstanding arrival correction before navigation or teardown. */
|
||||
export function cancelCameraArrival(viewer) {
|
||||
if (viewer) arrivals.get(viewer)?.();
|
||||
}
|
||||
|
||||
/** Register a correction and return an idempotent ownership release. */
|
||||
export function ownCameraArrival(viewer, cancel) {
|
||||
cancelCameraArrival(viewer);
|
||||
arrivals.set(viewer, cancel);
|
||||
return () => {
|
||||
if (arrivals.get(viewer) === cancel) arrivals.delete(viewer);
|
||||
};
|
||||
}
|
||||
+86
-54
@@ -25,6 +25,72 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
|
||||
for (const [key, visible] of [
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
state.owners.set(owner, openMapTiles);
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
|
||||
export function hideOsmCredit(viewer, owner) {
|
||||
const state = osmDisplays.get(viewer);
|
||||
if (!state?.owners.delete(owner)) return false;
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -94,42 +160,15 @@ export const DATA_CREDITS = [
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'photon-geocoder',
|
||||
html:
|
||||
'Keyless place search: ' +
|
||||
'<a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot) over ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'alpr-osm',
|
||||
html:
|
||||
'ALPR camera locations (automatic license plate readers): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
|
||||
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context and last-resort place search: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
html: 'Keyless place search: <a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
@@ -212,7 +251,6 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
|
||||
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
@@ -262,19 +300,9 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html:
|
||||
'Dams: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0) + Open Infrastructure Map',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
},
|
||||
{
|
||||
key: 'firms',
|
||||
@@ -307,9 +335,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
html: 'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
@@ -342,15 +368,24 @@ export const TOMTOM_CREDIT = {
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region outline resolves (public
|
||||
* domain — no attribution required; credited as a courtesy). */
|
||||
/** Registered when the first Natural Earth region or state/province outline
|
||||
* resolves (public domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
key: 'natural-earth',
|
||||
html:
|
||||
'Physical region boundaries from ' +
|
||||
'Country, state/province and physical region boundaries from ' +
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled US county outline resolves (public
|
||||
* domain — credited as a courtesy). */
|
||||
export const US_CENSUS_CREDIT = {
|
||||
key: 'us-census-counties',
|
||||
html:
|
||||
'US county boundaries from the ' +
|
||||
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
@@ -386,10 +421,7 @@ export const BHOTE_KOSHI_CREDIT = {
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'Flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
@@ -37,3 +37,86 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
);
|
||||
assert.equal(osm.length, 1);
|
||||
assert.equal(osm[0].key, 'openstreetmap');
|
||||
assert.match(osm[0].html, /Map and place data/);
|
||||
assert.match(osm[0].html, /© OpenStreetMap contributors/);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
|
||||
/Vector tiles:/,
|
||||
);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
|
||||
/Overture Maps Foundation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
assert.match(
|
||||
[...visible][0].html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match([...visible][0].html, />© OpenStreetMap</);
|
||||
assert.ok([...visible][0].showOnScreen);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(visible.size, 1);
|
||||
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
|
||||
assert.equal(visible.size, 2);
|
||||
hideOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 2, 'traffic still owns both credits');
|
||||
showOsmCredit(viewer, 'datacenters');
|
||||
hideOsmCredit(viewer, 'traffic');
|
||||
assert.equal(
|
||||
visible.size,
|
||||
1,
|
||||
'bundled data does not retain the tile distributor',
|
||||
);
|
||||
hideOsmCredit(viewer, 'datacenters');
|
||||
assert.equal(visible.size, 0);
|
||||
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
|
||||
showOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
* the layer's pure helpers for the tests and the share-link code.
|
||||
*/
|
||||
|
||||
import * as credits from './dataCredits.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
@@ -23,6 +24,7 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
|
||||
@@ -20,3 +20,24 @@
|
||||
time is interpolated between the frames whose decoded position equals a
|
||||
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
|
||||
category, speed-check and track-accuracy tests; never loaded at runtime.
|
||||
- `ofm-austin-{14-3743-6745,12-935-1686}.pbf` and
|
||||
`ofm-camp-mabry-12-935-1685.pbf` — OpenFreeMap / OpenMapTiles tiles from
|
||||
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
|
||||
retrieved 2026-09-23. Trimmed to one transportation feature per class/direction
|
||||
and one military landuse feature, preserving original geometry and dictionaries.
|
||||
OpenFreeMap © OpenMapTiles Data from OpenStreetMap; © OpenStreetMap
|
||||
contributors, ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
These modified data fixtures retain ODbL attribution and database share-alike;
|
||||
commercial use and redistribution are permitted under that license. Test-only.
|
||||
- `osm-alpr-austin-11-467-843.pbf` — one OSM camera feature (original geometry
|
||||
and attribute dictionary) from the community-hosted hourly US extract,
|
||||
`https://tiles.dontgetflocked.com/cameras-us-hourly/11/467/843.mvt`,
|
||||
retrieved 2026-09-23. © OpenStreetMap contributors, ODbL 1.0; modified by
|
||||
trimming to one feature, test-only. Same attribution/share-alike terms above.
|
||||
|
||||
- `traffic-road-access.json` — representative transportation properties and two
|
||||
original vertices per feature near the Texas Capitol, Camp Mabry greenbelt,
|
||||
and Austin commercial car parks, from OpenFreeMap z14 tiles, retrieved
|
||||
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
|
||||
Modified, test-only OpenStreetMap data; same ODbL attribution and database
|
||||
share-alike terms as the OpenFreeMap fixtures above.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
+125
-82
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
* @file Flow→road matching: assign TomTom congestion levels to OpenStreetMap roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* In OpenStreetMap road mode the layer renders OpenFreeMap polylines, while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
@@ -11,8 +11,10 @@
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* bearing agrees with the road travel direction within 30°. Two-way
|
||||
* roads are represented as separate travel directions by the layer. A
|
||||
* flow line whose `coverage` is `full` describes both directions of its
|
||||
* road, so either travel bearing may match it.
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
@@ -29,7 +31,7 @@ const M_PER_DEG_LAT = 111320;
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
/** @const {number} Max travel-bearing disagreement (degrees). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
@@ -56,15 +58,14 @@ function bearingDeg(dx, dy) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* Travel-bearing disagreement; opposing flow never matches.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
* @returns {number} Angular difference in [0, 180].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
@@ -80,61 +81,45 @@ function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
* Index flow polylines for nearest-segment queries in local meters.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
* Shared by congestion matching and the Hybrid road-source dedupe, so both
|
||||
* use one distance, bearing and direction rule.
|
||||
*
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, closure:boolean, coverage?:string}>} flowSegments
|
||||
* @returns {null|{
|
||||
* project: (lon:number, lat:number) => [number, number],
|
||||
* nearest: (x:number, y:number, bearing:number, radiusFor?:(segment:object) => number) => {best:object|null, ambiguous:boolean, candidate:boolean},
|
||||
* }} Null when no usable segment exists.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createFlowSegmentIndex(flowSegments) {
|
||||
if (!Array.isArray(flowSegments)) return null;
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
if (!anchor) return null;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
const project = (lon, lat) => [
|
||||
(lon - refLon) * mPerDegLon,
|
||||
(lat - refLat) * M_PER_DEG_LAT,
|
||||
];
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
// Spatial hash of flow segments (midpoint-keyed 100 m cells).
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
const cellKey = (cx, cy) => (cx + 0x8000) * 0x10000 + (cy + 0x8000);
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
const bothDirections = flow.coverage === 'full';
|
||||
const roadType = flow.roadType;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const [ax, ay] = project(coords[i][0], coords[i][1]);
|
||||
const [bx, by] = project(coords[i + 1][0], coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
@@ -150,10 +135,15 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
bothDirections,
|
||||
roadType,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
const key = cellKey(
|
||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||
Math.floor((seg.ay + seg.by) / 2 / CELL_SIZE_M),
|
||||
);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
@@ -163,13 +153,87 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
if (grid.size === 0) return null;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
/**
|
||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
||||
*/
|
||||
function nearest(px, py, bearing, radiusFor = null) {
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
let ambiguous = false;
|
||||
let candidate = false;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(cellKey(gx, gy));
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
candidate = true; // within radius, bearing not yet checked
|
||||
if (radiusFor) {
|
||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||
if (d2 > radius * radius) continue;
|
||||
}
|
||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||
if (
|
||||
diff >= BEARING_TOLERANCE_DEG &&
|
||||
!(seg.bothDirections && 180 - diff < BEARING_TOLERANCE_DEG)
|
||||
)
|
||||
continue;
|
||||
if (d2 < bestDist2 - 1) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
ambiguous = false;
|
||||
} else if (Math.abs(d2 - bestDist2) <= 1) {
|
||||
if (
|
||||
best &&
|
||||
(best.level !== seg.level || best.closure !== seg.closure)
|
||||
)
|
||||
ambiguous = true;
|
||||
else if (!best) best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { best, ambiguous, candidate };
|
||||
}
|
||||
return { project, nearest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string, oneway?:number}>} roads
|
||||
* Parsed OpenStreetMap road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} flowSegments
|
||||
* Decoded flow polylines from `flowDecode.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing before bearing rejection.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0) return empty;
|
||||
const index = createFlowSegmentIndex(flowSegments);
|
||||
if (!index) return empty;
|
||||
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
// Sample each road and vote.
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
@@ -180,8 +244,7 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
[xs[i], ys[i]] = index.project(coords[i][0], coords[i][1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
@@ -202,37 +265,17 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
const sampleBearing =
|
||||
(roads[r].oneway === -1 ? 180 : 0) +
|
||||
bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const { best, ambiguous, candidate } = index.nearest(
|
||||
px,
|
||||
py,
|
||||
sampleBearing,
|
||||
);
|
||||
if (candidate) hadCandidate = true;
|
||||
if (best && !ambiguous) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ test('coincident parallel road matches with the flow level', () => {
|
||||
assert.equal(matches[0].closure, false);
|
||||
});
|
||||
|
||||
test('opposite-direction flow still matches (two-way bearing fold)', () => {
|
||||
test('opposite-direction flow matches only the reverse travel direction)', () => {
|
||||
// Same line drawn south->north vs road north->south equivalence.
|
||||
const reversed = line({ lengthM: 500, bearingDeg: 0, eastM: 4 }).reverse();
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [flow(reversed, 0.3)]);
|
||||
const { matches } = matchFlowToRoads([{...ROAD_NS, oneway:-1}], [flow(reversed, 0.3)]);
|
||||
assert.ok(matches[0], 'reversed flow should still match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.3) < 1e-9);
|
||||
});
|
||||
@@ -159,3 +159,28 @@ test('median: single value and empty', () => {
|
||||
assert.equal(median([0.7]), 0.7);
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('one-way rejects opposite closure and two travel directions retain separate congestion', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const forward = {coords, oneway:1};
|
||||
const reverse = {coords, oneway:-1};
|
||||
const east = {coords, trafficLevel:1, closure:false};
|
||||
const west = {coords:[...coords].reverse(), trafficLevel:0, closure:true};
|
||||
assert.equal(matchFlowToRoads([forward],[west]).matches[0], null);
|
||||
for (const flows of [[east,west],[west,east]]) {
|
||||
assert.deepEqual(matchFlowToRoads([forward,reverse],flows).matches,
|
||||
[{level:1,closure:false},{level:0,closure:true}]);
|
||||
}
|
||||
assert.equal(matchFlowToRoads([forward],[east,{...east,trafficLevel:0,closure:true}]).matches[0], null);
|
||||
});
|
||||
|
||||
test('a full-coverage flow line matches both travel directions; one-side does not', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const directions = [{coords, oneway:1}, {coords, oneway:-1}];
|
||||
const full = {coords, trafficLevel:0.4, closure:false, coverage:'full'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [full]).matches,
|
||||
[{level:0.4,closure:false},{level:0.4,closure:false}]);
|
||||
const oneSide = {...full, coverage:'one_side'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [oneSide]).matches,
|
||||
[{level:0.4,closure:false}, null]);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
@@ -37,6 +38,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||
|
||||
function services() {
|
||||
const records = new Map();
|
||||
const credited = [];
|
||||
let selection;
|
||||
return {
|
||||
records,
|
||||
credited,
|
||||
showOsmCredit: (viewer) => credited.push(viewer),
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
@@ -121,6 +124,7 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
osmDerived: true,
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
@@ -150,6 +154,10 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.deepEqual(
|
||||
hosts.map((host) => host.credited),
|
||||
instances.map(({ viewer }) => [viewer]),
|
||||
);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
instances[0].click();
|
||||
assert.ok(hosts[0].selection());
|
||||
@@ -184,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
|
||||
/** Shared ambient infrastructure card contract; the host owns projection and persistence. */
|
||||
export function createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source,
|
||||
position,
|
||||
title,
|
||||
details = [],
|
||||
accent,
|
||||
priority,
|
||||
}) {
|
||||
return {
|
||||
id: String(id),
|
||||
source: source,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: title,
|
||||
details: details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
@@ -18,5 +20,16 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
stats.coverage?.kind === 'subject' &&
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
}
|
||||
@@ -19,3 +19,16 @@ test('first fetch, retry, cached data and success have distinct copy', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready' }), 'Mapped sites loaded');
|
||||
assert.equal(installationFeedback({ stale: true }), 'Showing cached mapped sites');
|
||||
});
|
||||
test('a counted result names what was found and where', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready', count: 3 }), '3 mapped sites in view');
|
||||
assert.equal(installationFeedback({ status: 'empty', count: 0 }), 'No mapped sites in view');
|
||||
const coverage = { kind: 'subject', radiusM: 100_000 };
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'ready', count: 1, coverage }),
|
||||
'1 mapped site within 100 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'unavailable', failureReason: 'tiles_unavailable' }),
|
||||
'Map tiles temporarily unavailable',
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ const FADE_IN_MS = 150;
|
||||
const FADE_OUT_MS = 300;
|
||||
const MIN_LIFETIME_MS = 2500;
|
||||
const COOLDOWN_MS = 1200;
|
||||
function zeroCount(_value, key, map) {
|
||||
map.set(key, 0);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
return Math.max(min, Math.min(max, Math.floor(Number(value) || 0)));
|
||||
@@ -757,6 +760,8 @@ export class LabelArbiter {
|
||||
this._layerBuckets = new Map();
|
||||
this._demand = new Map();
|
||||
this._quotas = new Map();
|
||||
this._acceptedByLayer = new Map();
|
||||
this._paintedByLayer = new Map();
|
||||
this._weightedQuotaScratch = [];
|
||||
this._quotaPriorityScratch = [];
|
||||
this._quotaRemainderScratch = [];
|
||||
@@ -770,6 +775,12 @@ export class LabelArbiter {
|
||||
this._queue = new SpatialCandidateQueue();
|
||||
}
|
||||
|
||||
/** Release an identity when its source changes between ambient and protected paint. */
|
||||
remove(key) {
|
||||
if (this.states.delete(key)) this._stateListDirty = true;
|
||||
this.selectedKeys.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.states.clear();
|
||||
this.selectedKeys.clear();
|
||||
@@ -781,6 +792,8 @@ export class LabelArbiter {
|
||||
this._candidates.length = 0;
|
||||
this._layerBuckets.clear();
|
||||
this._quotas.clear();
|
||||
this._acceptedByLayer.clear();
|
||||
this._paintedByLayer.clear();
|
||||
this._selectedCandidates.length = 0;
|
||||
this._selectedPlacements.length = 0;
|
||||
this._droppedKeys.length = 0;
|
||||
@@ -889,6 +902,12 @@ export class LabelArbiter {
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const limits = options.limitsByLayer;
|
||||
demand.forEach((count, layerId) => {
|
||||
demand.set(layerId, Math.min(count, limits?.get(layerId) ?? Infinity));
|
||||
});
|
||||
const acceptedByLayer = this._acceptedByLayer;
|
||||
acceptedByLayer.forEach(zeroCount);
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
let activeCount = 0;
|
||||
let totalDemand = 0;
|
||||
@@ -937,6 +956,9 @@ export class LabelArbiter {
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const layerCount = acceptedByLayer.get(candidate.layerId) || 0;
|
||||
if (layerCount >= (limits?.get(candidate.layerId) ?? Infinity))
|
||||
return false;
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
// Stateless candidates ignore the re-entry cooldown (they return on the
|
||||
@@ -955,6 +977,7 @@ export class LabelArbiter {
|
||||
selectedCandidates[selectedCount] = candidate;
|
||||
selectedPlacements[selectedCount] = placement;
|
||||
selectedCount++;
|
||||
acceptedByLayer.set(candidate.layerId, layerCount + 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -968,7 +991,8 @@ export class LabelArbiter {
|
||||
if (preserveIncumbents) {
|
||||
for (let i = 0; i < layerCount && accepted < target; i++) {
|
||||
const candidate = layerList[i];
|
||||
if (!this.states.get(candidate.key)?.selected) continue;
|
||||
if (candidate.stateless || !this.states.get(candidate.key)?.selected)
|
||||
continue;
|
||||
if (attempt(candidate, true)) accepted++;
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1163,7 @@ export class LabelArbiter {
|
||||
* Reproject accepted/fading identities from the current frame candidate map.
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
renderEntries(currentCandidates, now = Date.now(), out = [], limits = null) {
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
@@ -1151,6 +1175,20 @@ export class LabelArbiter {
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
const painted = this._paintedByLayer;
|
||||
painted.forEach(zeroCount);
|
||||
// Reserve the live winners before spending any remaining slots on exit fades.
|
||||
if (limits)
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
if (!state.selected) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (candidate)
|
||||
painted.set(
|
||||
candidate.layerId,
|
||||
(painted.get(candidate.layerId) || 0) + 1,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
let temporalAlpha;
|
||||
@@ -1171,9 +1209,14 @@ export class LabelArbiter {
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
if (limits && !state.selected) {
|
||||
const count = painted.get(candidate.layerId) || 0;
|
||||
if (count >= (limits.get(candidate.layerId) ?? Infinity)) continue;
|
||||
painted.set(candidate.layerId, count + 1);
|
||||
}
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
|
||||
@@ -345,6 +345,9 @@ test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||||
'it took the free corner while the preferred one was blocked');
|
||||
const paint = arbiter.renderEntries(new Map([[blocker.key, blocker], [mover.key, mover]]), 1000, []);
|
||||
assert.equal(paint.find((entry) => entry.candidate.key === mover.key).placement.corner, 'below',
|
||||
'painting honors this solve’s collision-free corner even without sticky placement');
|
||||
|
||||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||||
// card returns to its preferred placement immediately.
|
||||
@@ -504,3 +507,44 @@ test('live identities expose selected and temporal fading membership by layer/so
|
||||
]));
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('stateless labels yield occupied slots and re-rank by area on each solve', () => {
|
||||
const make = (key, x, priority) => ({ key, layerId: 'installations', sourceId: key,
|
||||
priority, keyholeAlpha: 1, stateless: true, screenX: x, screenY: 100,
|
||||
placements: [{ corner: 'above', rect: { x, y: 100, w: 100, h: 20 } }],
|
||||
});
|
||||
const large = make('large', 100, 100), small = make('small', 300, 10);
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.selectedKeys.size, 2);
|
||||
small.placements[0].rect.x = 100;
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1016 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['large'], 'incumbency never forces a stateless overlap');
|
||||
const larger = make('larger', 100, 1000);
|
||||
arbiter.solve([large, small, larger], { capacity: 2, now: 1032 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['larger'], 'area priority outranks a smaller incumbent');
|
||||
});
|
||||
|
||||
|
||||
test('a source ceiling bounds shared-domain winners and exit fades without evicting incumbents', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const sites = Array.from({ length: 40 }, (_, i) => candidate('installations', i));
|
||||
const centers = Array.from({ length: 30 }, (_, i) => candidate('flights', i));
|
||||
const limits = new Map([['installations', 24]]);
|
||||
const solve = (list, now) => arbiter.solve(list, { capacity: 54, limitsByLayer: limits, now });
|
||||
solve([...sites, ...centers], 0);
|
||||
const first = new Set(arbiter.selectedKeys);
|
||||
assert.equal([...first].filter(key => key.startsWith('installations:')).length, 24);
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (const site of sites) site.placements[0].rect.x += 0.25;
|
||||
solve([...sites, ...centers], i * 16);
|
||||
assert.deepEqual(arbiter.selectedKeys, first);
|
||||
}
|
||||
const replacements = sites.map((site, i) => ({ ...site, key: `installations:new${i}` }));
|
||||
const current = [...replacements, ...centers];
|
||||
solve(current, 2000);
|
||||
const rendered = arbiter.renderEntries(new Map(current.map(c => [c.key, c])), 2100, [], limits);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'installations').length, 24);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'flights').length, 30);
|
||||
});
|
||||
+219
-15
@@ -1,3 +1,5 @@
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
'enabled+options',
|
||||
@@ -5,6 +7,10 @@ const VALID_DISPOSITIONS = new Set([
|
||||
]);
|
||||
|
||||
export const LAYER_STATE_VERSION = 2;
|
||||
export const LAYER_STATE_TOKEN_ALPHABET =
|
||||
'0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
|
||||
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
||||
const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
/**
|
||||
@@ -17,12 +23,13 @@ const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
*/
|
||||
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
||||
/**
|
||||
* Ceilings for the untrusted v2 layer fields. Both are far above any legitimate
|
||||
* payload (16 one-character tokens; a dozen short option assignments), so a
|
||||
* value past them is malformed or hostile. Reject the WHOLE payload, matching
|
||||
* the unknown-token rule — never salvage a prefix.
|
||||
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
||||
* complete reserved one-character space plus every two-character base-36
|
||||
* allocation, while the option ceiling covers a dozen short assignments.
|
||||
* Reject the WHOLE payload, matching the unknown-token rule — never salvage a
|
||||
* prefix.
|
||||
*/
|
||||
const MAX_ENABLED_LAYERS_CHARS = 64;
|
||||
const MAX_ENABLED_LAYERS_CHARS = 4_096;
|
||||
const MAX_LAYER_OPTIONS_CHARS = 512;
|
||||
export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2';
|
||||
export const LAYER_RESTORE_ORIGINS = Object.freeze({
|
||||
@@ -291,6 +298,13 @@ function imageryPinOption(key, token) {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
osm: 'o',
|
||||
hybrid: 'h',
|
||||
}),
|
||||
]),
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
@@ -446,6 +460,94 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The original single-character assignments are a closed compatibility set.
|
||||
* Keep their exact mapping pinned by the independent snapshot in the tests.
|
||||
*/
|
||||
export const LEGACY_LAYER_STATE_TOKENS = Object.freeze({
|
||||
'ais-live-vessels': 'a',
|
||||
'alpr-cameras': 'p',
|
||||
'bhote-koshi-2026': 'h',
|
||||
'bhote-koshi-locator': 'z',
|
||||
bikeshare: 'b',
|
||||
cctv: 'c',
|
||||
directions: 'n',
|
||||
earthquakes: 'e',
|
||||
'fire-perimeters': '2',
|
||||
flights: 'f',
|
||||
'local-dams': 'q',
|
||||
'local-datacenters': 'd',
|
||||
'local-firms': 'w',
|
||||
military: 'm',
|
||||
'military-awareness': 'g',
|
||||
'military-installations': 'i',
|
||||
radio: 'r',
|
||||
'recent-imagery': '1',
|
||||
'rocket-launches': 'x',
|
||||
satellites: 's',
|
||||
'telegeography-submarine-cables': 'u',
|
||||
traffic: 't',
|
||||
transit: 'j',
|
||||
'weather-cyclones': 'y',
|
||||
'weather-lightning': 'l',
|
||||
'weather-radar': 'v',
|
||||
'weather-satellite': 'o',
|
||||
wind: 'k',
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanent token ownership. Existing share links are public authored state,
|
||||
* so an allocation stays here even if its layer is later removed. The JSON
|
||||
* ledger is also read directly from the published Git base by the checker.
|
||||
*/
|
||||
export function parseLayerStateTokenReservations(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error('Layer-state token ledger must be an array');
|
||||
}
|
||||
const reservations = Object.create(null);
|
||||
const reservedIdsByToken = new Map();
|
||||
for (const row of rows) {
|
||||
if (
|
||||
!Array.isArray(row) ||
|
||||
row.length !== 2 ||
|
||||
typeof row[0] !== 'string' ||
|
||||
typeof row[1] !== 'string'
|
||||
) {
|
||||
throw new Error('Invalid layer-state token ledger row');
|
||||
}
|
||||
const [id, token] = row;
|
||||
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
|
||||
throw new Error(`Invalid layer-state token reservation: ${id}`);
|
||||
}
|
||||
if (Object.hasOwn(reservations, id)) {
|
||||
throw new Error(`Duplicate layer-state token reservation id: ${id}`);
|
||||
}
|
||||
if (reservedIdsByToken.has(token)) {
|
||||
throw new Error(`Duplicate layer-state token reservation: ${token}`);
|
||||
}
|
||||
if (
|
||||
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
|
||||
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
|
||||
(token.length === 1 &&
|
||||
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
|
||||
LEGACY_LAYER_STATE_TOKENS[id] !== token)
|
||||
) {
|
||||
throw new Error(`Legacy layer-state token is immutable: ${id}`);
|
||||
}
|
||||
reservations[id] = token;
|
||||
reservedIdsByToken.set(token, id);
|
||||
}
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
if (reservations[id] !== token) {
|
||||
throw new Error(`Missing or changed legacy layer-state token: ${id}`);
|
||||
}
|
||||
}
|
||||
return Object.freeze(reservations);
|
||||
}
|
||||
|
||||
export const LAYER_STATE_TOKEN_RESERVATIONS =
|
||||
parseLayerStateTokenReservations(reservationRows);
|
||||
|
||||
/**
|
||||
* Canonical serialization registry. Its order, not runtime registration order,
|
||||
* owns stable URL ordering.
|
||||
@@ -542,7 +644,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'u',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'traffic',
|
||||
token: 't',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'traffic',
|
||||
}),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'weather-cyclones',
|
||||
@@ -579,6 +686,60 @@ export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||
LAYER_STATE_REGISTRY.map((entry) => entry.id),
|
||||
);
|
||||
|
||||
/** Consume unreserved digits before the two-character base-36 namespace. */
|
||||
export function nextLayerStateToken(
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
const occupied = new Set(Object.values(reservations || {}));
|
||||
for (const digit of '0123456789') {
|
||||
if (!occupied.has(digit)) return digit;
|
||||
}
|
||||
for (const first of LAYER_STATE_TOKEN_ALPHABET) {
|
||||
for (const second of LAYER_STATE_TOKEN_ALPHABET) {
|
||||
const candidate = `${first}${second}`;
|
||||
if (!occupied.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error('Layer-state token namespace exhausted');
|
||||
}
|
||||
|
||||
function allocationRank(token) {
|
||||
if (NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token)) return Number(token);
|
||||
if (typeof token !== 'string' || token.length !== 2) return Infinity;
|
||||
const first = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]);
|
||||
const second = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]);
|
||||
return first < 0 || second < 0 ? Infinity : 10 + first * 36 + second;
|
||||
}
|
||||
|
||||
/** Check a proposed ledger against the published merge-time base. */
|
||||
export function validateLayerStateAllocations(
|
||||
baseReservations,
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
if (!baseReservations || typeof baseReservations !== 'object') {
|
||||
throw new Error('Base layer-state token reservations are required');
|
||||
}
|
||||
for (const [id, token] of Object.entries(baseReservations)) {
|
||||
if (reservations[id] !== token) {
|
||||
throw new Error(`Published layer-state token changed or removed: ${id}`);
|
||||
}
|
||||
}
|
||||
const newlyReserved = Object.entries(reservations)
|
||||
.filter(([id]) => !Object.hasOwn(baseReservations, id))
|
||||
.sort((left, right) => allocationRank(left[1]) - allocationRank(right[1]));
|
||||
const occupied = { ...baseReservations };
|
||||
for (const [id, token] of newlyReserved) {
|
||||
const expected = nextLayerStateToken(occupied);
|
||||
if (token !== expected) {
|
||||
throw new Error(
|
||||
`Layer-state token for ${id} must be the next free token ${expected}`,
|
||||
);
|
||||
}
|
||||
occupied[id] = token;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const REGISTRY_BY_ID = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
@@ -619,10 +780,39 @@ export function isExplicitLayerStateOrigin(origin) {
|
||||
}
|
||||
|
||||
/** Validate the static registry itself before it is used to seal a manager. */
|
||||
export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
export function validateLayerStateRegistry(
|
||||
registry = LAYER_STATE_REGISTRY,
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
if (!Array.isArray(registry) || registry.length === 0) {
|
||||
throw new Error('Layer-state registry must be a non-empty array');
|
||||
}
|
||||
if (
|
||||
!reservations ||
|
||||
typeof reservations !== 'object' ||
|
||||
Array.isArray(reservations)
|
||||
) {
|
||||
throw new Error('Layer-state token reservations must be an object');
|
||||
}
|
||||
const reservedIdsByToken = new Map();
|
||||
for (const [id, token] of Object.entries(reservations)) {
|
||||
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
|
||||
throw new Error(`Invalid layer-state token reservation: ${id}`);
|
||||
}
|
||||
if (
|
||||
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
|
||||
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
|
||||
(token.length === 1 &&
|
||||
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
|
||||
LEGACY_LAYER_STATE_TOKENS[id] !== token)
|
||||
) {
|
||||
throw new Error(`Legacy layer-state token is immutable: ${id}`);
|
||||
}
|
||||
if (reservedIdsByToken.has(token)) {
|
||||
throw new Error(`Duplicate layer-state token reservation: ${token}`);
|
||||
}
|
||||
reservedIdsByToken.set(token, id);
|
||||
}
|
||||
const ids = new Set();
|
||||
const tokens = new Set();
|
||||
for (const entry of registry) {
|
||||
@@ -633,8 +823,14 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
if (ids.has(entry.id))
|
||||
throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
if (!/^[a-z0-9]$/.test(entry.token || ''))
|
||||
if (!LAYER_STATE_TOKEN_PATTERN.test(entry.token || ''))
|
||||
throw new Error(`Invalid layer-state token: ${entry.id}`);
|
||||
if (reservations[entry.id] !== entry.token) {
|
||||
throw new Error(`Unreserved layer-state token: ${entry.id}`);
|
||||
}
|
||||
if (reservedIdsByToken.get(entry.token) !== entry.id) {
|
||||
throw new Error(`Layer-state token reservation mismatch: ${entry.token}`);
|
||||
}
|
||||
if (tokens.has(entry.token))
|
||||
throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||
tokens.add(entry.token);
|
||||
@@ -756,18 +952,26 @@ export function encodeLayerStateParams(params, state) {
|
||||
|
||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||
export function decodeLayerStateParams(params) {
|
||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
|
||||
const layerFields = params.getAll('l');
|
||||
if (
|
||||
params.get('v') !== String(LAYER_STATE_VERSION) ||
|
||||
layerFields.length !== 1
|
||||
)
|
||||
return null;
|
||||
const rawLayers = String(params.get('l') || '');
|
||||
const rawLayers = layerFields[0];
|
||||
const rawOptionsField = String(params.get('lo') || '');
|
||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
|
||||
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
|
||||
const layerTokens = rawLayers.split('.').filter(Boolean);
|
||||
// `l=` is the one valid explicit-empty representation. Any non-empty token
|
||||
// set containing an unknown member rejects the complete layer payload so a
|
||||
// typo or future token cannot silently become an authoritative empty set.
|
||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||
const layerTokens = rawLayers ? rawLayers.split('.') : [];
|
||||
// `l=` is the one valid explicit-empty representation. Reject repeated
|
||||
// fields, empty members, duplicate members, or unknown members; silently
|
||||
// removing one would turn a malformed share into a different state.
|
||||
if (
|
||||
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
|
||||
new Set(layerTokens).size !== layerTokens.length
|
||||
)
|
||||
return null;
|
||||
const enabledLayerIds = layerTokens.map(
|
||||
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||
);
|
||||
|
||||
@@ -6,17 +6,22 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
nextLayerStateToken,
|
||||
normalizeLayerState,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.js';
|
||||
import radioLayer from './radio.js';
|
||||
@@ -198,6 +203,144 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
'ais-live-vessels': 'a',
|
||||
'alpr-cameras': 'p',
|
||||
'bhote-koshi-2026': 'h',
|
||||
'bhote-koshi-locator': 'z',
|
||||
bikeshare: 'b',
|
||||
cctv: 'c',
|
||||
directions: 'n',
|
||||
earthquakes: 'e',
|
||||
'fire-perimeters': '2',
|
||||
flights: 'f',
|
||||
'local-dams': 'q',
|
||||
'local-datacenters': 'd',
|
||||
'local-firms': 'w',
|
||||
military: 'm',
|
||||
'military-awareness': 'g',
|
||||
'military-installations': 'i',
|
||||
radio: 'r',
|
||||
'recent-imagery': '1',
|
||||
'rocket-launches': 'x',
|
||||
satellites: 's',
|
||||
'telegeography-submarine-cables': 'u',
|
||||
traffic: 't',
|
||||
transit: 'j',
|
||||
'weather-cyclones': 'y',
|
||||
'weather-lightning': 'l',
|
||||
'weather-radar': 'v',
|
||||
'weather-satellite': 'o',
|
||||
wind: 'k',
|
||||
});
|
||||
for (const { id, token } of LAYER_STATE_REGISTRY) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '0');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
|
||||
'4',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }),
|
||||
'02',
|
||||
);
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...digitsExhausted,
|
||||
...Object.fromEntries(
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => [
|
||||
`pair-0${second}`,
|
||||
`0${second}`,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
'10',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
nextLayerStateToken(
|
||||
Object.fromEntries(
|
||||
[
|
||||
...'0123456789',
|
||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
||||
),
|
||||
].map((token, index) => [`occupied-${index}`, token]),
|
||||
),
|
||||
),
|
||||
/namespace exhausted/,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '0', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': '0' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '01', disposition: 'enabled-only' }],
|
||||
{ retired: '00', 'future-layer': '01' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 0/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
beforeLastDigit,
|
||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
digitsExhausted,
|
||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
|
||||
),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ retired: '00' }, { newcomer: '00' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry([
|
||||
@@ -206,6 +349,30 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
]),
|
||||
/Duplicate layer-state id/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '000', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': '000' },
|
||||
),
|
||||
/Invalid layer-state token reservation/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: 'a', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': 'a' },
|
||||
),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'flights', token: '0', disposition: 'enabled-only' }],
|
||||
{ flights: '0' },
|
||||
),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
|
||||
const manager = new DataLayerManager({});
|
||||
manager.register(fakeLayer('earthquakes'));
|
||||
@@ -289,6 +456,25 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
|
||||
);
|
||||
});
|
||||
|
||||
test('all production layers and options round-trip through a v2 share URL', () => {
|
||||
const allEnabled = normalizeLayerState({
|
||||
enabledLayerIds: REGISTERED_LAYER_IDS,
|
||||
options: {
|
||||
cctv: { coverageMode: 'viewshed', showProjection: false, autoHop: true },
|
||||
flights: { models3d: true, models3dMode: 'all' },
|
||||
radio: { filter: 'news', volume: 0.37 },
|
||||
},
|
||||
});
|
||||
const shareUrl = new URL('https://example.invalid/');
|
||||
shareUrl.hash = encode(allEnabled);
|
||||
const params = new URLSearchParams(shareUrl.hash.slice(1));
|
||||
const restored = decodeLayerStateParams(params);
|
||||
|
||||
assert.equal(params.get('l')?.split('.').length, REGISTERED_LAYER_IDS.length);
|
||||
assert.deepEqual(restored?.enabledLayerIds, REGISTERED_LAYER_IDS);
|
||||
assert.deepEqual(restored?.options, allEnabled.options);
|
||||
});
|
||||
|
||||
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')),
|
||||
@@ -300,6 +486,27 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed enabled-layer lists reject the entire payload', () => {
|
||||
for (const value of ['.c', 'c.', 'c..e', 'c.c', '00', 'c.00']) {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
|
||||
null,
|
||||
`l=${value}`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams('v=2&l=c.e')).enabledLayerIds,
|
||||
['cctv', 'earthquakes'],
|
||||
);
|
||||
for (const fields of ['l=f&l=f', 'l=f&l=unknown', 'l=&l=f']) {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams(`v=2&${fields}`)),
|
||||
null,
|
||||
fields,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Nepal event and locator have distinct enabled-only share tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
@@ -2421,7 +2628,17 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
|
||||
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['fire-perimeters', 'recent-imagery', 'wind']);
|
||||
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2');
|
||||
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded);
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'fire-perimeters',
|
||||
'recent-imagery',
|
||||
'wind',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token,
|
||||
'2',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
nextLayerStateToken,
|
||||
parseLayerStateTokenReservations,
|
||||
validateLayerStateAllocations,
|
||||
} from './layerState.js';
|
||||
import {
|
||||
PRE_LEDGER_LAYER_STATE_TOKENS,
|
||||
checkLayerStateTokens,
|
||||
readPublishedLayerStateReservations,
|
||||
} from '../../scripts/check-layer-state-tokens.mjs';
|
||||
|
||||
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
|
||||
const SOURCE_PATH = 'src/data/layerState.js';
|
||||
const CODEC_SOURCE_PATH = fileURLToPath(
|
||||
new URL('./layerState.js', import.meta.url),
|
||||
);
|
||||
const CHECKER_PATH = fileURLToPath(
|
||||
new URL('../../scripts/check-layer-state-tokens.mjs', import.meta.url),
|
||||
);
|
||||
const NEXT_PATH = fileURLToPath(
|
||||
new URL('../../scripts/next-layer-state-token.mjs', import.meta.url),
|
||||
);
|
||||
const reservationRows = JSON.parse(
|
||||
readFileSync(
|
||||
new URL('./layerStateTokenReservations.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
test('pre-ledger published ownership is independent of the candidate mapping', () => {
|
||||
assert.notStrictEqual(
|
||||
PRE_LEDGER_LAYER_STATE_TOKENS,
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
);
|
||||
assert.deepEqual(PRE_LEDGER_LAYER_STATE_TOKENS, LEGACY_LAYER_STATE_TOKENS);
|
||||
assert.equal(Object.keys(PRE_LEDGER_LAYER_STATE_TOKENS).length, 28);
|
||||
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['recent-imagery'], '1');
|
||||
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['fire-perimeters'], '2');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(PRE_LEDGER_LAYER_STATE_TOKENS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
flights: '00',
|
||||
}),
|
||||
/Published layer-state token changed or removed: flights/,
|
||||
);
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function withPublishedBase(
|
||||
{ rows, source = 'export const changed = true;\n' },
|
||||
check,
|
||||
) {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-ledger-'));
|
||||
try {
|
||||
git(cwd, 'init', '-q');
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
writeFileSync(path.join(cwd, SOURCE_PATH), source);
|
||||
if (rows !== undefined) {
|
||||
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
|
||||
}
|
||||
git(cwd, 'add', 'src/data');
|
||||
git(
|
||||
cwd,
|
||||
'-c',
|
||||
'user.name=Layer Token Test',
|
||||
'-c',
|
||||
'[email protected]',
|
||||
'commit',
|
||||
'-q',
|
||||
'-m',
|
||||
'published base',
|
||||
);
|
||||
check(cwd);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
.map(
|
||||
({ id, token }) =>
|
||||
` Object.freeze({ id: '${id}', token: '${token}', disposition: 'enabled-only' }),`,
|
||||
)
|
||||
.join('\n');
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
writeFileSync(path.join(cwd, 'package.json'), '{"type":"module"}');
|
||||
writeFileSync(
|
||||
path.join(cwd, SOURCE_PATH),
|
||||
source.replace(registryEnd, `${additions}\n${registryEnd}`),
|
||||
);
|
||||
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
|
||||
return path.join(cwd, SOURCE_PATH);
|
||||
}
|
||||
|
||||
test('reservation ledger is complete, pinned, and rejects duplicate or malformed rows', () => {
|
||||
assert.deepEqual(
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{ ...LEGACY_LAYER_STATE_TOKENS },
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
||||
/Missing or changed legacy/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
reservationRows[0],
|
||||
]),
|
||||
/Duplicate layer-state token reservation id/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', 'a']]),
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
|
||||
.future,
|
||||
'0',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([
|
||||
...reservationRows.filter(([id]) => id !== 'transit'),
|
||||
['future', 'j'],
|
||||
]),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations([...reservationRows, ['future']]),
|
||||
/Invalid layer-state token ledger row/,
|
||||
);
|
||||
});
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '0']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '3',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '0',
|
||||
}),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '0',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
'scripts/check-layer-state-tokens.mjs',
|
||||
);
|
||||
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
|
||||
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
|
||||
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
|
||||
const runCheck = () =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '0']],
|
||||
[{ id: 'pr-b', token: '0' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '0' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
assert.equal(staleRebase.status, 1);
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 3/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
'the helper reports a token without rewriting the ledger',
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '3' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /29 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
delete beforeNine['retired-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeNine, {
|
||||
...beforeNine,
|
||||
pair: '00',
|
||||
lastDigit: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const baseBefore0a = {
|
||||
...allDigits,
|
||||
...Object.fromEntries(
|
||||
[...'012345678'].map((second) => [`retired-0${second}`, `0${second}`]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(baseBefore0a), '09');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(baseBefore0a, {
|
||||
...baseBefore0a,
|
||||
after: '0a',
|
||||
before: '09',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const baseBefore10 = {
|
||||
...allDigits,
|
||||
...Object.fromEntries(
|
||||
[...LAYER_STATE_TOKEN_ALPHABET]
|
||||
.slice(0, -1)
|
||||
.map((second) => [`retired-0${second}`, `0${second}`]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(baseBefore10), '0z');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(baseBefore10, {
|
||||
...baseBefore10,
|
||||
after: '10',
|
||||
before: '0z',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'03456789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
|
||||
id: `qa-layer-${token}`,
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers);
|
||||
const codec = await import(pathToFileURL(fixtureModule));
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const expectedLayerIds = codec.REGISTERED_LAYER_IDS;
|
||||
const state = codec.normalizeLayerState({
|
||||
enabledLayerIds: expectedLayerIds,
|
||||
});
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 67);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
|
||||
withPublishedBase(
|
||||
{
|
||||
rows: [...reservationRows, ['retired-layer', '00']],
|
||||
source:
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 29);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '00',
|
||||
}),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => checkLayerStateTokens('HEAD', cwd),
|
||||
/changed or removed/,
|
||||
);
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[CHECKER_PATH, '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(cli.status, 1);
|
||||
assert.match(cli.stderr, /changed or removed/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('checker fails closed on unknown or malformed published ledgers', () => {
|
||||
withPublishedBase({}, (cwd) => {
|
||||
assert.throws(
|
||||
() => readPublishedLayerStateReservations('HEAD', cwd),
|
||||
/no recognizable layer-state token ledger/,
|
||||
);
|
||||
});
|
||||
withPublishedBase({ rows: [...reservationRows, ['future', 'a']] }, (cwd) => {
|
||||
assert.throws(
|
||||
() => readPublishedLayerStateReservations('HEAD', cwd),
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
["ais-live-vessels", "a"],
|
||||
["alpr-cameras", "p"],
|
||||
["bhote-koshi-2026", "h"],
|
||||
["bhote-koshi-locator", "z"],
|
||||
["bikeshare", "b"],
|
||||
["cctv", "c"],
|
||||
["directions", "n"],
|
||||
["earthquakes", "e"],
|
||||
["fire-perimeters", "2"],
|
||||
["flights", "f"],
|
||||
["local-dams", "q"],
|
||||
["local-datacenters", "d"],
|
||||
["local-firms", "w"],
|
||||
["military", "m"],
|
||||
["military-awareness", "g"],
|
||||
["military-installations", "i"],
|
||||
["radio", "r"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["satellites", "s"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
["transit", "j"],
|
||||
["weather-cyclones", "y"],
|
||||
["weather-lightning", "l"],
|
||||
["weather-radar", "v"],
|
||||
["weather-satellite", "o"],
|
||||
["wind", "k"]
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -1074,6 +1076,7 @@ export class LayerLifecycle {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
|
||||
const desiredState = Boolean(shouldEnable);
|
||||
if (!desiredState) cancelCameraArrival(this.viewer);
|
||||
if (entry.destroying) {
|
||||
return {
|
||||
intentEpoch: null,
|
||||
@@ -2095,6 +2098,7 @@ export class LayerLifecycle {
|
||||
async destroyLayer(layerId) {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry || entry.destroying) return false;
|
||||
cancelCameraArrival(this.viewer);
|
||||
entry.destroying = true;
|
||||
this._invalidateRefresh(layerId, entry, 'layer-destroyed');
|
||||
// Teardown becomes authoritative before the first await. Advancing the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
@@ -14,10 +15,6 @@ const VISIBILITY_UPDATE_MS = 450;
|
||||
export const LOCAL_OVERLAY_COHORT_LIMIT = 160;
|
||||
const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
// Stems are anchored at ellipsoid height 0, but high-elevation features
|
||||
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
|
||||
// burying the short close-in stem inside the photoreal mesh. Once the
|
||||
@@ -182,33 +179,15 @@ export function createLocalInfrastructureOverlayEntry({
|
||||
accent,
|
||||
}) {
|
||||
const copy = localInfrastructureOverlayCopy(properties, layerId);
|
||||
return {
|
||||
id: String(id),
|
||||
return createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source: layerId,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,6 +373,7 @@ export function createLocalGeoJsonLayer(
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
osmDerived = false,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
@@ -409,6 +389,8 @@ export function createLocalGeoJsonLayer(
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
},
|
||||
) {
|
||||
let _dataSource = null;
|
||||
@@ -538,6 +520,7 @@ export function createLocalGeoJsonLayer(
|
||||
};
|
||||
|
||||
const disableLayer = (viewer) => {
|
||||
hideOsmCredit?.(viewer, id);
|
||||
_enabled = false;
|
||||
clearGroundRetryRender();
|
||||
_activeLodIds = new Set();
|
||||
@@ -942,6 +925,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id);
|
||||
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe
|
||||
if (_enabled && !_preRenderRemover) {
|
||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Natural Earth physical regions pack
|
||||
# Natural Earth packs
|
||||
|
||||
Offline named-region polygons for the voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
|
||||
@@ -8,6 +8,8 @@ range geometry with no network dependency.
|
||||
|------|----------------|----------|
|
||||
| `regions.json` | `ne_10m_geography_regions_polys` (ranges, deserts, plateaus, peninsulas, islands, …) | 1,046 named |
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
@@ -34,3 +36,70 @@ See DATA_SOURCES.md.
|
||||
|
||||
Duplicate names exist upstream (two "Cordillera Oriental", a sliver + real
|
||||
"Canadian Shield", …); the lookup module resolves ties by largest area.
|
||||
|
||||
## States and provinces (`states_provinces.json`)
|
||||
|
||||
Worldwide first-level administrative units for area annotations ("outline
|
||||
Texas", "outline Bavaria"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
**Source:** `geojson/ne_10m_admin_1_states_provinces.geojson` from
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
at commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
|
||||
`22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5`), fetched
|
||||
2026-09-25. `geojson/ne_10m_populated_places_simple.geojson` from the same
|
||||
commit (SHA-256
|
||||
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`) is read
|
||||
only to mark ambiguous names; none of it is bundled.
|
||||
|
||||
**License:** public domain, as above.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only admin1` downloads
|
||||
the pinned files (to the system temp directory, or `--cache <dir>`), checks
|
||||
their SHA-256 and rewrites the pack byte for byte. Parameters are in the
|
||||
script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URLs, hashes, license, curation) and `features[]`
|
||||
with `name`, `nameEn` (when it differs), `alt[]` (Latin-script `name_alt`
|
||||
variants and abbreviations), `postal`, `type` (`type_en`), `rank`
|
||||
(`min_label`), `country` (`admin`), `iso2`, `iso` (ISO 3166-2), `amb[]`,
|
||||
`ambAbroad[]`, `cityState`, `label` ([lon, lat] label point) and
|
||||
`polygons`. `polygons` is a list of parts, largest first, each
|
||||
`[outer, ...holes]`; every ring is open and stored as integers in units of
|
||||
10^-3 degrees (10^-`d` when a feature carries `d`), the first vertex absolute
|
||||
and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- The 7 unnamed features and 2 degenerate Nauru districts are dropped
|
||||
(4,596 → 4,587).
|
||||
- Douglas-Peucker per ring at 0.004 × √(unit area in km²) km, clamped to
|
||||
0.005°–0.02° (Texas ≈ 0.02°, Bavaria ≈ 0.01°); coordinates rounded to
|
||||
3 decimals. The 75 units too small to survive (Maldives atolls, Malta's
|
||||
councils) keep 4 decimals at 0.001° (`d: 4`); 3 smaller still (Vatican
|
||||
City) keep their source rings at 5 decimals (`d: 5`).
|
||||
- Parts under 20 km² or under 0.03% of the unit's largest part are dropped;
|
||||
holes under 20 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- `amb` lists names that are also a country (`admin`/`geonunit` of any
|
||||
unit), a populated place of 200,000+ (`pop_max`) inside the unit's box
|
||||
when the unit is larger than 1,000 km², or a populated place of 50,000+
|
||||
elsewhere in the same country; `ambAbroad` lists names shared only with a
|
||||
populated place of 50,000+ in another country. `cityState` marks a unit of
|
||||
at most 1,000 km² that contains its namesake city (Berlin, Vienna, Paris).
|
||||
548 units carry `amb`, 108 `ambAbroad`, 138 `cityState`.
|
||||
- Result: 3,648,916 bytes (1,260,545 with gzip -9); 6,492 parts, 80 holes,
|
||||
407,339 vertices.
|
||||
|
||||
## Countries (`countries.json`)
|
||||
|
||||
Natural Earth 10m admin-0 countries, public domain under the terms above.
|
||||
Source: `geojson/ne_10m_admin_0_countries.geojson` at the same pinned commit;
|
||||
SHA-256 `239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255`.
|
||||
Regenerate with `node scripts/build-admin-packs.mjs --only countries`.
|
||||
Uses the state/province encoding and simplification, retaining parts of at least
|
||||
20 km² and 0.003% of the largest part (including Alaska and Hawaii); the largest
|
||||
part always stays. Holes under 20 km² are dropped.
|
||||
Includes England, Scotland, Wales and Northern Ireland from
|
||||
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-0 countries","mapUnits":{"url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_map_units.geojson","sha256":"57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862"},"source":"ne_10m_admin_0_countries","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson","sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.00003,"minHoleKm2":20},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Afghanistan","alt":["Islamic State of Afghanistan"],"country":"Afghanistan","iso":"AFG","iso2":"AF","type":"Country","polygons":[[[74383,37127,-15,40,89,10,43,55,123,-1,98,67,73,-84,98,17,-103,100,-129,63,-139,-18,-205,51,-12,-27,-80,3,-36,-65,-479,-121,-112,45,142,76,14,90,-268,53,-189,-16,-164,-81,-64,-69,-277,-95,-133,-191,-398,-62,-423,-268,-184,-12,-100,83,-122,297,63,241,36,453,60,55,8,82,-61,46,-196,-51,-83,33,101,258,-1,67,-407,222,-15,-40,-159,13,-112,-41,20,-18,-85,-40,-17,-72,-36,-12,-77,-142,-216,-148,8,-34,-90,7,-7,-56,82,-71,36,-114,-35,-82,-90,-82,-150,8,-89,68,-117,-35,-293,-3,-131,-142,32,-191,32,-22,-130,-107,-66,-13,-122,65,-139,151,-97,18,28,-59,-80,50,-26,-17,10,-61,-151,27,-42,-72,-223,-54,-12,-47,-104,-2,-10,-93,-92,8,-166,-92,-142,138,-105,46,8,79,-34,40,-114,25,-72,-34,-58,62,-244,-97,-136,97,-26,59,-91,43,-712,-53,-151,54,-63,56,-225,67,-94,70,-22,-49,-80,-19,-38,-80,3,-85,-87,-88,-474,-24,-284,-115,-23,-64,23,-116,-174,-277,-11,-237,-116,-152,-174,-60,-38,-60,-107,8,-102,-55,-12,-106,-155,31,-124,-53,-184,-18,-152,-88,-326,-15,-20,-30,28,-56,102,-71,-137,-74,24,-100,-21,-88,-162,-49,-224,-140,-159,-21,-103,54,-146,-140,-52,153,-227,145,-402,-8,-212,123,-41,74,-82,-10,20,-70,-95,-158,-8,-90,-85,-43,-6,-64,51,-91,-72,-169,-10,-118,-105,-92,-12,-85,-248,-122,190,-197,-246,-12,-151,-168,35,-298,-41,-130,25,-73,144,-78,190,-4,75,-42,-89,-30,-3,-68,-261,-265,-5,-93,267,-809,-15,-161,-40,-61,47,-532,865,-122,55,-52,1,-81,83,-225,-24,-168,-958,-989,1634,-450,1091,89,517,-110,87,97,305,86,558,-30,1160,295,145,122,-122,101,86,187,17,192,-54,164,98,322,184,54,113,106,34,113,246,119,74,-6,18,-73,311,-28,419,126,5,61,-159,16,-42,119,127,-9,147,102,123,15,81,50,92,137,116,-59,243,-41,52,29,-131,29,108,40,149,-48,29,-74,99,-98,236,70,259,265,-48,193,17,170,-35,162,193,192,-50,117,101,85,11,171,60,47,224,40,109,-26,114,38,53,67,77,5,169,120,-8,64,-131,124,19,125,-56,85,-160,36,-111,174,26,90,130,27,325,-87,194,-18,340,26,22,42,56,-6,123,53,45,111,14,192,-130,86,-35,89,109,26,15,115,122,75,86,127,181,75,41,59,-2,63,129,118,-108,111,91,109,-39,83,11,56,-100,61,22,91,-159,232,-185,94,52,98,335,203,-11,44,63,86,173,-61,-10,83,102,19,164,81,20,47,113,27,-19,48,61,24,240,32,176,75,238,-3,324,47,581,15,262,-76,59,15,36,67,106,4,158,92,63,10,80,-42,5,60]]]},{"name":"Akrotiri Sovereign Base Area","alt":["Akrotiri"],"country":"Akrotiri Sovereign Base Area","iso":"WSB","iso2":"-99","type":"Country","polygons":[[[32970,34632,-7,-7,-9,18,12,9,24,-6,-2,27,-33,4,-19,-19,-19,9,-3,-7,-48,19,-6,22,-28,0,-13,-13,-13,-2,-7,-15,-32,5,-7,-7,1,-16,73,18,80,-14,2,-18,29,-45,-4,-25,89,0,-21,28,7,37]]]},{"name":"Aland","alt":["Åland Islands"],"country":"Aland","iso":"ALD","iso2":"AX","type":"Country","polygons":[[[20236,60232,42,47,-76,14,-21,-7,11,26,-14,11,-52,11,-33,21,-21,-5,-48,11,13,-27,-41,22,-68,0,-22,46,-24,4,-44,-6,-61,-45,42,-8,39,-34,31,32,35,-31,10,-32,-28,-10,-13,35,-13,-48,-21,-14,-7,-28,-34,15,5,-24,-9,-17,-22,9,-27,49,32,51,-48,7,22,-21,-62,-17,-21,-31,3,19,-8,7,-36,2,24,-49,11,-5,13,-42,68,-96,26,22,18,4,17,-6,-1,-10,9,-5,13,0,23,7,5,10,14,5,23,0,14,-9,17,-32,-3,-10,10,-2,10,6,-9,30,57,10,39,-7,5,11,-19,17,13,61,-34,-20,-23,17,-43,13,-27,22,-2,10,40,7,-7,-21,68,-20,-1,18,16,15,-37,24,-18,38,27,-12,22,9,9,18,-10,19,37,-3,-4,-17,-33,-38,8,-19,29,-21,-3,-24,7,0,17,32,29,16,77,6,-16,-12,-49,-2,-21,-10,-13,-25,-2,-14,8,-6,42,0,25,-9,13,3,26,29,29,5,-21,21]],[[20025,60042,79,-14,17,-11,-16,-21,18,-7,48,10,13,-6,-9,-22,50,34,-32,61,-30,22,-62,7,-33,-22,-34,-9]],[[19634,60240,-26,17,-40,-2,-31,-23,0,-31,-24,-17,14,-35,38,-19,20,5,18,29,-11,33,55,-41,24,2,0,33,-32,30]],[[20284,59958,23,-4,15,9,3,14,74,4,48,14,-10,10,-98,15,-37,-21,-21,-22]],[[20256,60145,8,-27,-6,-10,-36,42,-24,-46,4,-16,64,-28,12,6,-9,20,29,36,-20,20]],[[20411,60260,-31,10,-21,-4,-12,-13,-2,-15,8,-9,30,12,5,-19,-7,-23,12,-2,48,48,-2,13,-11,8]],[[20583,60059,-38,-3,-44,-43,26,-8,23,14,37,-11,19,15,2,13,-7,3,17,3,-8,3]]]},{"name":"Albania","alt":["Republic of Albania"],"country":"Albania","iso":"ALB","iso2":"AL","type":"Country","polygons":[[[2Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-1 states and provinces","source":"ne_10m_admin_1_states_provinces","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_1_states_provinces.geojson","sha256":"22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5","ambiguitySource":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","ambiguitySha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.0003,"minHoleKm2":20,"ambiguity":{"insideMinPopulation":200000,"elsewhereMinPopulation":50000,"cityStateMaxKm2":1000}},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Badakhshan","alt":["Badahšan"],"postal":"BD","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDS","label":[70.804,36.781],"polygons":[[[74892,37231,-29,14,-74,86,-129,63,-29,-13,-110,-5,-66,18,-77,0,-34,27,-23,-7,-5,13,-12,-27,-80,3,-16,-13,-2,-34,-18,-18,-352,-81,-38,-28,-52,2,-37,-14,-29,25,-62,-9,-16,8,-5,21,34,34,59,9,49,33,14,90,-78,3,-190,50,-44,-1,-63,-27,-82,12,-36,-15,-49,-42,-41,0,-38,-24,-64,-69,-72,-6,-94,-55,-111,-34,-133,-191,-149,-18,-34,-14,-69,11,-146,-41,-133,-94,-290,-174,-88,-20,-96,8,-42,18,-58,65,-25,86,-65,92,-3,63,-29,56,19,150,37,50,7,41,-7,101,24,77,-14,81,5,43,20,28,21,82,-13,41,8,18,52,37,8,82,-61,46,-36,2,-160,-53,-83,33,15,72,29,44,57,142,-1,67,-24,30,-116,45,-101,73,-67,10,-52,57,-47,7,-15,-40,-65,20,-28,-13,-66,6,-35,-27,-77,-14,20,-18,-44,-33,-41,-7,-17,-72,-36,-12,-77,-142,-55,-26,-131,-113,-30,-9,-4,-17,12,-17,-47,-10,-43,17,-11,-25,4,-31,31,-50,51,-21,28,-45,8,-69,-35,-82,-32,-6,-17,-38,-41,-38,-29,-9,-125,19,-5,-20,6,-123,37,-21,-24,-28,-14,-83,30,-88,-1,-53,91,-92,-28,-47,-40,-16,-19,-39,-30,-19,-3,-307,33,-100,1,-53,102,-57,84,-78,137,-23,80,0,31,-20,3,-134,-46,-112,33,-67,-161,-91,-28,-30,-19,-45,-23,-135,63,-9,90,37,95,-31,20,-45,56,-55,-19,-69,2,-39,6,-13,33,-4,58,24,16,45,36,26,43,-40,28,-8,106,-5,19,32,-17,84,12,26,82,16,54,57,-45,41,4,35,47,24,42,56,23,87,85,89,-4,15,42,42,15,37,79,38,17,38,84,30,76,70,79,27,-16,28,5,16,33,30,30,56,39,-5,87,-57,47,1,11,11,-19,40,-2,32,102,19,102,64,62,17,16,13,-5,26,9,8,113,27,-22,35,20,22,132,32,152,15,63,43,113,32,238,-3,78,22,45,-10,51,22,150,13,75,-10,65,15,177,-3,131,18,133,-5,92,-19,82,-42,88,-15,59,15,21,59,15,8,106,4,158,92,63,10,64,-46,16,4,10,54,-28,14,-42,53,-94,44,-17,21,2,19,89,10,43,55,90,11,33,-12,28,29,25,4,45,34,16,-2,57,-82]]]},{"name":"Badghis","alt":["Badghes","Badghisat","Badgis"],"postal":"BG","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDG","label":[63.85,35.27],"polygons":[[[63125,35860,-21,-4,-15,-13,-5,-17,28,-56,72,-58,25,-8,5,-12,-34,-26,-77,-20,-26,-21,0,-66,24,-34,-21,-88,-13,-11,-82,-11,-67,-27,-95,-60,-39,-101,-84,-115,-28,-70,-20,-148,16,-51,49,-17,34,-1,69,-50,46,-14,47,-29,82,-6,31,-35,8,-45,77,-11,62,-24,71,34,116,-10,73,-22,99,-52,76,-13,42,10,77,-19,58,-30,227,53,67,45,45,10,26,39,52,24,107,0,57,-15,110,6,20,29,33,11,123,-4,57,-12,78,30,179,40,48,29,21,56,10,100,-46,76,-74,50,-57,58,-22,9,-32,-1,-49,25,-35,46,-116,-14,-102,12,-101,29,-5,17,6,79,13,53,-1,22,-8,7,-118,-20,-47,21,11,27,-15,22,-220,19,-24,15,-2,107,-12,37,-43,46,-51,18,-8,11,28,54,3,44,26,86,3,83,-11,30,-30,14,-16,-2,-124,-53,-184,-18,-71,-57,-81,-31,-87,-15]]]},{"name":"Baghlan","alt":["Baglan","Bughlan"],"postal":"BL","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BGL","amb":["baghlan"],"label":[68.983,35.859],"polygons":[[[69956,35887,-32,41,-3,19,-20,20,-32,67,-81,65,-10,115,-12,19,-54,8,-11,61,-20,48,-10,3,-56,-32,-92,-26,-79,-48,-99,-17,-23,9,-21,-11,-33,47,-35,8,-35,41,-117,31,-82,62,-84,16,-53,-20,-20,7,-43,28,-64,64,-12,22,-88,-13,-96,20,-217,7,-82,23,-38,-14,-19,-14,-10,-28,152,-201,46,-50,120,-67,31,-26,9,-18,-64,-80,-129,-85,-14,-26,-12,-108,-156,-177,-4,-35,42,-29,-5,-30,-36,-42,-10,-34,-54,-46,-3,-25,20,-60,-12,-32,7,-36,-40,-99,-31,-16,-20,-28,-5,Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
# Military area names
|
||||
|
||||
This database is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code.
|
||||
© [OpenStreetMap contributors](https://www.openstreetmap.org/copyright).
|
||||
Distribution: [Overture Maps Foundation](https://docs.overturemaps.org/attribution/), base/land_use release **2026-09-23.1**.
|
||||
OSM snapshots: **2026-09-06**, with two named records from **2026-09-12**.
|
||||
|
||||
Selected named military areas; geometry simplified to interior label points,
|
||||
bounds and area, coordinates rounded and records re-encoded. Names are unchanged.
|
||||
Rows: `[typedOsmId, name, lon, lat, west, south, east, north, classIndex, areaM2]`.
|
||||
Labels use five decimal places; bounds use four. Area is measured on the WGS84
|
||||
ellipsoid. Class indices refer to `classes`. Output SHA-256: [`names.sha256`](names.sha256).
|
||||
|
||||
Rebuild (maintainer tool; requires Python and `duckdb==1.5.5`):
|
||||
|
||||
```sh
|
||||
python scripts/build-military-names.py --cache /tmp/military-names
|
||||
python scripts/build-military-names.py --cache /tmp/military-names --verify
|
||||
```
|
||||
|
||||
The pinned parquet file list and query are in `scripts/military-names-files.json`
|
||||
and `scripts/military-names.sql`. The first build downloads DuckDB extensions and
|
||||
scans source columns; subsequent builds reuse the cached selected rows.
|
||||
@@ -0,0 +1 @@
|
||||
{"release":"2026-09-23.1","snapshots":["2026-09-06","2026-09-12"],"classes":["military_land","airfield","naval_base","range","barracks","base","training_area"],"records":[["r122949","Scott Air Force Base",-89.85277,38.54384,-89.8799,38.5292,-89.8041,38.5588,1,12413875],["r130448","Fort Campbell",-87.61569,36.63134,-87.8047,36.5355,-87.4231,36.7268,0,419957267],["r133445","Fort George G Meade",-76.74626,39.10227,-76.7766,39.0732,-76.7092,39.131,0,18817854],["r133514","Joint Base Andrews",-76.87745,38.80571,-76.9034,38.7823,-76.8491,38.829,5,17812387],["r139794","Whiteman AFB",-93.56134,38.72992,-93.5959,38.7024,-93.5308,38.7575,1,19580209],["r139848","Fort Leonard Wood",-92.16933,37.70114,-92.2496,37.6052,-92.0411,37.798,0,248751778],["r147157","Camp du Valdahon",6.32589,47.18508,6.2731,47.151,6.375,47.2189,0,34786523],["r169792","Offutt Air Force Base",-95.90817,41.1172,-95.9664,41.0986,-95.8781,41.1364,1,11200098],["r170440","McGuire Air Force Base",-74.57822,40.03444,-74.6026,40.0267,-74.5593,40.0586,5,5388371],["r171293","Cannon AFB",-103.31633,34.38639,-103.3371,34.3653,-103.2955,34.4054,1,13976465],["r171528","White Sands",-106.49662,32.38378,-106.5139,32.3657,-106.4793,32.4031,0,8047394],["r175474","West Point",-74.00783,41.35584,-74.1043,41.3163,-73.9467,41.413,0,64474104],["r176921","Pope Field",-79.01404,35.17337,-79.0319,35.1587,-78.9888,35.1886,1,7327036],["r176940","Fort Bragg",-79.15415,35.14031,-79.3814,35.0306,-78.9447,35.2512,5,554150015],["r206824","Fort Belvoir",-77.14369,38.7108,-77.1739,38.6729,-77.1208,38.7486,0,23156083],["r237502","Joint Base Lewis-McChord",-122.50741,47.03083,-122.802,46.9015,-122.3698,47.1595,0,358289411],["r252043","Francis E. Warren Air Force Base",-104.86328,41.16587,-104.8859,41.1317,-104.8394,41.2012,1,23635348],["r253786","Naval Base Point Loma",-117.24939,32.6941,-117.2562,32.6648,-117.2341,32.7131,5,4741248],["r253814","Marine Corps Air Station Miramar",-117.08249,32.87638,-117.1986,32.8331,-116.9871,32.9208,0,87161025],["r308744","Schietveld",5.44866,51.04934,5.4361,51.0439,5.4672,51.0548,0,1200107],["r309689","Ādažu poligons",24.46976,57.15377,24.3461,57.0837,24.563,57.2226,6,133266211],["r309926","Roberts Army Heliport",-120.74307,35.81448,-120.7467,35.8091,-120.7404,35.8197,1,331999],["r317709","Naval Reservation San Clemente Island",-118.51541,32.92142,-118.6206,32.8004,-118.3487,33.0422,0,163093405],["r317710","Naval Air Warfare Center China Lake",-117.62946,35.75329,-117.8897,35.272,-116.9246,36.2318,0,4495435805],["r317712","Marine Corps Base Camp Pendleton",-117.42402,33.35453,-117.5963,33.2056,-117.2554,33.5036,0,507618719],["r317716","Camp Roberts",-120.77817,35.79079,-120.8594,35.6884,-120.6887,35.8934,6,173959948],["r317718","Camp San Luis Obispo",-120.69818,35.33796,-120.7635,35.3048,-120.6642,35.3716,0,23703563],["r379564","US Coast Guard - Base Portsmouth",-76.35878,36.88311,-76.3678,36.8801,-76.3518,36.8859,0,636553],["r383103","Patuxent River NAS (Trapnell Field)",-76.40977,38.28455,-76.4423,38.2641,-76.3817,38.3042,1,9064162],["r389913","Helmut-Schmidt-Universität – Universität der Bundeswehr Hamburg",10.11122,53.56877,10.1059,53.5663,10.1165,53.5709,4,255487],["r392208","Joint Expeditionary Base Little Creek - Fort Story East East",-76.01769,36.92062,-76.0464,36.9092,-75.9918,36.9323,0,6066899],["r392665","Naval Station Norfolk",-76.29734,36.94167,-76.3307,36.9193,-76.2646,36.964,0,13143152],["r416869","Naval Base Kitsap-Bremerton",-122.64202,47.5574,-122.6622,47.5511,-122.6257,47.5649,5,2487880],["r417668","Camp Murray",-122.56814,47.11741,-122.5809,47.111,-122.5577,47.1229,0,1105917],["r420625","Yakima Training Center",-120.18138,46.75205,-120.4667,46.5473,-119.9159,46.9563,0,1322423534],["r437075","Vojenský újezd Libavá",17.54728,49.67197,17.4068,49.5838,17.6984,49.7602,6,235507050],["r438525","Vojenský újezd Boletice",14.07264,48.8478,13.947,48.7872,14.2431,48.9085,6,165484736],["r439469","Vojenský újezd Hradiště",13.13,50.26224,13.0045,50.1629,13.2555,50.3615,6,280845474],["r440731","Vojenský újezd Březina",16.96557,49.40648,16.8378,49.313,17.0541,49.5,6,149643828],["r451539","Naval Magazine Indian Island",-122.72867,48.05303,-122.7481,48.0187,-122.7014,48.0872,0,10831143],["r451698","Naval Radio Station Jim Creek",-121.92018,48.2028,-121.9584,48.167,-121.8873,48.2249,0,19804554],["r454382","National Security Investigative Service",31.31037,30.05187,31.31,30.0508,31.3117,30.0528,0,14562],["r533411","Naval Outlying Landing Field Coupeville",-122.63203,48.18948,-122.6479,48.171,-122.6152,48.2067,1,2626295],["r533644","Eglin Air Force Base",-86.51396,30.5777,-86.9829,30.3814,-86.1073,30.7498,5,1873152593],["r534267","Saufley Field",-87.34183,30.46763,-87.3512,30.4582,-87.3321,30.4773,0,2666728],["r541791","Whidbey Island Naval Air Station Seaplane Base",-122.56089,48.28799,-122.6453,48.2676,-122.5559,48.3083,0,9647535],["r543980","U.S. Coast Guard - Base Seattle",-122.34035,47.58933,-122.343,47.5879,-122.3375,47.5908,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
74cc07f12c496a622f2f6c5a81922c293bca5eb44e0901c967bbf94e15ee514f names.json
|
||||
@@ -0,0 +1,45 @@
|
||||
# US Census Bureau counties pack
|
||||
|
||||
County and county-equivalent outlines for area annotations ("outline Travis
|
||||
County", "outline Orleans Parish"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
| `counties.json` | `cb_2025_us_county_5m` (cartographic boundary file, 1:5,000,000) | 3,235 |
|
||||
|
||||
**Source:** U.S. Census Bureau cartographic boundary files,
|
||||
<https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip>
|
||||
(SHA-256 `faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74`),
|
||||
fetched 2026-09-25. Covers the 50 states, the District of Columbia, Puerto
|
||||
Rico and the Island Areas (American Samoa, Guam, the Northern Mariana Islands,
|
||||
the US Virgin Islands).
|
||||
|
||||
**License:** public domain — a work of the U.S. Government (17 U.S.C. § 105).
|
||||
No attribution is required; the app credits the U.S. Census Bureau. See
|
||||
DATA_SOURCES.md.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only counties`
|
||||
downloads the pinned zip (to the system temp directory, or `--cache <dir>`),
|
||||
checks its SHA-256, reads the shapefile and rewrites the pack byte for byte.
|
||||
Parameters are in the script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URL, hash, license, curation) and `features[]`
|
||||
with `name` (`NAME`), `full` (`NAMELSAD`, e.g. "Travis County"), `lsad` (the
|
||||
county word: County, Parish, Borough, Census Area, Municipality, city, …),
|
||||
`state` (`STATE_NAME`), `st` (`STUSPS`), `geoid` (`GEOID`) and `polygons`.
|
||||
`polygons` is a list of parts, largest first, each `[outer, ...holes]`; every
|
||||
ring is open and stored as integers in units of 10^-4 degrees, the first
|
||||
vertex absolute and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Shapefile rings are grouped into parts (clockwise outers) and holes
|
||||
(counter-clockwise rings, assigned to the outer that contains them), so an
|
||||
independent city inside a county (Fairfax city in Fairfax County) is a hole.
|
||||
- Douglas-Peucker per ring at 0.003 × √(county area in km²) km, clamped to
|
||||
0.0005°–0.01° (Travis County ≈ 0.0014°); coordinates rounded to 4 decimals.
|
||||
- Parts under 1 km² or under 0.1% of the county's largest part are dropped;
|
||||
holes under 1 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- Result: 1,541,464 bytes (533,326 with gzip -9); 3,524 parts, 18 holes,
|
||||
139,904 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"US Census Bureau cartographic boundary counties","source":"cb_2025_us_county_5m","url":"https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip","sha256":"faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74","license":"Public domain (U.S. Government work, 17 U.S.C. § 105)","schema":"features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":4,"curation":{"toleranceFactor":0.003,"minToleranceDeg":0.0005,"maxToleranceDeg":0.01,"decimals":4,"minPartKm2":1,"minPartShare":0.001,"minHoleKm2":1},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Autauga","full":"Autauga County","lsad":"County","state":"Alabama","st":"AL","geoid":"01001","polygons":[[[-869212,326575,103,-284,72,-457,50,-60,-44,-368,241,-533,189,-364,-66,-131,114,7,302,-128,-203,-103,16,-150,284,-296,80,-164,-135,-212,48,-232,178,-14,251,321,-90,296,8,222,314,-34,228,154,93,-17,-11,-307,77,-113,278,-74,279,227,22,211,354,85,45,-71,-13,-243,195,-130,134,137,340,-58,122,-288,156,-1,233,41,43,191,158,9,142,137,62,277,116,-60,335,101,-19,2975,-3011,-17,8,-440,-1032,-16,-1010,41]]]},{"name":"Baldwin","full":"Baldwin County","lsad":"County","state":"Alabama","st":"AL","geoid":"01003","polygons":[[[-880146,302224,523,71,1238,-23,379,22,651,113,786,90,988,247,398,60,-1,35,90,83,425,101,28,95,-216,-29,-72,29,-33,62,56,52,276,108,107,101,9,231,127,136,-20,107,111,150,69,36,192,1,78,74,245,129,46,61,-42,103,-291,41,-80,54,-68,9,-104,83,-45,50,-10,60,-50,39,50,106,-6,47,-85,108,-80,38,27,45,-16,122,112,220,75,50,19,60,73,17,20,45,-17,37,57,32,40,105,21,162,73,57,43,97,14,156,-26,195,-97,162,-11,85,-125,49,-109,86,-94,19,-116,93,-56,-23,-93,33,-81,58,-68,99,-211,51,-94,119,-209,100,-39,179,-97,106,11,66,-269,219,-99,119,-186,82,-53,107,-101,34,-14,56,-100,76,11,92,-87,88,54,142,87,74,-14,100,62,39,82,231,157,270,29,129,-96,330,-166,0,-5,2471,-163,-16,-442,191,-379,401,-509,-48,-64,-36,-240,90,-79,163,-55,-124,232,-237,3,-71,-160,-60,-167,88,-101,-33,-54,-162,-156,9,122,-127,-230,-119,-35,-93,151,-177,-114,-38,-99,112,-94,-178,-104,63,-166,-51,-74,-118,-330,55,53,-298,-316,-4,66,-144,262,-11,-65,-255,-296,-322,124,-212,151,-4,75,-197,-162,-188,69,-73,-143,-139,82,-184,-86,-220,216,-60,193,-264,-280,-105,-65,-209,-247,-204,-29,-117,54,-100,265,-125,96,-124,-18,-134,-404,-122,-35,-172,-261,-131,-55,-89,-41,-188,253,-551,-74,-133,272,-99,252,-162,193,-15,174,-213,68,-203,-25,-299,108,-199,25,-151,-94,-251,-223,-384,15,-63,119,-105,137,-612,413,-259,199,-61,79,-81,73,-155,206,-211,365,-210,173,-205,2,-139,-113,-149,-211,-82,-300,1,-424,-140,-192,-17,-212,29,-355,200,-130,-49,-178,-210,-572,-52,-53,-77]]]},{"name":"Barbour","full":"Barbour County","lsad":"County","state":"Alabama","st":"AL","geoid":"01005","polygons":[[[-857483,316180,1629,-4,1184,30,506,-11,4,872,1829,-36,236,74,35,313,406,182,111,139,256,-100,-78,251,53,263,-106,240,120,442,99,98,378,514,283,688,45,489,-1358,-7,-69,31,-5,112,-187,183,-179,84,-51,169,-256,189,21,98,-1525,-16,-172,-73,-24,-934,13,-310,-340,-6,-86,79,5,-81,-420,-1,2,-144,-749,-23,-55,-35,-120,-330,-155,-154,-285,-339,-90,-231,1,-82,-204,-243,98,-215,-58,-264,116,-235,-87,-237,-301,-199,-77,-293,33,-29,-122,-108,18,-375,-253,-409]]]},{"name":"Bibb","full":"Bibb County","lsad":"County","state":"Alabama","st":"AL","geoid":"01007","polygons":[[[-874212,328745,1017,6,3,-436,859,7,208,30,1933,18,1431,-7,-8,2136,-216,-1,-178,98,-177,45,0,147,-177,-1,8,199,-184,-2,-3,597,-560,77,0,133,43,123,-55,551,-498,3,-275,-253,-174,2,-4,141,-336,-60,-437,-332,-1,-659,-826,24,-297,-392,-69,-877,-1034,-28]]]},{"name":"Blount","full":"Blount County","lsad":"County","state":"Alabama","st":"AL","geoid":"01009","polygons":[[[-869634,338582,91,-133,6,-296,172,0,526,279,1248,-26,1138,-676,-25,-71,700,-6,3,367,557,-1,93,89,637,132,186,-45,898,712,-75,205,256,210,-33,79,-446,-3,309,317,66,146,3,604,97,257,192,269,-102,127,-953,932,-361,445,10,96,-418,11,-138,-112,-152,54,-85,-271,-160,-13,-126,-200,67,-199,-287,-207,-207,-300,-64,-164,-230,32,-293,-150,-34,-156,-298,-120,-18,130,-158,-111,200,-260,-27,-35,-91,96,-135,-139,78,-121,-189,-36,-23,-123,-263,-221,-109,-17,-39,-217,-277,-106,-3,-112,-281,51,-12,-160,-149,91,-319,-179,-181,3,2,-156,-393,-93,16,-359,-392,-46]]]},{"name":"Bullock","full":"Bullock County","lsad":"County","state":"Alabama","st":"AL","geoid":"01011","polygons":[[[-859969,320510,466,36,-43,72,83,-72,345,2,181,-74,10,-583,84,-218,943,0,-10,-869,1333,-1,-1,82,90,231,285,339,155,154,120,330,55,35,749,23,-2,144,420,1,-5,81,86,-79,340,6,-13,310,24,934,172,73,-172,-1,-61,880,-4215,-26,-211,143,-18,452,-185,137,-23,-302,-879,-27,1,-217,-127,-1]]]},{"name":"Butler","full":"Butler County","lsad":"County","state":"Alabama","st":"AL","geoid":"01013","polygonLine truncated
|
||||
@@ -1631,6 +1631,20 @@ test('installation summaries disclose viewport-scoped coverage', () => {
|
||||
assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s');
|
||||
});
|
||||
|
||||
test('installation summaries name a subject window instead of the viewport', () => {
|
||||
const stats = { coverage: { kind: 'subject', radiusM: 100_000 } };
|
||||
const empty = summarizeInstallationViewport([], { available: true, stale: false, stats });
|
||||
assert.equal(empty.count, 0);
|
||||
assert.match(empty.reason, /none mapped within 100 km/);
|
||||
assert.match(empty.reason, /not a complete 250 km survey/);
|
||||
const found = summarizeInstallationViewport(
|
||||
[{ id: 'ofm:installation:1', label: 'Military area', distanceM: 1200 }],
|
||||
{ available: true, stale: false, stats },
|
||||
);
|
||||
assert.equal(found.count, 1);
|
||||
assert.equal(found.reason, 'mapped matches within 100 km of the subject');
|
||||
});
|
||||
|
||||
test('compact Context snapshots retain installation coverage', () => {
|
||||
const snapshot = buildAwarenessContextSnapshot({
|
||||
subject: { layerId: 'flights', id: 'abc' },
|
||||
@@ -3060,3 +3074,27 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
for (const layer of [aisLiveVesselsLayer, flightsLayer, militaryFlightsLayer, militaryInstallationsLayer])
|
||||
replaceMethod(layer, 'getNearby', () => [], restores);
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
runtime.dispatch('gev:entity-selected', {
|
||||
layerId: 'military-installations', id: 'osm:military:w1', label: 'Parent base',
|
||||
latitude: 31.13, longitude: -97.78,
|
||||
properties: { memberNames: ['Area one', 'Area two', 'Area three', '<Area four>'] },
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of ['Area one', 'Area two', 'Area three', '<Area four>']) assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const sourceSlot = createSourceSlot(
|
||||
createInstallationSource(),
|
||||
['getMappedSites', 'searchNearby'],
|
||||
'Installation source',
|
||||
{ destroy: () => {} },
|
||||
);
|
||||
export const configureInstallationSource = sourceSlot.configure;
|
||||
const layer = createApplicationInstallations({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
installRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext } from './contextStore.js';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
|
||||
test('clicking a selected installation again or empty map clears it through refresh', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
@@ -42,6 +42,23 @@ test('clicking a selected installation again or empty map clears it through refr
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('background installation paints neither reopen Contacts nor resurrect a cleared selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type:'node',id:42,lat:30.2,lon:-97.7,tags:{military:'base',name:'Site'} }] });
|
||||
try {
|
||||
const selected=()=>run.contextLabels().length;
|
||||
run.click('osm:node:42');
|
||||
assert.equal(selected(),1);
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id,'osm:node:42');
|
||||
assert.equal(selected(),1,'data and floor refreshes must not publish a fresh selection');
|
||||
clearSelectedEntityContextForLayer('military-installations');
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(),null);
|
||||
assert.equal(selected(),1,'an external clear must survive the next paint');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(),9);
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
@@ -798,7 +815,7 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
||||
|
||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
assert.match(installationsSource,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*if \(!isUnavailableCapability\(error\)\)\s*parts\.viewport\.scheduleUnavailableRetry\(error\?\.retryAfterMs\);/,
|
||||
'a failed load schedules the retry immediately after reporting unavailable');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||
@@ -815,3 +832,60 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||
'the fired retry re-checks enablement and never races an in-flight load');
|
||||
});
|
||||
|
||||
|
||||
test('a merged installation stays visible when only its second fragment intersects', () => {
|
||||
const far = [[-96.6,30.4],[-96.4,30.4],[-96.4,30.6],[-96.6,30.6]];
|
||||
const near = [[-97.5,30.4],[-97.4,30.4],[-97.4,30.6],[-97.5,30.6]];
|
||||
assert.equal(installationWithinViewport({latitude:30.5,longitude:-96.5,footprint:far,footprints:[[far],[near]]},VIEWPORT), true);
|
||||
});
|
||||
|
||||
test('a Contacts anchor replaces the viewport and ignores small subject moves', async () => {
|
||||
// Field report: a follow/Cockpit camera never fires moveEnd and often looks at
|
||||
// the horizon, so the Context row read "?" for the whole track.
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 7,
|
||||
lat: 30.3125, lon: -97.765, tags: { military: 'base', name: 'Camp Mabry' } }] });
|
||||
try {
|
||||
const box = (href) => {
|
||||
const url = new URL(href, 'https://example.test');
|
||||
return Object.fromEntries(['south', 'west', 'north', 'east']
|
||||
.map((key) => [key, Number(url.searchParams.get(key))]));
|
||||
};
|
||||
const centre = (href) => (box(href).south + box(href).north) / 2;
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.3125, longitude: -97.765 }), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const anchored = box(run.requests.at(-1));
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.3125) < 0.01);
|
||||
assert.ok(anchored.north - anchored.south > 1.5, 'window reaches ~100 km each way');
|
||||
assert.equal(run.stats().coverageLabel, 'WITHIN 100 KM');
|
||||
assert.equal(run.stats().coverage.kind, 'subject');
|
||||
const count = run.requests.length;
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.35, longitude: -97.765 }), false,
|
||||
'a 4 km move keeps the window');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.6, longitude: -97.765 }), true,
|
||||
'a 32 km move shifts the window');
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(run.requests.length, count + 1);
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.6) < 0.01);
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor(null), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.deepEqual(box(run.requests.at(-1)), { south: 30, west: -98, north: 31, east: -97 },
|
||||
'clearing the subject returns to the viewport');
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('unchanged installations keep their entities across reloads', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
try {
|
||||
const before = run.entities().slice();
|
||||
assert.ok(before.length > 0);
|
||||
await militaryInstallationsLayer.update();
|
||||
const after = run.entities();
|
||||
assert.equal(after.length, before.length);
|
||||
assert.ok(after.every((entity, index) => entity === before[index]),
|
||||
'a reload with the same geometry must not recreate entities');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
export { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
import { requestWithDeadline } from '../sources/requestDeadline.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
loadPack = (signal) =>
|
||||
loadBundledJson(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
() =>
|
||||
import('./local_data/osm_military_names/names.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
|
||||
...retryOptions
|
||||
} = {}) {
|
||||
return createRetryableLoader(async () => {
|
||||
const pack = await requestWithDeadline(loadPack, { timeoutMs });
|
||||
const records = pack.records.map(
|
||||
([
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
kind,
|
||||
areaM2,
|
||||
]) => ({
|
||||
id: `osm:military:${osmKey}`,
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
bbox: [west, south, east, north],
|
||||
class: pack.classes[kind],
|
||||
areaM2,
|
||||
kind: 'installation',
|
||||
namedArea: true,
|
||||
pointOnly: true,
|
||||
validation: 'unreviewed',
|
||||
sources: [
|
||||
{ name: 'OpenStreetMap', id: osmKey },
|
||||
{ name: 'Overture Maps Foundation', id: osmKey },
|
||||
],
|
||||
}),
|
||||
);
|
||||
records.sort(
|
||||
(a, b) => b.areaM2 - a.areaM2 || a.osmKey.localeCompare(b.osmKey),
|
||||
);
|
||||
return { records, byId: new Map(records.map((r) => [r.osmKey, r])) };
|
||||
}, retryOptions);
|
||||
}
|
||||
|
||||
/** Load and index the separate data asset only when installation context needs it. */
|
||||
export const loadMilitaryNames = createMilitaryNamesLoader();
|
||||
|
||||
/** Join exact source identity before parcel merging; reject geographically inconsistent ids. */
|
||||
export function nameMilitaryFragment(fragment, names) {
|
||||
const osmKey = militaryOsmKey(fragment.featureKey);
|
||||
const named = names?.byId.get(osmKey);
|
||||
if (!named || !fragment.footprint?.length) return fragment;
|
||||
const [west, south, east, north] = named.bbox;
|
||||
const epsilon = (fragment.tileEpsilon || 0) * 2 + 0.0001;
|
||||
const xs = fragment.footprint.map((p) => p[0]);
|
||||
const ys = fragment.footprint.map((p) => p[1]);
|
||||
if (
|
||||
Math.max(...xs) < west - epsilon ||
|
||||
Math.min(...xs) > east + epsilon ||
|
||||
Math.max(...ys) < south - epsilon ||
|
||||
Math.min(...ys) > north + epsilon
|
||||
)
|
||||
return fragment;
|
||||
return {
|
||||
...fragment,
|
||||
name: named.name,
|
||||
class: named.class,
|
||||
osmKey,
|
||||
namedArea: true,
|
||||
areaM2: named.areaM2,
|
||||
nameRecord: named,
|
||||
};
|
||||
}
|
||||
|
||||
/** Area-ranked spatial thinning of visible label points, including dateline views. */
|
||||
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
|
||||
const height = box.north - box.south;
|
||||
const cells = new Set(),
|
||||
records = [];
|
||||
let count = 0;
|
||||
for (const record of names?.records || []) {
|
||||
const x = (record.longitude - box.west + 360) % 360;
|
||||
if (x > width || record.latitude < box.south || record.latitude > box.north)
|
||||
continue;
|
||||
count++;
|
||||
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
|
||||
if (cells.has(cell) || records.length >= cap) continue;
|
||||
cells.add(cell);
|
||||
records.push(record);
|
||||
}
|
||||
return { records, count };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import {
|
||||
loadMilitaryNames,
|
||||
militaryOsmKey,
|
||||
nameMilitaryFragment,
|
||||
militaryNamesInView,
|
||||
MILITARY_POINT_CAP,
|
||||
} from './militaryNames.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../sources/openFreeMap.js';
|
||||
import { createControls } from '../layers/installations/controls.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
} from '../sources/militaryTileGeometry.js';
|
||||
|
||||
const sites = [
|
||||
['w27894251', 'Camp Mabry'],
|
||||
['r317712', 'Marine Corps Base Camp Pendleton'],
|
||||
['r253814', 'Marine Corps Air Station Miramar'],
|
||||
['w60546523', 'Ramstein Air Base'],
|
||||
['w102826331', 'RAF Lakenheath'],
|
||||
['r13529728', 'Fort Hood'],
|
||||
];
|
||||
|
||||
test('wide-view counts report visible points rather than retained off-screen candidates', () => {
|
||||
const state = {
|
||||
wide: true,
|
||||
records: Array.from({ length: 8 }, () => ({ kind: 'installation' })),
|
||||
lastUpdate: 1,
|
||||
status: 'ready',
|
||||
};
|
||||
const { methods } = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: {},
|
||||
parts: {
|
||||
namedMarkers: {
|
||||
stats: () => ({
|
||||
namedMarkers: 8,
|
||||
pointsOnScreen: 3,
|
||||
labelsOnScreen: 2,
|
||||
labelCap: 24,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 3);
|
||||
assert.equal(stats.namedMarkers, 8);
|
||||
assert.equal(stats.statusMessage, '3 mapped sites in view');
|
||||
});
|
||||
|
||||
test('the separately licensed pack has unique typed identities, areas, names and a pinned lean output', async () => {
|
||||
const raw = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
);
|
||||
const hash = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.sha256', import.meta.url),
|
||||
'utf8',
|
||||
).split(' ')[0];
|
||||
assert.equal(createHash('sha256').update(raw).digest('hex'), hash);
|
||||
assert.ok(gzipSync(raw, { level: 9 }).length <= 2_000_000);
|
||||
const names = await loadMilitaryNames();
|
||||
assert.equal(names.records.length, 36466);
|
||||
assert.equal(names.byId.size, names.records.length);
|
||||
assert.ok(
|
||||
names.records.every(
|
||||
(r) => r.areaM2 > 0 && Number.isFinite(r.latitude + r.longitude),
|
||||
),
|
||||
);
|
||||
for (const [id, name] of sites) assert.equal(names.byId.get(id).name, name);
|
||||
});
|
||||
|
||||
test('checked-in OpenFreeMap fixtures retain typed OSM feature ids and geographical identity', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const tile = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
|
||||
const mabry = tile.military.find((r) => r.featureKey === '278942512');
|
||||
assert.ok(mabry);
|
||||
assert.equal(militaryOsmKey(mabry.featureKey), 'w27894251');
|
||||
assert.equal(nameMilitaryFragment(mabry, names).name, 'Camp Mabry');
|
||||
assert.equal(militaryOsmKey('3177123'), 'r317712');
|
||||
for (const value of [
|
||||
'tile:12/935/1685:0',
|
||||
'278942510',
|
||||
'3177127',
|
||||
'bad',
|
||||
1e20,
|
||||
])
|
||||
assert.equal(militaryOsmKey(value), null);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '3177123' }, names).name,
|
||||
'Military area',
|
||||
'a valid but distant id fails soft',
|
||||
);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '278942510' }, names).name,
|
||||
'Military area',
|
||||
);
|
||||
});
|
||||
|
||||
test('a merged title uses the largest named member and exposes all other member names', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const fragment = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685)
|
||||
.military[0];
|
||||
const parent = nameMilitaryFragment(fragment, names);
|
||||
const child = {
|
||||
...parent,
|
||||
featureKey: '12',
|
||||
osmKey: 'w1',
|
||||
nameRecord: {
|
||||
...parent.nameRecord,
|
||||
id: 'osm:military:w1',
|
||||
osmKey: 'w1',
|
||||
name: 'Small barracks',
|
||||
areaM2: 1,
|
||||
},
|
||||
};
|
||||
for (const records of [
|
||||
[child, parent],
|
||||
[parent, child],
|
||||
]) {
|
||||
const merged = mergeMilitaryFragments(records)[0];
|
||||
assert.equal(merged.name, 'Camp Mabry');
|
||||
assert.equal(merged.id, 'osm:military:w27894251');
|
||||
assert.deepEqual(
|
||||
[merged.longitude, merged.latitude],
|
||||
[parent.nameRecord.longitude, parent.nameRecord.latitude],
|
||||
);
|
||||
assert.deepEqual(merged.memberNames, ['Small barracks']);
|
||||
}
|
||||
});
|
||||
|
||||
test('wide views rank and thin names within a bounded viewport, including the dateline', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
for (const box of [
|
||||
{ west: -130, south: 20, east: -60, north: 60 },
|
||||
{ west: -15, south: 35, east: 35, north: 65 },
|
||||
{ west: 170, south: -60, east: -150, north: 70 },
|
||||
]) {
|
||||
const view = militaryNamesInView(names, box);
|
||||
assert.ok(view.records.length > 0);
|
||||
assert.ok(view.records.length <= MILITARY_POINT_CAP);
|
||||
assert.ok(view.count >= view.records.length);
|
||||
assert.equal(
|
||||
new Set(view.records.map((r) => r.id)).size,
|
||||
view.records.length,
|
||||
);
|
||||
for (const r of view.records) {
|
||||
assert.ok(r.latitude >= box.south && r.latitude <= box.north);
|
||||
assert.ok(
|
||||
box.east < box.west
|
||||
? r.longitude >= box.west || r.longitude <= box.east
|
||||
: r.longitude >= box.west && r.longitude <= box.east,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a close view keeps the parent name inside its polygon when the usual label is off-screen', () => {
|
||||
const record = {
|
||||
id: 'osm:military:w1',
|
||||
namedArea: true,
|
||||
name: 'Parent base',
|
||||
longitude: 1,
|
||||
latitude: 1,
|
||||
footprints: [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
const visible = militaryNameInView(record, {
|
||||
west: 5,
|
||||
south: 5,
|
||||
east: 6,
|
||||
north: 6,
|
||||
});
|
||||
assert.equal(visible.id, record.id);
|
||||
assert.deepEqual([visible.longitude, visible.latitude], [5.5, 5.5]);
|
||||
assert.equal(
|
||||
militaryNameInView(record, { west: 0, south: 0, east: 2, north: 2 }),
|
||||
record,
|
||||
);
|
||||
const hole = {
|
||||
...record,
|
||||
footprints: [
|
||||
[
|
||||
record.footprints[0][0],
|
||||
[
|
||||
[4, 4],
|
||||
[7, 4],
|
||||
[7, 7],
|
||||
[4, 7],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
militaryNameInView(hole, { west: 5, south: 5, east: 6, north: 6 }),
|
||||
hole,
|
||||
);
|
||||
});
|
||||
@@ -325,3 +325,39 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the smallest containing bundled physical region without a network geocoder. */
|
||||
export async function naturalRegionAtPoint(latitude, longitude) {
|
||||
if (
|
||||
![latitude, longitude].every(Number.isFinite) ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
await loadIndex();
|
||||
let best = null;
|
||||
for (const entry of _entries) {
|
||||
const [west, south, east, north] = entry.bbox;
|
||||
if (
|
||||
longitude < west ||
|
||||
longitude > east ||
|
||||
latitude < south ||
|
||||
latitude > north
|
||||
)
|
||||
continue;
|
||||
if (best && entry.areaKm2 >= best.areaKm2) continue;
|
||||
if (entry.polygons.some((ring) => pointInRing(ring, latitude, longitude)))
|
||||
best = entry;
|
||||
}
|
||||
return best
|
||||
? {
|
||||
label: best.name,
|
||||
locality: null,
|
||||
region: best.name,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
source: 'Natural Earth',
|
||||
kind: best.kind,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user