Merge upstream main into ChatGPT Realtime OAuth

This commit is contained in:
devv-shayan committed 2026-10-04 12:29:54 +05:00
commit 6c88b78140
402 files changed
+48518 -3623

No files matched your search

+39 -19
View File
@@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY=
# single shared key keeps working — splitting is opt-in.
# SERVER-SIDE ONLY.
GOOGLE_MAPS_SERVER_API_KEY=
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
# (/api/google/nearby-places), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
# (plus per-API quotas under APIs & Services -> Quotas).
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
# Per-IP rate limit for the Google Places cost endpoints
# (/api/google/nearby-places + /api/google/text-search), in requests per minute.
# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it
# with a positive integer; 60 is a sensible tightening whenever HOST is not
# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a
# typo, a stray unit) falls back to the default rather than to unlimited, so a
# slip cannot quietly disarm the guard. A positive value below 1 counts as 1.
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
# spend protection set provider-side budget alerts: Google Cloud Console ->
# Billing -> Budgets & alerts (plus per-API quotas under APIs & Services ->
# Quotas).
# GEV_RATELIMIT_GOOGLE_PER_MIN=60 # uncomment to tighten from the default 120
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
# imagery, and Cesium World Terrain. The free Community plan is for eligible
@@ -71,15 +74,19 @@ OPENAI_REALTIME_REASONING_EFFORT=low
OPENAI_REALTIME_CONTEXT_TOKENS=3000
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
# Per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
# Set a positive integer to cap per-IP spend when self-hosting on a network.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
# use several times over: an open tab asks for a HUD summary every 15s (4/min)
# and a voice session mints one token. Raise or lower it with a positive
# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo,
# a stray unit) falls back to the default rather than to unlimited, so a slip
# cannot quietly disarm the guard. A positive value below 1 counts as 1.
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
# Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Default: oauth — requires client credentials below
@@ -107,6 +114,11 @@ PORT=4173
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
# HOST=localhost
# HOST=0.0.0.0
# Optional: comma-separated LAN hostnames to accept, in addition to localhost
# and IP addresses, for example GEV_ALLOWED_HOSTS=globe.lan,globe.internal.
# Binding HOST=0.0.0.0 alone does NOT trust every Host header; suffix and
# wildcard entries are ignored.
# GEV_ALLOWED_HOSTS=
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
# browser — the client polls the /api/firms proxy). Free key:
@@ -116,7 +128,7 @@ PORT=4173
# Optional: Live AIS vessels layer
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
VITE_AIS_LIVE_API_URL=/api/ais-live
VITE_AIS_LIVE_API_URL=/api/vessels
# Client render cap. The server can keep a larger AISStream cache, but drawing
# every global vessel at once can overwhelm WebGL on laptops.
VITE_AIS_LIVE_MAX_ROWS=12000
@@ -189,3 +201,11 @@ 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=
# Optional: who may frame the app in embed mode (?embed=1), as CSP
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
# GEV_EMBED_FRAME_ANCESTORS=
+5
View File
@@ -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
+227 -34
View File
@@ -1,5 +1,183 @@
# Changelog
## [Unreleased]
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
county, military-name and neighborhood packs were emitted twice by the
production build, as the JSON the browser fetches and as an unused
JavaScript copy; `dist/` drops from 54 MB to 42 MB. Under Node the loader
reads the JSON file directly (reporting that Node 24.14 or newer is needed
on a runtime too old to do so), and a test keeps app code from importing a
pack as a module.
- Apply the Host check before the app's own routes. Vite installs its Host
check after the middleware that plugins add, so the provider and `/api`
routes used to answer any Host, including a DNS-rebinding name, in every
mode. A first-running middleware now applies the same allowed hosts on the
dev and preview servers.
- Refuse cross-site browser requests to the cost-bearing and log endpoints
(`/api/realtime/token`, `/api/openai/hud-summary`,
`/api/google/nearby-places`, `/api/google/text-search`,
`/api/realtime/debug-log`): a foreign or opaque
Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403,
while loopback tools and LAN use keep working. The dev and preview servers
send one shared Content-Security-Policy (James Sumpter, #242).
- The voice debug log records the server's own time: a record can no longer
supply its `loggedAt`. The dev and preview servers also send
`X-Content-Type-Options: nosniff` (findings by Sunil, #710).
- Keep the Host-header check when binding to all interfaces. `HOST=0.0.0.0`
used to accept any Host; the dev and preview servers now accept IP
addresses, `localhost` and the LAN hostnames listed in `GEV_ALLOWED_HOSTS`
(suffix and wildcard entries are ignored), which also keeps DNS-rebinding
names out in LAN mode. The `.local` suffix is no longer accepted by default;
list such a name explicitly (Puspo Aditya, #97, fixes #21).
- Throttle the cost-bearing proxies by default instead of on request. The
OpenAI endpoints (`/api/realtime/token`, `/api/openai/hud-summary`) now allow
30 requests per minute per client IP and the Google Places endpoints
(`/api/google/nearby-places`, `/api/google/text-search`) 120 — the caps the
Pinokio build already ships, so the packaged app is unaffected and only an
unconfigured server changes, from unlimited to what the product already runs
with. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` still
override the caps, and exactly `0` disables them; a value that cannot be read
as a number now falls back to the default rather than to unlimited, so a typo
cannot silently disarm the guard (daikaginza, #683).
- Tighten the local MCP server and embed mode. The panel's `panel_request`
needs the key in its MCP server's panel page, and refuses Provider
Settings, credential and model endpoints, `/mcp` and development server
routes in any letter case, encoding or dot suffix; panels on one server share six
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
requires a Host on the port it reached and an Origin from that host, and
times out request bodies after 30 seconds; stdio honors
`notifications/cancelled`. Embed mode is no longer framable unless
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page, and view answers go
only to the origin that sent the view. SECURITY.md describes the MCP surface
and the panel's browser keys.
- Cockpit now enters on the existing matching map style while keeping one fixed,
duplicate-free carousel over `Normal / CRT / NVG / FLIR / Anime / Noir / Snow`.
Normal remains a real unfiltered option; Cockpit-only choices still restore
the captured map style through both Exit Cockpit and Reset.
- Cyber's compact right-rail and Cockpit utility buttons now center their glyphs
vertically and share the same inset and edge alignment.
- Keep Cyber Voice help/error popups and Location/Visual Presets pins clear of
their decorative frames. Leave space above attribution for its full logo row.
- Add an intentionally future-facing, opt-in panel surface contract for new
panels to inherit compatible Normal, Cyber and Cockpit styling. Its first
production adopter will land separately after this change.
- Restore the user's previous visual preset when they explicitly switch from
Cyber to another HUD layout, without overriding scene or shared-link state.
- Move the aligned Cyber side-panel rails upward on desktop so the left stack
clears the lower coordinate card; keep Cockpit's independent visor layout.
## [0.2.0] — 2026-10-02 — God's Eye View through Your Agent
- Show God's Eye View inside AI conversations. Answers that can be shown
include a view (camera, layers, style, map, marks, something to follow),
and `show_in_gods_eye_view` displays it as the live globe in clients that
support MCP Apps, such as Claude Desktop and the Codex and ChatGPT desktop
apps, and as a link everywhere else. The panel loads the app's panel build
(`npm run build:panel`) and its data through the MCP server, so a local
server needs no HTTPS or tunnel. `?embed=1` shows the globe alone for pages
that frame the app.
- Add tools for language-model clients and a local MCP server, served over
stdio by `npm run mcp` and over HTTP at `/mcp` on the development and preview
servers for local requests only. `npm run mcp`
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter,
terrain, installation and map-feature queries, plus a situation brief, over
stdio to clients such as Claude Code, reading
from a running app. Tools are defined
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
exposed through the protocol adapter in `gods-eye-view/tools/mcp`. The
catalog also covers weather maps, wind, recent imagery, submarine cables,
license plate reader cameras, datacenters and dams, the Bhote Koshi flood,
military awareness and links that open the app over an area with layers on.
Voice offers the same queries next to its app actions.
- 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
flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD
@@ -53,6 +231,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
@@ -93,7 +284,7 @@
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
`MapSourceController.subscribe()` reports every settled map switch.
## Unreleased — local receiver feeds
### Local receiver feeds
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978
@@ -122,7 +313,7 @@
- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`).
- See `docs/LOCAL-RECEIVERS.md`.
## Unreleased — local RTL-SDR and Local ADS-B
### Local RTL-SDR and Local ADS-B
- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in
desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek,
@@ -147,7 +338,7 @@
- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see
`THIRD_PARTY_NOTICES.md`.
## Unreleased — weather review
### Weather review
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
observed-weather shell except global infrared, sampled by the shell's own
@@ -413,26 +604,26 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
## Satellite pass prediction
### Satellite pass prediction
- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola.
- Mark passes visible from Earth-shadow and civil-twilight checks.
- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite.
- `next_iss_pass` retains the next geometric pass and adds visibility metadata. `next_satellite_pass` adds bounded loaded-catalog name/NORAD lookup and optional visible-only filtering (Rehaan Delmotra, #451; maintainer adaptation).
## Voice component boundaries
### Voice component boundaries
- Separate voice controls, Realtime connection requests and the action runner.
- Allow compatible endpoints and server-selected models through construction options.
- Cancel pending token/SDP requests on Stop or teardown and reject expired secrets.
## Configurable geospatial services
### Configurable geospatial services
- Compose geocoding, place context and routes through independent providers.
- Allow compatible endpoint configuration without changing voice tools or annotation behavior.
- Isolate configured source caches and reject results after cancellation.
## ALPR camera locations
### ALPR camera locations
- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets,
gradient direction wedges and animated tactical labels into the reusable ALPR
@@ -452,32 +643,32 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
- Separate the request adapter, camera model, presentation, and instance lifecycle.
Source cancellation also guards late response bodies and rejects invalid query bounds.
## Release disabled infrastructure rendering
### Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
are disabled, avoiding retained visualizer work and entity memory.
- Keep parsed datasets cached for re-enable; rebuild entities without refetching.
## Camera layer components
### Camera layer components
- Separate camera source requests, placement, frames, projection, cards and calibration.
- Own visibility listeners and pending initialization within each layer lifetime.
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
## Traffic and bikeshare components
### Traffic and bikeshare components
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
- Give each flow source its own bounded decode cache and cancellation checks.
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
## Installation and context components
### Installation and context components
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
- Retain source and ground-floor ownership in standalone composition; reject malformed
installation snapshots and ignore failures from cancelled requests.
## Satellite and mission layer components
### Satellite and mission layer components
- Separate catalog loading, orbit calculations, display, tracking and interaction
into instance-owned satellite components.
@@ -485,20 +676,20 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
operations, retaining existing layer controls and satellite coordination.
- Cancel late mission source work and reject malformed launch snapshots.
## Fire layer components
### Fire layer components
- Split fire source loading, state, rendering, cards, selection and viewport work
into reusable components with application-owned scene services.
- Cancel late refreshes, retain good data after malformed responses, and preserve
selection identity without repeating a user-selection notification on refresh.
## Earthquake components
### Earthquake components
- Separate earthquake snapshot loading, record validation, and display ownership.
- Cancel pending earthquake refreshes on disable or destruction, retaining the
last good snapshot after malformed or failed refreshes.
## September 8, 2026
### September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
@@ -509,7 +700,8 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
This changelog records public product changes. For the authoritative description
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
## [Unreleased]
### Other changes
- 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.
@@ -529,7 +721,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Separate submarine cable sources and rendering components, and export bundled geography lookup modules.
### Added
#### Added
- DISPLAY ▸ Draw: draw on the world by hand. Pick Area, Line or Pin, click the
vertices, double-click or press Enter to finish, label and colour it; Backspace
@@ -539,7 +731,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
together. While you are drawing, the draw tool owns the pointer and no layer
selects what you click through (#235 — thanks @cora-fresh-labs).
### Fixed
#### Fixed
- Keep traffic-road bounds crossing the antimeridian monotonic and inside the
longitude range accepted by the Overpass request path, preserving the small
@@ -606,7 +798,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
toggle. Camera departure cancels pending work, arrival checks the final view,
and superseded requests cannot keep a newer view loading.
### Added
#### Added
- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the
existing `FIRMS_MAP_KEY` and 30-minute cache.
@@ -734,9 +926,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Extract shared surface keyboard handling for the welcome launcher and Provider
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
### Added
#### Added
### Security
#### Security
- The CCTV media route no longer forwards a client `Range` header to the upstream
camera host as it arrived. A single `bytes=` range is canonicalized and
@@ -767,7 +959,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Cancel the active location lookup when its controls are disposed.
### Fixed
#### Fixed
- `DATA_SOURCES.md` states what the project does with camera frame content: a
successful upstream response is relayed as the provider served it, nothing in
@@ -846,7 +1038,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
fetching, AIS records/tracks and shared request helpers; preserve existing
routes, local setup, fallback behavior and rendering.
### Added
#### Added
- **Directions layer** — keyless A→B directions without a geocoder or a
microphone (thanks @spcpza). The row's chips arm a globe click for A and B
@@ -874,7 +1066,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
voice routes since launch, previously uncredited), with the OpenStreetMap
credit and the "fix the map" link the service's usage policy asks for.
### Changed
#### Changed
- The interface asks Google Fonts for only the icon glyphs it draws, instead of
the whole variable icon font, and no longer requests a second icon family that
@@ -889,7 +1081,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Rename standalone browser startup to `src/standalone/` and add a Node-only
`gods-eye-view/build/vite` export with checked package ownership.
### Development
#### Development
- The CCTV launcher and preview-server tests resolve their temporary fixture
root through `fs.realpath`, so they pass on macOS, where the system temp
@@ -923,7 +1115,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
infrastructure modules and their consumer tests. Package boundary checks keep
those exports separate from app startup and local Node services.
### Fixed
#### Fixed
- Reduce terrain-height timeouts when Re:Earth slows down. Batches are
sized against measured response latency on both browser and server to reduce
@@ -1014,7 +1206,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
and upstream error bodies; response statuses and cache fallback remain intact.
Includes the security fixes contributed by Tom-Neverwinter in PR #171.
### Fixed
#### Fixed
- Map Source keyboard opening retries focus until the selected tile is visible.
Leaving the disclosure, pointer interaction, or closing the tray cancels the
@@ -1056,7 +1248,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
that had since recovered. A miss is now cached only when every source
consulted actually returned a verdict.
### Added
#### Added
- Keyless place search. The LOCATION search box and the `fly_to_location` voice
tool now resolve place names through Photon (komoot, over OpenStreetMap) when
@@ -1076,6 +1268,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0.
Browser QA awaits the new asynchronous executable-path lookup.
### Live CCTV video
- Live HLS video shares one decoder between the camera panel and projection,
with a DelDOT HTTPS source pack. Credit: Daniel Slay (@Danielslay86), PR #489.
- Maintainer adjustments bound sessions and downloads, remove disk/subprocess
remuxing, reject redirects, and clean up playback on switching or disabling.
## [0.1.1] — 2026-09-01 — Installation and live-data fixes
### Changed
@@ -1313,9 +1512,3 @@ represent previously published GitHub Releases.
- Initial project version.
### Live CCTV integration candidate
- Live HLS video shares one decoder between the camera panel and projection,
with a DelDOT HTTPS source pack. Credit: Daniel Slay (@Danielslay86), PR #489.
- Maintainer adjustments bound sessions and downloads, remove disk/subprocess
remuxing, reject redirects, and clean up playback on switching or disabling.
+33
View File
@@ -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
View File
@@ -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
+7 -1
View File
@@ -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**.
@@ -275,6 +279,8 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
---
**💬 Or ask from Claude and Codex.** God's Eye View is also an MCP server: ask Claude Desktop, Codex or ChatGPT desktop to show a place, and the live globe opens right in the conversation. Setup takes a minute — see [docs/MCP_SETUP.md](docs/MCP_SETUP.md).
## 🛰️ What's on the Globe
Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
@@ -506,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
### 🔒 Sharing an instance
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux). IP addresses work as they are; if you use a LAN hostname, add it with `GEV_ALLOWED_HOSTS=globe.lan`, since binding to `0.0.0.0` does not trust arbitrary Host headers. But know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Per-IP throttles on the cost-bearing endpoints are on by default (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example` to tune them), but before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
Provider Settings is disabled when the server is shared, so remote users cannot
access the key-entry panel.
+15 -4
View File
@@ -18,8 +18,8 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| Key | Where it lives | How the browser uses it |
|-----|----------------|--------------------------|
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Two deliberately client-side keys — restrict them
@@ -71,14 +71,16 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
## Network exposure — the operator threat model
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks).
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
@@ -90,6 +92,15 @@ The dev server is a **key broker**: every server-side key above is spendable by
loopback only. Use a separately reviewed authentication proxy for remote
access and keep provider-side quotas as the spend backstop.
## MCP server and panel
God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`) and at `/mcp` on the development and preview servers.
- **`/mcp` answers only direct local requests**: a loopback connection naming a loopback host on the port it reached, a browser `Origin` (when sent) from that same host, no proxy forwarding headers, and launcher sharing off. It accepts only JSON, so a web page cannot post to it cross-site. This is transport safety, not authentication: any program on your machine can use the tools, and some of them spend the same provider quotas as the app.
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
## Scope & expectations
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
+94
View File
@@ -0,0 +1,94 @@
/**
* Host header allowlist for the Vite dev/preview server. The configuration
* factory passes the configured names in; this module reads no environment.
*
* Binding to a wildcard interface only controls which network interfaces accept
* connections. It must not turn into a wildcard Host-header policy: this server
* brokers configured API keys through local proxy routes.
*/
import { isIP } from 'node:net';
export const DEFAULT_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1']);
/**
* Resolve Vite's allowedHosts setting from an optional comma-separated list.
* Empty entries and suffix/wildcard entries are ignored; repeated names keep
* their first occurrence. Vite treats leading-dot values as suffix wildcards,
* which would defeat an explicit host policy.
*
* @param {string|undefined|null} configuredHosts
* @returns {string[]}
*/
export function resolveAllowedHosts(configuredHosts) {
const hosts = String(configuredHosts ?? '')
.split(',')
.map((host) => host.trim())
.filter((host) => host && !host.startsWith('.') && !host.includes('*'));
return [...new Set([...DEFAULT_ALLOWED_HOSTS, ...hosts])];
}
/**
* Whether a Host header names a host the server answers, by Vite's own rule:
* IP addresses, `localhost` and `*.localhost` always; then `additional` names
* (the configured server host) and `allowedHosts` (a leading dot allows a
* domain and its subdomains). `allowedHosts === true` allows every host.
*
* @param {string|undefined} hostHeader
* @param {string[]|true} allowedHosts
* @param {string[]} [additional]
* @returns {boolean}
*/
export function isAllowedHost(hostHeader, allowedHosts, additional = []) {
if (allowedHosts === true) return true;
const host = String(hostHeader ?? '').trim();
if (!host) return false;
if (host[0] === '[') {
const end = host.indexOf(']');
return end > 0 && isIP(host.slice(1, end)) === 6;
}
const colon = host.indexOf(':');
const hostname = (colon === -1 ? host : host.slice(0, colon)).toLowerCase();
if (isIP(hostname) === 4) return true;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (additional.includes(hostname)) return true;
return (allowedHosts || []).some(
(allowed) =>
allowed === hostname ||
(allowed[0] === '.' &&
(allowed.slice(1) === hostname || hostname.endsWith(allowed))),
);
}
/**
* Vite plugin that applies the Host check before every other middleware.
* Vite installs its own check after the middleware plugins add in
* `configureServer`, so without this the app's `/api` routes would answer any
* Host, including a DNS-rebinding name. It runs first (`enforce: 'pre'`) and
* uses the same allowed hosts as Vite, on the dev and preview servers.
*/
export function hostCheckPlugin() {
const middleware = (config, preview) => {
const allowed = preview
? config.preview.allowedHosts
: config.server.allowedHosts;
const additional = [config.server.host, config.preview.host].filter(
(value) => typeof value === 'string' && value,
);
return (req, res, next) => {
if (isAllowedHost(req.headers.host, allowed, additional)) return next();
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Blocked request. This host is not allowed.');
};
};
return {
name: 'host-check',
enforce: 'pre',
configureServer(server) {
server.middlewares.use(middleware(server.config, false));
},
configurePreviewServer(server) {
server.middlewares.use(middleware(server.config, true));
},
};
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Lets chosen pages frame the app in embed mode (`?embed=1`). Every
* document keeps the server's `X-Frame-Options: DENY` and
* `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names who may
* frame embed-mode documents: CSP frame-ancestors sources, or `*` for any
* page. A framing page can change what the app shows, so no page may frame
* it by default. Other documents, including Provider Settings, are never
* framable. "Any" sends no framing restriction at all: `frame-ancestors *`
* would still refuse sandboxed frames with an opaque origin.
*/
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
/** The directives of a CSP header value, minus any frame-ancestors rule. */
function withoutFraming(value) {
return String(value ?? '')
.split(';')
.map((directive) => directive.trim())
.filter(Boolean)
.filter((directive) => !/^frame-ancestors\b/i.test(directive));
}
/** Whether a request is for an embed-mode document. */
export function isEmbedDocumentRequest(url) {
let parsed;
try {
parsed = new URL(url || '/', 'http://localhost');
} catch {
return false;
}
return (
parsed.searchParams.get('embed') === '1' &&
(parsed.pathname === '/' || parsed.pathname.endsWith('.html'))
);
}
/** Vite plugin applying the embed framing policy on dev and preview servers. */
export function embedFramingPlugin({
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '',
} = {}) {
const allowed = ancestors.trim();
// Unset: embed documents keep the server's framing protection.
if (!allowed) return { name: 'embed-framing' };
const anywhere = allowed === '*';
const policy = `frame-ancestors ${allowed}`;
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isEmbedDocumentRequest(req.url)) return next();
// The server's own headers are written when the response is sent, so
// intercept them rather than setting ours first.
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
const key = String(name).toLowerCase();
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
if (key === 'content-security-policy') {
// Keep every other directive of the server's policy (script-src,
// connect-src, ...) and only swap the framing rule.
const kept = withoutFraming(value);
const next = anywhere ? kept : [...kept, policy];
return next.length ? setHeader(name, next.join('; ')) : res;
}
return res;
};
if (!anywhere) setHeader('Content-Security-Policy', policy);
next();
});
};
return {
name: 'embed-framing',
configureServer: install,
configurePreviewServer: install,
};
}
+153
View File
@@ -0,0 +1,153 @@
/**
* The MCP Apps panel build. A panel loads the app through its MCP server,
* one tool call per file (see src/tools/globePanel.js), so the app must be
* few files: one app script, one stylesheet, and Cesium's own script, which
* carries its workers and starts them from memory. The build is served at
* PANEL_BASE next to the app.
*/
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers (workerFilesPrelude). */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
export const PANEL_OUT_DIR = 'dist/panel';
/** A browser Vite config changed to produce the panel build. */
export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
build: {
...config.build,
outDir: PANEL_OUT_DIR,
emptyOutDir: true,
modulePreload: false,
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
output: { inlineDynamicImports: true },
},
},
};
}
const CONTENT_TYPES = {
'.css': 'text/css',
'.geojsonl': 'application/geo+json-seq',
'.glb': 'model/gltf-binary',
'.html': 'text/html',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.woff2': 'font/woff2',
'.xml': 'application/xml',
};
/**
* Vite plugin serving the panel build at PANEL_BASE on the dev server, as
* the preview server and production builds serve it from the build output.
*/
export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) {
return {
name: 'panel-build',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const { pathname } = new URL(req.url || '/', 'http://localhost');
if (!pathname.startsWith(PANEL_BASE)) return next();
const { readFile } = await import('node:fs/promises');
const { extname, resolve, sep } = await import('node:path');
const root = resolve(server.config.root, outDir);
let relative;
try {
relative = decodeURIComponent(pathname.slice(PANEL_BASE.length));
} catch {
res.statusCode = 400;
res.end('Bad path');
return;
}
const file = resolve(root, relative || 'index.html');
if (file !== root && !file.startsWith(root + sep)) return next();
try {
const body = await readFile(file);
res.setHeader(
'Content-Type',
CONTENT_TYPES[extname(file)] || 'application/octet-stream',
);
res.end(body);
} catch {
res.statusCode = 404;
res.end(
relative ? 'Not found' : 'No panel build; run npm run build:panel',
);
}
});
},
};
}
/**
* Cesium files its workers load themselves, as paths under Cesium's base.
* Workers request them from the panel page's own site, which has none of
* the app's files, so the panel build embeds them in the workers script.
*/
export const PANEL_WORKER_FILES = Object.freeze([
'Assets/approximateTerrainHeights.json',
]);
/**
* A script, run in each worker before Cesium's, that answers the worker's
* requests for `files` (Cesium-relative path → text) from memory and lets
* every other request through.
*/
export function workerFilesPrelude(files) {
const answer = (FILES) => {
const find = (url) => {
const path = String(url).split(/[?#]/)[0];
for (const name of Object.keys(FILES))
if (path.endsWith(`/${name}`)) return FILES[name];
return null;
};
const xhr = XMLHttpRequest.prototype;
const { open, send, setRequestHeader } = xhr;
xhr.open = function (method, url, ...rest) {
this.__gevFile = find(url);
if (this.__gevFile === null) return open.call(this, method, url, ...rest);
};
xhr.setRequestHeader = function (...args) {
if (this.__gevFile === null) return setRequestHeader.apply(this, args);
};
xhr.send = function (body) {
const text = this.__gevFile;
if (text === null || text === undefined) return send.call(this, body);
const type = this.responseType;
const response =
type === 'json'
? JSON.parse(text)
: type === 'arraybuffer'
? new TextEncoder().encode(text).buffer
: text;
const state = {
readyState: 4,
status: 200,
statusText: 'OK',
response,
responseText: typeof response === 'string' ? response : '',
getAllResponseHeaders: () => 'content-type: application/json\r\n',
getResponseHeader: (name) =>
name.toLowerCase() === 'content-type' ? 'application/json' : null,
};
for (const [name, value] of Object.entries(state))
Object.defineProperty(this, name, { configurable: true, value });
setTimeout(() => {
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new ProgressEvent('load'));
this.dispatchEvent(new ProgressEvent('loadend'));
});
};
};
return `(${answer})(${JSON.stringify(files)});\n`;
}
+67 -10
View File
@@ -1,5 +1,50 @@
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
/**
* Content-Security-Policy for every document the dev/preview server serves.
* No inline script is permitted, and the only foreign script origins are the
* three that the Bhote Koshi event's embedded media needs (YouTube, Facebook
* and X), which frame-src matches for their players and posts. 'unsafe-eval' is
* required: Knockout (bundled inside @cesium/widgets) resolves the global
* object with `(0, eval)("this")` at module load, and without it the Cesium
* widget never initializes (verified in headless Chrome). It also covers
* Cesium's WASM decoders. `blob:` is required in the built app: Cesium's
* bundled workers bootstrap through `importScripts(blob:...)`, which a worker
* checks against script-src (the dev server loads them by URL instead).
* Widen any other directive only for a real violation.
*/
export const BROWSER_CSP = [
"default-src 'self'",
// The Bhote Koshi event's embedded media: the YouTube player API, the
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
"connect-src 'self' blob: data: https: wss: ws:",
"worker-src 'self' blob:",
"child-src 'self' blob:",
// The same media's players and posts, which load in frames.
"frame-src 'self' blob: https://www.youtube-nocookie.com https://www.youtube.com https://www.facebook.com https://platform.twitter.com",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
/** Response headers shared by the dev and preview servers. */
export const BROWSER_HEADERS = Object.freeze({
'X-Frame-Options': 'DENY',
'Content-Security-Policy': BROWSER_CSP,
// Responses are only ever used as the type the server declares.
'X-Content-Type-Options': 'nosniff',
});
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
@@ -9,10 +54,19 @@ export function createBrowserViteConfig({
cesiumToken,
host = 'localhost',
port = 4173,
allowedHosts = DEFAULT_ALLOWED_HOSTS,
command,
} = {}) {
return {
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
plugins: [
// First, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
...plugins,
embedFramingPlugin(),
panelBuildPlugin(),
],
...(publicDir === undefined ? {} : { publicDir }),
// A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from.
@@ -31,19 +85,22 @@ export function createBrowserViteConfig({
server: {
host: host || 'localhost',
port: parseInt(port, 10) || 4173,
allowedHosts:
host === '0.0.0.0' || host === '::'
? true
: ['localhost', '127.0.0.1', '.local'],
// A wildcard bind only chooses interfaces; it never disables Vite's
// Host-header check. LAN hostnames are added explicitly (see
// build/allowedHosts.js); IP addresses are always accepted.
allowedHosts: [...allowedHosts],
fs: {
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
},
// These headers protect the document containing Provider Settings.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
},
// These headers protect the document containing Provider Settings and
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
// Embed-mode documents are framable instead: embed-framing.js rewrites
// only the frame-ancestors directive and keeps the rest of the policy.
headers: BROWSER_HEADERS,
},
// The preview server serves the same documents, so it carries the same
// framing + CSP hardening (one constant, no drift).
preview: { headers: BROWSER_HEADERS },
define: {
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
+7 -3
View File
@@ -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'`
+565 -60
View File
@@ -1,10 +1,51 @@
# God's Eye View Current State
## Cyber HUD — September 23, 2026
## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers,
style, map, marks and an aircraft or satellite to follow, written in the
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
The panel runs the app's panel build (`npm run build:panel`, served at
`/panel/`) and loads it, with its data, through the MCP server, so it works
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
new views from the page that frames it; framing is off unless
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find
what to show. See [tools and the MCP server](TOOLS.md).
## Tools and local MCP server — October 1, 2026
`gods-eye-view/tools` defines queries that answer questions from the app's data,
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
reading from a running app's `/api` routes (default `http://localhost:4173`).
The development and preview servers also serve the tools over HTTP at `/mcp`,
accepting only direct local requests: a loopback host on the port reached,
an `Origin` (when sent) from that same host, no proxy forwarding headers, and
launcher sharing off.
Queries cover earthquakes, active fires, recent launches, aircraft (in an
area, by identifier, tracks, and type and route lookups), ships (in an area,
by identifier, and tracks), satellites (next
pass over a point, and those overhead now), public cameras (including a
camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share
stations, transit vehicles, road traffic flow, weather, weather map images
(radar, satellite, lightning), wind, the most recent satellite image of an
area, submarine cables, datacenters and dams, the Bhote Koshi flood event pack, regional briefs, tropical cyclones, fire
perimeters, terrain height, military installations and map features, plus a
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
opens the app over an area. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
action and shared visual state. An explicit first transition into Cyber selects
FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain
FLIR with Ironbow 0.42. An explicit switch back to another HUD layout restores
the visual preset used before Cyber; restored links and scenes remain
authoritative. The skin uses shared red/slate panel treatments in map and cockpit,
with compact 200px collapsed controls and wider expanded panels. Other HUD
layouts retain their existing presentation.
@@ -17,6 +58,21 @@ headers and decorative frames. The narrow-screen rail remains scrollable to reac
each panel. Radio retains the shared nested Context player and compact
header disclosure, without relocating playback controls on theme changes.
Voice help/error popups and Location/Visual Presets pins extend beyond their
Cyber frames without being clipped: the shapes decorate non-interactive
pseudo-elements. The lower-left telemetry card leaves room for attribution's
full logo row. New panels can use the [shared surface contract](panel-surfaces.md)
for theme tokens, rail input and a fixed header with a bounded scroll body.
The contract is intentionally future-facing and opt-in; its first production
adopter will land after this change. Existing owners still control disclosure,
persistence and placement, while small-screen rail popup clipping and other
integration limits remain documented in the contract.
On desktop, Cyber raises the left rail so it clears the lower coordinate card.
`layoutRightPanelRail` reads that rendered top as its measured `baseTop`, which
keeps the right rail aligned. Cockpit retains its separate rail placement for
its visor layout.
Sonar has one contact-highlighting method. Native Cesium points, billboards and
labels are treated in GPU draw commands without replacing their positions,
identities or pick commands. Model and canvas/DOM overlays retain their own
@@ -224,6 +280,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 +375,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 +498,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 +530,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 +562,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 +580,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 +805,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
@@ -807,10 +931,21 @@ errors.
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
non-numeric and out-of-range coordinates before the opt-in limiter and upstream
non-numeric and out-of-range coordinates before the per-IP limiter and upstream
request. Text search also requires a nonblank query. Keyless requests retain
their `configured: false` response.
The cost-bearing proxies are throttled per client IP without configuration:
`/api/realtime/token` and `/api/openai/hud-summary` share 30 requests per
minute per IP, `/api/google/nearby-places` and `/api/google/text-search` share
120 — the caps the Pinokio build already sets, so the packaged app is
unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` override
those; exactly `0` disables the limiter, while a value that cannot be read as a
number falls back to the default rather than to unlimited. Over-limit requests
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
The client key is the socket peer address only — a forwarded-for header is not
trusted, so a proxied deployment shares one bucket per upstream hop.
CCTV media waits at most 15 seconds for upstream response headers and returns
504 on timeout. Its timer stops when headers arrive, so live bodies can continue
streaming; a separate 30-second idle deadline then bounds the gap between
@@ -1121,8 +1256,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
@@ -1300,14 +1461,14 @@ This is the current runtime/source-of-truth snapshot for the project.
> dependency list rather than a fix for one layer, and the dependencies reach
> it by different routes:
> - **AIS vessels is its reachable producer.** `enable()`/`update()` both
> resolve as soon as the first `/api/ais-live` poll answers, so the manager's
> resolve as soon as the first `/api/vessels` poll answers, so the manager's
> lifecycle settles to `enabled` — but until the server-side socket delivers
> a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports
> `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status.
> 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
@@ -1704,7 +1865,7 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
> the share payload; Radio restores only its allowlisted filter and volume.
> - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not
> socket state — AISStream can complete the handshake and then deliver
> nothing forever. `/api/ais-live` reports `live | stale | reconnecting |
> nothing forever. `/api/vessels` reports `live | stale | reconnecting |
down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED
> at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and
@@ -1849,11 +2010,12 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> registered for lifecycle and restoration but is not duplicated in Data Layers.
> Inside Cockpit, the focused summary card is titled Contact in both visible
> copy and its accessible control labels.
> The top-center Cockpit vision cycle shows the inherited map preset name
> (for example, `NOIR`) followed by CRT, NVG, FLIR, and NOIR. That inherited entry
> leaves the selected map preset unchanged inside Cockpit. NONE is not offered
> in the cycle; CRT, NVG, FLIR, and NOIR temporarily override that preset, while returning to it or exiting
> Cockpit restores the captured map style and its exact shader intensities.
> The top-center Cockpit vision cycle is the fixed, duplicate-free sequence
> Normal, CRT, NVG, FLIR, Anime, Noir, and Snow. Entry selects the map preset
> already in use, so a FLIR map opens on the existing FLIR item while
> Normal remains available exactly once. NONE is not offered. Cockpit choices
> temporarily override the map preset, while Exit Cockpit and Reset both restore
> the captured map style and its exact shader intensities.
> Selecting a Cockpit vision treatment with configurable parameters opens
> Cockpit Display and reveals those parameters through the existing right-side
> accordion; an inherited parameterless Normal preset does not force it open.
@@ -2084,7 +2246,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
@@ -2749,14 +2911,14 @@ its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| 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 |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| 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) |
@@ -3365,10 +3527,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.
@@ -3480,16 +3643,100 @@ silently demoting every later lookup for the session.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **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).
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (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)
@@ -3498,11 +3745,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,
@@ -3586,13 +3855,14 @@ silently demoting every later lookup for the session.
first, activates the Contacts sub-view, and returns the settled 250 km window.
Its `aircraft` count is the exact civilian-plus-military total when both feeds
can answer, or `unknown` when either component is unavailable.
- Cockpit's top vision switch cycles five rendered looks: the inherited map
style, CRT, NVG, FLIR, and Noir. There is no empty `NONE` entry.
- Cockpit's top vision switch cycles all seven map looks: Normal, CRT, NVG,
FLIR, Anime, Noir, and Snow. Entry selects the matching map style without adding
a duplicate carousel item. There is no empty `NONE` entry.
### Live AIS Vessels (June 2026)
- Server-side `ws` websocket to `wss://stream.aisstream.io/v0/stream` maintained by Vite middleware; `AISSTREAM_API_KEY` never reaches the browser (AISStream has no browser CORS). The `ws` package is used rather than Node's built-in WebSocket specifically because only it can hard-abort a wedged socket (see the watchdog note in the delta block at the top).
- Browser polls same-origin `/api/ais-live` cache every 60s.
- Browser polls same-origin `/api/vessels` cache every 60s.
- The first enable in a session starts one 30-second client grace timer. Until
an accepted vessel position arrives, `live`/`open`/`connecting` transport reports
`LOADING`; the timer is not restarted by the 60-second poll. Expiry or a
@@ -3716,7 +3986,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.
@@ -3836,13 +4135,13 @@ are omitted rather than framing the wrong part of the globe.
- Proxy error payloads are sanitized (no internal error details returned to clients).
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
- `OPENAI_API_KEY` remains server-side only and remains the default voice credential. The optional OAuth mode reads the local ChatGPT/Codex access token server-side on loopback only; the browser receives only ephemeral Realtime client secrets from `/api/realtime/token`.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` 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).
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?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.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
@@ -3925,12 +4224,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 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 `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map 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 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.
@@ -4077,7 +4376,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`
@@ -4085,14 +4384,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
@@ -4164,11 +4617,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
@@ -4400,3 +4853,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.
+71
View File
@@ -0,0 +1,71 @@
# MCP setup
Use God's Eye View from Claude and Codex: tools, links, and the live globe
inside the conversation. Agents: follow these steps in order and check each
one before going on.
## 1. Run the app
```sh
npm install
npm run build:panel # the in-conversation globe; rerun after app changes
npm run dev # http://localhost:5173, keep it running
```
The MCP server reads from this app.
## 2. Add the server to a client
The server is `server/mcp/stdio.js` in this checkout. Use absolute paths
(clients do not load your shell profile), with each argument as its own entry.
| Client | How |
| --- | --- |
| Claude Code | `claude mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex CLI | `codex mcp add gods-eye-view -- node /abs/path/gods-eye-view/server/mcp/stdio.js --api-base http://localhost:5173` |
| Codex and ChatGPT desktop | Same `~/.codex/config.toml` as the CLI (block below), or Settings → MCP servers → Add server (STDIO). |
| Claude Desktop | Settings → Developer → Edit Config, then add the JSON below. |
```toml
[mcp_servers.gods-eye-view]
command = "node"
args = ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
```
```json
{
"mcpServers": {
"gods-eye-view": {
"command": "node",
"args": ["/abs/path/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]
}
}
}
```
**Windows with the checkout in WSL:** use `"command": "wsl.exe"` and put
`"-e"` and the absolute Linux path to `node` first in `args`, for example
`["-e", "/home/you/.local/share/mise/installs/node/24/bin/node", "/home/you/gods-eye-view/server/mcp/stdio.js", "--api-base", "http://localhost:5173"]`.
## 3. Restart and try it
Fully quit the client (on Windows, from the tray icon) and reopen it, then
start a new chat; clients keep the server they started with. Ask: *"Show San
Diego in God's Eye View with military flights on."*
- Claude Desktop (a chat, not the Code tab), Codex and ChatGPT desktop show
the live globe; the first load takes 10–20 seconds.
- Claude Code and the Codex CLI answer with data and a link.
## Troubleshooting
- **The server does not appear:** run the command from step 2 in a terminal;
it should print `God's Eye View MCP server reading http://localhost:5173`
and wait. A path or `args` mistake fails here.
- **The panel says it could not load:** `npm run build:panel` was not run, or
the dev server is down.
- **Logs:** the server writes each call, and why one failed, to stderr. Claude
Desktop keeps it in its logs folder as `mcp-server-gods-eye-view.log`; in
Codex, right-click the panel → DevTools for the panel's console.
See [tools and the MCP server](TOOLS.md) for what the tools do.
+302
View File
@@ -0,0 +1,302 @@
# Tools and the MCP server
Tools answer questions from God's Eye View data for language-model clients.
They are defined once and exposed through adapters: the Model Context Protocol
(MCP) and function calling, which voice uses.
To use them from Claude or Codex, see [MCP setup](MCP_SETUP.md).
## Layers
| Owner | Responsibility |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers |
| `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts |
| `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries |
| `src/tools/functions.js` | Function-calling adapter: tool records and results for function-calling clients |
| `src/tools/services.js` | The default services: the layers' source factories and request services, given a resolving fetch |
| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` |
| `server/standalone/voiceTools.js`, `src/standalone/toolCatalog.js` | Standalone voice composition: the session's tool list and the browser catalog |
Dependencies point downward only. `gods-eye-view/tools` and
`gods-eye-view/tools/mcp` are portable exports: they reach no application,
rendering, Node, Cesium or browser-global code, which
`npm run check:boundaries` enforces. In the application, only voice reaches
them: `withToolCatalog` in `src/voice/gevRealtime.js` imports the
function-calling adapter, and the standalone entry supplies the catalog.
## Definitions and composition
`defineTool({ name, kind, title, description, inputSchema, requires, run })`
validates and freezes a tool. `kind` is `query` (answers from data, read-only)
or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js`
checks completely; unsupported keywords are rejected at definition time.
`run(args, { services, signal })` resolves to `{ summary, data }`: one sentence
for people and a structured object for programs. A tool may also return `images`,
each `{ mimeType, data }` with base64 data; the MCP adapter sends them as image
content. MCP results carry the summary and the data as JSON text, plus the data
as `structuredContent`, for clients that read only one of them.
`composeCatalog({ tools, services, replace, interceptors })` builds a catalog:
- **Tools**: an application adds its own tools to `coreTools`. Reusing a name
fails unless the name is listed in `replace`.
- **Services**: each tool names the services it reads in `requires`. Tools
whose services are not supplied are left out.
- **Interceptors**: `(call, next) => next(call)` functions wrap every call,
outermost first. They can observe, reject or change a call.
- **Composite tools**: `run` receives `tools`, with `has(name)` and
`call(name, args)`, to call other tools through the same catalog, so
replaced tools and interceptors apply. Interceptors see such calls with
`parent`, the calling tool's name.
### Surfaces
`src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on
both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or
off for one surface. MCP leaves out place search, routing, plain
weather and wind, the regional brief, the HUD caption, radio, bike share and
transit, which assistants already cover or which add little without the
globe. `catalogForSurface(catalog, surface, overrides)` is the
view a surface exposes: it lists and calls only the tools it offers, while
composite tools still reach the whole catalog. `toolsForSurface` gives the
same selection as a list of definitions, such as for the voice session's tool
list. Voice leaves out tools that answer with images, link to the app, or
repeat what its app actions answer.
Expected failures throw `ToolError` with one of `invalid_arguments`,
`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are
reported to clients without details.
## Services
`gods-eye-view/tools/services` builds the default set with
`createToolServices({ fetchImpl, appUrl })`. Services are the portable source factories the layers already use, such as
`createUsgsEarthquakeSource`, `createFirmsSource` and `createLaunchSource`,
plus a `places` service with `resolve(name, { signal })`. Sources request
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
code runs wherever an application routes those paths.
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
application request services from `gods-eye-view/application/requests`.
`situation_brief` and `military_awareness` run each section whose services are supplied and mark the
others unavailable. `app` is `{ baseUrl }`, the address links open. The `bikeshare` service is `{ systems, getStations }`: the system registry and
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
is configured; `createRouteService` plans routes through `/api/route`.
## Views
`gods-eye-view/view` (`src/view/index.js`) describes what the app shows,
independent of how it is shown: a camera (lat, lon, altitude, heading,
pitch), data layers, visual style, map imagery, and an aircraft, military
aircraft or satellite to follow. `createView` builds and bounds one,
`viewToParams` and `viewFromParams` write and read it in the share-link
format the app restores, and `viewUrl` gives the address that opens it. The
style names are the ones share links use. Ships cannot be followed from a
link yet. An aircraft's `follow` may ask for its cockpit view
(`cockpit: true`); links cannot carry that and open the app following it.
A view can also carry `annotations`, the marks the app's `annotate_map`
action draws (pins, highlights, areas, arrows, routes and labels at a place
name or coordinates). Links carry them in the `an` parameter, and the app
draws them once the link has been restored.
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
tilted camera over an area looks at its center from behind; camera fields
given with an area override its framing. `resolveViewArguments` turns them
into a view.
### The God's Eye View panel
`show_in_gods_eye_view` names an MCP Apps view (`io.modelcontextprotocol/ui`):
`_meta.ui.resourceUri` is `ui://gods-eye-view/globe`, a `text/html;profile=mcp-app`
resource from `createGlobePanelResource({ runtime })` in
`src/tools/globePanel.js`, with the panel's script from
`src/app/globePanelRuntime.js`. Clients that display apps render it inside
the conversation. The panel completes the MCP Apps handshake
(`ui/initialize`, `ui/notifications/initialized`,
`ui/notifications/size-changed`), and for each
`ui/notifications/tool-result` carrying a view it loads the app in inline
embed mode the first time and posts later views to that same app, so the
globe changes without reloading. Its "Open in God's Eye View" button asks
the host to open the link (`ui/open-link`).
Hosts serve panels from their own sites and may refuse other addresses;
Codex, for one, refuses any address on the user's machine. So the panel
never requests the app's server itself. It loads everything from the app's
own paths through `panel_request`, a tool meant for the panel: it is
marked `_meta.ui.visibility: ["app"]`, and each call must carry the key the
MCP server puts in its panel page. Any client can read that page, so the key
keeps the tool from clients that only list it, and is not access control. The
MCP server requests the path from the app's server and returns the response,
compressed and in parts when large.
The same path works in every host. Only
map imagery, tiles and fonts load directly, from the providers the
resource's `csp` lists. `panel_request` refuses Provider Settings
(`/api/setup`), credential and model endpoints (`/api/realtime`,
`/api/openai`), `/mcp` and the development server's internal routes, in any
letter case, encoding or dot suffix.
The panel loads the app's panel build, which `npm run build:panel` writes to
`dist/panel` and the servers serve at `/panel/`: one app script, one
stylesheet, and Cesium's script, which carries its workers and starts them
from memory (`CESIUM_WORKERS`). Files those workers load themselves are
embedded in a prelude the panel runs ahead of them, since a worker's
requests reach the panel page's own site. Rebuild it after changing
the app.
Hosts differ in ways the panel works around, all inside the panel only:
images and stylesheet files arrive as `data:` URLs, since some hosts refuse
`blob:` images; code that needs an https address for the app gets
`GEV_APP_BASE_URL`, since some hosts serve the page from their own scheme;
the globe keeps drawing from a timer when the host reports the panel hidden
and stops animation frames; and 2D canvases are kept in memory
(`willReadFrequently`), since a host that treats the panel as off screen may
drop their GPU contents and show the overlays as black over the globe.
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
an app-only tool with `ui: { visibility: ['app'] }`;
`createMcpServer({ resources })` serves `resources/list` and
`resources/read`.
### Embed mode
`?embed=1` shows only the globe: clean view, with the HUD, panels, welcome
and setup prompts hidden; provider attribution stays. A page that frames it
changes the view by posting `{ type: 'gev:view', id, view }` to the frame.
The app applies it through its own actions (style, map, exactly the view's
layers, annotations, then the followed entity, retried until its layer has
it, or the camera when nothing is followed or the entity is not there yet,
since a camera flight would end the follow; then cockpit view when asked) and
answers `{ type: 'gev:view-applied', id, ok, steps }` to the origin that
sent the view. It posts `{ type: 'gev:ready' }` once it can take views, and
only its parent page can send them. See `src/app/embed.js`.
No page may frame the app by default: every document keeps
`X-Frame-Options: DENY` and `frame-ancestors 'none'`. Setting
`GEV_EMBED_FRAME_ANCESTORS` lets the pages it names (CSP frame-ancestors
sources, or `*` for any page) frame embed-mode documents only. The MCP Apps
panel loads the app into its own page and needs no framing.
Answers that have something to show include `data.view`: the view that
shows them, with the matching layers on, an area framed from above, and a
single aircraft or satellite followed, plus `url` to open it (null when the
app's address is not configured). `suggestView` in `src/tools/views.js`
builds one.
## The `area` argument
Location-scoped tools take `area` as exactly one of a `place` name, a `bbox`
(`[west, south, east, north]`, crossing the antimeridian when west exceeds
east), or `lat`, `lon` and `radius_km`. Lists default to 25 rows, at most 200,
and report `total`, `returned` and `truncated`.
## MCP
`createMcpServer({ catalog, name, version, instructions, descriptions, decorate })`
implements `initialize`, `ping`, `tools/list` and `tools/call` for protocol
revisions 2025-11-25, 2025-06-18 and 2025-03-26. `descriptions` overrides a
tool's title or description for this surface; `decorate(definition, tool)`
merges extra fields into each listed definition. `createMcpHttpHandler(server)`
returns a `Request`-to-`Response` handler for stateless Streamable HTTP: one
JSON-RPC message per POST, answered with JSON. The host owns routing and any
access control in front of it.
## Voice
Voice offers the catalog's queries next to its app actions.
`toFunctionTools(tools, { exclude })` turns tools into
`{ type: 'function', name, description, parameters }` records, and
`toFunctionOutput(name, result)` turns a result into
`{ ok, tool, summary, data }`, counting images in `images_omitted` instead of
sending them.
The voice session token endpoint takes its tool list as `realtime.tools`.
`realtimeSessionTools(additional)` appends function tools to the app actions,
skipping names an action already uses, so `next_satellite_pass` stays the
action. The standalone server supplies the core queries that
`src/tools/surfaces.js` offers on voice, which leaves out tools that answer
with images, link to the app, or repeat what voice's app actions answer
(aircraft, ships, earthquakes, fires, datacenters, dams and satellites
overhead, which `analyst_query` covers).
In the browser, `initGevVoiceCommands({ toolCatalog })` takes a function that
resolves a catalog. App action names go to the action runner; other names the
catalog has go to `catalog.call` with the call's abort signal. The standalone
entry composes the catalog with `createToolServices` over the page's fetch and
loads it the first time voice calls a query.
## Running locally
Start the app (`npm run dev` or `npm run preview`), then register the stdio
server with an MCP client, for example Claude Code:
```bash
claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp
```
`npm run mcp -- --api-base http://localhost:4173` selects another server.
The development and preview servers also serve the same tools over HTTP at
`/mcp`, for clients that connect by URL:
```bash
claude mcp add --transport http gods-eye-view http://localhost:4173/mcp
```
The route accepts only requests from this machine that name a loopback host
on the port they reached and, when a browser sends an `Origin`, come from
that same host. It refuses requests a proxy forwarded and refuses all
requests while launcher sharing is on. This is local transport safety, not
authentication. The
local server's tools request the app's `/api` routes and the public feeds
the sources already use. The panel's `panel_request` also requests the app's
own files and data routes for the panel: it requires the key in the panel's
page, and refuses Provider Settings, credential and model endpoints, `/mcp`
and the development server's internal routes. See SECURITY.md.
## Tools
| Tool | Reads | Returns |
| --------------------- | ------------- | ------------------------------------------------------------- |
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
| `places_nearby` | `placeSearch` | Notable places around a place or point (Google Places) |
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available |
| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route |
| `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road |
| `get_weather` | `weather` | Current conditions at a place or point |
| `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area |
| `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location |
| `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) |
| `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) |
| `find_infrastructure` | `infrastructure` | OpenStreetMap datacenters or dams in an area, nearest first (ODbL) |
| `get_bhote_koshi_flood` | `events` | The 2026 Bhote Koshi flood: evidence trail in story order, flood path and imagery dates (CC BY-NC 4.0) |
| `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines |
| `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area |
| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first |
| `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points |
| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side |
| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) |
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
+2 -2
View File
@@ -1,6 +1,6 @@
# OpenSky Auth Setup
God's Eye View uses explicit auth modes for `/api/opensky`:
God's Eye View uses explicit auth modes for `/api/flights`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
@@ -54,7 +54,7 @@ OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
Inspect proxy headers:
```bash
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
curl -si http://localhost:4173/api/flights | grep -iE 'HTTP/|X-OpenSky-Auth'
```
Useful reasons:
+150
View File
@@ -0,0 +1,150 @@
# Panel surface contract
`src/ui/styles/panel-surfaces.css` supplies an opt-in visual surface for new
panels. It uses the application's default color, font, border and radius tokens;
Cyber supplies its existing red/slate palette, monospace typography and shaped
frame. This is CSS shared by the existing panel system, not a panel registry or
a controller. A panel ID does not need a new theme selector.
## Markup
A rail panel uses an outer owner with its existing stable `id`, matching
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
surface. Keep the header and any outward popup outside the scrolling body:
```html
<section
id="example-panel"
data-panel-id="example-panel"
class="panel-collapsible"
>
<div data-panel-surface>
<header data-panel-header>
<h2 class="panel-title panel-surface-title">Example</h2>
<button
class="panel-collapse-btn panel-surface-control"
data-collapse-target="example-panel"
aria-expanded="true"
aria-label="Collapse Example"
>
−
</button>
</header>
<div data-panel-body data-rail-scroller>
<label>Filter <input class="panel-surface-control" type="text" /></label>
<button class="panel-surface-control" type="button">Apply</button>
<p class="panel-surface-error" role="status" hidden></p>
</div>
<aside data-panel-popup hidden aria-label="Example help">
<!-- The panel owns this popup's placement and interactions. -->
</aside>
</div>
</section>
```
Use native controls and accessible names. `.panel-surface-control` is an
optional skin for buttons, text fields, selects and textareas; it provides
hover, focus, disabled and invalid-state styling. Native `disabled` owns actual
disablement; `aria-disabled` alone only describes a state and must be enforced
by the controller. `.panel-surface-error` supplies error color, not announcement
or validation behavior. Keep checkbox/radio semantics and labels intact.
`data-panel-header` stays outside `data-panel-body`, so it remains reachable
when content scrolls. Give the body a bounded height through its containing
surface. The rail's allocation supplies that bound; a dock or dialog owner must
supply its own `max-height` or height. Avoid a flex body whose contents shrink to
fit when those contents should instead scroll.
## Placement and ownership
| Mount | Shared contract | Owner responsibilities |
| ------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Direct child of `#left-panel-stack` | An owner with a direct `[data-panel-surface]` child receives relative positioning and allocated height; inner surface/body skin | Add the wrapper shown above, use the existing layout/disclosure owners, and retain left-rail focus/auto-collapse rules. |
| Direct child of `#right-context-rail` | The same hooks with right-rail allocation and intrinsic measurement | Keep `data-rail-scroller` on the body so measurement preserves its scroll position. Respect Cyber's one-expanded-panel policy. |
| Surface inside `#command-dock` | Surface, controls, popup decoration and inherited dock concealment | Supply geometry, stacking, disclosure, pin behavior if wanted, safe viewport bounds and dismissal. This CSS does not extend the existing Location/Visual Presets tray stack manager to arbitrary panels. |
| `dialog[data-panel-surface]` | The same surface/body skin; a closed dialog remains hidden | Set width/max-height, use `showModal()` or `show()` and `close()`, and own labels, initial/return focus and dismissal. Do not put `panel-collapsible` on a dialog. |
For rails, `--panel-expanded-width` and `--panel-collapsed-width` customize
the desired widths within the rail; otherwise Cyber uses its existing left,
right and collapsed panel widths. `--left-panel-allocated-height` and
`--right-panel-allocated-height` belong
to the existing layout owner, not to individual panels. The surface must be
the wrapper's first non-glow child because left-rail intrinsic measurement
inspects that child. Do not put an absolutely positioned popup before the
surface or inside its body. Position a popup beside or above its surface using
component CSS; the shared rules deliberately do not choose a direction that
could run beyond the viewport.
Rail measurement still considers visible child extents. A popup extending
below the surface can therefore increase its measured height. Prefer a popup
beside or above the surface, or integrate an external overlay host with the
feature's layout owner; this CSS does not change measurement algorithms.
At viewport widths of 720px or less, both rails are intentional scroll
containers. Their overflow can clip outward popups even when the panel's own
surface is unclipped. Use a popup contained within the rail's visible bounds,
or an explicitly owned overlay host outside the rail; the surface contract
does not provide automatic popup escape from these small-screen scroll regions.
For dock disclosure, use a semantic button with `.panel-surface-control` and
the feature's own accessible name, `aria-expanded` state and event binding.
Do not copy the rail example's `.panel-collapse-btn` class into the dock:
existing dock rules intentionally hide that class. The generic surface skin
does not replace the dock's disclosure policy.
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
at initialization and routes state through `setPanelCollapsed`. For additions
present at startup, keep those hooks and the existing chrome lifecycle. A
dynamically mounted panel must bind and dispose its own disclosure using
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
the application owning state changes. Do not rerun the whole application chrome
initializer just to add one panel. `layoutLeftPanelRail` and
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
host owns layout scheduling, observations and collapse callbacks. Dynamic
content that changes size may require an owner-scheduled layout pass or an
owned `ResizeObserver`; adding CSS attributes does not register new observers.
Persisted/share-link state, pinning, provider loading, keyboard shortcuts and
Cockpit-specific mounting still require deliberate integration with their
current owners. This contract does not add entries to named persistence, dock,
Cockpit or share-state policies. Dispose listeners, observers and timers,
dismiss open popups/dialogs and restore focus before removing a dynamic panel.
## Decoration, popups and visibility
The shared surface reserves `::before` for its non-interactive background. The
Cyber frame clips this pseudo-element only; the surface stays unclipped with
visible overflow. This follows the existing Display and Global Context pattern.
Putting the shape on an interactive ancestor clips all descendant popups even
if their `z-index` is high. A scrolling body also clips descendants by design,
so outward popups must be siblings of the body, or use an explicitly owned
overlay host outside every clipping ancestor.
`data-panel-popup` gives a positioned popup the same decorative and control
tokens. It is not a popover API, focus trap, collision solver or top-layer
manager. The owner must keep it inside the viewport, above relevant siblings,
and reachable by pointer and keyboard. A native modal dialog participates in
the browser top layer; a surface in a rail does not. Transform, containment,
mask, clipping or stacking rules on an additional ancestor can still obstruct
either an ordinary panel or its popup. Arbitrary future markup is not covered.
`hidden`, a closed native dialog and collapsed bodies/popups remain hidden.
Clean UI and recording conceal conforming surfaces, including outward popups;
owners must close modal dialogs when entering these modes so an invisible
modal cannot leave the application inert. Existing Cockpit rail and dock
exclusions remain in force. New left-rail/Cockpit functionality needs an
explicit product integration; the surface attribute grants no mode exception.
Never use theme styling to alter stored preferences or force hidden content
visible.
## Check an addition
Use the real application with the default and Cyber themes. Check open,
collapsed, hidden, disabled/error, theme round trips, clean UI/recording and
the intended Cockpit behavior. Exercise keyboard focus, Escape, pointer input,
the last scrolling item, resize without reload and any pin/unpin controls.
For an outward popup, test visible pixels and pointer hit-testing outside the
surface; a positive bounding box alone does not prove it escaped clipping.
Check each mount the addition actually supports. The CSS contract cannot
establish provider correctness, disclosure lifecycle or responsive placement
without those feature-specific checks.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gods-eye-view",
"version": "0.1.1",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gods-eye-view",
"version": "0.1.1",
"version": "0.2.1",
"license": "MIT",
"dependencies": {
"@jtarrio/signals": "^0.10.0",
+12 -3
View File
@@ -1,7 +1,7 @@
{
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
"version": "0.2.1",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
@@ -35,16 +35,20 @@
"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",
"build": "vite build",
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs"
"qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js"
},
"dependencies": {
"@jtarrio/signals": "^0.10.0",
@@ -248,6 +252,11 @@
"./layers/transit/standalone": "./src/data/transit.js",
"./layers/transit/source": "./src/layers/transit/source.js",
"./sources/transit-service": "./src/sources/transitService.js",
"./sources/map-features": "./src/sources/overpassFeatures.js"
"./sources/map-features": "./src/sources/overpassFeatures.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js"
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ For commercial use, obtain separate permission or exclude both this pack and the
derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source
and build. Deleting this directory alone does not remove the compiled river
data. Excluding the Nepal scene also requires removing its registrations and
imports before building.
imports before building, including the `get_bhote_koshi_flood` tool in
`src/tools/queries/events.js`.
Sources:
+759
View File
@@ -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(),
);
+84
View File
@@ -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()
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Build the MCP Apps panel: the app under dist/panel, served at /panel/.
* See build/panel.js.
*/
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import standaloneConfig from '../server/standalone/vite.config.js';
import {
PANEL_BASE,
PANEL_OUT_DIR,
PANEL_WORKER_FILES,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
workerFilesPrelude,
} from '../build/panel.js';
const root = fileURLToPath(new URL('../', import.meta.url));
const outDir = join(root, PANEL_OUT_DIR);
const config = standaloneConfig({ command: 'build', mode: 'production' });
await build({
configFile: false,
root,
...panelBuildConfig(config),
});
// Cesium's plugin copies its files under the base path inside outDir.
const nested = join(outDir, PANEL_BASE, 'cesium');
await rm(join(outDir, 'cesium'), { recursive: true, force: true });
await rename(nested, join(outDir, 'cesium'));
await rm(join(outDir, PANEL_BASE.split('/')[1]), { recursive: true });
// Cesium's workers load these files themselves; embed them in a prelude the
// panel runs ahead of Cesium's workers script.
const files = {};
for (const name of PANEL_WORKER_FILES)
files[name] = await readFile(join(outDir, 'cesium', name), 'utf8');
await writeFile(
join(outDir, PANEL_WORKER_PRELUDE_PATH),
workerFilesPrelude(files),
);
console.log(`Panel build written to ${PANEL_OUT_DIR}`);
+4
View File
@@ -37,6 +37,10 @@ const portableExport = (key) =>
) ||
[
'./director',
'./tools',
'./tools/mcp',
'./tools/services',
'./view',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
+131
View File
@@ -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;
}
}
}
+57 -1
View File
@@ -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",
@@ -44,6 +55,7 @@
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"src/tooling/allowedHosts.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
@@ -65,6 +77,7 @@
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/tooling/placeProviders.test.mjs",
"server/providers/common/same-site.js",
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/space/launch-library.js",
@@ -576,6 +589,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",
@@ -839,5 +854,46 @@
"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",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
"src/tools/queries/aviation.test.mjs",
"src/tools/queries/space.test.mjs",
"src/tools/queries/media.test.mjs",
"src/tools/queries/places.test.mjs",
"src/layers/transit/source.test.mjs",
"src/tools/queries/mobility.test.mjs",
"src/tools/queries/environment.test.mjs",
"src/tools/queries/maritime.test.mjs",
"src/tools/queries/share.test.mjs",
"src/tools/localRoute.test.mjs",
"src/tools/queries/atmosphere.test.mjs",
"src/tools/queries/imagery.test.mjs",
"src/tools/queries/cables.test.mjs",
"src/tools/queries/surveillance.test.mjs",
"src/tools/queries/infrastructure.test.mjs",
"src/tools/queries/events.test.mjs",
"src/tools/functions.test.mjs",
"src/tools/surfaces.test.mjs",
"src/tools/places.test.mjs",
"src/view/index.test.mjs",
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs"
]
+66
View File
@@ -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"
]
+11
View File
@@ -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'))
+22
View File
@@ -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.');
}
+341 -23
View File
@@ -9,7 +9,9 @@
"src/data/infrastructure.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js"
"src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
],
"external": ["cesium"]
},
@@ -26,7 +28,13 @@
"vite-build": {
"runtime": "node",
"exports": ["./build/vite", "./build/html"],
"modules": ["build/vite.js", "build/application-html.js"],
"modules": [
"build/vite.js",
"build/application-html.js",
"build/allowedHosts.js",
"build/embed-framing.js",
"build/panel.js"
],
"external": ["vite-plugin-cesium"]
},
"live-providers": {
@@ -67,6 +75,8 @@
"server/providers/common/http.js",
"scripts/google-server-key.mjs",
"src/data/placeProviderPayloads.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"src/data/routeSteps.js",
"src/sources/httpBody.js",
"src/sources/rateLimit.js"
@@ -249,7 +259,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": []
},
@@ -276,6 +291,8 @@
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/voice/voiceCost.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"src/voice/actionSchemas.js",
"server/providers/openai/toolDescriptions.js",
"src/sources/rateLimit.js"
@@ -290,6 +307,7 @@
"server/providers/common/source-root.js",
"server/standalone/key-setup.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
"server/standalone/key-setup-hardening.mjs"
],
"external": []
@@ -374,7 +392,8 @@
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/mapStackChips.js",
"src/keySetupCore.mjs"
"src/keySetupCore.mjs",
"src/localRequestGate.mjs"
],
"external": []
},
@@ -393,6 +412,7 @@
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
"src/data/feedState.js",
"src/data/layerSnapshot.js"
],
@@ -508,9 +528,12 @@
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/militaryAwarenessEngine.js",
"src/data/scenePick.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
"src/loadingFeedback.js",
"src/locationStatus.js",
"src/mapStackChips.js",
@@ -615,7 +638,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"]
},
@@ -719,7 +743,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"]
},
@@ -747,6 +772,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",
@@ -788,6 +814,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",
@@ -824,6 +851,8 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
@@ -843,6 +872,8 @@
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -911,6 +942,7 @@
"src/data/issPass.js",
"src/data/satelliteClass.js",
"src/data/satellitePass.js",
"src/sources/tle.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
@@ -960,6 +992,7 @@
"installations-layer": {
"exports": ["./layers/installations"],
"modules": [
"src/data/cameraSensitivity.js",
"src/data/installationFeedback.js",
"src/data/militaryInstallationData.js",
"src/layers/installations/controls.js",
@@ -973,9 +1006,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"],
@@ -1019,18 +1066,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"]
},
@@ -1042,6 +1100,7 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -1108,15 +1167,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",
@@ -1204,7 +1271,9 @@
"src/maps/imagery.js",
"src/maps/registry.js",
"src/maps/terrain.js",
"src/maps/terrainRetry.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
"src/maps/imageryHost.js"
],
"external": ["cesium"]
@@ -1319,6 +1388,7 @@
"src/app/atmosphereCompat.js",
"src/bloom.js",
"src/camera.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js",
@@ -1387,6 +1457,8 @@
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
@@ -1406,6 +1478,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",
@@ -1444,6 +1520,7 @@
"src/hudSummaryResponse.js",
"src/keySetup.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
"src/keylessGeocoder.js",
"src/layers/aircraft/classification.js",
"src/layers/alpr/index.js",
@@ -1474,6 +1551,7 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -1608,6 +1686,7 @@
"src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/orbits.js",
"src/sources/tle.js",
"src/layers/satellites/records.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/rendering.js",
@@ -1631,21 +1710,27 @@
"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",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -1671,6 +1756,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",
@@ -1958,7 +2044,26 @@
"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",
"src/sources/infrastructureData.js",
"src/tools/functions.js",
"src/voice/actionSchemas.js",
"src/view/index.js",
"src/app/embed.js"
],
"external": [
"hls.js",
@@ -2052,6 +2157,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",
@@ -2063,6 +2169,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",
@@ -2115,6 +2225,7 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
@@ -2249,6 +2360,7 @@
"src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/orbits.js",
"src/sources/tle.js",
"src/layers/satellites/records.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/rendering.js",
@@ -2271,21 +2383,27 @@
"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",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -2322,6 +2440,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",
@@ -2418,7 +2537,22 @@
"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",
"src/sources/infrastructureData.js"
],
"external": [
"hls.js",
@@ -2445,6 +2579,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",
@@ -2452,6 +2590,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",
@@ -2474,7 +2613,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",
@@ -2487,7 +2628,7 @@
},
"layer-lifecycle": {
"exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js"],
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"],
"external": []
},
"ui-navigation": {
@@ -2495,7 +2636,8 @@
"modules": [
"src/navigationPolicy.js",
"src/ui/navigationController.js",
"src/app/stateChannel.js"
"src/app/stateChannel.js",
"src/data/cameraArrival.js"
],
"external": ["cesium"]
},
@@ -2504,6 +2646,8 @@
"modules": [
"src/data/installationFeedback.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js",
"src/navigationPolicy.js",
"src/ui/shareRestoration.js",
@@ -2608,6 +2752,7 @@
"exports": ["./layers/vessels/ingestion"],
"modules": [
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/recordPolicy.js"
],
"external": []
@@ -2656,9 +2801,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"],
@@ -2687,9 +2837,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"],
@@ -2712,8 +2874,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"]
},
@@ -2850,7 +3020,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"]
},
@@ -2919,7 +3090,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": []
},
@@ -2943,5 +3115,151 @@
"src/layers/perimeters/source.js"
],
"external": []
},
"tools": {
"exports": ["./tools"],
"modules": [
"src/tools/index.js",
"src/tools/catalog.js",
"src/tools/schema.js",
"src/tools/area.js",
"src/tools/results.js",
"src/tools/places.js",
"src/tools/queries/hazards.js",
"src/tools/queries/space.js",
"src/tools/queries/aviation.js",
"src/tools/queries/media.js",
"src/tools/queries/places.js",
"src/tools/queries/mobility.js",
"src/tools/queries/environment.js",
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
"src/sources/tle.js",
"src/data/satellitePass.js",
"src/tools/queries/atmosphere.js",
"src/layers/wind/inspection.js",
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
"src/tools/queries/imagery.js",
"src/tools/queries/cables.js",
"src/tools/queries/surveillance.js",
"src/tools/queries/infrastructure.js",
"src/tools/queries/events.js",
"src/data/bhoteKoshiFloodPath.js",
"src/tools/functions.js",
"src/tools/surfaces.js",
"src/layers/transit/policy.js",
"src/data/transitFeeds.js",
"src/layers/transit/movement.js",
"src/data/contactPlayback.js",
"src/data/layerSnapshot.js",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/view/index.js",
"src/tools/views.js",
"src/tools/globePanel.js",
"src/tools/queries/panelRequest.js"
],
"external": ["satellite.js"]
},
"tools-mcp": {
"exports": ["./tools/mcp"],
"modules": [
"src/tools/mcp/index.js",
"src/tools/mcp/protocol.js",
"src/tools/mcp/http.js",
"src/tools/catalog.js",
"src/tools/schema.js"
],
"external": []
},
"tools-panel": {
"exports": ["./tools/panel"],
"modules": [
"src/tools/panel.js",
"src/tools/globePanel.js",
"src/app/globePanelRuntime.js"
],
"external": []
},
"tools-services": {
"exports": ["./tools/services"],
"modules": [
"src/tools/services.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/source.js",
"src/layers/cctv/source.js",
"src/layers/cctv/sourcePolicy.js",
"src/layers/cyclones/source.js",
"src/sources/httpBody.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js",
"src/layers/firms/source.js",
"src/layers/installations/source.js",
"src/sources/requestDeadline.js",
"src/data/militaryNames.js",
"src/sources/militaryTileGeometry.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/data/local_data/osm_military_names/names.json",
"src/sources/openFreeMap.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/data/tomtomTiles.js",
"src/sources/capability.js",
"src/data/militaryInstallationData.js",
"src/layers/launches/source.js",
"src/layers/perimeters/source.js",
"src/layers/radio/source.js",
"src/layers/radio/policy.js",
"src/layers/satellites/source.js",
"src/layers/traffic/source.js",
"src/layers/traffic/roadModes.js",
"src/data/flowMatch.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/flowDecode.js",
"src/sources/overpassRoads.js",
"src/layers/transit/source.js",
"src/sources/transitHistory.js",
"src/services/requests.js",
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/overpassFeaturesRecords.js",
"src/sources/featureGeometry.js",
"src/sources/live/standalone.js",
"src/sources/live/contract.js",
"src/sources/live/aircraft.js",
"src/sources/live/vessels.js",
"src/tools/places.js",
"src/tools/catalog.js",
"src/tools/schema.js",
"src/layers/weather/source.js",
"src/layers/wind/source.js",
"src/sources/windBody.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js",
"src/layers/submarineCables/bundledSource.js",
"src/layers/alpr/source.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
"view": {
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
"external": []
}
}
+655
View File
@@ -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) };
}
+588
View File
@@ -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);
+4 -4
View File
@@ -253,19 +253,19 @@ async function main() {
const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population.
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null,
}));
}
if (url.pathname === '/api/adsblol/mil') {
if (url.pathname === '/api/military') {
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.pathname === '/api/opensky') {
if (url.pathname === '/api/flights') {
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
}
if (url.pathname === '/api/opensky-track') {
if (url.pathname === '/api/flights/track') {
return void request.respond(stubJson({ path: [] }));
}
if (url.pathname === '/api/openai/hud-summary') {
+103 -39
View File
@@ -42,7 +42,7 @@ page.on('pageerror', (error) => consoleErrors.push(error.message));
page.on('response', (response) => {
const url = new URL(response.url());
const expectedOptionalTrackMiss = response.status() === 404
&& url.pathname === '/api/opensky-track';
&& url.pathname === '/api/flights/track';
if (
url.origin === new URL(appUrl).origin
&& response.status() >= 400
@@ -108,7 +108,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -124,7 +124,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -132,7 +132,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights/track') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -832,7 +832,7 @@ try {
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
@@ -1572,6 +1572,10 @@ try {
const cockpit = manager.cockpitView;
const current = document.getElementById('cockpit-vision-current');
const label = document.getElementById('cockpit-vision-current-label');
const settle = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
const intensities = () => Object.fromEntries(
Object.entries(manager.stages || {}).map(([name, stage]) => [name, stage.uniforms?.intensity]),
);
const read = () => ({
mode: current?.dataset.cockpitVision,
label: label?.textContent?.trim(),
@@ -1582,62 +1586,106 @@ try {
parametersCollapsed: document.getElementById('param-slider-panel')?.classList.contains('collapsed'),
signalCollapsed: cockpit.signalCollapsed,
focusRetained: document.activeElement === current,
activeStyle: manager.activeStyle,
intensities: intensities(),
});
const reenter = async () => {
cockpit.exit({ restoreTracking: true });
await settle();
for (let attempt = 0; attempt < 40; attempt += 1) {
if (cockpit.enter() === true) return true;
await settle(200);
}
return false;
};
manager._setCockpitDisclosure('display', false);
manager._setCockpitDisclosure('radio', false);
cockpit.setVisionMode('optical');
current.focus({ preventScroll: true });
const states = [read()];
for (let index = 0; index < 5; index += 1) {
for (let index = 0; index < 7; index += 1) {
current.click();
states.push(read());
}
const originalStyle = manager.activeStyle;
// A Noir map preset: Cockpit enters on Noir, Normal clears every shader
// without changing the map preset, and Exit Cockpit restores Noir.
cockpit.exit({ restoreTracking: true });
manager.setStyle('noir');
await new Promise((resolve) => setTimeout(resolve, 900));
await settle();
const enteredOnNoir = await reenter();
const entryNoir = read();
cockpit.setVisionMode('optical');
const inheritedNoir = read();
cockpit.setVisionMode('noir');
const explicitNoir = read();
cockpit.setVisionMode('optical');
const restoredNoir = {
...read(),
activeStyle: manager.activeStyle,
intensity: manager.stages.noir?.uniforms?.intensity,
};
const normalInsideNoir = read();
cockpit.setVisionMode('crt');
const cockpitOnlyCrt = read();
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
await settle();
const restoredNoir = { ...read(), cockpitActive: cockpit.active };
manager.setStyle(originalStyle);
await new Promise((resolve) => setTimeout(resolve, 900));
await settle();
const reentered = await reenter();
cockpit.setVisionMode('optical');
return { states, inheritedNoir, explicitNoir, restoredNoir };
return {
states,
enteredOnNoir,
entryNoir,
normalInsideNoir,
cockpitOnlyCrt,
exitedNoir,
restoredNoir,
reentered,
};
});
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
check(
'Cockpit vision control exposes inherited, CRT, NVG, FLIR, and NOIR before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode))
=== JSON.stringify(['optical', 'crt', 'nvg', 'thermal', 'noir', 'optical'])
&& visionCycle.states[4]?.label === 'NOIR'
&& visionCycle.states[4]?.aria === 'Current cockpit vision style: Noir. Activate for next style.',
JSON.stringify(visionCycle),
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
);
check(
'user vision changes open Display for every temporary treatment and retain selector focus',
visionCycle.states[1]?.mode === 'crt'
&& visionCycle.states.slice(1, 5).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 5).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 5).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 5).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 5).every((state) => state.signalCollapsed)
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
&& visionCycle.states.every((state) => state.focusRetained),
JSON.stringify(visionCycle.states),
);
check(
'inherited Noir and explicit Noir keep distinct ownership and restore the Noir map preset',
visionCycle.inheritedNoir?.mode === 'optical'
&& visionCycle.inheritedNoir?.label === 'NOIR'
&& visionCycle.explicitNoir?.mode === 'noir'
&& visionCycle.explicitNoir?.label === 'NOIR'
&& visionCycle.restoredNoir?.mode === 'optical'
'each Cockpit vision mode drives only its own shader and Normal clears them all',
visionCycle.states.slice(1, 7).every((state) => {
const target = {
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow',
}[state.mode];
return Object.entries(state.intensities).every(([name, value]) =>
name === target ? value > 0 : value === 0);
})
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0),
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
);
check(
'Cockpit enters on the Noir map preset, Normal clears it, and Exit Cockpit restores it',
visionCycle.enteredOnNoir
&& visionCycle.entryNoir?.mode === 'noir'
&& visionCycle.entryNoir?.label === 'NOIR'
&& visionCycle.normalInsideNoir?.mode === 'optical'
&& visionCycle.normalInsideNoir?.label === 'NORMAL'
&& visionCycle.normalInsideNoir?.activeStyle === 'noir'
&& Object.values(visionCycle.normalInsideNoir?.intensities || {}).every((value) => value === 0)
&& visionCycle.cockpitOnlyCrt?.mode === 'crt'
&& visionCycle.cockpitOnlyCrt?.activeStyle === 'noir'
&& visionCycle.exitedNoir
&& !visionCycle.restoredNoir?.cockpitActive
&& visionCycle.restoredNoir?.activeStyle === 'noir'
&& visionCycle.restoredNoir?.intensity === 1,
&& visionCycle.restoredNoir?.intensities?.noir === 1
&& visionCycle.restoredNoir?.intensities?.retro === 0
&& visionCycle.reentered,
JSON.stringify(visionCycle),
);
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('noir'));
@@ -2266,10 +2314,16 @@ try {
// Both the UI and compatibility facade delegate to this navigation owner.
const manager = window.__godsEyeView.styleManager._locationNavigation;
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
const styleManager = window.__godsEyeView.styleManager;
const styleBefore = styleManager.activeStyle;
// A Cockpit-only choice that Reset must undo.
styleManager.cockpitView.setVisionMode('snow');
window.__qaCockpitReset = {
calls: 0,
original: manager.resetToGlobeView,
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
styleBefore,
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
};
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
window.__qaCockpitReset.calls += 1;
@@ -2305,16 +2359,26 @@ try {
resetHidden: document.getElementById('cockpit-reset-globe')?.hidden,
height,
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
snowDuringCockpit: qa.snowDuringCockpit,
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
name === gev.styleManager.activeStyle
? stage.uniforms?.intensity > 0
: stage.uniforms?.intensity === 0),
};
});
check(
'keyboard Cockpit Reset uses one canonical route and preserves Contact selection',
'keyboard Cockpit Reset uses one canonical route, preserves Contact selection and restores the map style',
resetState.calls === 1
&& !resetState.cockpitActive
&& !resetState.trackedEntity
&& resetState.resetHidden
&& Math.abs(resetState.height - 18_000_000) < 150_000
&& resetState.subjectPreserved,
&& resetState.subjectPreserved
&& resetState.snowDuringCockpit > 0
&& resetState.styleRestored
&& resetState.visionRestored,
JSON.stringify(resetState),
);
check('runtime console remains clean', consoleErrors.length === 0 && localHttpErrors.length === 0,
+590
View File
@@ -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/flights 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/flights') {
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/military')
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);
+5 -5
View File
@@ -12,7 +12,7 @@
* honoring the politeness bounds.
*
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
* - /api/opensky → 12 straight-flying planes, category 0
* - /api/flights → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the
@@ -260,14 +260,14 @@ async function main() {
});
}
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
const states = S.planes.map((f) => {
const s = S.stateAt(f, tRel);
return [
+4 -2
View File
@@ -10,7 +10,7 @@
* upstream failures so the run is deterministic and never depends on live
* AISStream / CelesTrak availability:
*
* (i) AIS invalid key — /api/ais-live is answered with
* (i) AIS invalid key — /api/vessels is answered with
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
*
@@ -193,10 +193,12 @@ async function main() {
page.on('request', (req) => {
const url = req.url();
// (i) AIS invalid-key: the exact payload the spec calls for.
if (url.includes('/api/ais-live') && !url.includes('/track')) {
if (url.includes('/api/vessels') && !url.includes('/track')) {
req.respond({
status: 200,
contentType: 'application/json',
// The bundled server names its vessel provider; attribution reads it.
headers: { 'X-Feed-Source': 'AISStream' },
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
});
return;
+159
View File
@@ -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();
}
+2 -2
View File
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
window.__TERRAIN.ok += 1;
return realFetch(input, init);
}
if (app && url.pathname === '/api/opensky') {
if (app && url.pathname === '/api/flights') {
const c = window.__CONTACT;
const t = Math.floor(Date.now() / 1000);
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
@@ -99,7 +99,7 @@ await page.evaluateOnNewDocument((site, icao) => {
null, true, 1.5, 90, 0, null, null, null, false, 0]],
}));
}
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
if (app && url.pathname === '/api/flights/track') return Promise.resolve(json({ path: [] }));
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
return realFetch(input, init);
};
+4 -4
View File
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
}));
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/flights/track')) return json({ path: [] });
if (url.includes('/api/military/track')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/flights')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false });
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
if (url.includes('/api/vessels')) return json({ status: 'open', rows: [] });
return realFetch(input, init);
};
});
+4 -4
View File
@@ -525,11 +525,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica',
@@ -548,7 +548,7 @@ async function main() {
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__TURN_HITS.mil++;
const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel);
+412
View File
@@ -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;
+404
View File
@@ -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,
}));
}
+223
View File
@@ -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,
};
},
};
}
+10 -10
View File
@@ -738,9 +738,9 @@ check({
});
check({
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/opensky)',
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/flights)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
const n = r.json?.states?.length || 0;
return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned');
@@ -750,7 +750,7 @@ check({
check({
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
// Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
// (An earlier guess at these names made this check pass vacuously.)
const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
@@ -826,9 +826,9 @@ check({
});
check({
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/ais-live)', needsKey: 'AIS',
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/vessels)', needsKey: 'AIS',
run: async () => {
const r = await jget('/api/ais-live', { timeoutMs: 40000 });
const r = await jget('/api/vessels', { timeoutMs: 40000 });
const rows = r.json?.rows?.length || 0;
const status = r.json?.status;
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
@@ -864,7 +864,7 @@ check({
const guard = keyGuard('AIS', env.keys.AIS);
if (guard) return guard;
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A');
const r = await jget('/api/ais-live');
const r = await jget('/api/vessels');
return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows)
? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`)
: fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
@@ -1022,7 +1022,7 @@ check({
check({
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
run: async () => {
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/ais-live'];
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/vessels'];
const leaked = [];
const unscannable = [];
for (const p of paths) {
@@ -1036,7 +1036,7 @@ check({
// An error page is not a payload. Scanning five 500s and finding no key
// is trivially true and proves nothing — a broken app must not satisfy a
// negative assertion. The one documented exception is the keyless
// 503 {status:'missing-key'} from /api/ais-live, which IS its real shape.
// 503 {status:'missing-key'} from /api/vessels, which IS its real shape.
const documentedKeyless = r.status === 503
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
if (!r.ok && !documentedKeyless) {
@@ -2071,13 +2071,13 @@ async function preflight() {
env.keys.FIRMS = await statusKey('/api/firms/status');
env.keys.TOMTOM = await statusKey('/api/tomtom/status');
try {
const ais = await jget('/api/ais-live');
const ais = await jget('/api/vessels');
if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false;
else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
else env.keys.AIS = 'error';
} catch { env.keys.AIS = 'error'; }
try {
const os = await jget('/api/opensky?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const os = await jget('/api/flights?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
if (!os.ok) env.keys.OPENSKY = 'error';
+337
View File
@@ -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();
}
+485
View File
@@ -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();
}
+1 -1
View File
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
});
return;
}
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
await request.respond({
status: 200,
contentType: 'application/json',
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -219,7 +219,7 @@ async function main() {
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') {
if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
+4 -4
View File
@@ -229,11 +229,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
window.__SPR_HITS.opensky++;
const states = S.flights.map((f) => {
const s = S.stateAt(f, tRel);
@@ -249,7 +249,7 @@ async function main() {
});
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__SPR_HITS.mil++;
const ac = S.military.map((m) => {
const s = S.stateAt(m, tRel);
+362
View File
@@ -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();
}
+533
View File
@@ -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;
+315
View File
@@ -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;
+8 -8
View File
@@ -17,9 +17,9 @@
* `fetch` shim in the page that intercepts the poll endpoints and returns
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
* shapes the layers parse:
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] }
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/ais-live → connected, zero-row snapshot
* - flights → GET /api/flights → { states: [ <state-vector[]> ] }
* - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/vessels → connected, zero-row snapshot
* The shim stays installed so the layers' setInterval pollers keep the
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned).
*
@@ -343,7 +343,7 @@ async function main() {
const url = new URL(requestUrl, window.location.href);
const isAppRequest = url.origin === appOrigin;
// OpenSky (commercial flights): { states: [ state-vector[] ] }
if (isAppRequest && url.pathname === '/api/opensky') {
if (isAppRequest && url.pathname === '/api/flights') {
window.__SYNTH_HITS.opensky++;
// A withheld contact models the ordinary case where the recipient's
// first authoritative refresh does not yet carry the shared aircraft.
@@ -371,11 +371,11 @@ async function main() {
// Trail backfill (fires on trackById). Stub with valid-but-empty
// payloads so the deterministic run never 404s against the dev proxy.
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] }
if (isAppRequest && url.pathname === '/api/opensky-track') {
if (isAppRequest && url.pathname === '/api/flights/track') {
return Promise.resolve(jsonResponse({ path: [] }));
}
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] }
if (isAppRequest && url.pathname === '/api/adsblol/trace') {
if (isAppRequest && url.pathname === '/api/military/track') {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
}
// adsbdb enrichment (fires for tracked/model-eligible planes): empty
@@ -388,7 +388,7 @@ async function main() {
// Contacts enables the optional AIS source alongside aircraft. Keep
// this tracking harness independent of local AIS credentials while
// preserving the truthful "awaiting first message" lifecycle.
if (isAppRequest && url.pathname === '/api/ais-live') {
if (isAppRequest && url.pathname === '/api/vessels') {
return Promise.resolve(jsonResponse({
status: 'connected',
rows: [],
@@ -450,7 +450,7 @@ async function main() {
return Promise.resolve(jsonResponse({ places: [] }));
}
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check)
if (isAppRequest && url.pathname === '/api/adsblol/mil') {
if (isAppRequest && url.pathname === '/api/military') {
window.__SYNTH_HITS.mil++;
const ac = window.__SYNTH.military.map((m) => ({
hex: m.hex,
+179
View File
@@ -0,0 +1,179 @@
/**
* Serves the local MCP server at /mcp on the development and preview servers.
*
* This is local transport safety, not authentication: requests must come from
* this machine, carry no proxy forwarding headers, arrive while sharing is
* off, name a loopback host on the port they arrived at (blocking DNS
* rebinding) and, when a browser sends an Origin, come from that same host.
*/
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
import { hasProxySignals } from '../../src/localRequestGate.mjs';
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const MAX_BODY_BYTES = 1024 * 1024;
/** How long a client may take to send its request body. */
const BODY_TIMEOUT_MS = 30 * 1000;
/**
* Whether a request may reach the local MCP server. `localPort` is the port
* the connection arrived at; `headers` are the request's, keyed by lower-case
* name; `env` is checked for launcher sharing.
*/
export function isLocalMcpRequest({
remoteAddress,
localPort,
host,
origin,
headers = {},
env = {},
}) {
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
// A proxy or tunnel in front of the server can make a remote request look
// local, and sharing exposes the server beyond this machine.
if (hasProxySignals(headers) || isSharingEnabled(env)) return false;
let hostUrl;
try {
hostUrl = new URL(`http://${host}`);
} catch {
return false;
}
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
// The Host names the port the request reached, so tools are never pointed
// at another local service.
if (localPort != null && (hostUrl.port || '80') !== String(localPort))
return false;
if (origin == null) return true;
try {
const originUrl = new URL(origin);
return (
['http:', 'https:'].includes(originUrl.protocol) &&
originUrl.host === hostUrl.host
);
} catch {
return false;
}
}
/** Vite plugin that mounts the local MCP server at /mcp. */
export function localMcpPlugin({
createServer = createLocalMcpServer,
bodyTimeoutMs = BODY_TIMEOUT_MS,
} = {}) {
const handlers = new Map();
const handlerFor = (apiBase) => {
if (!handlers.has(apiBase))
handlers.set(apiBase, createMcpHttpHandler(createServer({ apiBase })));
return handlers.get(apiBase);
};
const install = (server) => {
server.middlewares.use('/mcp', async (req, res) => {
const host = req.headers.host || '';
if (
!isLocalMcpRequest({
remoteAddress: req.socket?.remoteAddress,
localPort: req.socket?.localPort,
host,
origin: req.headers.origin,
headers: req.headers,
env: process.env,
})
) {
res.writeHead(403, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'The MCP server only accepts local requests',
}),
);
return;
}
// A client that disconnects before its answer cancels the tool call.
const disconnect = new AbortController();
const onClose = () => {
if (!res.writableFinished) disconnect.abort();
};
res.on('close', onClose);
try {
const body =
req.method === 'POST'
? await readBody(req, bodyTimeoutMs)
: undefined;
const request = new Request(`http://${host}/mcp`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) =>
value === undefined ? [] : [[name, String(value)]],
),
body,
signal: disconnect.signal,
});
const response = await handlerFor(`http://${host}`)(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(Buffer.from(await response.arrayBuffer()));
} catch (error) {
if (disconnect.signal.aborted) return;
if (res.headersSent) return res.destroy();
res.writeHead([408, 413].includes(error?.status) ? error.status : 400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error:
error?.status === 413
? 'Request too large'
: error?.status === 408
? 'Request timed out'
: 'Bad request',
}),
);
} finally {
res.off('close', onClose);
}
});
};
return {
name: 'local-mcp',
configureServer: install,
configurePreviewServer: install,
};
}
function readBody(req, timeoutMs = BODY_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
// A client that stops sending must not hold the request open.
const timer = setTimeout(
() =>
reject(Object.assign(new Error('Request timed out'), { status: 408 })),
timeoutMs,
);
const fail = (error) => {
clearTimeout(timer);
reject(error);
};
req.on('data', (chunk) => {
size += chunk.length;
// Keep draining without storing, so the 413 response can still be sent.
if (tooLarge || size <= MAX_BODY_BYTES) {
if (!tooLarge) chunks.push(chunk);
return;
}
tooLarge = true;
chunks.length = 0;
fail(Object.assign(new Error('Request too large'), { status: 413 }));
});
req.on('end', () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks));
});
req.on('error', fail);
});
}
+51
View File
@@ -0,0 +1,51 @@
/** The local MCP server: Core's tools over services backed by a running app. */
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import {
composeCatalog,
coreTools,
catalogForSurface,
} from '../../src/tools/index.js';
import {
createGlobePanelResource,
panelRuntime,
} from '../../src/tools/panel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
const INSTRUCTIONS =
"Tools answer questions from God's Eye View's live public data. Location " +
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
'Results are capped; check truncated and total before concluding there is nothing more. ' +
"Answers that can be shown in God's Eye View include data.view; to show one, call " +
'show_in_gods_eye_view with that view, adding layers, style, a camera or marks as ' +
"needed. It shows live God's Eye View where the client displays apps and " +
'returns a link everywhere.';
/** Construct the local MCP server for Core's tools. */
export function createLocalMcpServer({
apiBase = DEFAULT_API_BASE,
fetchImpl,
} = {}) {
const { version } = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
);
// Each server makes a new key for its panel. The panel's page carries it
// and panel_request requires it; any client may read the page, so it keeps
// the tool from clients that only list it, and is not access control.
const panelKey = randomBytes(32).toString('base64url');
return createMcpServer({
catalog: catalogForSurface(
composeCatalog({
tools: coreTools,
services: createLocalToolServices({ apiBase, fetchImpl, panelKey }),
}),
'mcp',
),
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
resources: [createGlobePanelResource({ runtime: panelRuntime, panelKey })],
});
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Local composition of tool services: Core's default services, pointed at a
* running God's Eye View server's `/api` routes.
*/
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { createToolServices } from '../../src/tools/services.js';
// Bundled data some sources read by module-relative URL.
const BUNDLED_DATA = new URL('../../src/data/local_data/', import.meta.url);
export const DEFAULT_API_BASE = 'http://localhost:4173';
/**
* Resolve the sources' relative `/api/...` requests against `apiBase`, and
* serve `file:` URLs inside the bundled data directory from disk, as a
* browser would load those module-relative assets.
*/
export function createApiFetch({
apiBase = DEFAULT_API_BASE,
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const base = new URL(apiBase);
if (!['http:', 'https:'].includes(base.protocol))
throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`);
return (input, init) => {
const url = String(input instanceof Request ? input.url : input);
if (url.startsWith('file:')) return readBundled(url);
return fetchImpl(
typeof input === 'string' && input.startsWith('/')
? new URL(input, base)
: input,
init,
);
};
}
async function readBundled(url) {
const href = new URL(url).href;
if (!href.startsWith(BUNDLED_DATA.href) || href.includes('/../'))
return new Response(null, { status: 404 });
try {
const body = await readFile(fileURLToPath(href));
return new Response(body, {
headers: {
'Content-Type': href.endsWith('.json')
? 'application/json'
: 'application/octet-stream',
},
});
} catch {
return new Response(null, { status: 404 });
}
}
/** Construct every service Core's tools read, backed by the local server. */
export function createLocalToolServices(options = {}) {
return createToolServices({
fetchImpl: createApiFetch(options),
appUrl: options.apiBase ?? DEFAULT_API_BASE,
panelKey: options.panelKey,
});
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Local MCP server over stdio for agents such as Claude Code and Codex.
*
* npm run mcp -- [--api-base http://localhost:4173]
*
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
* Tools read data from a running God's Eye View server (`npm run dev` or
* `npm run preview`) at the API base. Diagnostics go to stderr.
*/
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
import { DEFAULT_API_BASE } from './services.js';
/**
* A one-line description of a request for the diagnostic log: its method,
* and the tool or resource it names. Arguments and data are never logged.
*/
export function describeRequest(message) {
const method = typeof message?.method === 'string' ? message.method : null;
if (!method) return null;
const subject =
method === 'tools/call'
? message.params?.name
: method === 'resources/read'
? message.params?.uri
: null;
return typeof subject === 'string' ? `${method} ${subject}` : method;
}
/**
* Serve newline-delimited JSON-RPC until `input` ends. A client's
* `notifications/cancelled` aborts the named request, which then gets no
* response, as the protocol asks.
*/
export async function serveStdio(server, { input, output, log = () => {} }) {
const lines = createInterface({ input, crlfDelay: Infinity });
const pending = new Set();
const running = new Map();
for await (const line of lines) {
if (!line.trim()) continue;
const work = respond(server, line, log, running).then((response) => {
if (response) output.write(`${JSON.stringify(response)}\n`);
});
pending.add(work);
work.finally(() => pending.delete(work));
}
await Promise.all(pending);
}
async function respond(server, line, log, running) {
let message;
try {
message = JSON.parse(line);
} catch {
return parseErrorResponse();
}
if (message?.method === 'notifications/cancelled') {
running.get(message.params?.requestId)?.abort();
return null;
}
const described = describeRequest(message);
if (described) log(described);
const id = message?.id;
const cancellable = id !== undefined && id !== null && !running.has(id);
const controller = new AbortController();
if (cancellable) running.set(id, controller);
let response;
try {
response = await server.handle(message, { signal: controller.signal });
} finally {
if (cancellable) running.delete(id);
}
if (controller.signal.aborted) return null;
// A failed tool call answers the client, not the log; say why here too.
if (response?.result?.isError)
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
return response;
}
/** Read `--api-base <url>` from command-line arguments. */
export function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--api-base' && argv[index + 1])
options.apiBase = argv[(index += 1)];
else throw new Error(`Unknown argument: ${argv[index]}`);
}
return options;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const options = parseArgs(process.argv.slice(2));
console.error(
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
);
await serveStdio(createLocalMcpServer(options), {
input: process.stdin,
output: process.stdout,
log: (line) => console.error(`<- ${line}`),
});
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+8 -4
View File
@@ -1,7 +1,7 @@
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
*
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
* Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
* cached body as STALE instead of relaying the failure, and it backs off from
* upstream for the Retry-After period (bounded) so a rate limit is never
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-ADS-B-Cache': cacheStatus,
'X-Feed-Source': 'adsb.lol',
'X-Feed-Cache': cacheStatus,
...(['HIT', 'STALE'].includes(cacheStatus)
? {
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
}
: {}),
...extra,
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
}
const installMiddleware = (server) => {
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
server.middlewares.use('/api/military', async (req, res, next) => {
try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT');
+38 -10
View File
@@ -25,6 +25,36 @@ let _openskyCacheMeta = null;
let _openskyCacheSourceEpochMs = null;
/** TTL for the OpenSky response cache (ms). */
const OPENSKY_CACHE_MS = 9000;
/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
const OPENSKY_STATES_URL =
'https://opensky-network.org/api/states/all?extended=1';
/**
* Fetch and read the global snapshot, giving each attempt
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
* the connection fails. A second failure throws, which the proxy answers
* from its stale cache or the regional fallback.
* @param {Record<string, string>} headers Request headers.
* @returns {Promise<{upstream: Response, body: string}>}
*/
async function fetchOpenSkyStates(headers) {
for (let attempt = 1; ; attempt++) {
try {
const upstream = await fetch(OPENSKY_STATES_URL, {
headers,
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
});
return { upstream, body: await upstream.text() };
} catch (error) {
if (attempt >= 2) throw error;
console.warn(
'[OpenSky] global snapshot attempt failed, retrying:',
error?.message || error,
);
}
}
}
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
@@ -107,6 +137,7 @@ export async function getOpenSkyToken() {
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
},
);
@@ -349,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*/
export function openSkyProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/opensky', async (req, res) => {
server.middlewares.use('/api/flights', async (req, res, next) => {
try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
res.setHeader('X-Feed-Source', 'OpenSky Network');
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
@@ -470,10 +505,7 @@ export function openSkyProxy() {
}
}
let upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers },
);
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
@@ -485,15 +517,11 @@ export function openSkyProxy() {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers: retryHeaders },
);
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok &&
+4 -4
View File
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
/**
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
*
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently.
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI.
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
}
function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => {
middlewares.use('/api/flights/track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '')
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
}
});
middlewares.use('/api/adsblol/trace', async (req, res) => {
middlewares.use('/api/military/track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '')
+2
View File
@@ -143,8 +143,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
if (url.pathname === '/sources') {
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
sources: sources.map((source) => ({
id: source.id,
pack: source.pack,
name: source.name,
city: source.city,
cityId: source.cityId,
+22 -2
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { normalizeSourceItem } from './normalize.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -143,6 +143,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/**
* Assemble and cache the merged CCTV source list.
@@ -209,8 +212,12 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => normalizeSourceItem(item))
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
.filter((item) => item.id),
available: Number.isFinite(items.available)
? items.available
: items.length,
region: items.region ?? null,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
@@ -234,6 +241,16 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
loadGroundHeights(sourceRoot),
);
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
// Each pack's own nearest-first cap and the shared catalog cap both trim.
const trimmedPacks = packs
.map((pack, index) => ({
pack: pack.name,
available: Math.max(pack.available, allocation.packs[index].offered),
served: allocation.packs[index].kept,
// Where the pack's cameras are, including those not served.
region: pack.region ?? cameraRegion(pack.sources),
}))
.filter((pack) => pack.served < pack.available);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
@@ -244,6 +261,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
@@ -257,5 +275,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
return getCctvSources;
}
+25 -1
View File
@@ -470,7 +470,31 @@ export function prioritizeSources(cameras, maxCount, anchors) {
return a.idx - b.idx;
});
return scored.slice(0, cap).map((entry) => entry.camera);
const kept = scored.slice(0, cap).map((entry) => entry.camera);
// How many cameras the pack offered before trimming, and where, for
// coverage reports.
Object.defineProperty(kept, 'available', { value: list.length });
Object.defineProperty(kept, 'region', { value: cameraRegion(list) });
return kept;
}
/** The bounding box of cameras with coordinates, or null. */
export function cameraRegion(cameras) {
let region = null;
for (const camera of cameras || []) {
const lat = Number(camera?.lat);
const lon = Number(camera?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
region = region
? {
west: Math.min(region.west, lon),
south: Math.min(region.south, lat),
east: Math.max(region.east, lon),
north: Math.max(region.north, lat),
}
: { west: lon, south: lat, east: lon, north: lat };
}
return region;
}
/**
+41 -13
View File
@@ -2,24 +2,52 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
/**
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
* returns `null` and the caller skips the check entirely — a runtime no-op that
* preserves the original behavior. Only a positive integer N enables a fixed
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
* window state persists across requests). The global backstop is set to a
* generous multiple of the per-IP cap so a single host can't starve the rest.
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
*
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
* The default is ON. An unset or blank value takes `defaultPerMin`; a positive
* integer overrides it; an explicit `0` disables the limiter for deployments
* that genuinely want no throttle.
*
* A value that cannot be read as a number — a typo, a stray unit, a negative —
* also takes the default rather than disabling the guard. That asymmetry is the
* point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed
* server's API key to whoever asks. Removing the throttle stays possible, but
* only by saying `0`, which cannot be typed by accident.
*
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
* @returns {number} Requests/min/IP; `0` means unlimited.
*/
export function resolvePerMinuteCap(envValue, defaultPerMin) {
const raw = typeof envValue === 'string' ? envValue.trim() : envValue;
if (raw === undefined || raw === null || raw === '') return defaultPerMin;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin;
// Only an explicit 0 disables the guard: a positive fraction such as 0.5
// would floor to 0, so it takes the strictest real cap instead.
if (parsed > 0 && parsed < 1) return 1;
return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out
}
/**
* Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
* Returns `null` only when the cap resolves to `0` (the explicit opt-out), in
* which case the caller skips the check entirely. Otherwise a fixed 60s window
* of N requests/IP, built lazily once and reused so its per-IP window state
* persists across requests. The global backstop is a generous multiple of the
* per-IP cap so a single host can't starve the rest.
*
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
*/
export function makeOptInRateLimiter(envValue) {
const max = Number(envValue);
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
export function makeCostRateLimiter(envValue, defaultPerMin) {
const max = resolvePerMinuteCap(envValue, defaultPerMin);
if (max <= 0) return null; // explicit opt-out
return makeRateLimiter({
windowMs: 60_000,
max: Math.floor(max),
globalMax: Math.floor(max) * 20,
max,
globalMax: max * 20,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
/**
* Cross-site request gate for cost-bearing and log endpoints. Feeds the
* request to the pure, unit-tested `admitSameSiteRequest` and answers 403
* itself when the request is refused. Returns true when it has already
* responded (the caller returns); false when the handler should continue.
* Refuses cross-site browser requests (foreign or opaque Origin, a
* Sec-Fetch-Site other than same-origin/none, or reverse-proxy headers) while
* keeping loopback non-browser tools and the HOST=0.0.0.0 LAN opt-in working.
* @param {import('node:http').IncomingMessage} req
* @param {import('node:http').ServerResponse} res
* @returns {boolean} True when a 403 was sent.
*/
export function admitSameSite(req, res) {
const verdict = admitSameSiteRequest({
hostHeader: req.headers?.host,
protocol: req.socket?.encrypted ? 'https:' : 'http:',
origin: req.headers?.origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: req.headers || {},
});
if (verdict.ok) return false;
res.statusCode = verdict.status;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: verdict.error }));
return true;
}
/**
* Wrap a middleware handler so cross-site requests are refused with 403
* before the handler runs. Loopback tools and same-origin browser requests
* pass straight through.
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown} handler
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown}
*/
export function sameSiteGated(handler) {
return (req, res, next) => {
if (admitSameSite(req, res)) return undefined;
return handler(req, res, next);
};
}
+6 -3
View File
@@ -26,8 +26,11 @@ import { firePerimetersProxy } from './firePerimeters.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
/** Construct the local provider plugins in their established order. */
function localProviderPlugins() {
/**
* Construct the local provider plugins in their established order.
* `realtime` configures the voice session token endpoint.
*/
function localProviderPlugins({ realtime } = {}) {
return [
openSkyProxy(),
celestrakProxy(),
@@ -49,7 +52,7 @@ function localProviderPlugins() {
adsbLolProxy(),
aisLiveProxy(),
trackBackfillProxies(),
openAiRealtimeProxy(),
openAiRealtimeProxy({ realtime }),
googlePlacesContextProxy(),
windProxy(),
weatherProxy(),
+40 -9
View File
@@ -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,
}),
);
}
+8 -3
View File
@@ -6,6 +6,7 @@ import {
createRealtimeOAuthStatusHandler,
createRealtimeTokenHandler,
} from './openai/realtime.js';
import { sameSiteGated } from './common/same-site.js';
/**
* Vite plugin: OpenAI Realtime ephemeral client secret.
@@ -19,16 +20,20 @@ function openAiRealtimeProxy({
realtime = {},
} = {}) {
function install(middlewares) {
middlewares.use('/api/openai/hud-summary', handleHudSummary);
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
middlewares.use(
'/api/realtime/debug-log',
createDebugLogHandler({ sourceRoot }),
sameSiteGated(createDebugLogHandler({ sourceRoot })),
);
middlewares.use(
'/api/realtime/token',
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
sameSiteGated(
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
),
);
middlewares.use(
+2 -1
View File
@@ -80,10 +80,11 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
try {
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
const record = JSON.parse(body || '{}');
// The server's own timestamp comes last, so a record cannot supply one.
await append(
`${JSON.stringify({
loggedAt: new Date().toISOString(),
...record,
loggedAt: new Date().toISOString(),
})}\n`,
);
res.statusCode = 204;
+5 -5
View File
@@ -2,7 +2,7 @@ import {
HUD_SUMMARY_INSTRUCTIONS,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
import { readRequestBody } from '../common/request.js';
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
// fallback has no provider cost and resolves above without consuming a
// paid-endpoint quota slot.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
// Keyless HUD fallback has no provider cost and resolves above without
// consuming a paid-endpoint quota slot.
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
try {
const body = await readRequestBody(req, 64 * 1024);
+2
View File
@@ -19,6 +19,7 @@ function realtimeInstructions(
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
@@ -48,6 +49,7 @@ function realtimeInstructions(
// string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
+24 -11
View File
@@ -1,33 +1,46 @@
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
/**
* Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both
* places that already name a value agree on it: `.env.example` recommends 30
* and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app
* is unaffected and only an unconfigured server changes. The HUD asks for a
* summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and
* a voice session adds one token mint; the cap clears ordinary use several
* times over and bites only a caller working the endpoint far harder than the
* app does.
*/
const OPENAI_DEFAULT_PER_MIN = 30;
// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later
// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading
// process.env here at import time would always see them unset and silently stay unlimited even when
// process.env here at import time would always see them unset and fall back to the default even when
// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env;
// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default).
// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out.
let _openAiRateLimiter;
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */
function openAiRateLimiter() {
if (_openAiRateLimiter === undefined)
_openAiRateLimiter = makeOptInRateLimiter(
_openAiRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
OPENAI_DEFAULT_PER_MIN,
);
return _openAiRateLimiter;
}
/**
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
* When `limiter` is null (unlimited, the default) this is a no-op returning
* `true`, so the handler proceeds exactly as before.
* Apply the limiter to a request, writing a 429 when over the cap.
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
* returning `true`, so the handler proceeds unthrottled.
*
* @param {((key:string)=>boolean)|null} limiter
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
*/
function enforceOptInRateLimit(limiter, req, res) {
if (!limiter) return true; // unlimited (default) — no behavior change
function enforceRateLimit(limiter, req, res) {
if (!limiter) return true; // explicit 0 opt-out — unthrottled
if (limiter(clientKey(req))) return true;
res.statusCode = 429;
res.setHeader('Content-Type', 'application/json');
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
return false;
}
export { enforceOptInRateLimit, openAiRateLimiter };
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
+5 -4
View File
@@ -1,4 +1,4 @@
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
import {
resolveVoiceModel,
isKnownVoiceTier,
@@ -177,6 +177,7 @@ function createRealtimeTokenHandler({
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
environment = process.env,
models = {},
tools = GEV_REALTIME_TOOLS,
} = {}) {
return async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
@@ -218,8 +219,8 @@ function createRealtimeTokenHandler({
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
let bearer;
if (authMode === 'oauth') {
@@ -331,7 +332,7 @@ function createRealtimeTokenHandler({
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance),
tools: GEV_REALTIME_TOOLS,
tools,
tool_choice: 'auto',
},
};
+12
View File
@@ -2,3 +2,15 @@ import { createActionTools } from '../../../src/voice/actionSchemas.js';
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
/**
* The session's tools: the app actions, then additional function tools
* whose names no action already uses.
*/
export function realtimeSessionTools(additional = []) {
const names = new Set(GEV_REALTIME_TOOLS.map((tool) => tool.name));
return [
...GEV_REALTIME_TOOLS,
...additional.filter((tool) => !names.has(tool.name)),
];
}
+37 -6
View File
@@ -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' }));
}
+32 -22
View File
@@ -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,
};
+110 -62
View File
@@ -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 -1
View File
@@ -7,7 +7,7 @@ export {
export { installRouteMiddleware } from './places/routes.js';
export {
makeRateLimiter,
makeOptInRateLimiter,
makeCostRateLimiter,
clientKey,
} from './common/rate-limit.js';
export { haversineKm } from './common/geo.js';
+25 -6
View File
@@ -2,21 +2,35 @@ import {
googleServerApiKey,
keylessGooglePlacesResponse,
} from './google-key.js';
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
} from '../../../src/data/placeProviderPayloads.js';
// Construct lazily after the standalone environment has loaded.
// undefined = not built yet; null = unlimited; fn = active limiter
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
let _googleRateLimiter;
/** Google cost endpoint (nearby-places). Null = unlimited (default). */
/**
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
* packaged app keeps behaving exactly as it does today and only an
* unconfigured server changes — from unlimited to what the product already
* runs with. Places calls are user-driven (a search, a nearby lookup, an
* installation probe) rather than polled, so this sits far above what the app
* generates and bites only a caller enumerating the endpoint. `.env.example`
* suggests a tighter 60 when the host is not localhost; that still applies.
*/
export const GOOGLE_DEFAULT_PER_MIN = 120;
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
function googleRateLimiter() {
if (_googleRateLimiter === undefined)
_googleRateLimiter = makeOptInRateLimiter(
_googleRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
GOOGLE_DEFAULT_PER_MIN,
);
return _googleRateLimiter;
}
@@ -50,6 +64,9 @@ export function googlePlacesContextProxy({
} = {}) {
function install(middlewares) {
middlewares.use('/api/google/nearby-places', async (req, res) => {
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
// about this endpoint's method surface.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
@@ -80,7 +97,7 @@ export function googlePlacesContextProxy({
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
// `places: []` contract that the client expects on every error response.
const _grl = googleRateLimiter();
@@ -161,6 +178,8 @@ export function googlePlacesContextProxy({
// a view-biased Text Search lands on the actual feature. Same key, field
// mask, throttle, and `places: []` error contract as nearby-places above.
middlewares.use('/api/google/text-search', async (req, res) => {
// Paid like nearby-places, so gated the same way, first.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
@@ -200,7 +219,7 @@ export function googlePlacesContextProxy({
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
// contract the client expects on every error response.
const _grl = googleRateLimiter();
+7 -30
View File
@@ -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();
+5 -4
View File
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
*/
export function aisLiveProxy() {
function install(middlewares) {
middlewares.use('/api/ais-live', async (req, res) => {
middlewares.use('/api/vessels', async (req, res) => {
try {
res.setHeader('X-Feed-Source', 'AISStream');
ensureAisStreamConnection();
const incoming = new URL(req.url || '', 'http://localhost');
// Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch
// /api/ais-live/track would be silently answered with vessel rows.
// /api/vessels/track would be silently answered with vessel rows.
if (
incoming.pathname === '/track' ||
incoming.pathname.startsWith('/track/')
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
}
/**
* Drive the watchdog once. Called on every /api/ais-live request and on the
* Drive the watchdog once. Called on every /api/vessels request and on the
* background interval, so recovery does not depend on browser traffic.
*/
function ensureAisStreamConnection() {
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
keyFingerprint: aisKeyFingerprint(),
});
}
/** Status metadata for /api/ais-live, safe to call before the first connect. */
/** Status metadata for /api/vessels, safe to call before the first connect. */
function aisStreamStatusSnapshot() {
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
if (snapshot) return snapshot;
+9 -1
View File
@@ -1,8 +1,11 @@
import { fileURLToPath } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import { resolveAllowedHosts } from '../../build/allowedHosts.js';
import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js';
import { standaloneVoiceTools } from './voiceTools.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
@@ -13,11 +16,16 @@ export default defineConfig(({ command, mode }) => {
if (process.env[key] === undefined) process.env[key] = value;
}
return createBrowserViteConfig({
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
plugins: [
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
localMcpPlugin(),
apiNotFoundPlugin(),
],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN,
host: process.env.HOST,
port: process.env.PORT,
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
command,
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* The voice session's tools for the standalone server: the app actions plus
* the catalog tools voice offers (see src/tools/surfaces.js), which the
* browser runs through the same catalog.
*/
import {
coreTools,
toFunctionTools,
toolsForSurface,
} from '../../src/tools/index.js';
import { realtimeSessionTools } from '../providers/openai/tools.js';
export function standaloneVoiceTools() {
return realtimeSessionTools(
toFunctionTools(toolsForSurface(coreTools, 'voice')),
);
}
+236
View File
@@ -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();
});
+32 -4
View File
@@ -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;
+3 -1
View File
@@ -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');
};
+5 -1
View File
@@ -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 });
+77 -10
View File
@@ -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
View File
@@ -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,
);
+28 -4
View File
@@ -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();
+92 -95
View File
@@ -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;
+1 -1
View File
@@ -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 }),
+305
View File
@@ -0,0 +1,305 @@
/**
* Embed mode and views in the running app.
*
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
* welcome and setup prompts hidden. A page that frames it changes what it
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
*
* Annotations in a share link are drawn once the link has been restored,
* embedded or not.
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
export const EMBED_VIEW_MESSAGE = 'gev:view';
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
export const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
satellite: 'satellites',
};
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
* frames it, or inline when a panel page loads the app into itself and sets
* `globalThis.GEV_EMBED_INLINE` first.
*/
export function isEmbedded(location = globalThis.location) {
return (
isEmbeddedInline() ||
new URLSearchParams(location?.search || '').get('embed') === '1'
);
}
/** Whether a panel page loaded the app into itself. */
export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/**
* Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them
* Cesium's render loop; while frames stop arriving, draw from a timer.
* Returns a function that stops it.
*/
export function keepPanelRendering(
viewer,
{ windowRef = globalThis.window, now = () => performance.now() } = {},
) {
let lastFrame = now();
let frameRequest = null;
const onFrame = () => {
lastFrame = now();
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => {
if (
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
const delay = (ms, signal) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Fly the camera to a view's camera and resolve when the flight ends. */
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
* app's own actions (`run(name, args)`); a failed step is reported and the
* rest still run. Resolves to the steps and whether each succeeded.
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
) {
const steps = [];
const act = async (name, args) => {
let result = null;
try {
result = await run(name, args);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
steps.push({
step: name,
ok: result?.ok !== false,
...(result?.error ? { error: result.error } : {}),
});
return result;
};
if (view.style) await act('set_visual_style', { style: view.style });
if (view.map) await act('set_map_stack', { stack: view.map });
const wanted = new Set(view.layers);
for (const layer of dataManager.getAll()) {
if (layer.enabled && !wanted.has(layer.id))
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
if (!view.follow) await act('stop_tracking', {});
// Annotations go first: drawing them may frame the marks, and the view's
// own camera should have the last word.
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
}
// A followed entity owns the camera, and a camera flight would end the
// follow, so fly to the view only while the entity is not there yet. It
// appears once its layer has data; retry until then.
const layerId = FOLLOW_LAYERS[view.follow.kind];
const follow = async () => {
try {
return (
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
true
);
} catch {
return false;
}
};
let followed = await follow();
if (!followed) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
if (signal?.aborted) break;
await delay(retryMs, signal);
followed = await follow();
}
}
steps.push({ step: 'follow', ok: followed });
if (followed && view.follow.cockpit)
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
return steps;
}
/**
* Install embed mode when the page asks for it, and draw any annotations the
* opening link carries. Returns a function that removes the message handler.
*/
export function installViews({
shell,
viewer,
dataManager,
run,
signal,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
});
if (!isEmbedded(location)) return () => {};
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
const stopRendering = isEmbeddedInline()
? keepPanelRendering(viewer, { windowRef })
: () => {};
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
// Answers go back only to the origin that asked. An inline panel posts to
// its own window, and a sandboxed framing page has no origin to name.
const post = (message, origin = '*') => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
const replyOrigin =
isEmbeddedInline() || !event.origin || event.origin === 'null'
? '*'
: event.origin;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
},
replyOrigin,
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
});
return () => {
stopRendering();
windowRef.removeEventListener('message', onMessage);
};
}
+325
View File
@@ -0,0 +1,325 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyView,
installViews,
isEmbedded,
keepPanelRendering,
} from './embed.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
flights,
camera: {
flyTo(options) {
flights.push(options);
options.complete();
},
},
};
};
const fakeLayers = (enabled) => ({
getAll: () =>
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
id,
enabled: enabled.has(id),
})),
isEnabled: (id) => enabled.has(id),
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
calls.push([name, args]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
};
test('embed mode is opt-in by query parameter', () => {
assert.equal(isEmbedded({ search: '?embed=1' }), true);
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
});
test('a view is applied through the app actions in order', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
layers: ['ais-live-vessels'],
style: 'thermal',
map: 'osm',
annotations: [{ type: 'pin', target: 'Taipei 101' }],
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set(['earthquakes'])),
run,
});
assert.deepEqual(
calls.map(([name, args]) => [
name,
args.layerId ?? args.style ?? args.stack ?? null,
args.enabled ?? null,
]),
[
['set_visual_style', 'thermal', null],
['set_map_stack', 'osm', null],
['set_layer_visibility', 'earthquakes', false],
['set_layer_visibility', 'ais-live-vessels', true],
['stop_tracking', null, null],
['clear_annotations', null, null],
['annotate_map', null, null],
],
);
assert.equal(viewer.flights.length, 1);
assert.ok(steps.every((step) => step.ok));
assert.ok(steps.some((step) => step.step === 'camera'));
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
track_entity: () => ({ ok: ++tries >= 3 }),
});
const view = createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'aircraft', id: 'abc123' },
});
const steps = await applyView(view, {
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
retryMs: 1,
});
assert.equal(tries, 3);
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
const parent = {
postMessage: (message, target) => {
posted.push(message);
targets.push(target);
},
};
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = {
initialRestorePromise: Promise.resolve(),
clean: null,
setCleanView(value) {
this.clean = value;
},
};
const { calls, run } = recorder();
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data, origin = 'https://host.example') => {
const event = new Event('message');
Object.assign(event, { source, data, origin });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
send(parent, {
type: 'gev:view',
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
id: 2,
ok: false,
error: 'A view needs a camera lat and lon',
});
assert.equal(posted[2].id, 3);
assert.equal(posted[2].ok, true);
assert.ok(
calls.some(
([name, args]) =>
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
assert.deepEqual(targets, [
'*',
'https://host.example',
'https://host.example',
]);
remove();
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = (message) => posted.push(message);
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
globalThis.GEV_EMBED_INLINE = true;
try {
assert.equal(isEmbedded({ search: '' }), true);
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: recorder().run,
location: { search: '', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(posted[1].id, 7);
assert.equal(posted[1].ok, true);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
]);
});
test('a panel keeps drawing from a timer while animation frames stop', () => {
let clock = 0;
let tick = null;
let frame = null;
const windowRef = {
requestAnimationFrame: (fn) => ((frame = fn), 1),
cancelAnimationFrame: () => (frame = null),
setInterval: (fn) => ((tick = fn), 1),
clearInterval: () => (tick = null),
};
const drawn = [];
const viewer = {
resize: () => drawn.push('resize'),
render: () => drawn.push('render'),
};
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Frames arrive: the render loop draws, so the timer does nothing.
clock = 100;
frame();
tick();
assert.deepEqual(drawn, []);
// Frames stop, as in a panel its host reports hidden: the timer draws.
clock = 400;
tick();
assert.deepEqual(drawn, ['resize', 'render']);
stop();
assert.equal(tick, null);
assert.equal(frame, null);
});
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const drawn = [];
const viewer = {
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const steps = await applyView(
createView({
camera: { lat: 32.7, lon: -117.2 },
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
}),
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
);
// Following worked at once, so no camera flight ended it.
assert.equal(viewer.flights.length, 0);
assert.deepEqual(calls.slice(-2), [
['track_entity', { query: 'ae1234', layerId: 'military' }],
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
]);
assert.ok(steps.every((step) => step.ok));
assert.throws(
() =>
createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'satellite', id: '25544', cockpit: true },
}),
/no cockpit view/,
);
});
+662
View File
@@ -0,0 +1,662 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
// The server refuses the panel's requests without its key.
arguments: { ...args, key: config.panelKey },
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+2
View File
@@ -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,
+2
View File
@@ -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,
+24 -1
View File
@@ -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,
);
},
},
},
});
}
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more