diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..e5eb65448 --- /dev/null +++ b/.env.example @@ -0,0 +1,121 @@ +# God's Eye View — environment variables +# Copy to .env and fill in your keys. On macOS the launcher can also read keys +# from the Keychain (see README); on Linux/Windows use this file or env vars. +# +# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are +# injected into the browser bundle by design (they're used client-side) and +# WILL be visible in devtools. Restrict/scope them rather than trying to hide +# them (see SECURITY.md). All other keys below stay server-side. + +# Required: Google Maps API key (Map Tiles API must be enabled). +# CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud. +GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here +# 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 + +# Optional: Cesium ion token for Bing world imagery stacks and Cesium World Terrain. +# CLIENT-EXPOSED — use a public assets:read token with URL restrictions. +CESIUM_ION_TOKEN= + +# Optional: OpenAI Realtime voice control. Do not prefix with VITE_. +OPENAI_API_KEY= +OPENAI_REALTIME_MODEL=gpt-realtime-2 +# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on. +# Model ids and prices are external facts that drift — if OpenAI moves this id, +# override it here rather than editing src/voice/voiceCost.js. +OPENAI_REALTIME_MODEL_MINI=gpt-realtime-2.1-mini +OPENAI_REALTIME_VOICE=marin +OPENAI_REALTIME_REASONING_EFFORT=low +# Keep only a short conversational window. Current map state is fetched live. +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 +# (/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 + +# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Default: oauth — requires client credentials below +# Set to "anon" to skip aircraft auth (rate-limited but works) +OPENSKY_AUTH_MODE=oauth + +# Optional: OpenSky OAuth credentials (from opensky-network.org account dashboard) +OPENSKY_CLIENT_ID= +OPENSKY_CLIENT_SECRET= + +# Optional: OpenSky credentials JSON file path (alternative to above) +# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json + +# Optional: dev server config +PORT=4173 +# Network binding. Default is localhost — the server is reachable ONLY from +# this machine. This is the safe default: the dev server brokers your +# configured API keys, so anyone who can reach it can spend your quota. +# LAN opt-in: set HOST=0.0.0.0 to share on your local network. WARNING: that +# exposes every key-brokering proxy to everyone on the network — use only on +# networks you trust, and consider the GEV_RATELIMIT_* throttles above. +# HOST=localhost +# HOST=0.0.0.0 + +# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the +# browser — the client polls the /api/firms proxy). Free key: +# https://firms.modaps.eosdis.nasa.gov/api/map_key/ +# Without a key the FIRMS Active Fires layer is empty ("KEY REQUIRED"). +# FIRMS_MAP_KEY= + +# 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 +# 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 +# Active label cap after screen-space clustering. Labels exist for all rendered +# ships, but only clustered winners are shown to keep text readable. +VITE_AIS_LIVE_LABEL_MAX_ROWS=900 +# Private AISStream key for the server-side websocket. Do not prefix with VITE_. +AISSTREAM_API_KEY= +# Optional AISStream subscription filters. +# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]] +# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport +# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket +# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the +# default worldwide subscription — a narrow filter above can be legitimately +# quiet, so set this explicitly (sized for your filter) to opt back in. +# 0 disables the silence watch entirely. +# AISSTREAM_SILENCE_TIMEOUT_MS=120000 + +# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile +# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: +# the browser fetches same-origin /api/tomtom/* and never sees the key. +# Keyless fallback: the traffic layer runs its built-in simulation (white dots, +# hardcoded per-road-class speeds) — no key required for the layer to work. +# TOMTOM_API_KEY= +# Optional soft cap on upstream tile fetches per UTC day (default 40000). +# Over the cap the proxy serves cached/stale tiles instead of hitting upstream. +# TOMTOM_DAILY_TILE_BUDGET=40000 + +# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset +# unless you're customizing the camera source pack. +# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON +# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file) +# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL +# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog +# CCTV_MAX_SOURCES=48 # overall cap on registered cameras +# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry +# CCTV_FORCE_AUSTIN= # force Austin-only sources +# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag) +# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag) diff --git a/.gitignore b/.gitignore index e43b0f988..e746e2d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ +node_modules +dist +.env +*.log +screenshots/ +qa-shots/ +output/ +.gev-logs/ +.gev-cache/ .DS_Store +.gstack/ +3d-models/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..04cc6c3d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,171 @@ +# Changelog + +This changelog records public product changes. For the authoritative description +of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). + +## [Unreleased] — 2026-08-24 + +### Added + +- Added honest aircraft identity narration: callsign, operator, registration, + type, and route come only from selected-contact context, and missing operator, + route, or type enrichment is named explicitly. +- Added local, publication-compatible copies of the two README PNGs, with source + records and third-party-license boundaries in `docs/media/README.md`. +- Added regression coverage for aircraft identity narration and optional-key + loading feedback. + +### Changed + +- First-run presentation now opens with Detection `DENSE` at 75%, `ELASTIC` + allocation, Fade 7%, Outside 1%, scope feather 11%, and aircraft 3D models in + `PROXIMITY`. Stored state and share links still override these baselines. +- The 17 selected README GIFs remain unchanged and are documented separately + from the two owner-published PNGs. +- Bundled datacenter and dam snapshots now omit contact-oriented fields and + note values containing email or phone identifiers. Feature geometry, names, + operator/capacity/river metadata, counts, and ODbL terms are unchanged. +- Public documentation and the L9 release matrix no longer reference non-public + planning material or repository history. + +### Fixed + +- A missing optional FIRMS key no longer turns the complete Environmental + mission into `LOAD FAILED`. The FIRMS row still reports `KEY REQUIRED`, while + earthquakes continue to load. Real lifecycle and fetch failures retain + failure priority. +- The mapped-installations layer retries after an unavailable request when it is + enabled or the camera settles. +- Aircraft trails attach to the rendered aircraft transform and remain near the + rear center across headings. Parked aircraft do not draw a moving head + segment. +- Grounded aircraft keep validated floor evidence through temporary terrain + outages and wait for measured photoreal-surface evidence before a 3D model + takes over from its billboard. +- Cockpit altitude uses aviation MSL data rather than Cesium render height. + +### Security + +- Production transitive dependencies resolve to patched DOMPurify and + protobufjs releases without changing the Cesium version or application APIs. +- Production dependency audit reports no known advisories; remaining audit + findings are confined to development and QA tooling. + +## [Unreleased] — 2026-08-23 + +### Added + +- Added a first-run mission launcher for Contacts, Space Missions, + Environmental, and manual exploration. +- Added terrain-validity gating and bounded last-known placement for grounded + aircraft models. + +### Changed + +- Environmental consistently presents both earthquakes and NASA FIRMS fires, + with honest optional-key degradation. +- The tracked aircraft trail acceptance bar is visual: roughly rear-center, + stable across headings, with minor hull overlap allowed and no conspicuous + top, bottom, or lateral projection. + +## [Unreleased] — 2026-08-18 to 2026-08-22 + +### Added + +- Added the four-source Map Source tray, share-link v2 state, cockpit/context + voice parity, MSL altitude readouts, and close-range tracked aircraft models. +- Added the L9 release-candidate matrix, AIS feed watchdog, voice cost controls, + satellite classes, and the shared world-overlay host. +- Added deterministic first-run, map-source, floor, overlay, tracking, and + aircraft-model regression harnesses. + +### Changed + +- Consolidated world labels, cards, tracked readouts, CCTV thumbnails, cable + labels, mission labels, and detection presentation under shared allocation and + lifecycle rules. +- Reduced idle rendering through the render governor and explicit scope mask. +- Improved cockpit layout, context restoration, keyless feed honesty, and + aircraft 2D/3D handoffs. + +### Fixed + +- Fixed degenerate depth picks, map-source restore states, route-camera motion, + bright-ground label readability, grounded display flooring, and cross-layer + tracking cleanup. +- Fixed stale overlay callbacks, parked-idle render leaks, cable-label sweep + starvation, and several share-link state conflicts. + +## [Unreleased] — 2026-08-02 to 2026-08-16 + +### Added + +- Added Global Context modes, Cockpit briefing surfaces, Radio context, + satellite mission replay, and real per-class aircraft models with adjacent + provenance records. +- Added a shared screen-space overlay system with bounded allocation for labels, + cards, callouts, detection brackets, and selected-object presentation. + +### Changed + +- Unified right-side product controls and responsive cockpit/map layouts. +- Migrated public-safe neighborhood geometry to DataSF and tightened safe local + development defaults. +- Improved proxy resilience, annotation outline bounds, CCTV enable pacing, + contact de-emphasis, and deterministic visual stacking. + +## [Unreleased] — July 2026 + +### Added + +- Added live NASA FIRMS fires, optional live TomTom traffic, Caltrans and TfL + CCTV packs, CCTV viewsheds and direct-manipulation calibration, citywide CCTV + cards, Natural Earth regions, analyst queries, and voice routing QA. +- Added the end-to-end vertical-datum system for aircraft, vessels, CCTV, + annotations, trails, and terrain-aware rendering. +- Added aircraft class silhouettes, path-derived display heading, ADSBDB + enrichment, cached CelesTrak TLE lookup, and next-ISS-pass prediction. + +### Fixed + +- Fixed elevated-airport aircraft placement, vessel sea-surface placement, + close-zoom FIRMS anchors, antimeridian region framing, annotation resolution, + cross-layer tracking ownership, and CCTV projection lifecycle issues. + +## [Unreleased] — June 2026 + +### Added + +- Added OpenAI Realtime voice control, scene-aware entity context, viewport image + grounding, the AI HUD summary, live AIS vessels, infrastructure layers, map + source switching, free-text navigation, and server-side data proxies. +- Added hybrid map annotations, 3D aircraft, panoptic detection, tracking + harnesses, and public data attribution. +- Added MIT source licensing, security guidance, contribution guidance, data + source notices, and third-party asset boundaries. + +### Changed + +- Removed the experimental AI video-edit style and retained seven deterministic + visual styles. +- Moved Realtime text-history trimming to the server-side retention policy while + keeping only the latest viewport image in conversation context. + +## [0.7.0] — 2026-02-18 + +- Added the Bikeshare Pulse layer and panoptic label improvements. +- Improved tracked-item boxes, post-render alignment, and CCTV projection + quality. +- Removed the experimental shift-drag CCTV calibration interaction. + +## [0.6.0] — 2026-02-10 + +- Added the initial multi-layer 3D globe experience, visual styles, live + aircraft, satellites, earthquakes, CCTV, traffic, FIRMS, infrastructure, and + performance controls. +- Added entity inspection, tracking, scenes, keyboard controls, and shareable + views. + +## [0.1.0] — 2026-02-09 + +- Initial project version. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b604c31ab --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to God's Eye View + +Thanks for being here. God's Eye View is an open foundation for live spatial intelligence in the browser, and it gets better when more people run it, break it, and extend it. + +## Getting set up + +Use Node.js 24.14.x or 26.x (also enforced by `package.json`). + +```bash +git clone https://github.com/bilawalsidhu/gods-eye-view.git +cd gods-eye-view +nvm install 24.14.0 +nvm use 24.14.0 +npm install +./scripts/dev-fresh.sh # or: GOOGLE_MAPS_API_KEY="…" npm run dev +``` + +You need a **Google Maps API key** with the Map Tiles API enabled (see the [README](README.md#-api-keys)). Most data layers work with no other accounts. On macOS the launcher pulls keys from the Keychain; on any platform you can pass them as env vars or use a `.env` (copy `.env.example`). + +Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.** + +## Good first contributions + +The highest-leverage places to jump in: + +- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. +- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `vite.config.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened). +- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. +- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). + +## Architecture in one minute + +- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). +- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/.js`.** Keep them separate. +- **Secrets stay server-side.** Anything needing a private key goes through a Vite proxy in `vite.config.js`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. +- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. + +## Coding style + +- ES modules, **2-space indent, single quotes, semicolons.** +- JSDoc on exported/public functions. +- Match the surrounding code — comment density, naming, and idiom. +- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required. + +## Pull requests + +1. Branch off `main`. +2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. +4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. +5. Describe what you changed and how you verified it (screenshots welcome for anything visual). + +## Ground rules + +- This is a tool for **public** data. Don't add scraping of sources whose terms forbid it, private/paywalled datasets, or anything that misrepresents public-data inference as authoritative intelligence. +- Be decent to each other. Assume good faith, keep it constructive. + +By contributing, you agree your contributions are licensed under the project's [MIT License](LICENSE). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md new file mode 100644 index 000000000..68241fbef --- /dev/null +++ b/DATA_SOURCES.md @@ -0,0 +1,138 @@ +# Data Sources & Attribution + +God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md). + +How to read this: + +- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. +- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. +- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. +- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. +- **README media provenance lives beside the media.** [`docs/media/README.md`](docs/media/README.md) records the creator and likeness permissions for the 17 capture GIFs, plus the public source, publication permission, and reuse boundary for the two README PNGs. Third-party content visible within this media remains subject to its provider or owner's terms. + +--- + +## Live sources (fetched at runtime — not stored in this repo) + +| Source | Used for | License / terms | Attribution | +|--------|----------|-----------------|-------------| +| **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}` | +| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | +| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | +| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | +| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | +| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **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; quotas depend on the current account plan) | "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) | +| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" | +| **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 | +| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | +| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | +| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" | +| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **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)" | + +### Notes on the live sources + +- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). +- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. +- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. +- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. +- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a configurable daily tile-budget governor, default 40,000 requests) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. Set that governor within the current allowance for your TomTom account; the application default is a safety limit, not a promise of free quota. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). +- **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. +- **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. +- **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. +- **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. + +--- + +## Bundled snapshots (committed under `src/data/local_data/`) + +Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README. + +| 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) | +| **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) | + +### ⚠️ TeleGeography is bundled but NonCommercial + +The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial**-**ShareAlike**). It is bundled so the cables layer works out of the box, but it is **not covered by this project's MIT license**. CC BY-NC-SA permits redistribution with attribution and share-alike — which is exactly how it ships here — but the **NonCommercial** clause means: + +> If you use God's Eye View commercially, delete `src/data/local_data/telegeography_submarine_cables/` (or obtain a commercial license from TeleGeography). It is one self-contained folder; the rest of the app runs without it. + +The richer structured dataset is licensed separately/commercially by TeleGeography. + +### ODbL share-alike (datacenters, dams) + +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). + +The bundled public-release copies omit contact-oriented tags and any note value +that contains an email or phone identifier. Those fields are not used by the +application. This privacy transform does not change feature geometry, identity, +name, operator, capacity, or river metadata, and the resulting derived databases +remain under ODbL 1.0. + +### NASA FIRMS acknowledgement + +> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS). + +FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the +`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21, +Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY +transaction quota. Requires a free `FIRMS_MAP_KEY` +(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. +The former bundled 2026-05-25 snapshot was removed 2026-07-16. + +### Natural Earth physical regions (`natural_earth/`) + +Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ — +fetched from the canonical `nvkelso/natural-earth-vector` GitHub repo, commit +`ca96624a56bd078437bca8184e78163e5039ad19`, 2026-07-28): `ne_10m_geography_regions_polys` +(mountain ranges, deserts, plateaus, peninsulas, islands, …) → `regions.json` and +`ne_10m_geography_marine_polys` (seas, gulfs, straits, bays) → `marine.json`. They back the +voice-annotation resolver's named-natural-region lookup (`src/data/naturalEarthRegions.js`), +so "outline the Alps" draws the real range polygon offline. + +Curation (provenance in each file's `meta` header): named features only, outer rings only, +Douglas-Peucker simplified at ~0.01° with coordinates rounded to 3 decimals, sub-20 km² +MultiPolygon crumbs and zero-area sliver artifacts dropped (7.3 MB source → 2.5 MB pack). + +Natural Earth is **public domain** (no permission needed, no attribution legally required — +https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made with Natural +Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver +wiring (see below). + +### DataSF Analysis Neighborhoods (`neighborhoods/`) + +`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official +**"Analysis Neighborhoods"** dataset (41 neighborhood polygons; DataSF dataset `j2bu-swwd`, +catalog map view +[`p5b7-5n3h`](https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h)). +It backs the voice-annotation resolver's offline neighborhood-boundary lookup +(`src/data/neighborhoodPolygons.js`), so "outline Chinatown" draws the city's real +boundary polygon with no network dependency. + +The dataset is licensed **PDDL 1.0** (Open Data Commons Public Domain Dedication and +License — public domain; the DataSF metadata declares `licenseId: "PDDL"`). No attribution +is legally required; we note the source here and in the folder's `SOURCE.md`, which records +the retrieval date (2026-07-30), exact download URL, license evidence, and the +deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m +Douglas-Peucker simplification, 6-decimal rounding). + +--- + +## In-app attribution + +The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..8cff5265a --- /dev/null +++ b/LICENSE @@ -0,0 +1,54 @@ +MIT License + +Copyright (c) 2026 Bilawal Sidhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +NOTE ON THIRD-PARTY DATA AND ASSETS — THE MIT LICENSE ABOVE COVERS THE SOURCE +CODE ONLY. + +The datasets bundled under src/data/local_data/, and all data fetched from +third-party providers at runtime, are owned by their respective sources and +are NOT licensed under MIT. They are included/visualized for convenience and +remain subject to their own licenses and terms. Notably: + + - TeleGeography Submarine Cable Map (src/data/local_data/ + telegeography_submarine_cables/): CC BY-NC-SA 3.0 — NonCommercial + + ShareAlike. NOT for commercial use. If you use this project commercially, + remove these files or obtain a commercial license from TeleGeography. + - Datacenters and Dams (OpenStreetMap / Open Infrastructure Map extracts): + Open Database License (ODbL) 1.0 — attribution + share-alike on the data. + - NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation + requested). + +Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS, +OSM Overpass, City of Austin, GBFS) are fetched at runtime under each +provider's terms; some (e.g. Google Maps, OpenSky) restrict commercial use +and require you to supply your own credentials. + +In short: the MIT grant does not extend to any third-party data. See +DATA_SOURCES.md for the full per-source license + attribution summary, and +remove any dataset whose terms don't fit your use. + +The 3D models under public/models/ are also third-party works and are NOT +licensed under MIT. They remain under their individual licenses. See +public/models/README.md for the author, source, license, modification notice, +and attribution covering each model file. diff --git a/README.md b/README.md index 0610bda31..c041b8835 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,371 @@ -# God's Eye View +
-From the project behind the viral God's Eye View series, formerly known as WorldView. +# 🌐 God's Eye View -

- Live flights, vessels and submarine cables on the globe - Thermal visual style over Palm Jumeirah -

-

- Thermal visual style over live air traffic - Public camera feeds projected into 3D -

+### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real. -You asked, so it's happening. Code is in final testing ahead of public release. +Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras, with clearly labeled modeled views where a live feed is unavailable. Hands-free voice control powered by a realtime AI agent. -**Updates** +*No place left behind.* -- **Aug 18, 2026** — release candidate in trusted testing (thanks!); public release by August 24, 2026 -- **Jul 30, 2026** — launch date set: August 18, 2026 -- **Jun 22, 2026** — placeholder repo created +![Orbital HUD, a tracked live globe, FLIR terrain — then OPEN SOURCED](docs/media/hero-open-source-reveal.gif) -⭐ Star this repo. Get notified by email via [Map the World](https://maptheworld.ai/). + + The God's Eye View video series on YouTube + -Community survey about open sourcing WorldView and God's Eye View +▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) + +
--- -Live open source spatial intelligence on a photorealistic 3D globe. Half the magic is that it feels like a forbidden cockpit — then you realize the sources are public and the code is inspectable. +
-God's Eye View live globe preview +**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)** -▶️ Watch the video series [[5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q); [25M+ on Socials](https://www.google.com/search?q=god%27s+eye+view)]. +
- - Bilawal Sidhu YouTube popular videos - +--- + +## 🌍 Why This Exists + +**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it. + +Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control. + +> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable. + +The live layers are grounded in public feeds: the airliner crossing your screen is reporting telemetry, the camera is installed at a published location, and the ISS position is propagated from current orbital elements. The client deliberately renders flights one polling interval behind real time so it can interpolate smoothly. Some experiences are modeled rather than live: keyless traffic is labeled as a simulation, camera poses are estimated until calibrated, and launch ascent playback is marked `RECONSTRUCTED ESTIMATE`. Each layer keeps its source and freshness state visible, including partial, delayed, simulated, and unavailable states. + +--- + +## 🎛️ What This Thing Does + +- **🛩️ Cockpit view:** Ride inside a tracked flight — the camera holds the terrain under you all the way down. +- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. +- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. +- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. +- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. +- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow. +- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. +- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. +- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave. +- **🎥 Scene director:** Capture cinematic camera tours for clips and demos. +- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark. +- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth. + +--- + +## ⚡ Quick Start + +Requires Node.js 24.14.x or 26.x (enforced by `package.json`). + +1. Copy `.env.example` → `.env` and set `GOOGLE_MAPS_API_KEY`. +2. Install and run: + +```bash +npm install +npm run dev -- --host localhost --port 4173 +``` + +3. Open **`http://localhost:4173`**. Cold start settles in under two seconds on a recent laptop (median 1.86 s in a point-in-time M5/Chrome capture — [docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware requirement). A first-run card offers to stage a mission for you — **Live Contacts**, **Space Missions**, **Environmental** — or leaves you to explore manually. + +**That one key is the whole entry fee.** Everything in this README is color-coded — 🟢 needs nothing · 🟡 free key · 🔴 metered — and Google Maps is the only 🔴 you need: it buys the photorealistic planet, and most of the globe lights up 🟢 from there. Full map in [Keys & Costs](#-api-keys). + +The dev server binds to **localhost** — your keys stay on your machine. Sharing on a LAN and the cost rails live in [Keys & Costs](#-api-keys) and [SECURITY.md](SECURITY.md). + +**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls your keys straight from the Keychain. + +--- + +## 🕐 The First Five Minutes + +No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo: + +1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up. +2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR. + +![Riding with a live aircraft in cockpit view while switching sensor modes](docs/media/06-cockpit-ar.gif) + +3. **Drop into a busy airport.** Search one and descend to the taxiways with **3D** aircraft on — grounded contacts, taxi trails, the whole apron working in real time. + +![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif) + +4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind. + +![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif) + +5. **Track something in orbit.** Turn on **Satellites** and click the ISS — you ride along at orbital distance, orbit ring and all. + +![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif) + +6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor. + +![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif) + +7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."* +8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."* + +**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out. + +--- + +## 🛩️ The Cockpit + +> Every plane should let you do this. + +Real-time cockpit mode, built from live flight data: the camera rides your contact with real terrain holding underneath, all the way down — sensor styles come along for the ride, and **Contacts** keeps the 250 km roster one click away: jump plane to plane and fall straight into the next cockpit. + +![Jumping between live aircraft and falling straight into a cockpit view](docs/media/12-switch-aircraft-cockpit.gif) + +The cockpit even carries its own briefing strip: nearby live signals, regional headlines, and real local weather — with an opt-in **WX** mode that renders volumetric clouds from actual observations around your aircraft. + +![A live military contact ridden through Normal, NVG, and Ironbow FLIR with dense detection](docs/media/start-here/military-cockpit-dense-google-3d.gif) + +*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.* + +--- + +## 🎙️ Talk to It + +> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. + +Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote: + +- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows. +- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry. +- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels. +- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director. +- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. + +Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook: + +**🎥 Direct it** — drone-operator camera verbs: +> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."* + +**🖊️ Annotate it** — a whiteboard over the real world: +> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."* + +![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif) + +![A spoken distance measurement spanning an airport, inspected from orbit](docs/media/04-airport-distance.gif) + +**🔎 Interrogate it** — analyst queries against the live layers: +> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"* + +**🎛️ Operate it** — the whole console, hands-free: +> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."* + +**And the rapid-fire tier** — one sentence each: +> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to Bing aerial."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"* + +![The globe populating with the world's radio stations as another live layer](docs/media/15-global-radio-layer.gif) + +*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.* + +--- + +## 🛰️ What's on the Globe + +Thirteen live layers. **Ten of them need nothing at all** — no key, no account, no signup. (🟢 nothing · 🟡 free key · 🔴 metered.) + +| Layer | What you get | Source | Auth | +|-------|--------------|--------|------| +| 🗺️ **Map Stack** | Google Photorealistic 3D, Bing aerial, OSM | Google / Ion / OSM | 🔴 Google (required) · 🟡 ion for Bing · 🟢 OSM | +| ✈️ **Live Flights** | Thousands of live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **Satellites** | A roughly 840-object core catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | +| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | +| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) | +| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | + +![A reconstructed Falcon 9 ascent climbing and curving into its projected orbit](docs/media/08-falcon9-replay.gif) + +*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.* + +**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712). + +![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif) + +**Missing a layer you want?** Open an issue — or add it and send the PR. + +--- + +## 🎖️ Field Missions + +Once the basics click, run these: + +| Mission | How | +|---|---| +| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. | +| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. | +| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. | +| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. | +| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. | +| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. | +| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. | +| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. | +| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. | +| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. | +| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. | + +*🎙️ = voice missions — they need an OpenAI key.* + +![Resolving a selected aircraft's recent flight path into stacked 3D loops above the terrain](docs/media/07-helicopter-loops.gif) + +*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.* + +![Asking for a walking route and flying the generated path through the 3D city](docs/media/10-walking-route-flythrough.gif) + +*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.* + +![Descending from regional context into dense rows of retired aircraft at the boneyard](docs/media/08-boneyard.gif) + +*Walk the boneyard: rows of retired airframes, fully resolved in 3D.* + +--- + +## 🔧 Under the Hood + +Some of the engineering that makes it feel real rather than like a tech demo: + +- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking. +- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps. +- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker. +- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating. +- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance. +- **Local-first key handling.** Secret-bearing providers such as OpenAI, AISStream, OpenSky OAuth, TomTom, and FIRMS are brokered server-side. Proxy destinations are fixed or allowlisted, and the higher-risk paths add bounded requests, timeouts, response caps, and sanitized errors as appropriate. The only provider credentials intentionally exposed to the browser are Google Maps and Cesium ion; restrict both at the provider. +- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on. + +``` +src/ +├── main.js # Bootstrap: Google 3D tiles, layer registration +├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── hud.js # Intelligence HUD + AI scene summary +├── mapStackController.js # Google 3D / Bing / OSM switching +├── iconOrientation.js # Screen-projected world-space headings + horizon cull +├── voice/ # OpenAI Realtime session + 28 voice tools +├── data/ # One module per layer + management + context store +│ └── local_data/ # Bundled datasets (per-folder provenance) +└── scenes/ # Cinematic scene director +``` + +See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runtime reference. + +--- + +## 🔑 API Keys + +**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real. + +Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**. + +### What you need for the good experience + +Five keys cover the fully keyed experience. Three currently offer no-cost developer access; Google Maps and OpenAI are usage-metered. Provider prices and allowances change, so use the linked pricing pages before relying on a budget estimate: + +| | Key | Why | Get it | +|---|-----|-----|--------| +| 🔴 | **Google Maps** *(required)* | The photorealistic 3D planet ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — metered; [check current pricing](https://developers.google.com/maps/billing-and-pricing/pricing) and URL-restrict it | +| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. Want another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered; [check current API pricing](https://openai.com/api/pricing/) | +| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup | +| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | +| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — check the current developer allowance for your account | + +![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) + +*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.* + +### Cherry on top + +| | Key | Why | Get it | +|---|-----|-----|--------| +| 🟡 | **Cesium ion** | 🗺️ Bing imagery map stacks (public `assets:read` token) | [cesium.com/ion](https://cesium.com/ion) — [check the plan that fits your use](https://cesium.com/platform/cesium-ion/pricing/) | +| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) | +| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) | + +All of them are worth getting. None of them are required to start. + +```bash +# Put keys in .env (see .env.example), or pass them as env vars: +OPENAI_API_KEY="…" AISSTREAM_API_KEY="…" npm run dev -- --host localhost --port 4173 + +# On macOS, store any of them in the Keychain and dev-fresh.sh pulls them in: +security add-generic-password -U -s "google-maps-api" -a "api-key" -w +security add-generic-password -U -s "openai-api" -a "api-key" -w +security add-generic-password -U -s "aisstream-api" -a "api-key" -w +security add-generic-password -U -s "firms-map" -a "map-key" -w +security add-generic-password -U -s "cesium-ion" -a "token" -w +security add-generic-password -U -s "tomtom-api" -a "api-key" -w +``` + +OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth credentials with `./scripts/opensky-import-client.sh /path/to/credentials.json`. + +### 💸 What it actually costs + +Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages: + +| | Cost reality | +|---|---| +| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. | +| **🟡 Optional developer access** | AISStream, FIRMS, TomTom, Cesium ion, and authenticated OpenSky may offer no-cost access, but limits and permitted uses differ. Cesium ion and OpenSky in particular have plan or use restrictions; verify the current provider terms for your deployment. | +| **🔴 Google 3D tiles** | Map Tiles usage is billed by session, with current prices and free-usage caps varying by billing region. Check Google's pricing page, restrict the key, set quotas, and configure a budget alert before sustained use. | +| **🔴 OpenAI voice** | Realtime audio is usage-metered and the total depends on the selected model, conversation length, and audio volume. The app shows a live session estimate, warns at $2, and applies a **$5 in-app session cap**; provider-side usage limits remain the billing backstop. | + +### 🧗 The floor is low on purpose + +Everything above is the deliberately cheap baseline — enough to get a real taste of geospatial intelligence, GEOINT, and OSINT without ever talking to a sales team. You'll also notice the ceiling: terrestrial AIS goes quiet mid-ocean and satellite AIS costs real money; premium imagery, SAR, and the deeper commercial feeds live behind enterprise contracts. That's not a limit of the architecture — every layer here is a pattern you can point at your own data sources. This repo hands you the foundation; what you fuse into it is up to you. + +### 🔒 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, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md). + +--- + +## 📋 Responsible & Open + +God's Eye View runs on **public data, clear sources, and local-first execution.** No secrets, no private datasets, no mystery scraping — anything involving a private key is brokered server-side. It has the visual grammar of a classified ops room, built entirely from open signals and inspectable code. + +**The line.** This project models **events, assets, infrastructure, and systems** — aircraft, vessels, satellites, fires, cameras, cities. It does not build features for named-person search, face recognition, or tracking individuals, and pull requests that cross that line won't be merged. People are not a query type here. + +**Come build it.** This is the canonical live 3D client from the project that kicked off the recent wave of spatial-intelligence tools — and it's a canvas: the layers here are the signals one person could find and fuse. Add a city pack, a data source, a style, a voice tool. It's the window through which you see the world; bring that window to others. + +**Status:** An evolving open-source client for exploration and learning — a fast, hackable foundation, not a hardened production service. Released under the **[MIT License](LICENSE)**. Bundled and live datasets carry their own terms — see **[DATA_SOURCES.md](DATA_SOURCES.md)**. Security model: **[SECURITY.md](SECURITY.md)**. Want to contribute? **[CONTRIBUTING.md](CONTRIBUTING.md)**. + +Media note: Bilawal Sidhu created and owns the 17 capture GIFs on this page. He also published the two README PNGs in the existing public project and authorized their continued inclusion here. Any appearance by Bilawal is included with his permission. These files are project documentation, not MIT-licensed standalone assets. Platform interfaces, trademarks, avatars, data, and third-party imagery visible within them remain subject to their respective owners' terms. See [media provenance](docs/media/README.md) and [source terms](DATA_SOURCES.md). + +> [!IMPORTANT] +> God's Eye View is an exploratory visualization of public and third-party data. +> Data may be delayed, incomplete, modeled, inferred, or wrong. Do not use it +> for flight or maritime navigation, emergency response, medical or health +> decisions, investment decisions, or other safety-critical or operational +> purposes. Verify important information with authoritative sources. + +--- + +## 🧭 What's Next + +First — thank you. To everyone who watched the God-view demos and went off to build their own, and to everyone who kept asking for the code: I'm grateful. And when I polled whether this should go open source, you weren't subtle about it: + +Community survey on open-sourcing God's Eye View + +So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet. + +One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. For that, we're building something cool. More in the future — [halfpixel.ai](https://halfpixel.ai). + +--- + +
+ +▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project + +**🌐 God's Eye View. No place left behind.** + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..191ce855d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# Security + +God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. + +## Reporting a vulnerability + +Please report security issues **privately** — do not open a public issue for anything exploitable. + +- Use GitHub's [private vulnerability reporting](https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new) (Security tab → "Report a vulnerability"), or +- Reach the maintainer directly via the contact on the GitHub profile. + +Include repro steps and impact. We'll acknowledge, investigate, and credit you (if you'd like) once a fix ships. + +## How secrets are handled + +The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (Vite middleware in `vite.config.js`) brokers requests that need private credentials, so the browser never receives those long-lived secrets. Google Maps and Cesium ion are the two deliberate client-side exceptions described below. + +| 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` | + +### Two deliberately client-side keys — restrict them + +These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: + +1. **Google Maps API key** — loads the Photorealistic 3D Tiles in the browser. **Restrict it** (HTTP referrer + API restriction to the Map Tiles API) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — only for the Bing world-imagery map stacks) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. + +> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side. + +Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS the launcher reads keys from the Keychain; on other platforms use env vars or a local `.env`. + +## Server-side proxy hardening + +The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay: + +- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts. +- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. +- **Bounded high-risk paths.** Request bodies and high-volume or attacker-influenced upstream responses are capped where that boundary matters; network paths use explicit timeouts or other bounded lifecycles appropriate to the feed. +- **Sanitized public failures.** Proxy handlers return controlled error messages instead of credentials or raw internal details. +- **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. + +## 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). +- **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**. +- **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. + +## 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). +- All data shown is from **public** sources. See [DATA_SOURCES.md](DATA_SOURCES.md). Respect each provider's terms and rate limits. +- The voice agent receives feed-sourced text (place names, callsigns) as scene context. It is instructed to act only via a fixed set of app-control tools and not to execute arbitrary instructions found in data, but treat model output as untrusted and keep the tool surface limited. + +## Responsible use + +This is an interface for signals that are **already public**. Use it accordingly: respect privacy, follow data providers' terms, and don't represent public-data inference as authoritative intelligence. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..b0e11461e --- /dev/null +++ b/TESTING.md @@ -0,0 +1,220 @@ +# God's Eye View — Testing Guide (voice map-annotation whiteboard + live tracking) + +> [!NOTE] +> This is a **manual field-test scenario script** for the June-2026 whiteboard + +> tracking work. The AUTOMATED gates live elsewhere: `npm test` (unit), +> `npm run test:track` (tracking invariants), and the headless harnesses under +> `scripts/qa-*.mjs` — see [docs/CURRENT-STATE.md](docs/CURRENT-STATE.md) for the full test surface. + +This guide covers the hardened annotation and tracking behavior. Record a voice +note + screenshots as you go; each scenario +lists what **✅ pass** looks like and (where it applies) the **❌ old bug** it replaces. + +## Focus/horizon moving evidence + +With the Vite development server already running on port 4173, capture all four +focus/recession scenarios with a deterministic virtual frame clock: + +```sh +node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \ + --screenshots-dir qa-shots/focus-evidence \ + --json qa-shots/focus-evidence/report.json +``` + +For a quick operator loop, `--smoke` captures only S1 in six frames. For visual +sign-off, add `--headful`; this removes the SwiftShader launch flags and uses +the machine's real GPU. The harness brings its page to the foreground at the +start of every scenario so a headful Chromium run keeps streaming tiles. + +Google 3D remains the default basemap. If its tile stream is the bottleneck +rather than the behavior under test, select an existing map stack explicitly: + +```sh +--basemap bing-aerial +--basemap osm +``` + +Accepted values are `photoreal`, `bing-aerial`, `bing-labels`, and `osm`; Bing +stacks still require the app's usual Cesium ion token. + +Tune both systems without editing source by adding, for example: + +```sh +--params '{"focus":{"dimFloor":0.35,"nearerBehavior":"partial"},"horizon":{"scaleFloor":0.5,"alphaFloor":0.4}}' +``` + +The script never starts the server. During scenarios it pauses Cesium's default +render loop, advances focus time explicitly, and renders each frame before the +screenshot, so computed alpha/scale sequences repeat for identical parameters. +Headless Chromium still forces SwiftShader, so its pixels are relative CI/A-B +evidence only; headful real-GPU output is the sign-off surface. Screenshots use +scenario/frame names and the JSON report records effective tuning plus +per-contact alpha, scale, and screen data. Before each scenario's first capture, +the harness gives the active Google photoreal tileset up to 45 seconds to reach +Cesium's `tilesLoaded`/`allTilesLoaded` condition. Every frame records +`tilesSettled: true|false` and whether that gate applied; a timeout is recorded +as false, never promoted to a pass. Non-photoreal stacks record the gate as +settled and not applicable because no Google 3D tileset is active. + +> [!IMPORTANT] +> Do not use a screenshot for visual judgment unless its report frame records +> `tilesSettled: true`. + +## Setup + +- **URL:** http://localhost:4173 — auto-flies to Austin on load. Give photoreal tiles ~10s. +- **Voice (the real feature):** click **GEV MIC** (bottom of screen) → wait for **LISTENING** → + just talk. It marks the map *as it talks*, without announcing that it's drawing. Click + **STOP** when done. (Needs `OPENAI_API_KEY`; `dev-fresh.sh` injects it from Keychain.) +- **Console (deterministic, no mic/live-data needed):** open DevTools (**Cmd-Opt-J**) and use: + - `window.__gevAnnotations.tour()` — self-running narrated SF tour (camera + marks in sequence) + - `window.__gevAnnotations.demo()` — lays the SF set down at once + - `window.__gevAnnotations.clear()` — erase all marks + - `window.__gevAnnotations.count()` — how many marks are live + +--- + +## What changed (the list) + +**Live aircraft tracking & camera (Batch D)** — civilian + military, mirrored: +- **R14** warm-up: tracked plane glides immediately (no freeze-then-jump / backward snap), + via a display-layer reconciliation that smooths any position discontinuity over ~0.9s. +- **R15** framing: calibrated initial follow distance (altitude-scaled, 3–30 km). +- **R16** trail: head stays glued to the moving icon per-frame; never trails ~1s behind or + pokes out in front of the nose. +- **R13** z-order: screen annotations fade where they'd cover the **tracked** plane + its label. + +**Voice annotation — scoping & honesty (Batch B):** +- **R1** resolver scope: a place name resolves to the *right* thing (building vs neighborhood + vs natural feature vs city) instead of an oversized admin blob. +- **R2** boundary stitching: complex outlines no longer close with bay-spanning chords. +- **R6** route honesty: a routing outage draws a *labeled* straight "direct line", never a + silently faked road. +- **R12** partial honesty: if some targets fail, it says so — no masking partial as success. +- **R10** cache: transient failures aren't negative-cached for the whole session. + +**Annotation rendering & motion (Batch C):** +- **R8** route flow: route dashes **animate** (flow along the path). +- **R9 / R17** perf: idle annotations stop their animation loop; height cache is bounded. +- **R11** no-freeze: large/complex boundaries simplify without locking the UI. + +**Abuse guards (Batch A)** — mostly internal: input caps on `annotate_map`, clear/new-topic +cancellation, hardened Overpass + route proxies. Not much to see by hand (see §4). + +--- + +## 1. Live aircraft tracking (Batch D) — the 3 issues you reported + z-order + +> Needs a live plane. Track by **clicking a plane icon**; **Esc** (or click empty space) untracks. +> If you see no planes, scroll out once — Texas airspace is busy. + +**1a · Warm-up smoothness (your issue #1 → R14)** +1. Click a moving plane. 📸 Snap the instant it locks, then watch ~2–3s. +- ✅ Starts gliding smoothly *immediately*. ❌ *Was:* froze ~1s, then jumped forward. + +**1b · Initial framing (your issue #2 → R15)** +1. On the same lock-on, look at how the camera frames the plane. 📸 Snap the initial view. +- ✅ Plane + label readable with context, no scrolling. ❌ *Was:* jammed in too tight. + +**1c · Trail glued to the plane (your issue #3 → R16)** +1. Keep tracking ~10s. 📸 Snap the cyan trail while it moves. +- ✅ Trail tail stays attached to the plane; nothing pokes out *in front* of the nose; no ~1s lag. + ❌ *Was:* trail lagged a beat behind. + +**1d · Military layer (same fixes, mirrored)** +1. Enable the **Military** layer (left column). Track an **amber** plane. Repeat 1a–1c. +- ✅ Identical smooth behavior. + +**1e · Z-order: tracked plane stays on top (→ R13, trickiest to stage)** +1. While tracking a plane over Austin, GEV MIC: *"annotate downtown Austin."* +2. Orbit/zoom so the plane crosses the annotation's outline or label. 📸 Snap the overlap. +- ✅ The annotation **dims** where it covers the plane/label — plane stays visible on top. + ❌ *Was:* the annotation drew over and hid the plane. + +--- + +## 2. Voice annotation — scoping & honesty (Batch B) + +> All from the Austin default view (no camera move needed). Click GEV MIC, then speak. + +**2a · Scope: building vs region (→ R1)** — say each, watch what gets outlined: +- *"annotate the Texas State Capitol"* → ✅ the **Capitol building**, not the whole state. +- *"annotate Barton Creek"* → ✅ the **creek / greenbelt**, not Barton Creek *Mall* or the city. +- *"annotate downtown Austin"* → ✅ the **downtown district**, a sensible neighborhood-size area. +- *"annotate the University of Texas at Austin"* → ✅ the **campus**, not the city. +- 📸 Snap each outline. ❌ *Was:* incidental names ballooned into giant county/state blobs. + +**2b · Classic SF set (optional)** — first say *"take me to San Francisco,"* then: +- *"annotate the Mission District"* → neighborhood. *"...the Presidio"* → the full former-base + outline (not a single point, not a bay-spanning blob → R2). *"...Chinatown"* → the district. + +**2c · Route honesty (→ R6)** +1. *"draw a route from Austin-Bergstrom airport to the Texas Capitol."* +- ✅ A real **road** route with a flowing dashed line + a labeled callout. +- If a route ever comes back as a **straight** line, it should be **labeled** as a direct/approx + line (not pretending to be a road). ❌ *Was:* silent fake straight-line "routes." + +**2d · Partial-failure honesty (→ R12)** +1. *"annotate the Texas Capitol and the Flibbergibbet Building."* +- ✅ It annotates the Capitol **and tells you** it couldn't find the other one. + ❌ *Was:* reported success and silently dropped the missing one. + +**2e · Clear / new-topic race (→ R4)** +1. *"annotate downtown Austin"* and, right as it starts, *"actually, clear everything."* +- ✅ Marks clear and **stay** cleared (the in-flight one doesn't pop back a second later). +- Sanity in console: `window.__gevAnnotations.count()` → `0` after a clear. + +--- + +## 3. Annotation rendering & motion (Batch C) — deterministic, no mic + +**3a · Full experience (smoke test)** +1. Console: `window.__gevAnnotations.tour()`. 📸 A couple of frames as it runs. +- ✅ Camera flies to SF; Palace highlight → arrow → Presidio **draped** outline → ILM pin → + Crissy Field **route** appear in sequence; marks stay glued as the camera moves. + +**3b · Route dashes animate (→ R8)** +1. During/after `tour()` (or `demo()`), watch the **route** line (Crissy Field shoreline). +- ✅ The dashes **flow** along the path (animated), not static. ❌ *Was:* dashes never moved. + +**3c · Draping & persistence** +1. After `demo()`, orbit the camera around the Presidio outline. +- ✅ The footprint **drapes onto** the 3D ground/buildings and conforms; callouts/rings/arrows + are the hand-drawn SVG style and stay anchored to their real spot. + +**3d · Large boundary, no freeze (→ R11)** +1. Console: + `window.__gevAnnotations.annotate([{type:'area',target:'Travis County, Texas',label:'Travis County',color:'green',footprint:true}],{flyTo:true,persist:true})` +- ✅ The complex county boundary simplifies and draws **without** freezing/janking the UI. + +--- + +## 4. Robustness / guards (Batch A) — mostly internal + +These are server-side / defensive and don't have a clean visual tell. Light checks only: +- Rapid-fire several annotate commands, then a clear — UI should stay responsive and end clean + (`count()` → `0`). +- Nothing here should change normal behavior; flag it only if something feels *broken* (stuck + spinner, marks that won't clear, errors in the console). + +--- + +## 5. General feel — open feedback + +While recording, call out anything in these areas — this is the feedback I most want: +- **Tracking feel:** does follow motion feel smooth/natural, or floaty/laggy/overshooting? +- **Framing:** is the initial tracked view a good "hero" shot, or too close/far? +- **Annotations:** do outlines land on the *right* thing? Are labels readable / well-placed / + not overlapping? Does the hand-drawn style read well over photoreal tiles? +- **Voice:** does it mark things *as it talks* (not after), and confirm only what actually + happened? Any command it misunderstood? +- **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold. + +## If something looks off + +- **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in. +- **A voice mark didn't land:** the place may not geocode — try a more specific name + (e.g. "Palace of Fine Arts, San Francisco"). +- **No planes:** OpenSky data may be momentarily sparse; scroll out or wait a poll cycle. +- **No GEV MIC button / voice errors:** `OPENAI_API_KEY` didn't load — use the console API for + the annotation tests and skip the voice-only ones (§2). diff --git a/assets/god-view-cctv.jpg b/assets/god-view-cctv.jpg deleted file mode 100644 index 55c57f16f..000000000 Binary files a/assets/god-view-cctv.jpg and /dev/null differ diff --git a/assets/god-view-flights.jpg b/assets/god-view-flights.jpg deleted file mode 100644 index ea761d140..000000000 Binary files a/assets/god-view-flights.jpg and /dev/null differ diff --git a/assets/god-view-globe.jpg b/assets/god-view-globe.jpg deleted file mode 100644 index 2ca3ec56d..000000000 Binary files a/assets/god-view-globe.jpg and /dev/null differ diff --git a/assets/god-view-hero.gif b/assets/god-view-hero.gif deleted file mode 100644 index fc3b7a52f..000000000 Binary files a/assets/god-view-hero.gif and /dev/null differ diff --git a/assets/god-view-palm.jpg b/assets/god-view-palm.jpg deleted file mode 100644 index 22ef53638..000000000 Binary files a/assets/god-view-palm.jpg and /dev/null differ diff --git a/config/cctv_sources.austin.json b/config/cctv_sources.austin.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/config/cctv_sources.austin.json @@ -0,0 +1 @@ +[] diff --git a/config/cctv_sources.shinjuku.json b/config/cctv_sources.shinjuku.json new file mode 100644 index 000000000..cc13a5d65 --- /dev/null +++ b/config/cctv_sources.shinjuku.json @@ -0,0 +1,59 @@ +[ + { + "id": "tokyo-shinjuku-east-1", + "name": "Shinjuku Crossing East Cam", + "city": "Tokyo", + "cityId": "tokyo", + "provider": "Pilot Feed Pack", + "sourceKind": "pilot", + "feedType": "mp4", + "url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", + "lat": 35.689614, + "lon": 139.700523, + "headingDeg": 242, + "pitchDeg": -19, + "fovDeg": 66, + "rangeM": 560, + "mountHeightM": 29, + "groundElevationM": 40, + "license": "Demo sample stream for projection pipeline testing" + }, + { + "id": "tokyo-shinjuku-west-2", + "name": "Shinjuku Station West Cam", + "city": "Tokyo", + "cityId": "tokyo", + "provider": "Pilot Feed Pack", + "sourceKind": "pilot", + "feedType": "mp4", + "url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4", + "lat": 35.690976, + "lon": 139.699237, + "headingDeg": 128, + "pitchDeg": -17, + "fovDeg": 72, + "rangeM": 600, + "mountHeightM": 32, + "groundElevationM": 40, + "license": "Demo sample stream for projection pipeline testing" + }, + { + "id": "tokyo-shibuya-scramble", + "name": "Shibuya Scramble North Cam", + "city": "Tokyo", + "cityId": "tokyo", + "provider": "Pilot Feed Pack", + "sourceKind": "pilot", + "feedType": "mp4", + "url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", + "lat": 35.659695, + "lon": 139.700539, + "headingDeg": 26, + "pitchDeg": -20, + "fovDeg": 74, + "rangeM": 610, + "mountHeightM": 30, + "groundElevationM": 35, + "license": "Demo sample stream for projection pipeline testing" + } +] diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md new file mode 100644 index 000000000..c19e23981 --- /dev/null +++ b/docs/CURRENT-STATE.md @@ -0,0 +1,2584 @@ +# God's Eye View Current State + +Updated: August 24, 2026 + +> **2026-08-23 — first-run mission launcher** (`src/firstRunExperience.js`, +> `#first-run-launcher`, styles at the tail of `style.css`). After startup +> settles, a fresh session gets one card offering **Live Contacts · Space +> Missions · Environmental · Explore manually**. No layer and no optional API +> call happens until a tile is clicked. The right-hand DISPLAY rail +> (`pp-toggles`) now starts **collapsed** on a first run rather than expanded — +> a stored collapse state still wins, as before. +> +> **The ENVIRONMENTAL tile is quakes AND fires** — live USGS earthquakes plus +> NASA FIRMS active fires (`layerIds: ['earthquakes', 'local-firms']`), with the +> tile subcopy naming both. **The launcher optimizes for the fully configured +> experience:** it does not trim what it offers down +> to the lowest-configured install. The mission does not branch on whether a key +> is present — everyone gets the same tile. +> +> Keyless, the honest surface is the **layer row**, which reads +> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`, and the earthquakes half +> still delivers in full. The shared loading reducer now treats an explicitly +> declared missing optional key as a configured terminal state rather than a +> failed multi-layer mission, so the global chip completes without showing +> `LOAD FAILED`. A genuine lifecycle or fetch failure still retains failure +> priority. +> +> **Acceptance changed with that ruling (2026-08-23).** `qa-firstrun` no longer +> asserts "a keyless Environmental never shows a failure chip" — that stopped +> being a launch requirement when the tile went back to promising both feeds. +> The Environmental section now **branches on the observed key state** and says +> which branch it took: KEYED asserts both datasets actually arrive and that no +> LOAD FAILED banner appears while the mission runs; KEYLESS asserts the +> layer-row honesty (`KEY REQUIRED`), the quakes half loading, and that the +> deliberate missing-key state never becomes a global failure. +> +> **An INFRASTRUCTURE tile is deliberately absent.** It was built, playtested, +> and cut: one click enabling `local-datacenters` + `local-dams` + +> `telegeography-submarine-cables` puts ~5,700 entities on a full-earth view and +> the frame rate goes with them. The layers are unchanged and still reachable by +> hand and by voice ("infrastructure mode" is still mapped). Do not re-add the +> tile before the bundled-infra globe-LOD declutter lands — that is the real +> fix, and it is post-launch work. +> +> **Show policy — it is NOT one-shot.** Precedence, highest first: a share link +> never sees it → `?welcome=0` suppresses → `?welcome=1` replays (past both +> suppressions, for demos/support) → the durable +> `localStorage['gev:first-run-mission:v1'] === 'suppressed'`, written **only** +> by the "Don't show this again" checkbox → the per-session +> `sessionStorage['gev:first-run-mission-session:v1'] === 'dismissed'`, written +> by **every** close path (mission, Explore, ESC). So it returns each fresh +> browser session until the visitor ticks the box; clearing storage un-ticks it, +> which is accepted. Both stores fail open — an unreadable store still shows the +> launcher rather than silently swallowing first launch. +> +> **What a mission may persist (do not "simplify" this).** Layer enablement is +> durable in this app (`gev:layer-state:v2`, written by +> `LayerStateCoordinator._commitExplicit` only for origin `user`/`voice`/`tool`). +> A mission enables **its own** layers at `origin: 'user'` — durable, exactly as +> clicking those rows is, because picking the mission *is* that choice. The two +> Context missions also expand the Context panel, as the visible tabs do; the +> globe missions open no panel. Everything else is off limits: detection +> mode/density, `gev:detection-allocation:v1`, 3D models, feather, and above all +> `_detectionUserOverridden` — setting that flag means "the operator hand-edited +> detection" and would silently disable the CRT/NVG/FLIR auto-preset contract for +> the session. The full table is a comment block in the module and is pinned by +> `src/firstRunExperience.test.mjs`. +> +> **Voice is instruction-only.** Both globe missions are expressible with +> shipped tools (`set_layer_visibility`'s enum already carries +> `local-datacenters`, `local-dams`, `telegeography-submarine-cables`, +> `local-firms`, `earthquakes`; `zoom_to_globe` supplies the camera), so +> `GEV_REALTIME_TOOLS` is **byte-identical to `main`** and pinned by sha256 in +> the unit suite. One instruction paragraph in `vite.config.js` teaches the +> phrase mapping; deleting it is the complete rollback. +> +> **ESC arbitration — three rules, do not collapse them into one.** (1) The +> launcher **yields**: a MutationObserver watches `body` for the surfaces that +> take the screen (`cockpit-mode`, `scene-playback-mode`, `recording-mode`, +> `ui-clean-view` — `EXCLUSIVE_SURFACE_CLASSES`, kept in step with the CSS hide +> rule by a unit pin), and session-dismisses rather than contesting the key; if +> one is already up at init it **waits** instead of appearing over it. (2) A +> surface can take the screen with **no class to watch** — the Cesium attribution +> lightbox is full-screen at `z-index: 200` against the card's `175`, which left +> the launcher measurable (`getClientRects()` non-empty) and buried, so ESC +> dismissed a card nobody could see and burned the session flag. `isTopmost()` +> therefore also **hit-tests the card's own centre** with `elementFromPoint`; any +> overlay, classed or not, disarms the handler. Every inconclusive answer counts +> as uncovered, so the guard can never be why ESC stops working. (3) A small +> control that claims only the **key** (a disclosure, a popover) is not something +> to yield to: whoever handles ESC first calls `preventDefault()` **and** +> `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events. +> `stopPropagation()` alone does **not** stop later listeners on the same +> `document` — that is exactly how the compact Radio disclosure made one key +> close the disclosure *and* dismiss the launcher. +> +> **Accepted:** a surface class that never clears means no launcher for that page +> load, with no timeout. None of the four classes is restored at startup, so an +> already-blocked init is an error path, while a long recording or clean-view +> session is ordinary — a "reveal anyway" timer would trade a benign no-show for +> the card punching through a recording in progress. The no-show is benign: the +> handler is inert, no session flag is written, the observer still reveals the +> card if the class clears, and it returns next session either way. +> +> **Blocked storage un-ticks the box.** "Don't show this again" is a claim about +> the future, so a refused `setItem` reverts the checkbox and says so in the +> status line instead of showing a saved preference that was never saved. +> +> Gates: `node scripts/qa-firstrun.mjs --url ` (in-app checks across eight +> independent sections) plus its `--teeth` negative control, which removes +> the launcher and requires EVERY launcher-dependent section to go red — it +> always exits non-zero, `1` meaning the control is healthy and `2` meaning it +> is not. Plus the unit pins above. + +> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render +> governor (`src/renderGovernor.js` — hold/release from every per-frame +> animator; discrete mutators call `governorRequestRender`). Any NEW +> per-frame visual animation MUST register a hold; any new discrete scene +> mutation MUST request a frame — `scripts/qa-perf.mjs` is the gate. The +> circular scope is an explicit canvas (`src/scopeMask.js`, DISPLAY-rail +> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the +> six zero-intensity style stages anymore (those are disabled; see the +> history note in `_initStages`). Hidden tabs stop the render loop. +> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 +> at/above **10 Mm**, so +> faint stars survive in the corners of a TRUE full-globe view, fading quickly +> (smoothstep) to fully opaque by **7 Mm** — every working altitude below that +> is solid black, because there the same 6% bleed reads as smeared geometry. +> FEATHER is unaffected by the terminus ramp and there is NO new slider (its own +> default later moved 35 → 0 on 2026-08-22, 0 → 8 on 2026-08-23, and 8 → 11 at +> the 2026-08-24 final lock; see Current +> Global Post Defaults); hash key +> `sce` pins the terminus and is **clamped to 94..100 on BOTH parse and write** +> (out-of-range clamps into the band; absent or non-numeric = the adaptive +> default), so a shared link can neither freeze the ramp by accident nor carry +> an unsupported sub-94 terminus. Repaints are gated on a quantized 0.005 alpha +> step, so a full 20 Mm→ground descent costs 12 canvas repaints (the alpha span +> sets that, not the altitude span) and a parked camera costs zero. That +> quantization also means the PAINTED value plateaus at each end of the band: +> measured, the painted terminus stays 0.94 from the top of the band down to +> ~9.63 Mm (first step), and is solid black from ~7.37 Mm (last step) rather +> than exactly at the 10 Mm / 7 Mm clamp heights. SCOPE OFF costs less still: +> no height sampling and no canvas work after the single clear on the disable +> transition. The hard-crop (FEATHER 0) path honors the same terminus. + +This is the current runtime/source-of-truth snapshot for the project. + +> [!IMPORTANT] +> **Delta since the July-2 body below** (the detailed sections are still accurate +> for everything they describe; these landed after): +> - **"Never answered yet" is a THIRD state, distinct from empty (2026-08-23):** +> `sourceState` in `src/data/militaryAwareness.js` treats a dependency that is +> busy AND has never produced an answer (`loading === true && !lastUpdate`) as +> unavailable, so the Contacts panel prints `?` and voice says "unknown". This +> is deliberately NOT "busy": a source that has answered once keeps its real +> count through every later refresh poll. It is a CONTRACT over the whole +> 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 +> 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 +> `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 +> one. Its `status: 'idle'` is not what saves it — the lifecycle is; the +> module has no `loading` status at all. +> +> Any new dependency that can be slow must report `loading` and `lastUpdate` +> honestly for the contract to hold, and must be pinned against the shape its +> own module really returns — a fixture that invents a status the module cannot +> emit guards nothing (that is exactly how a hole here survived a green suite). +> - **A held ground snap is dropped when measured ground contradicts it +> (2026-08-23):** the WARM hold described below answers on DISTANCE travelled, +> which is only a proxy for whether the value still describes the ground. A +> contact can taxi ~200 m onto a different surface INSIDE the 250 m bound, and +> since a miss preserves the hold, nothing would ever correct the burial. So +> `heldSnapM` (`src/data/groundSnap.js`) also consults `cachedMeshFloor` at the +> contact's CURRENT position: a measured floor more than +> `HELD_SNAP_CONTRADICTION_M` (5 m) ABOVE the held value drops the hold and the +> contact is COLD again, back to the floored 2D billboard. The rule is +> ONE-SIDED on purpose — it targets BURIAL. A cell reading BELOW a real sample +> is the floor chain's expected under-read (one-shot latch over ~111 m, +> neighbours lean lowest, `displayFloorHeightM` only ever raises), not evidence +> of anything, and a two-sided cut was disproved on the track rig by a planted +> cell 66.7 m below a real sample at the same spot. Accepted residual, stated +> with its condition: a DOWNHILL taxi floats, and the only correction on this +> path is a SUCCESSFUL resample — nothing guarantees one. On the OSM fallback +> `sampleHeight` misses forever, so the float persists for as long as the +> contact stays inside the 250 m bound, with no timer beside it and no vertical +> cap of its own beyond whatever the ground drops within that radius. It is +> accepted because it errs UPWARD and stays visible. Three boundaries are +> load-bearing — that one direction, MESH cells only +> (`cachedGroundFloor`'s DEM fallback is a DIFFERENT surface; the skin/DEM +> spread is what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budget 15 m / 80 m for) and +> the contact's OWN cell only (a neighbour ~111 m away may be a terminal roof — +> `neighborFloorM` leans lowest for that reason — and must never be borrowed to +> DISCARD a real measurement). Do not widen either without re-arguing both. +> - **The trail's acceptance bar is visual (2026-08-23):** +> the trail terminates roughly BACK-CENTRE on the aircraft; MINOR hull overlap +> is acceptable; there is no conspicuous top, bottom or lateral protrusion; it +> is stable across headings; and a parked aircraft draws no moving head +> segment. That is the bar a future change is judged against — NOT sub-metre +> precision. The pins below are tighter than the bar on purpose, because a +> measurable property is what a test can hold, but a pin's tolerance is not the +> product requirement and tightening one is not an improvement to the picture. +> Measured on live traffic at the shipped transform, the airliner anchor sits +> 24.09 m aft (70 % of the model's rendered envelope, so inside it), 2.22 m +> below centre (6 %), and 2e-9 m off the centreline. +> - **The tracked trail attaches to REAL HULL, aft and below (2026-08-23):** +> `MODEL_TRAIL_ANCHOR_NATIVE` (`src/data/modelVisualAnchor.js`) holds, per GLB +> and in RAW glTF coordinates (see the transform-chain entry below), the point +> of the hull's CENTRELINE PROFILE (its y = 0 slice) closest to the aft-belly +> AABB corner. It is NOT the corner: a bounding-box corner is empty +> space, 4.80 m off the nearest triangle on `airplane.glb` and 6.14 m on +> `jet.glb`, so the trail ended in mid air beside the aircraft. The head segment +> is drawn from a point behind the aircraft to this anchor, so the anchor must +> stay aft (83–96 % of each aft extreme) or the segment enters at the tail and +> stops inside the fuselage. `modelScale.test.mjs` reads the POSITION BUFFERS — +> real vertices and triangles, because accessor min/max cannot tell a corner +> from a surface — and re-derives on-hull, aft, lowest-at-its-station, +> on-centreline, and the construction itself. Re-measure, never re-guess. +> - **The trail anchor rides CESIUM'S transform chain, not a hand-rolled one +> (2026-08-23 regression fix):** `MODEL_TRAIL_ANCHOR_NATIVE` stores RAW +> glTF coordinates and `modelAnchorWorld()` assembles +> `modelMatrix × components.transform × axisCorrection` — the same chain +> `ModelSceneGraph` renders with, built from Cesium's own exported +> `Axis.Y_UP_TO_Z_UP` / `Axis.Z_UP_TO_X_UP` and the model instance's own root +> transform. The anchors were previously PRE-CONVERTED by a single glTF +> Y-up → Z-up step (`[x,y,z]` → `[x,−z,y]`) and multiplied straight by +> `modelMatrix`; that is half the correction (the defaults also apply +> `Z_UP_TO_X_UP`, the complete mapping being raw `[x,y,z]` → `[z,x,y]`), and an +> aircraft's longitudinal axis is raw glTF X, so the aft offset landed on the +> RENDERED model's LATERAL axis. `modelMatrix` carries the heading, so both +> frames rotated together and the trail terminated a fuselage-length to one +> SIDE, swapping sides with the course. Measured live in each aircraft's own +> (aft, cross, up) frame: civil airliner (0.00, −81.47, −7.50) before, +> (81.47, 0.00, −7.50) after; rotorcraft (0.00, −73.67, −16.33) → +> (73.67, 0.00, −16.33). **Never pre-convert an anchor** — one transform, +> Cesium's, or a second hand-maintained convention drifts again. The pin sweeps +> headings 0/45/90/180/270/315 across every shipped asset plus a hovering +> rotorcraft and asserts NO lateral component, and it derives its reference axes +> from the ENU frame and the heading rather than through the function under +> test: the first version routed them through `modelAnchorWorld` and passed +> against the very bug it was written for, because a wrong transform rotates the +> anchor and its reference frame together. +> - **A stationary contact draws no trail head, and the head end NEVER gives +> (2026-08-23):** grounded tracking starts a trail unconditionally, and on a +> contact that has not moved the last body point sits where the aircraft is — so +> the head segment became a line from inside the model out to its own anchor, +> through half the fuselage. `trailHeadStart()` decides where that segment +> starts being drawn: nothing while the last body point is no further from the +> model centre than the ANCHOR'S OWN STATION (every millimetre would be drawn +> forward of the attachment point, into the fuselage — a parked contact sits at +> exactly zero), the whole segment once it has cleared the model's rendered +> ENVELOPE, and between them the drawn start slides along the segment so the +> visible length grows CONTINUOUSLY from zero. Three earlier cuts are recorded +> because their shape matters: testing segment LENGTH against the radius HID +> real trail (58 m aft of `airplane.glb`'s 34.41 m envelope gives a 33.98 m +> segment, suppressed, though ~23.6 m of it is open air); CLIPPING at the +> envelope and keeping only what lay outside stopped a moving trail visibly +> SHORT of the aircraft, since a bounding sphere encloses a lot of empty space +> around a slender airframe; and a BOOLEAN containment test flashed 10.33 m of +> trail on and off across 2 cm of travel at the boundary, and again on any fix +> that fell back inside. The END is never cut — that end is the whole point. +> `radiusM` carries `computedScale`, so the verdict is the same at every camera +> distance, and any contact that has moved more than its own size (every +> airborne one: 30 s of flight is kilometres) gets bit-identical geometry to the +> containment rule this replaces. +> - **The AIR bracket alpha floor SCALES with the OUTSIDE slider (2026-08-23):** +> `aircraftBracketAlphaFloor` (`src/data/detectionPolicy.js`) is piecewise +> linear through 0 → 0, **the default → 0.35**, and 100 % → 1.0. The default +> value reproduces the previously shipped flat 0.35 exactly at every keyhole +> alpha, so the approved bracket look is unchanged; a flat floor made every +> reachable stop below 35 % paint identically. `AIRCRAFT_BRACKET_FLOOR_ANCHOR` +> mirrors `KEYHOLE_OUTSIDE_OPACITY_DEFAULT` (kept Cesium-free on purpose), the +> two are pinned together, and **the anchor MOVES WITH THE DEFAULT** — both are +> `0.01` since the 2026-08-24 final lock (`0.03` on 08-23, `0.05` before). That pin is the +> tripwire for a default move: the mapping is pinned to bracket BRIGHTNESS, +> not slider position. The `detection-opacity-slider` `step` is **1** so low values are +> reachable — the mapping was always continuous from 0, but at the +> previous step of 5 the entire sub-default range was one stop wide. Both the +> markup and the ordering of 1–5 % are pinned in `detectionPolicy.test.mjs`. +> - **A grounded contact HOLDS its floor through a terrain outage (2026-08-21):** +> when the Re:Earth proxy fails, the floor cells a grounded contact stands on +> never warm, and the un-clamped render height for a contact reporting no +> altitude at all is the GEOID — ~150 m below the ground at an inland field. +> Both steps now hold instead. At POLL time `geoidSurfaceLastResortM()` +> (`src/data/renderAltitude.js`) withholds the geoid guess from any contact +> that already has a `renderAltitudeM`, so the sentinel path holds that height; +> the guess is reserved for a genuine first sighting. At DISPLAY time +> `_heldDisplayFloorM()` (`src/data/flights.js`) answers with the contact's own +> last resolved floor — valid within `HELD_FLOOR_MAX_DRIFT_KM` (1 km, one +> rollout's worth of travel) of the cell that supplied it — and otherwise with +> a resolved ADJACENT cell via `neighborFloorM()`, which takes the LOWEST of +> at least `NEIGHBOR_FLOOR_MIN_SAMPLES` (2) resolved neighbours and otherwise +> refuses. An earlier cut leaned HIGH, reasoning from "never below the visible +> surface"; that principle is about a contact's OWN measured ground and it +> inverts for a BORROWED cell, as playtesting confirmed — planes +> floating at terminal gates. The errors are not symmetric: too LOW is inert +> (`displayFloorHeightM` only ever raises, so an under-reading floor simply +> does not lift, bounded by one cell of grade), while too HIGH invents a +> position bounded by BUILDING height and a parked contact holds it — measured +> at 29.5 m of permanent float from a lone roof neighbour. A plane at a gate is +> on the apron, never on the roof. The honest residual is the mirror image: on +> a genuine slope the lowest neighbour under-reads, so the clamp lifts a little +> less than it could, which shows up as no lift rather than a wrong one and is +> corrected as soon as the contact's own cell warms. Both tiers are validated +> measurements out of the shared floor cache; with neither available the +> position passes through untouched, exactly as before. Adjacent-cell probes +> are throttled per contact (500 ms) and rationed no further: a probe is eight +> synchronous `Map` reads with no I/O — every DEM request is driven by +> `warmGroundFloor` from the poll loop, bounded there — and 200 synchronised +> all-cold contacts probing on the same tick measure 1.0 ms median / 1.2 ms worst on the all-cold workload (a noisier ad-hoc run of the same workload peaked at 2.1 ms), 1.4% of one 80 ms +> fleet tick (`scripts/qa-floorhold-probe-cost.mjs`). A global per-tick budget +> with a fairness queue was built over that and DELETED: it protected single- +> digit milliseconds and produced two starvation defects. Nothing can starve +> because there is no shared resource to be starved of. +> A floor that moves DOWN under a contact standing on a BORROWED one is +> APPROACHED exponentially (`FLOOR_EASE_TAU_MS`, 360 ms — ~20% of the +> remaining gap per fleet tick, hard-capped at `FLOOR_EASE_MAX_STEP` so a +> delayed or stalled tick cannot close more) from the value currently displayed, rather than +> interpolated from a fixed anchor over a fixed duration. The target moves: a +> second, lower neighbour can warm mid-approach, and re-evaluating a fixed +> anchor against a moved target jumps by the eased fraction of the change +> (measured at 100 m in one tick). Approaching from the displayed value has no +> such seam. Rises are always taken whole, including mid-approach, since an +> eased rise is time spent under the mesh; a change between two resolved floors +> keeps its existing timing. The hold state is retired the moment a contact +> stops being a grounded billboard (airborne or model-owned), but the floor +> itself is PARKED as a rehydration seed rather than destroyed: deleting it +> outright let an `on_ground` flap through a rotation cold-start the contact +> under the runway (observed with VIR138M at JFK). **A seed is a memory, not +> a reading**, and three bounds keep it honest. `HELD_FLOOR_MAX_DRIFT_KM` +> refuses it more than a kilometre from where it was measured. +> `FLOOR_SEED_GRACE_MS` (90 s, three polls) expires it on wall-clock age, +> judged BOTH while the contact is away and AGAIN at the moment it re-grounds — +> a contact that makes no calls in between (off the poll on a cruise, outside +> the corridor radius, tab hidden) never reaches the first check, and an +> earlier cut that only had that one reused a floor parked 198 s earlier. And +> the seed ranks BELOW the neighbour tier: two freshly resolved adjacent cells +> overrule it, which is what stops a contact re-grounding half a kilometre +> away from floating on the field it left (measured before that rule: a 200 m +> seed held over a 100 m/105 m neighbourhood, 100 m in the air). So a short hop +> back onto the same apron inside the grace window and the drift bound DOES +> reuse its floor — deliberately, and only while nothing fresh contradicts it. +> What starts clean is a genuine departure. A model→billboard handoff retires +> and rehydrates by exactly the same rules. What none of this fixes is the flap +> tick ITSELF: the display clamp passes airborne positions through by design +> (an airborne height is the fix-time clamp's job), and at a sea-level field +> the airborne fix IS baro + geoid N, ~4 m under the runway. That is an +> accepted one-tick transition residual, and +> `scripts/qa-floorhold-staircase.mjs` §F1 counts every tick so it stays +> visible: 1 of 23 ticks below the runway, all of it that airborne tick, 0 of +> 22 grounded ticks — against 12 of 22 grounded and not recovering before the +> seed existed. **A third tier that read the rendered mesh +> where no DEM existed to validate it was built and REMOVED** — measured +> against a real GPU with the proxy down, it recorded a coarse-LOD 20.6 m for +> ground that is really ~122 m; `tilesLoaded` goes true while coarse tiles are +> what is loaded, so without a DEM prior there is nothing to tell a surface +> from a mis-hit. Gates: `scripts/qa-floorhold-mutations.mjs` (22 named +> defects, each reverted individually and required to go red) and +> `scripts/qa-floor-hold.mjs` (live, real GPU — the proxy is failed mid-run and +> the contact is measured against `scene.sampleHeight`). This floor-hold path +> currently applies to `flights.js`; `militaryFlights.js` does not use it. +> - **Screen picks are validated before conversion (2026-08-21):** anything that +> comes back from `scene.pickPosition()` must clear +> `isPickedWorldPosition()` (`src/data/scenePick.js`) before it is converted +> to a Cartographic. The guard is a magnitude BAND — 6,000,000 m to +> 1,000,000,000 m, plus finite components — not a null check, because a depth +> read over empty sky can return a Cartesian that Cesium mishandles three +> different ways: non-finite throws `DeveloperError: normalized result is not +> a number`, exactly `(0,0,0)` returns undefined, and a near-center value such +> as `(500,0,0)` converts SILENTLY into a point 6,378 km underground that +> reverse-geocodes as 0°, 0°. The floor sits ~346 km below the smallest real +> surface magnitude (WGS84 polar radius 6,356,752 m); the ceiling is ~24× +> geostationary, so no real contact is rejected. **A degenerate pick is a +> MISSED pick:** the cascade in `getViewTargetCartesian()` and +> `pickWorldFromScreen()` falls through to `pickEllipsoid` and then the globe +> ray, and callers receive the same `null` they already handle for a miss — +> there is no new sentinel. Two consumers had no owner for a throw and were +> hardened to match: the moveEnd view-target prewarm runs inside +> `requestIdleCallback` (now catches and reports once per viewer at +> `console.debug`), and `IntelHUD._updateSummary()` awaits its context INSIDE +> a guard, because every caller invokes it as `void this._updateSummary(...)` +> and a rejection there is ownerless. Gate: +> `scripts/qa-view-target-prewarm.mjs`. +> - **Scene playback ownership + reconcile (2026-08-20):** five corrections to +> `src/scenes/director.js`, with the pure decisions in `src/scenes/scenePolicy.js`. +> (1) A shot reconciles ONLY the layers it declares. The old walk forced every +> undeclared layer off, so a recipe authored against the original four layers +> tore down CCTV, vessels and fires with no restore pass. Operator captures +> snapshot the whole registry, so those still reconcile in full. +> (2) Playback claims the camera through `runImmediateNavigation('scene', …)` +> instead of a bare `camera.flyTo`, so it releases the follow camera, respects +> the navigation policy and lets Cockpit refuse before anything mutates. +> (3) **Playback NEVER re-establishes tracking.** `SCENE_TRACKING_PARAM_KEYS` +> (`selectedFlightsTrackingId`, `selectedMilitaryTrackingId`, +> `selectedSatTrackingId`) are stripped on the way to the layer — a shot +> captured while following a contact would otherwise hand the camera straight +> back to the follow loop it was just taken from, recreating the two-writer +> jitter. The keys stay in the STORED capture; only the apply path drops them. +> A unit pin sweeps every layer's `getParams()` for the whole selection naming +> family (`SCENE_SELECTION_PARAM_PATTERN` — `selected…`/`tracked…`, `…TrackingId`, +> `…Mmsi`/`…Norad`/`…Icao`), and each match must be on the strip list or on +> `SCENE_KEPT_SELECTION_PARAM_KEYS`; a name like `trackedVesselMmsi` therefore +> cannot slip through by not matching the older spelling. CCTV's +> `selectedCameraId` is the one recorded keep: it raises a monitor plane and +> never writes `viewer.trackedEntity` or the camera. +> (4) An **isolating Context mode is exited before a shot's layers apply**. +> Space Missions refuses every enable outside its replay bundle, so with it +> left dirty all five recipes were refused (or, for Orbital Watch, composed +> over a replay they never declared). The verdict is read off the shared guard +> (`contextLayerEnableBlockReason`), not a mode name, so a future isolating +> mode is covered — the probe id `SCENE_EXCLUSIVITY_PROBE_LAYER_ID` is +> reserved by a test against the real layer registry. The exit itself is the +> ordinary `setContextMode('off')` exact-restore path and is deliberately NOT +> abortable: leaving the mode IS the restore to the operator's pre-mode state, +> and tearing that transaction in half would strand Context. A refused +> `setEnabled` is surfaced in the status line and a `shot_layers_refused` +> telemetry event instead of being reported as success. **Mixed accepted / +> refused layers are reported honestly, not rolled back** — a shot whose +> enables partly failed leaves the layers that did move in place. +> (5) **Cancellation cancels the work, not just the next step.** Checking a +> flag after an `await` only stops what has not started, so both awaited +> operations are now themselves cancellable. Layers: the run/LOAD owns an +> `AbortController` whose signal is passed to every `setEnabled`, and STOP, +> supersession, and run teardown abort it — the data manager rolls an aborted +> enable back through the module's own `disable()`, so no layer is left on +> carrying stale params. Visuals: `styleManager.applyVisualState(state, { +> isCurrent })` gates BOTH halves of the map-stack switch, which is its only +> suspension point. The switch is a *mutation*, not just a wait, and +> `mapStackController` invalidates a switch only when another `setStack()` +> arrives — a winning state that omits `mapStack` (every normalized shot does) +> never issues one, so a stale switch would otherwise stand on the globe. +> So: an already-superseded caller never starts the switch, and one superseded +> *during* it puts the globe back to the stack the winner inherited — but only +> while `getSwitchGeneration()` shows no newer switch has claimed it, because +> a newer switch is a live intent that must not be stomped. The shader-uniform +> commit after the await keeps its own gate. Precisely: a stale LOAD's +> *synchronous* prelude (style/bloom/HUD) can still have landed before it was +> superseded, and is then overwritten by the newer state; what cannot survive +> is its map stack, its uniform commit, its layers, or its camera flight. +> Post-`await` flag checks remain as a backstop. Known remainder: run cleanup +> still waits on a suspended +> visual/map operation, because `setRecordingMode(false)` is not idempotent +> (a second call restores HUD `auto` rather than the operator's saved mode) — +> worst case is recording chrome staying up until the promise settles, which +> then restores correctly. +> - **Ambient contact labels (2026-08-20):** detection callsign callouts paint +> on the shared normal-blend world-overlay canvas as their own host lane +> (`detection-callouts`) — NOT on the screen-blended sensor surface. `screen` +> can only lighten, so a dark backing plate drawn there is a no-op over sunlit +> ground and the text dissolves into the imagery. Brackets, the scanline wash +> and the mode banner stay on the sensor surface; the callout lane registers +> in the same `detection` slot, so callouts keep their z-position beneath every +> ordinary overlay card and beneath the tracked readout. Plate fills come from +> per-theme `calloutPlate` / `calloutPlateSpace` tokens (~63% / ~73% of the +> tracked card's `CARD_PLATE_ALPHA`), resolved once per style change; space-tier +> contacts take the heavier one. Rows are pooled, and an empty field must clear +> the replay buffer or the final callsigns strand on the canvas. **Do not move +> callouts back onto the sensor surface, and do not merge the plate tokens into +> `labelBg`** — that token is the scanline wash and shifting it retunes the +> sensor texture. Plates are additionally **backdrop-selective (2026-08-21)**: +> `skyBackdropFactor()` (`src/data/iconOrientation.js`, exact scaled-space +> ellipsoid silhouette) feathers the plate FILL ALPHA to `SKY_PLATE_SCALE` +> (0.18×) for labels above the horizon — sky backdrops read as near-bare text, +> terrain backdrops keep the full plate — across a smoothstep band of +> `HORIZON_FEATHER_RAD` (~1.09°/side). Only the plate alpha feathers; text, +> tier accents, leaders, brackets, and the tracked readout card are untouched. +> **The test is TWO-REGIME, and only the first regime is a ray test +> (2026-08-22).** Above the ellipsoid, `1` means the view ray genuinely misses +> the planet and the function is the exact complement of the occluder. **At and +> below the ellipsoid the horizon is EYE LEVEL** — the local geodetic +> horizontal plane through the camera — and that is NOT a ray-miss test: from a +> −18 m camera the ray to a contact 900 m up crosses the ellipsoid and still +> reads sky, deliberately, because the ellipsoid it crosses is not a surface +> anyone can see. It is reached by clamping the tangent cone's half-angle at +> 90°, the continuous limit of the same formula (the horizon dip goes to zero +> at the surface), and is the convention Cesium's `EllipsoidalOccluder` already +> uses there, so the two stay sign-consistent. This is not an edge case: +> coastal airports sit at NEGATIVE ellipsoid height (JFK ramp ≈ −30 m, geoid +> ≈ −34 m), so a ground-level cockpit is genuinely inside the ellipsoid and +> **must not** be treated as degenerate — doing so put a full plate behind +> every label on an empty sky. Fail-closed now covers only unanswerable input +> (null, zero-length ray, non-finite camera, camera at the planet's centre). +> Note the pairing: from such a camera the occluder culls every contact BELOW +> eye level before detection sees it, so ground-backed labels only reappear +> once the camera clears the ellipsoid. Rendered proof: +> `scripts/qa-cockpit-plates.mjs`. +> - **Required attribution has two named keep-out rules (2026-08-20):** the +> Google/Cesium credit line must stay visible in every state, and below 900px +> two surfaces used to paint over it — the command dock's popover tray (any +> width ≤900px) and the right context rail, which goes edge-to-edge below +> 720px and covered the credit with every dock panel closed. Both now yield; +> the credit itself never moves, shrinks, or hides. **The clearance is not a +> single constant:** `#command-dock` is anchored at `2vh` down to 721px and +> re-anchors to a flat `8px` at 720px while `#cesium-credits` keeps its `2vh` +> base, so anything reasoning "the 2vh terms cancel" is only true in the +> 721–900px band. `src/creditAttribution.test.mjs` is a **fail-closed** cascade +> model: it flattens `style.css`, resolves each anchor by importance → +> specificity → source order, evaluates a 14×11 viewport grid, and fails +> loudly on any construct it cannot resolve (`!important`, `inset`/`margin` +> shorthands, unvetted custom properties, unparsable or nested media queries, +> or an unrecognized selector positioning one of these elements). Extend the +> model rather than working around it — a silent skip here ships a ToS +> violation. +> - **Dock tray stacking is decided by ID count (2026-08-20):** the pinned-tray +> selectors `#command-dock.dock-has-two-pinned-trays …` carry one ID against +> five classes, so any narrow-width override written with two IDs outranks +> them and the upper tray silently loses `var(--dock-lower-pinned-height)`, +> landing on its pinned sibling. The ≤900px and ≤720px overrides therefore +> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding +> a new tray rule means checking it against the pinned variants, not just +> against the base rule. +> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed +> readout now follows a free-text geocode search as well as preset pills +> (`src/locationStatus.js` owns the copy for both), and every other camera +> destination invalidates the searched label — `_stampNavigation` covers +> voice/reset/takeover/selection, and scene playback calls the public +> `clearSearchedLocation()` per shot. `#command-dock` still hides +> `.location-mini-status` with `display: none !important`, so none of this is +> on screen; a `display:none` subtree is also out of the accessibility tree, +> so nothing is announced. Unhiding it is a separate product decision. +> - **The Street Traffic sync chip shows one percentage (2026-08-20):** the +> settled confirmation flash carries the layer's coverage figure and NO +> progress number. `reduceTrafficSyncFeedback` returns an empty +> `progressText` once the sync lands and `#traffic-sync-progress:empty` +> collapses the slot; the renderer writes the empty value rather than +> guarding on truthiness, or the busy `...` strands beside the settled label. +> - **`#active-style-name` has exactly one writer (2026-08-20):** the style-name +> mapping in `setStyle`. Location, search, and scene paths report where the +> camera is through the LOCATION surfaces, never the style slot. +> - **Share-link selected-subject Follow (2026-08-20):** a copied v2 link adds +> an ephemeral `at` epoch-seconds field; ordinary live hash updates omit it. +> A shared Flights, Military Flights, or Satellites selection restores only +> after the base destination camera, ordinary layer restoration, and a new +> destination-scoped source refresh settle. The source module—not lifecycle +> success or the UI—owns the final presence decision: Flights and Military +> use the exact accepted snapshot, while Satellites waits for the applicable +> dense catalog and treats a partial CelesTrak catalog as unable to prove +> absence. A found subject starts the normal moving Follow at its current +> position regardless of link age. An authoritatively missing subject is +> `expired` only when copy age is strictly greater than 90 seconds for Flights, +> 45 seconds for Military, or 5 minutes for Satellites; equality, missing or +> malformed time, and other non-found cases are unavailable. Feed/catalog +> failure has its own feed-unavailable message. These warnings use the +> universal top-center status banner and its standard failure dwell, beginning +> only after the shared-view startup cover clears. If +> teardown or disable invalidates an in-flight refresh, any selected-subject +> restore waiting behind it settles as cancelled instead of remaining pending. +> Terminal non-found cleanup compare-clears only the exact passive ID in memory and the live URL, never +> recipient local storage. A newer explicit selection, visibility request, +> destination, pointer gesture, wheel gesture, destroy, or source cancellation +> wins and suppresses late Follow/status work without cancelling unrelated +> shared layer visibility or options. Radio station selection remains outside +> 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 | +> 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 +> then stops at a terminal `down` with a slow 15-min retry running behind it +> (a retry never flips the chip back to "connecting" — only real data clears +> `down`). Liveness credit requires a frame that arrived on a still-owned +> socket AND decoded into a real AIS record: handshakes, malformed frames and +> error envelopes are never liveness, and orphan frames are dropped entirely. +> Failures are CLASSIFIED — auth rejections (error envelope, HTTP 401/403) +> are terminal with an hourly probe and an actionable chip, 429 honours +> `Retry-After` and otherwise enters at the slowest rung, and only genuine +> transport faults use the ladder; worst case is single-digit connection +> attempts per hour in every class. Degraded states stay visible in the chip +> even while cached vessels are still drawn. +> **Locked invariants — do not "fix" these:** teardown is `ws.terminate()`, +> never `close()` (the built-in WebSocket has no hard-abort and its `close()` +> never completes against a black-holed peer, leaking the single per-key +> connection); socket generations are monotonic for the module lifetime and +> never reused across a dispose, and every socket-map mutation is +> identity-checked (otherwise a pre-disposal close event orphans a +> post-disposal socket and two connections race for the one slot); durations +> use a monotonic clock, wall time only for display. Policy is a pure state +> machine (`src/data/aisWatchdog.js`) returning actions; the socket lifecycle +> is `src/data/aisStreamAdapter.js`, tested directly with mock sockets; the +> transport assumption is pinned in `src/data/aisWatchdogTransport.test.mjs`. +> - **Honest live AIS health:** the vessel layer treats socket connection, +> first message receipt, raw payload rows, and accepted vessel positions as +> separate stages. Each enabled session owns one 30-second first-connect +> grace: an open or connecting socket with no accepted position reads +> `LOADING`, and polls do not restart that deadline. The first accepted +> position ends the grace and establishes freshness; expiry, missing +> credentials, rejected transport, or another definitive failure reads +> `UNAVAILABLE`. Disable/re-enable starts a new isolated session. A socket +> with no received message or no usable positions does not advance +> `lastUpdate` or replace warm accepted vessels; +> warm selection and trail state remain visible as stale/degraded. Late +> responses from disabled, destroyed, or replaced layer requests cannot +> mutate or finalize the current lifecycle. Layer stats expose transport +> status, message time, and raw/accepted row counts for diagnosis. +> - **Vessel/fire camera transfer:** clicking an actionable AIS vessel sprite +> or painted card selects that MMSI and requests one close oblique camera +> transfer; re-clicking the selected vessel refocuses it. FIRMS detection +> sprites and actionable detection cards do the same using a refetch-stable +> identity that includes position, acquisition time, and source satellite. +> Aggregate fire cells remain non-actionable. Painted actionable cards are +> also mirrored into a named, focusable assistive-control list that exposes +> selected state and announces focus only after the backing record accepts +> activation. Global FIRMS cards reject far-side cells before +> filling the bounded overlay cohort; the shared overlay still owns final +> horizon culling. The UI validates world-focus +> requests before releasing tracking, refuses Cockpit-owned moves before any +> camera mutation, and releases follow owners before accepted flights. +> Sibling-owned picks win without clearing the vessel/fire selection or +> issuing a competing camera command. Deferred geocoding stamps intent but +> retains the current owner until a valid destination resolves; immediately +> before flight it rechecks shared navigation authority. Newer destinations, +> voice `move_camera`, `fly_route`, overhead framing, strongest-fire focus, +> vessel/aircraft/satellite tracking, reset, Cockpit entry, or teardown +> make older work and its UI completion inert. Teardown removes immediate +> camera-entry listeners before its first asynchronous restoration step and +> refuses any new immediate or deferred navigation after disposal begins. +> - **Loading, reset, and Display completion:** every registered layer exposes +> one normalized manager loading contract. Enable and disable feedback remains +> lifecycle-authoritative, while manager-owned periodic updates publish +> refreshing, failure, and recovery without replacing a producer's more +> specific error or availability state. The shared presentation is delayed to +> avoid flashes, visible outside the rails, and retained in +> Cockpit. One continuous overlapping load interval retains the strongest +> terminal outcome (`failed`, then `cancelled`, then `complete`) until every +> participant settles, so a later success cannot mask an earlier failure. +> A participating producer's terminal error, unavailable status, or +> key-required state also outranks generic completion without requiring a +> separate manager failure event; AIS first-connect expiry therefore ends as +> `LOAD FAILED`, not `LOAD COMPLETE`. +> Slow disable work is labeled as turning live data off rather than +> as a completed load. Street Traffic's dedicated sync chip shows genuine +> work and one bounded completion; steady TomTom coverage, including 0%, does +> not keep it open. Mapped Installations reports its bounded camera-driven +> requests to the same shared surface, but full-globe `zoom-in` guidance is +> not presented as loading. Terminal completion, cancellation, and failure +> labels are centered in that surface without an empty detail slot. The circular `RESET GLOBE` action sits beside the top-center share +> control in map view; Cockpit hides the complete action group and provides a +> cockpit-styled `RESET` beside `EXIT COCKPIT`. Both resets share one route +> with the voice action, release continuous/POI/Cockpit/entity and +> Space Mission camera ownership, and returns to the 18,000 km globe frame. +> Reset preserves the selected Contact while invalidating delayed automatic +> refocus work; the normal Context `FOCUS` action is the explicit route back +> to that same flight or vessel after the globe view settles. Location +> navigation uses the same selection-preserving camera +> handoff once a city, landmark, coordinates, or search destination resolves; +> failed searches leave the current camera owner untouched, and Context Focus +> can return to the preserved Contact. Focus also restores the selected +> aircraft's canonical follow frame after a manual zoom-away. +> Visual presets retain the order Normal, CRT, NVG, FLIR, Anime, Noir, Snow. +> Configurable preset selection—including same-style reselection—opens the +> shared Parameters surface directly below Detection and scrolls it into +> view; share-link restoration +> does not force that disclosure. Presets remain in the map Display. The +> shared Parameters surface moves into Cockpit Display for the session and +> returns on exit, with slider values contained by the panel at its supported widths; +> the bottom Visual Presets tray owns the MAP SOURCE label, centered status, +> and four-tile source row. Its compact wing is a keyboard disclosure: +> Enter/Space opens and focuses Map Source, Escape closes and returns focus, +> and unavailable sources remain tabbable with their reason exposed. Expanded left-panel +> headers use the same container-owned background treatment without changing +> their collapsed launchers; a soft 28% cyan divider identifies expanded +> titles on both side rails. +> Cockpit portals HUD, Detection, the single shared Parameters surface, and +> 3D controls, but not the visual-preset grid. Parameters follow the active +> Cockpit vision treatment and remain directly below Detection. Changing the +> top vision style does not close an open Display or Radio utility; explicitly +> opening Display still collapses Live Signals. Its left-side +> Data Layers and Contact interactions do not collapse an expanded +> Display or Radio utility. Presentation-only adaptive collapse is reconsidered after HUD +> and viewport changes, while explicit collapse remains the only persisted +> user intent. +> Display orders 3D immediately above Celestial and Clean UI immediately +> below it. The top-center action group places Clear Layers to the left of +> Share and Reset Globe to the right. Clear Layers turns off the currently +> selected manager-owned data layers, including an active Context choice, +> while retaining visual, HUD, map, and panel settings. A disabled layer may +> still release camera work that it owns through its normal teardown. +> Direct Data Layers entry into Space Missions excludes the new mission ON +> intent from its pre-entry snapshot. Its OFF control therefore leaves Space +> Missions off and restores Satellites to their exact pre-entry visibility +> and parameter state. +> The title and loading logos use a blue 10 px outer-eye stroke with a +> translucent slate fill, while the globe-and-cage gaze travels up to 34 SVG +> units toward the pointer for clearer feedback at the compact title size. +> - **Context, Cockpit, and Radio interaction contract:** explicit Contacts, +> Space Missions, and successful Cockpit actions reveal the Context panel, while +> restoration and replay preserve its prior collapsed state. Contacts uses the +> dedicated right-side chooser; the underlying Global Context coordinator is +> 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. +> 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. +> Expanded Cockpit Display uses a container-integrated header and soft 28% cyan divider +> matching the expanded left-side panel treatment; its collapsed launcher +> keeps the standalone glass surface and muted divider. Cockpit Display and +> Radio use right-rail chevrons: left to expand and right to collapse. +> Cockpit side surfaces use one expanded body per side: Display or Radio +> collapses Live Signals and vice versa. When both utilities close, Live +> Signals reopens unless the user explicitly collapsed it. Expansion notifications +> fire only on a real collapsed-to-expanded transition, preventing repeated close +> synchronization from re-entering the disclosure coordinator. Data Layers collapses +> Contact, while expanding Contact returns that panel to its visible +> launchers. Live viewport-height changes remeasure both utility lanes and +> keep their collapsed launchers inside the obstacle-free corridor above +> Contact and Live Signals. An expanded Data Layers panel is solved against the +> viewport rather than the Cockpit cards: the CONTACT card and the peripheral +> Intel HUD corners stop shortening its corridor while it is open, so it +> unfurls downward from its collapsed launcher position and renders over them +> (`#left-panel-stack` is z-index 147, above the Intel HUD readouts at 146 and +> the Cockpit HUD at 145), scrolling internally when the layer list is longer than +> the corridor. Cesium's credit line is never passable and still bounds the +> corridor. Layer toggles stay live from there, and collapsing returns the +> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden +> for the duration of Cockpit, both as a group and as individual controls. +> It uses the `radar` symbol and provides roving keyboard tab navigation. Its action row +> places the single Cockpit entry before Search Nearby Sites. Cockpit removes +> the duplicate floating map entry and topline exit; the bottom-center +> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus +> transfer and failure-safe shortcut routing. The exit control sits at the +> bottom-center compass position. A Cockpit-only control strip sits +> directly above Live Signals, anchored 12px under the REC readout it shares +> the right margin with and clamped to keep 8px above the briefing card, +> never rising past `max(96px, 12vh)`; Cockpit owns that anchor and +> republishes it every layout tick (the left accordion no longer donates its +> corridor). Its minimal Display popover exposes Intel HUD, +> Detection, Parameters, and 3D aircraft. During Cockpit, those existing +> standard Display controls move into the Cockpit popover and retain their normal +> nested interaction: HUD plus Tactical/Operator/Minimal layout, Detection plus +> Density/Allocation/Fade/Outside tuning, and 3D plus Proximity/All mode. The +> same nodes and state return to the map Display on exit, so Cockpit does not +> maintain a second control state. **Detection is owned by the CONTACTS +> session, not by Cockpit** (validated 2026-08-18): activating Contacts +> forces detection on at the shared military preset (`MILITARY_DETECTION_PRESET` +> — Dense @ 75%, the same frozen object the CRT/NVG/FLIR styles apply), and it +> then stays on for the whole session — cockpit enter, cockpit exit and +> third-person tracking are moves WITHIN Contacts and do not touch detection at +> all. A manual DETECT change during the session holds for the rest of it. +> Deactivating Contacts restores the pre-Contacts state, except that a map +> style chosen during the session keeps its own auto-enable preset (that rule +> is younger than the entry snapshot). The trigger lives on +> `_syncContactsDetection()`, called from `_syncContextModeButtons()` and gated +> on `!_contextModeChanging` so it fires at transaction settle, never at click +> — a failed activation cannot strand detection on. Policy in +> `src/contactsDetectionPolicy.js`. Detection continues to show surrounding +> aircraft in Cockpit while omitting only the active first-person subject's bracket; +> handoffs move that suppression to the new subject and exit restores the +> selected aircraft's map-view bracket. Remaining Cockpit bracket strokes render +> at 45% of their normal presentation opacity to reduce visor clutter; callouts, +> density, allocation, fade, and Outside tuning are unchanged and normal bracket +> opacity returns on exit. AIR presentation follows the same retained 3D mode: +> Proximity uses 150/185 km and All uses 400/450 km. With 3D off, in-range +> contacts remain rotating 2D silhouettes; with 3D on, ready admitted models +> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D, +> out-of-range contacts use rotation-free dots, and exit restores map treatment. +> Its Radio popover exposes compact power, +> transport, station, and volume controls. Cockpit Previous/Next preserves +> selection, autoplay, and broadcaster fallback audio without starting the +> map-view station flights that compete with the first-person camera. These +> are a Cockpit-only accordion: +> each static header uses the left accordion's label and divider with a dedicated +> directional-chevron disclosure button and no full-row hover treatment. Only one +> utility body expands at a time. On desktop, its collapsed sibling remains +> visible whenever both controls fit above Live Signals; a constrained +> corridor gives the expanded utility the full height and temporarily hides +> that sibling, restoring it as soon as room returns. Collapsed Display +> matches the collapsed Data Layers launcher width. Expanded Display uses the +> standard map Display panel's 272 px width, glass shell, header surface, and +> internal spacing. Radio retains its independent compact and expanded widths. +> Display no longer follows the Data Layers corridor: that corridor is solved +> against left-lane obstacles and has nothing to say about the right margin, +> which dropped the strip into Live Signals below roughly 830 px of browser +> height. Cockpit owns the strip's anchor and republishes it on every layout +> tick — including the settling pass after HUD transitions and asynchronous +> map-provider swaps — hanging it 12 px under the REC readout, clamping it up +> to keep 8 px above Live Signals, and never letting it rise past +> `max(96 px, 12vh)`. The utility height is measured from that resolved top +> and floors on a launcher height rather than a fixed minimum, so expansion is +> bounded to the real corridor below the Cockpit topline and above Live +> Signals, including when a tall panel takes over the corridor. A hidden +> sibling is also removed from the accessibility tree, and focus transfers to +> the expanded utility if a layout transition hides the focused launcher. The +> shared map Display, Radio detail, +> ordinary Global Context, and Scenes surfaces stay hidden in Cockpit. Data +> Layers remains available. Its Cockpit-only stack paints +> above the curved speed and altitude rulers, matching the existing Context, +> Signals, Display, and Radio surface ordering without changing map-view +> stacking. Narrow mobile Cockpit +> viewports suppress the legacy layer stack, peripheral HUD, and secondary +> Context/briefing panes so the flight instruments and primary controls remain +> unobstructed. Outside Cockpit, Radio starts collapsed while off. When Context +> is collapsed, its Radio header icon opens compact controls whose explicit +> Enable/Disable action owns power; stable close and full-panel buttons own +> compact dismissal and one-way detailed expansion independently. When Context +> is already expanded, that same icon skips the floating compact card, expands +> the embedded Radio section, scrolls it into view, and moves keyboard focus to +> its disclosure control. The detailed panel's normal accordion control owns +> its collapse. Compact/detailed state and playback continuity remain shared. +> Radio volume in the full, compact, and Cockpit surfaces, plus Space Mission +> replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb, +> glow, and mono value treatment. Their larger transparent hit areas, keyboard +> focus indication, ranges, disabled states, and mission speed scale remain +> control-specific. +> Outside Cockpit, panel collapse state is independent. Multiple expanded +> panels in either desktop lane share the measured viewport-safe corridor. +> If a later panel would receive less than half its intrinsic height, the +> layout presents it as a collapsed, accessible launcher without overwriting +> the user's saved preference; the panel most recently opened by the user +> keeps the lane, so an older expanded sibling yields when necessary. In a +> constrained Tactical lane, later competing panels collapse to their +> launchers even when they would narrowly exceed that threshold. While the +> left lane remains constrained, every collapsed sibling launcher is hidden +> and the primary panel uses the complete safe corridor; the launchers return +> when that panel closes or the stack fits again. An expanded +> Tactical Display claims the right lane and hides every collapsed CCTV and +> Context sibling, including a layout-collapsed Context; those launchers +> return when Display releases the lane. Display itself remains the single +> scroll surface when a visual preset adds Parameters: those rows do not form +> a nested scroll surface. The bottom Visual Presets MAP SOURCE row keeps the +> `3D` status and four source tiles inside its padded tray; the row wraps +> from one row to two at 620 px, and live viewport changes keep the complete +> tray inside the screen. +> Adaptive remeasurement preserves the user's +> scroll position across Tactical, Minimal, and HUD Off layouts. +> During an active Scene run, Clear Selected Layers and Reset Globe remain +> hidden until playback stops or completes because the Scene transport owns +> layer and camera sequencing for that interval. +> The Cockpit Contact summary exposes Previous and Next contact navigation +> plus its collapse control; it does not offer a Focus action because the +> first-person Cockpit camera remains owned by the tracked aircraft. +> A voice Cockpit request that retargets to a filtered civilian or military +> contact carries voice selection authority through navigation, so the +> aircraft entered is also the durable target used by Copy Link and reload. +> Missing context values render as `—` with an accessible “Unavailable” name. +> Because the panel hosts its own Previous/Next controls, the ONLY condition +> that hides it is the absence of a context snapshot. Contact identity never +> gates visibility — hiding on a non-aircraft subject stranded the operator +> the moment Next landed on a vessel or an installation. +> - **Contact readout: foreign subjects and CONTACT LOST.** When the selected +> contact is not the tracked aircraft (a vessel, an installation, another +> aircraft), the panel stays up and keeps its label, cohort counts, nearest +> contact, distance and evaluated time live. Only the nose-relative direction +> arrow and BRG readout are dashed: those two are measured in the tracked +> aircraft's own frame while the rest of that row is measured from the +> selected contact, and rendering both live presents one mixed-frame reading +> as a single measurement. +> A contact that leaves its feed holds the panel in a `CONTACT LOST` state +> (`data-state="lost"`, the same panel-level cue mechanism as `uncertain`, +> in the amber the app already spends on unknown/stale inputs): the last +> rendered values stay on screen rather than being recomputed against a +> position that stopped updating, and Previous/Next stay operable so the +> operator can step off. It fires on two paths, and both retain the snapshot: +> an eviction-origin selection clear (`reason: 'evicted'` — the aged-out +> branches in `flights.js` / `militaryFlights.js`, AIS pin exhaustion, and a +> viewport refresh that drops a selected record), and a refresh whose +> presence check comes back absent. A DELIBERATE clear (click-away, Escape, +> voice stop, layer disable) still clears the subject and takes the panel +> down; an untagged clear defaults to deliberate. +> - **Presence contract (`hasContact`).** `flights`, `militaryFlights` and +> `aisLiveVessels` each expose `hasContact(id)`: `true`/`false` in O(1) from +> the layer's own keyed map, or `null` when the layer holds no data and +> therefore cannot answer (disabled, or not yet loaded). Presence consumers +> MUST use it and must never infer absence from `getAllPositions`, which +> stops at its cap — the live flights layer routinely carries ~11k contacts +> against a 1,000-row cap, so "not in the returned rows" is not "gone". +> `null` leaves the previous verdict untouched, so a silent layer can never +> fabricate a CONTACT LOST cue. +> - **Compact data-attribution panel:** the complete Cesium credit inventory +> remains available without taking over the viewport. Its expanded desktop +> panel is capped at 70dvh/36rem, the wrapped 12px credit list scrolls inside +> it, and narrow screens retain Cesium's full-screen surface with an internal +> scroller. The title, close control, links, and persistent Google/Cesium line +> remain unchanged and visible. +> - **Distant-aircraft recession:** both civilian and military billboard fleets +> retain their locked class/ground scale and `NearFarScalar`, then multiply a +> limb-relative taper in the existing ~12 Hz tick. The taper is 1 below 0.5× +> geometric limb distance and smoothsteps to 0.45 scale plus a 0.35 alpha-haze +> factor at the limb. That treatment eases continuously back to identity from +> 3,500–4,500 km camera height, avoiding a globe-view threshold pop. These +> values, the start ratio, blend band, composed floor, and write epsilon remain +> runtime tunings. No aircraft is count-culled or made fully transparent. +> Focus emphasis and limb haze multiply at one deadband-gated write site, and +> their product is clamped to 0.20 before freshness alpha is applied. Ambient +> fleet models receive that same composed alpha; class/ground/cockpit repaints +> preserve the current limb scale instead of dropping it for a tick. +> - **Focus-aware contact de-emphasis:** civilian/military aircraft and +> satellites publish the selected target's padded screen bounds and camera +> distance from the same per-frame position cache already consumed by their +> tracked entity. Ambient flight sprites, AIS chevrons, CCTV icons, and +> satellite points then ease their own alpha where they compete with that +> target; no draw-order assumption participates. The always-visible rule is +> narrowly amended rather than removed: emphasis never falls below 0.25, +> contacts never blink or disappear, entry/exit use 6 px hysteresis, and +> writes use a 0.005 alpha deadband. Defaults are 18 px padding, 300 ms attack, +> 600 ms release, an 8%-of-target-distance range hysteresis band, and +> `nearerBehavior: 'allow'`; `dim` and `partial` remain runtime/evidence +> tunings. Ambient overlap includes each sprite's own rendered extent. The +> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a +> settled dim contact always completes its release after tracking ends. +> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical +> 5-decimal points individually, reconstructs reordered/overlapping batches +> in exact request order, and refreshes only missing or stale points. Network, +> 429, and 5xx failures receive bounded jittered retries with `Retry-After`; +> stale real heights remain usable per point, while an uncached absent height +> still returns 502 rather than becoming a fabricated ground value. Client +> geoid fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on +> the first later successful fetch. +> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests, +> 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. +> - **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 +> view, keeps the CCTV activation, and surfaces the existing refusal toast. +> Auto-hop and programmatic camera activation remain activation-only. CCTV +> world clicks must stay within 6 px and 400 ms; drag-like or long gestures +> are inert, and re-clicking the already active camera emits no focus request. +> A clean empty-space click clears the active CCTV camera in place without +> moving the view or disabling the layer. Sibling-layer picks and ADJUST-mode +> interactions never trigger that clear. The resulting null selection remains +> stable across rendering and updates; configured auto-hop is held until a +> later explicit activation or AUTO HOP toggle-on. +> - **Tracked-flight close-range feel:** the existing 150 m camera floor is +> unchanged, while the selected-only civilian and military model cap is now +> 200 px so minimum range reads as close. Pointer travel over 6 px suppresses +> selection and empty-space untracking; duration over 400 ms suppresses only +> untracking, so a stationary slow press on a plane still selects it. Escape +> still releases tracking in place. The 200 px feel needs close-range field +> verification; fleet model sizing remains unchanged. +> - **Deterministic sprite stacking:** contact collections reassert the stable +> bottom-to-top order CCTV, FIRMS, bikeshare, AIS, military, then civilian +> flights after every relevant layer init/enable and immediately after FIRMS +> lazily registers its detection sprites. Always-visible contact +> depth settings are unchanged. Cesium OIT weighted blending may soften strict +> alpha layering on some GPUs, so the ordering remains a real-browser check. +> - **Deterministic card stacking:** CCTV, FIRMS, vessel, and tracked-target +> cards use the shared world-overlay canvas. Detection paints through the +> same host/frame contract onto one host-owned blend surface beneath that +> canvas — parented into `#cesiumContainer` so its `screen` blend still +> reaches the WebGL scene. The exact detection, ambient-label, ambient-track, +> ambient-card, thumbnail, selected, and tracked lane sequence is binding; +> the detection callback runs first and z-index preserves its shipped z5 +> position below the z6 cards. +> - **Cross-layer vessel ownership:** clicking a sibling-layer contact leaves +> the active vessel card, HUD, context, and trail unchanged while the sibling +> handles the pick, preventing two camera commands from one click. Starting entity tracking still clears vessel +> inspection; AIS itself never sets `viewer.trackedEntity`. Own unkeyed or +> evicted vessel-record picks and `gev-trail:*` remain no-ops. CCTV choices +> made from the panel dropdown do not currently emit a cross-layer event and +> therefore do not clear vessel inspection. +> - **World-overlay host and Phase 2–6 source migrations:** +> `src/overlays/worldOverlay.js` owns one shared DPR-aware text/card canvas, +> one detection blend surface, and one world-overlay post-render +> scheduler. Both surfaces share the same sizing, clear, projection, +> and teardown paths. The host includes bounded per-source/per-collision-domain +> arbitration, horizon/viewport culling, shared keyhole fading, cached UI +> exclusions, cockpit source gating, pooled hit +> rectangles, and development diagnostics. **UI exclusion is a per-rectangle +> PLACEMENT PREFERENCE over currently visible chrome — it clips no canvas and +> vetoes no entry.** Rectangles are never coalesced into bounding unions +> (that requirement existed only for even-odd canvas holes, and the host +> punches none). Exclusion strength is decided per element by its EFFECTIVE +> stacking level (the outermost positioned ancestor carrying a z-index, i.e. +> the stacking context that competes with `#world-overlay-root`): chrome ABOVE +> the host — map panels z90-1000, the dock, the cockpit windows inside +> `#cockpit-hud` z145 — is a soft preference, so an entry with no +> collision-free placement keeps its full placement set and simply renders +> beneath that chrome. Chrome at or BELOW the host keeps an absolute veto: +> `#intel-hud` is z2, under both host surfaces (detection z5, cards z6), so a +> kept placement there would paint over HUD text. Placement runs two passes — +> prefer variants clear of all chrome, else variants clear of the below-host +> chrome, else drop the entry. Cockpit line art (rims, arcs, rails, toplines, readouts) is not +> in the inventory at all: the AR-HUD model puts world-space content beneath +> the cockpit's screen-space HUD by z-order. Its empty-host path performs no +> post-setup layout reads or canvas work under resize/mutation noise; removed +> entry records are pruned, and text measurement uses a host-lifetime, +> 1,024-entry LRU. Steady-state rendering reuses arbiter output, placement, +> paint-item, and paint-rectangle storage; track display text is rebuilt only +> when its title or detail changes. DOM mutations only flag exclusions dirty, +> with selector/layout scans deferred until there is overlay paint work, and +> host teardown severs pooled record/entry references before releasing every +> pool. A custom paint-lane contract gives source-owned batched painters the +> same DPR-sized context, per-frame view-projection matrix, ellipsoid +> occluder, keyhole geometry, and cached UI rectangles without transferring +> their selection policy into the host. Datacenters and Dams now publish card +> entries into that host on their +> existing 450 ms screen-grid cadence. Each source retains two deterministic +> contenders per grid cell and publishes at most 160 entries. The legacy +> 700/900 winner ceilings are no longer the effective shipped caps; active +> `ambient-card` source budgets sum to a bounded 1,150-card shared lane: two +> 96-card infrastructure budgets, FIRMS' shipped 18-card cohort, AIS's +> existing configured 900-row absolute ceiling, and CCTV's shipped 40-card +> ambient maximum. Runtime AIS demand still +> derives from its shipped 118 px grid: 112 candidates at the 1600×900 +> allocation viewport and 170 at full HD; its existing 150 px greedy +> separation usually admits fewer. The host owns final cross-source declutter +> without letting source count grow beyond the pre-migration source bounds. +> Cards use the infrastructure name plus available operator/capacity for +> datacenters or river for dams. Their native Cesium points, stems, polygons, +> selection/picking, terrain sampling, and enable/disable lifecycle remain in +> `localGeojson.js`; it creates no native label graphics. +> The bundled public-release snapshots omit contact-oriented fields and note +> values containing email or phone identifiers. Runtime cards and entity +> context do not depend on those fields; geometry, identity, name, +> operator/capacity/river metadata, and ODbL attribution remain intact. +> Stem position and +> polyline properties are constant between initialization, camera `moveEnd`, +> and successful near-surface ground samples rather than per-frame callbacks +> or every 450 ms visibility pass. Unchanged or sub-0.5 m tips do not call +> `setValue`, and each record alternates between two preallocated two-position +> stem arrays so real tip changes notify Cesium without steady-state allocation. +> Shared placement records retain the raw anchor plus one signed integer +> leader offset; painters apply that offset without materializing four pairs +> of computed doubles per entry. The arbiter's spread distances and compact +> placement-availability masks live in pooled numeric buffers. Because solve +> occupancy only grows, few-placement identities whose complete set is +> blocked are dismissed once instead of being returned and re-spread; the +> authoritative collision lookup remains the final semantic check. Protected +> entries that cannot separate completely choose the placement with the least +> total protected-rectangle overlap rather than stacking on the first option. +> Cards use the host's shared keyhole fade and global detection fade/opacity +> controls with no source-local edge constants; cached keyhole geometry is +> invalidated by live tuning as well as canvas size, so Fade changes reach the +> next frame. Outside opacity defaults to the shipped floor (5 % when this +> landed; 1 % since the 2026-08-24 final lock, 3 % on 08-23). The +> `KEYHOLE_OUTSIDE_OPACITY_DEFAULT` change from 0 to 0.05 also affects +> detection: callouts and brackets previously hard-culled outside the keyhole +> now paint at the OUTSIDE default (1% since the 2026-08-24 final lock; +> aircraft brackets hold the 0.35 readable floor) and consume ambient budget +> viewport-wide under the same +> fade-don't-cull principle; this remains pending visual review. Cards +> also reproduce the former +> `scaleByDistance` curve (1.0× at 250 km to 0.62× at 9,000 km). +> Mapped Military Installations create no empty native labels; selected names +> remain in the tracked readout. Submarine-cable labels moved into the shared +> host on 2026-08-18 (Option 2, superseding the Phase-5 Option-1 native +> exception): the same nearest-160 bounded cohort now publishes +> `ambient-label` entries on a dirty sweep with exactly two dirty +> conditions — camera `moveEnd` / layer enable / load completion, plus a +> motion fallback that samples the camera at most once per 2 s and only +> re-arms past 250 m of travel since the last swept position, so tracked +> and orbit cameras (which never emit `moveEnd`) cannot starve the sweep +> while a parked camera still costs zero. The former 500 ms timer path was +> removed in the same-day perf round — timer-driven stem re-sizes rebuilt +> the 2,629-instance batched stem primitive mid-motion. The 2,629 reference +> stems are staticized constants (no per-frame `CallbackProperty`), and an +> unchanged cohort is never republished so a parked camera stays +> governor-idle at the source level. +> The 2026-08-18 +> host fix extends that to the HOST level: chrome mutation observation is +> scoped (body childList filtered to inventory-chrome add/remove; +> per-occluder attribute observation element-only, no subtree) and the +> right-rail allocator writes `--right-panel-allocated-height` only on real +> change, so parked idle with ANY live overlay source measures 0 postRender +> fires / 5 s — empty-scene control parity (pre-fix ~56-61; dams +> cross-checked at 0). Genuine chrome changes (occluder add/remove, +> own-attribute flips, resizes) still invalidate; +> `qa-cables-overlay.mjs` gates idle at ≤6 / 5 s. +> - **Phase 6 detection consolidation:** `src/data/detection.js` retains its own +> `LabelArbiter` instance and existing 125 ms solve cadence, density/altitude +> budgets, layer quotas, Elastic/Weighted allocation, manual scalar matrix +> projection, tier palette, batched bracket paths, callout painter, +> acquire/keyhole fades, sparse focus ring, banner, scanlines, suspension, and +> diagnostic object. It no longer creates or sizes a canvas, clears pixels, +> builds a camera matrix or UI inventory, observes layout, or attaches a +> render listener. The host creates and lifecycle-manages +> `#world-overlay-detection-surface`, DPR-sizes it through the same frame path +> as `#world-overlay-canvas`, and invokes detection against its +> plain source-over context. Detection writes the exact shipped theme +> `mixBlendMode` and `filter + drop-shadow(...)` strings to that provided +> element, preserving scene-level CRT/NVG/FLIR glow and once-per-layer +> filtering. The surface is host-owned but **parented into `#cesiumContainer` +> at `z-index:5`**, not into `#world-overlay-root`: the root is a stacking +> context (`z-index:6`), i.e. an isolated blending group, and a surface inside +> it has its `mix-blend-mode: screen` silently discarded by the browser +> instead of compositing against the WebGL scene. z-index (5 under the root's +> 6) keeps it beneath ambient-through-tracked host paint. +> The >22 ms odd-frame relief valve is restored: the host does not clear the +> detection surface on a held frame, while unrelated shared lanes repaint, +> and `throttleSkipCount` remains a live diagnostic. Detection `data-*` fields +> now live on `#world-overlay-canvas`, while the StyleManager diagnostic API +> remains unchanged. Disable and suspension deactivate only the detection +> lane, leaving unrelated host entries intact; teardown unregisters the lane +> before the host is destroyed. The production-shaped allocation gate covers +> 5,000 visible Dense observations at 2,500 km under the unchanged 154 +> B/observation/frame ceiling. The host owns one world-overlay `postRender` +> listener; the repository has five `postRender` listeners total (host, +> celestial ring, annotations SVG, missions frame tick, and traffic). +> - **World-label accounting:** All intended label/card migrations use the +> shared host. The former native `LabelGraphics` exception for cable reference +> labels was retired on 2026-08-18; cable text may render over tile geometry, +> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness +> compass, annotation SVG callouts, and host-owned detection isolation target +> are explicit exceptions; the awareness ring and cockpit contact pips are +> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders, +> and behavior retain their tested presentation; a future annotation style +> system is outside this phase. `src/overlays/worldOverlayTokens.js` is the single home for +> shared world-overlay, CCTV-thumbnail, and detection-theme presentation +> constants. Former source renderers own no canvas, DPR, or world-overlay +> listener path; the duplicate detection rounded-rectangle helper and dead +> tactical compatibility helpers are gone. No temporary dual-renderer flag +> existed to remove. `activeCameraCardEnabled` remains a product +> presentation control, and `_detectionUserOverridden` remains the documented +> style-switch persistence state. Browser/GPU performance comparison remains +> operator-side; the final accounting records only comparable population +> reductions and deterministic Node allocation/solve measurements. Those +> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits +> supported product runtimes on Node 24 or 26, while the allocation runner +> separately enforces Node 24 for these two calibrated probes. Each probe +> compiles synchronously and discards explicit-GC +> transition chunks before applying the unchanged byte ceilings. The unit +> runner executes ordinary test files with Node's default parallelism, then +> runs only the two explicit-GC allocation microbenchmark files sequentially +> and one at a time with `--expose-gc`, so unrelated tests cannot perturb their +> calibrated budgets and each isolated test process has the same GC contract +> as its measurement worker. +> A worker spawn, exit, output, or availability failure is a failing gate, not +> a passing skip. +> - **Phase 5 earthquake labels:** Earthquake disc ellipses and pickable +> entities remain Cesium-native, but their magnitude text is now an +> `ambient-label` source in the shared world-overlay host. The source formats +> `M#.#` text and depth-band accent colors, publishes only the 96 largest +> current events with stable id tie-breaking, and declares a 48-winner +> ambient-label budget. Host keyhole fading, horizon culling, UI exclusion, +> and final collision apply. Disable/destroy clear and hide the source; real +> earthquake entities carry no native label graphic. +> - **Phase 5 bikeshare selection:** The selected station keeps its native cyan +> point highlight, while its station name, availability counts, capacity, and +> operational warnings now publish as one protected selected-lane host card. +> The card reads the point's authoritative Cartesian directly, declares zero +> ambient quota, and therefore cannot be evicted by ambient budgets. Shared +> keyhole fading, horizon culling, UI exclusion, and selected-card paint +> order apply. Clear, disable, and destroy remove the host entry; the selected +> Cesium entity carries no label graphic. +> - **Phase 5 ISS and tracked-satellite labels:** The ISS path and large red +> point remain native, while persistent `ISS` text is a one-entry moving +> ambient-label host source. Its getter reads the already-propagated point +> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS +> propagation. Satellite tracking continues to publish exactly one protected +> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the +> tracked entity is point-only. Tracking ISS suppresses the ambient entry and +> untracking restores it, preventing duplicate ISS text. Disable, orbit-text +> preference changes, catalog rebuild, and destroy clear/hide the source. +> - **Phase 5 active CCTV projection label:** The active camera's monitor plane +> remains native Cesium geometry, while its camera-name label is one protected +> selected-lane host entry. The entry closes over the same cached +> `positions.label` Cartesian updated whenever the plane geometry moves, so +> label and plane retain a single placement authority. Only the active, +> projection-visible camera publishes; hide, disable, runtime destruction, +> and state clear remove the source. Monitor-plane entities carry no native +> label graphic. +> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity +> is a billboard-only camera target with no native label. The flight source +> retains callsign/registration fallback, flight-level/altitude, speed, stale +> state, airline/type, and plausible-route formatting in `gevLabelModel`; the +> one protected tracked-lane host card renders that complete model. Its +> position getter reads only `_trackedDisplayCached`, the same authoritative +> Cartesian already consumed by the tracked visual and camera, and never +> advances dead reckoning from the host frame. +> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil +> and military label surface resolves **callsign → registration → icao24** +> (`_contactLabel()` in `flights.js`; the same chain inline in +> `militaryFlights.js`) — tracked readout, detection card, `getNearby`, +> `getDetectableObjects`, `getAllPositions().label`, `getTrackedSubject`, the +> analyst record, the Context subject/nearest list, the Cockpit signal list, +> and the `track_entity` voice narration. Registration is aircraft IDENTITY, +> not route, so unlike origin/destination it is **not** routePlausible-gated. +> Identity stays `icao24` on every keyed surface (`getNearby().icao24`, the +> detection `sourceId` declutter hashes, and the `id` that `trackById` and the +> Context cohorts resolve) — only the displayed string follows the chain. +> Because adsbdb enrichment can answer *after* selection, the Context subject +> re-resolves its label each refresh (`resolveSubjectLabel()`) instead of +> freezing the selection-time snapshot. +> - **Phase 5 tracked military aircraft label:** The military tracking entity +> is likewise billboard-only and label-free. Its source-owned model retains +> callsign/registration fallback, stale cue, aircraft type, registration, +> operator, altitude, and speed, rendered as the sole protected tracked-lane +> card with the amber military accent. Its getter reads only the military +> `_trackedDisplayCached` publisher, keeping the host, visual, and camera on +> one dead-reckoned frame sample. +> - **Phase 5b Space Mission labels:** Launch markers publish through a bounded +> 48-candidate / 24-winner ambient-label source. Selecting a mission clears +> that overview and publishes its launch-site, stage re-entry, payload +> position, and orbit annotations as protected selected-lane entries; every +> source-formatted line and accent remains intact. Static entries reuse the +> mission geometry Cartesians, the payload getter reads its per-frame live +> cache, and catalog-backed orbit text reads the cache updated with the ring +> matrix. Refresh, deselect, disable, and destroy replace or clear the real +> sources. Mission Cesium entities carry no label graphics, and the shared +> host replaces the former quadratic label overlap pass. +> - **Phase 5 cable depth-testing decision (2026-08-02, REVISED 2026-08-18 → +> Option 2):** the 2026-08-02 ruling kept submarine-cable reference labels +> native (`disableDepthTestDistance: 0`) as the sole approved world-label +> exception so photorealistic tiles could occlude label text. On 2026-08-18 +> that exception was retired after performance measurement: the native +> path evaluated 5,258 `CallbackProperty` channels per frame across 2,629 +> reference entities and re-batched a 160-label `LabelCollection` per sweep, +> costing the layer ~9.5 ms/frame during camera motion (≈42 → ≈59 fps +> measured headless at a mid-Atlantic orbit). Cable text is now a bounded +> nearest-160 `ambient-label` host cohort (`telegeographySubmarineCables.js` +> publishes on the dirty sweep — `moveEnd`/enable plus the 2 s/250 m motion +> fallback for tracked and orbit cameras — skipping identical +> cohorts); stems/points stay Cesium-native, depth-tested, and pickable, so +> only TEXT lost tile occlusion — the same trade every other host label +> already shipped: labels may render on top of tiles. The dedicated +> `submarine-cables` allocation row gates +> the new source at 17,015 B/frame median (106.3 B/candidate, Node 24) +> under a 19,000 budget, and the all-live aggregate row was recalibrated +> with the cable cohort folded in (164,711 B/frame median, 190.6 +> B/candidate, 182,000 budget); the intermediate phase rows keep their +> historical pre-cable composition. `load()` carries a load-generation +> ownership token (the militaryAwareness activationId pattern): a stale +> aborted load bails after every await and never clears a successor's +> lifecycle, so rapid toggle/destroy sequences cannot double-add data +> sources. +> The 639-candidate Phase-5b row (353 painted, 133,769 B/frame, 209.3 +> B/candidate) remains as an intermediate historical gate; as of the +> 2026-08-18 recalibration the all-live aggregate — every shared-host +> source including Radio and the migrated cable cohort — is 864 candidates +> / 398 painted at 164,711 B/frame (190.6 B/candidate) under a 182,000 +> budget. The image-inclusive ceiling remains attributable to CCTV, while +> the isolated mission row measures 108.2 B/candidate/frame under the +> shared 154 ceiling. +> FIRMS severity/source formatting, its pre-existing 150 px greedy selector, +> selected-fire semantics, and LOD distance limits stay in `firmsHeatmap.js`; +> `firmsLabels.js` is formatting-only. FIRMS entries use the host's tactical +> card painter, vertical above/below placement, severity top rule, shared UI +> exclusion/clip, horizon culling, distance-alpha channel, and always-on +> `edgeFade: 'keyhole'` policy. Selected fires are protected in the selected +> lane and bypass both the 18-card ambient cohort and distance fade while +> excluding ambient cards from their footprint. Disable and destroy clear +> the source rather than leaving a stale host entry. This is not a +> pixel-for-pixel port: global collision/UI avoidance can choose fewer cards, +> host horizon culling is explicit, and ordinary map mode now retains the +> shared Outside floor instead of bypassing edge fade through the former +> cockpit/celestial gate. +> - **Vessel card ownership:** `aisLiveVessels.js` retains the 800 ms visibility +> pass, 118 px one-winner grid, priority ranking, 150 px greedy separation, +> type/detail formatting, and the AIS positions produced by its unchanged sea +> datum path. It publishes ambient tactical cards and one protected selected +> card into the host. `vesselLabels.js` is formatting/policy-only: no vessel +> canvas, projection, paint loop, or post-render listener remains. Ambient +> vessels share `ambient-card`; the selected entry paints in the selected lane, +> bypasses the ambient cohort/distance fade, and its protected rectangle +> excludes sibling ambient cards. Tracked readout still excludes AIS, avoiding +> a duplicate card for the same selection. Disable and destroy clear the host +> source. This is not a pixel-for-pixel port: shared cross-source collision, +> UI exclusion, and horizon culling can admit fewer cards than the isolated +> canvas, and the shared always-on keyhole policy now applies the shipped +> Outside floor in ordinary map mode instead of using the interim active-mask +> gate. +> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model +> bridge only; it owns no canvas, projection, post-render listener, layout, +> keyhole fade, or paint path. Civilian flights, military flights, satellites, +> and mapped installations write explicit `gevLabelModel` objects and expose +> `gevDisplayPosition` getters backed by their layer-owned frame/display cache. +> Selected AIS vessels continue using the vessel source's protected selected +> card, so they do not create a duplicate tracked readout. The host registers +> the active readout in the protected tracked lane with a zero ambient quota; +> protected semantics bypass that quota and reserve the painted footprint +> against ambient cards. Moving sources never fall back to a fresh +> `entity.position.getValue()` in post-render. Annotation fade queries the +> host's actual `getOverlayPaintRect('tracked', trackedId)` after layout and +> unions it with the tracked billboard extent. Untrack, context clear, and UI +> destroy clear/hide the source. This is not a pixel-for-pixel port: placement +> is host-rounded, cross-source/UI exclusion and horizon culling now apply, +> accents are source-stable rather than detection-theme-derived, and the old +> screen-coordinate deadband was removed in favor of the authoritative layer +> frame cache. +> Civilian and military poll reconciliation refreshes this model after fresh +> kinematics and again when a missed poll enters the `STALE` grace period. +> Satellite pre-render propagation refreshes its altitude line from the same +> per-frame SGP4 sample used by the tracked dot and camera. +> - **CCTV thumbnail ownership:** `cctv.js` retains the 20/28/40 zoom selection, +> 40-card shipped maximum, 112 px source declutter, eviction grace, frame +> fetching/cadence/retry, stable slot cache, last-success persistence, hover +> pinning, and activation. `cctvCards.js` is now source policy plus pure +> lifecycle helpers only; it owns no canvas, post-render subscription, +> Cesium projection, layout solve, paint pass, or hit store. The shared host +> paints its exact 96×54 thumbnail inside the 104×77 shipped chrome, applies +> the same 1.0→0.45→0.35 altitude scale and 7,500→9,500 m fade, shared +> keyhole fade, full UI exclusions, and tracked/protected footprint +> exclusion. Ambient entries paint nothing before their first successful +> frame; a user-pinned entry retains the documented immediate empty-chrome +> exception, and later failures never clear the last successful frame. The +> active camera is excluded from the 40-card ambient quota and has no host +> card by default: its monitor plane is the active representation. The +> product option `cctvLayer.setCardPresentationOptions({ +> activeCameraCardEnabled: true })` may publish it through the retained +> protected path. CCTV leaders use the source cyan, remain vertical at the +> camera anchor except for the off-card edge clamp, and counter-scale to one +> CSS pixel through the altitude transform. Card hits come from +> `hitTestWorldOverlay` after a +> scene pick miss with no canonical scene-object ID. CCTV billboard ownership +> is proven by the owning collection/object rather than a bare upstream ID, +> so an independent sibling with a colliding ID still wins. As a completed, +> narrowly extended ownership hardening step, property-bearing Entity picks +> must also be the exact stored CCTV coverage/projection object; copying the +> `cctvCameraId` property cannot impersonate a camera. Card hits pass through the +> 6 px / 400 ms gesture guard +> before activation and the existing click-to-fly event. CCTV cards are not +> sprite-focus-dimmed. Disable/destroy clear and hide the host source, stop +> pacing, detach in-flight image handlers, and empty source caches. +> - **Cockpit left-panel chrome:** opening any left-stack panel fades the +> overlapping left pitch-rail glyphs out. The separately right-anchored pitch +> rail remains visible, and panel/context-card stacking is unchanged. +> - **Cockpit context stacking:** the context card renders above visor glass, +> pitch/heading instruments, altitude/speed tapes, and the signal window. +> Topline vision and exit controls retain the highest in-cockpit layer. +> - **Space Missions dependency shutdown:** disabling a mission dependency or +> leaving the mode closes mission context and restores the exact pre-entry +> Satellite enabled state and presentation parameters. Restored parameters +> never re-show primitives while the Satellite layer is disabled. +> - **Flights 3D/cockpit hardening:** the destination-direction cue is an +> inline SVG rather than a remotely loaded Material Icons ligature, so a +> blocked or late font can no longer expand the literal word `navigation` +> across the cockpit. The grounded `airplane.glb` belly offset is calibrated +> to the current asset's measured Y-up bounds (`0.063` native units). +> Contact Previous/Next and Live Signals aircraft handoffs now +> re-seed the first-person camera anchor at the selected flight immediately; +> bounded feed-correction smoothing remains scoped to the same aircraft. +> When NEXT has no flight inside the normal 250 km Context window, it searches +> both civilian and military feeds at successively doubled radii through +> 16,000 km and transfers the cockpit to the closest available aircraft. +> Tracked cameras now share one ENU frame across Cesium and the close-range +> guard: switches relocate to the new aircraft, zoom stops 150 m short of +> crossing the target, and the icon/model, label, trail head, and camera are +> prepared from the same frame. The guard frames once and then leaves +> Cesium's EntityView as the sole continuous camera writer. The tracked +> entity remains a pure position/billboard target with no unused aircraft +> orientation input; 3D→2D handoffs seed the current screen-projected course, +> course projection uses the camera's right/up basis so it remains valid +> through the >180° rear half of a tracked orbit, and the host readout consumes +> that same settled frame cache without a second dead-reckon. Selected +> 3D models are seeded at the tracked position before scene insertion and +> update before scene preparation from one frame-cached sample, retain real-world scale at +> ordinary ranges, cap at 200 px +> when very close for a continuous 2D→3D handoff, and use a 40 px selected +> model floor near the long-range 3D→2D cutoff so the glTF silhouette remains +> comparable to the selected billboard; ambient model sizing is unchanged. +> - **Minimal HUD right rail:** constrained focus layout keeps the collapsed +> CCTV and Context launchers visible and accessible while the expanded +> Display panel scrolls inside an explicit remaining-height budget. The rail +> reserves both launcher heights and inter-panel gaps without a self-reversing +> layout measurement. Tactical HUD instead gives the expanded Display, CCTV, +> or Context panel exclusive use of the rail and hides its collapsed siblings +> until the active panel is collapsed. +> - **Context-flow hardening:** the cockpit left accordion stays below the HUD +> inside its measured safe top/bottom lane. Global Context focus guards exist +> only for the synchronous selection window, history survives same-layer +> reselection, and NEXT availability uses the same UNKNOWN-cohort gate as the +> navigation action. Contacts entry does not wait for unrelated serialized +> layer teardown; Space Missions waits for every incompatible live/current +> layer to settle off before replay data starts. A rejected teardown keeps +> that layer authoritatively enabled, restores any siblings already stopped, +> and aborts replay entry; the isolation +> guard remains active until Rocket Launches finishes enabling. Manually enabling +> another Data Layer is additive in both an active mode and the neutral shell: +> it does not exit Context, and the added layer joins the pre-entry snapshot +> when the session is eventually restored. Manually disabling a required mode +> dependency still exits and restores that union. +> While Space Missions is active, direct incompatible enables are refused +> before initialization or polling and explain that the mode must be exited. +> User entry alone owns the pre-entry snapshot; programmatic dependencies do +> not replace it, and rapid re-entry snapshots the pending settled restore +> target rather than partial manager state. +> Contacts-mode entry chooses the nearest aircraft to the current camera from +> one uncapped civilian-plus-military pool (military wins an exact distance +> tie), then falls back to the nearest AIS vessel. An initially empty set gets +> one retry on the next Awareness refresh tick rather than permanently losing +> automatic acquisition. Clearing a manually selected subject cancels that +> pending retry so an intentional deselection cannot acquire another contact. +> NEXT keeps a cycle-scoped visited set separate from PREVIOUS history. Once +> every current target is visited, it starts a deterministic new walk with the +> current subject retained as visited instead of re-admitting all candidates +> into a nearest-contact ping-pong. Expanded flight searches use the same reset +> rule. Vessel focus uses a 3 km bounding sphere at entry and during cycling. +> The user-facing mode is **CONTACTS**: it cycles the nearest contact of +> whichever supported type is selected (civilian or military plane, AIS vessel, +> or mapped installation). Satellites are explicitly outside the Awareness +> navigation cohorts and retain their independent tracking UX. The cockpit +> briefing opt-in is labeled `CYCLE OFF` / `CYCLE ON`, with state-specific help +> that distinguishes page cycling from continuously refreshed live data. The +> neutral standby summarizes both chooser modes, while the `CONTACTS` and cycle +> controls keep their short visible state as the accessible name and expose +> longer help only as a title. +> - **CCTV focus and teardown:** explicit camera choices still activate during +> cockpit mode, but they retain aircraft tracking, suppress the view flight, +> and ask the user to exit cockpit. Layer enable is activation-only when a +> tracked entity or cockpit owns the view, with no flight or cockpit-exit toast. +> Voice select/next/previous/nearest actions report when tracking or cockpit +> refuses their requested flight without hiding the successful selection. +> Camera deactivation and layer disable both clear temporary probe clamps; +> disable re-arms the active record, re-enable restores nominal geometry without +> probing, and the next real activation re-runs the obstruction probe. Disable +> uses a direct hide sweep; obstruction hits retain the field-derived 12 m floor. +> Geometry-drain progress notifications are coalesced to roughly 300 ms or ten +> batches, whichever arrives first. Natural completion publishes its final +> state; disable publishes the terminal state explicitly when it cancels a drain. +> Coverage polylines are lazy: catalog init creates none, while enable in the +> default COVERAGE ON mode materializes the active camera and visible neighbor +> cohort (70 entities at the 14-camera cap). Activation always materializes the +> selected frustum, including COVERAGE OFF when projection remains on. +> Empty-space deselection removes the active projection and active-relative +> coverage emphasis but preserves the layer, coverage mode, ambient cards, +> catalog, panel settings, and viewer pose. With no active camera the panel +> exposes no stale dropdown/FOCUS/calibration target; NEXT starts at the first +> catalog entry and PREVIOUS starts at the last. +> While aircraft tracking or cockpit mode owns the view, the geometry drain +> rechecks ownership per batch and drops to two records every 250 ms. Enable +> focus decisions conservatively combine pre- and post-await ownership. +> - Share-link camera restoration re-applies its settled pose and requests a +> render after the flight completes, ensuring Google Photorealistic 3D Tiles +> stream at a deep-link destination without requiring manual camera input. +> - **Reversible Context handoff:** enabling Global Context snapshots the exact +> enabled-layer set and every runtime parameter it changes, then clears +> unrelated active Data Layers. Mode switches, required-dependency disables, direct +> disable, and teardown restore that pre-entry state before control leaves +> Context. Directly switching between Flights and Space Missions remains +> supported without losing the original snapshot. Contacts waits for the +> dependency releases it owns before restoring that snapshot, so the first +> Space Missions selection completes without a stale teardown superseding it. +> - **Space Mission horizon occlusion:** mission dots and hover reticles apply +> their surface-anchor horizon state before Cesium draws or picks the frame, +> while shared-host labels use the same hidden-globe horizon contract. Rear-side +> markers therefore cannot remain from the prior camera frame, including in +> request-on-demand photoreal views; the conservative limb margin remains. +> Graphics that begin with Cesium's implicit visible default are enrolled in +> that pass on their first frame rather than bypassing the cull. +> Selecting a mission isolates its launch anchor until Show All / Deselect +> or the panel close control clears the selection. +> - **Cockpit flight signals:** Live Signals shows the current and nearest +> flight names as larger clickable controls. Selecting a name transfers the +> active cockpit tracker to that flight; type and distance remain secondary +> text without repetitive event-category headings. +> - **Ground-safe cockpit:** the first-person anchor and final camera position +> are clamped to the shared mesh-first rendered-surface floor with 12 m of +> clearance. In the photoreal stack, grounded entry keeps the existing safe +> map camera until that rendered mesh cell resolves instead of trusting a +> delayed/raw aircraft height as a temporary surface. Successful one-shot +> model ground snaps populate the same shared mesh cache, and repeated +> grounded source timestamps lift their stored history when the floor warms. +> Landing and taxi tracks therefore cannot place the camera below terrain or +> inside photoreal 3D Tiles. +> - **Display panel width:** across desktop HUD layouts, the expanded right-rail +> Display panel uses a 272 px glass-backed surface; its collapsed tab and +> narrow-screen layout retain their existing responsive widths. +> - **Cockpit rolling telemetry:** the central speed, heading, and altitude +> values use per-digit vertical rolls when their displayed values change. +> Increasing and decreasing values move in opposite directions, and heading +> accounts for the 359°/000° wrap. +> - **Cockpit route cue:** when reliable destination coordinates are available, +> the estimated-destination arrow occupies a fixed centered slot above the +> lower telemetry, follows the apparent ground-plane perspective, and rotates +> to show relative bearing within a legible ±120° steering range. Flights +> without destination enrichment omit the cue. +> - The skylight feature set and six field-test hardening rounds shipped +> 2026-07-03; **CCTV v2** shipped 2026-07-04. +> Voice tools are **28**. Global Context can be entered or exited directly, +> and Cockpit voice control supports status, entry from a selected or tracked +> aircraft (establishing Contacts first), exit, and filtered Previous/Next navigation through the full +> nearby-contact cohort. Contacts exposes source-honest counts inside its +> 250 km subject window. +> - **Height-datum system (2026-07-08):** Caltrans + TfL camera packs (~900 +> cameras) and the height-datum work fix entity heights on the +> ellipsoidal globe end-to-end (geoid module, keyless Re:Earth terrain for the +> OSM stack, ground-floor system with rendered-mesh sampling, always-visible +> sprites/trails, OpenSky credit governor). The 2026-07-08 CHANGELOG entry +> records the subsystem's architecture, invariants, residuals, and verification. +> - **Height-datum test surface:** `npm test` 184 unit · `npm run +> test:track` 43 tracking invariants · headless QA harnesses under +> `scripts/qa-*.mjs` incl. `qa-height-datum.mjs` (numeric heights) and +> `qa-floor-verify.mjs` (any-airport ground-truth oracle). +> - **2026-08-19 — display-time ground floor (flights layer).** A grounded +> contact's render height is picked once per poll from the floor of its FIX +> cell, but what renders is the dead-reckoned position, which drifts across +> cells for the whole segment and for hundreds of metres while a contact +> coasts on a stale feed. On a graded apron that buried sprites under the +> mesh (measured −15.5 m at KAUS; `qa-floor-verify` reported FAIL). The fleet +> pass and the tracked display path now re-floor the DISPLAYED coordinate +> against `cachedGroundFloor` — read-only, grounded contacts only, and never +> while a 3D model owns the visual (T7 ground-snap one-shot). The poll's floor +> warm/sample batch additionally collects each grounded contact's display +> CORRIDOR (the ground it is about to cross — toward its newest fix while +> interpolating, along its course while coasting), need-ranked and deduped so +> parked contacts never crowd out moving ones. Budget is charged only for cells +> with no floor yet — warm cells are still emitted, because the mesh sampler +> must see a cell again once its DEM prior lands — which gives a service bound +> that falls out of the policy: every contact is served within +> `ceil(contacts / budget)` polls. Sample spacing along a projected arc derives +> from its length (never a fixed count, whose spacing widens with speed), and +> the cell walk steps at an eighth of a cell, so any cell the path occupies for +> ~14 m of ground or more is collected. The corridor +> integrates the SAME constant-rate turn the dead-reckon does, so a +> sustained-turn taxi warms its arc rather than a straight tangent. Visual +> ownership — not model existence — decides when the clamp stands aside: a model +> owns the visual only while it is actually RENDERING (`ready && show`, the +> pair the billboard handoff itself consults), and the tracked path also +> requires `_trackedModelRegimeActive()`. So neither a retained-but-hidden +> model (3D off, zoomed out, cockpit) nor one still loading suppresses +> flooring while the billboard is what the user sees. **The visual/data split is deliberate**: visual +> consumers are floored at the per-frame cache, while `_describeFlight` — and +> so `findByQuery`, `getTrackedInfo`, `getTrackedSubject` — keeps reporting +> sensor truth (barometric `altitudeM`, fix-time `renderAltitudeM`), because a +> query or an altimeter should answer what the aircraft reported, not where its +> icon was nudged to clear the tiles. Residual, unchanged by this +> work: the floor is a ~111 m cell, so intra-cell relief (terminals, jet +> bridges) and contacts moving faster than the 30 s warm batch can still read +> metres low. `qa-floor-verify.mjs` now **exits non-zero on FAIL** (1 = FAIL, +> 2 = INCONCLUSIVE); it previously exited 0 on every verdict, which is how the +> burial stayed invisible. It also honours `QA_BASE_URL` and puppeteer's +> pinned Chrome-for-Testing, like the other harnesses. +> - **2026-07-16:** the FIRMS Active Fires layer is **LIVE** — +> the bundled 2026-05-25 snapshot (58 MB) is deleted; a new `/api/firms` proxy +> (vite.config.js) merges VIIRS NOAA-20/NOAA-21/Suomi-NPP NRT world CSVs +> (days=2 → trailing-24h clamp, 30 min memory+disk cache, single-flight, +> serve-stale-on-failure) behind server-side `FIRMS_MAP_KEY` (keyless → 503 + +> in-app KEY REQUIRED chip). Client polls 10 min (`src/data/firmsHeatmap.js`; +> adapter `src/data/firmsAdapt.js`, CSV parser `src/data/firmsCsv.js`). +> `/api/firms/status` reports cache age + MAP_KEY transaction usage. +> - **2026-07-16:** Traffic supports optional live +> TomTom flow through the server-side, budget-governed `/api/tomtom` proxy; +> keyless installs retain the byte-identical white-dot simulation. +> - **2026-07-22 (CCTV v3 Parts A+B):** replay, color-coded viewsheds, +> save-gated direct-manipulation calibration, `viewshed`/`adjust` voice +> actions, shared-floor E/N drag grounding, and bounded snapshot requests are +> integrated. Citywide static-plane LOD/pacing work remains outside runtime. + +> **2026-07-02 milestone:** the skylight aircraft/satellite/enrichment work and +> pre-ship hardening fixes landed. +> Runtime changes reflected below: **voice tools 17→20 at the 2026-07-02 milestone** (`next_iss_pass` + the 19 already on +> main), type-aware 8-class aircraft sprites + path-derived rate-limited display heading, adsbdb +> flight enrichment (cached proxy + route-plausibility gate), disk-cached CelesTrak TLE proxy, +> ISS pass prediction, and per-layer data attribution. Gate at close: unit 98/98, build clean, +> track 19/19, + five QA harnesses (heading 16/16, sprites 9/9, cctv 5/5, failstate 5/5, +> attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`. +> The live runtime now declares 28 voice tools; the 17→20 count above is retained only as milestone history. + +## Canonical Docs Order + +Use docs in this order when details conflict: + +1. `docs/CURRENT-STATE.md` (this file) +2. `docs/opensky-auth.md` (OpenSky authentication) +3. `CHANGELOG.md` (release history) + +Historical planning documents may not match runtime behavior. + +## Current Baseline + +- Repository metadata and public URLs use the `bilawalsidhu/gods-eye-view` + project identity. Runtime behavior is defined by this document and the current + source tree rather than historical branch notes. + +## Runtime Stack + +- Vite + CesiumJS app with Google Photorealistic 3D Tiles +- Scene/HUD/style systems in `src/ui.js` and `src/hud.js` +- Layer management in `src/data/manager.js` +- Map stack switching in `src/mapStackController.js` +- Voice control in `src/voice/` (OpenAI Realtime over WebRTC) +- Voice map whiteboard annotations in `src/annotations/` +- 3D aircraft/model tracking surfaces in `src/data/flights.js` and `src/data/militaryFlights.js` +- Detection overlay and tracked-target readout in `src/data/detection.js`, `src/data/detectionDraw.js`, and `src/data/trackedReadout.js` +- Proxy middleware and API wiring in `vite.config.js` + +### Active Data Layers in Runtime + +Qualified Radio playback requests—category, station, country, coordinates, or +nearby place—always use station selection. Unqualified “turn on/start the radio” +requests use Play; a qualified Play-shaped tool call is normalized to Select so +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 | +| 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 | +| CCTV | Austin + Caltrans (CA) + TfL London 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 | +| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | + +`src/data/militaryAwareness.js` remains registered internally as the Contacts +coordinator, but it is not a user-visible Data Layers entry. Its visible entry +point is the right-side `CONTEXT` chooser's `CONTACTS` mode. + +At global scale, ambient Radio cluster badges are hard-opacity shared-host +entries: count/category updates and identity replacement do not run keyhole or +enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon +culling can still remove an invalid placement, and Cesium still owns the cluster +point geometry and picks. Their 50,000 km line-of-sight range covers the +supported full-globe camera above 24,000 km, including farther horizon clusters. +Unclustered visible stations publish nearest-first ambient labels through that +same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby; +cluster and singleton candidates still share the Radio source's 64-entry ambient +cohort. Cesium continues to own each 13 px station point, horizon visibility, +and direct/nearby picking. Native points and shared-host text use one 50,000 km +interaction limit, so a painted singleton or cluster label always retains a +pickable point at its anchor throughout the supported high-global view. No +Radio entity uses native Cesium label text. + +Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns +the required Radio lifecycle/authority repair, so PR #10 must not merge or ship +unless PR #11 is included in the delivered stack. +Selected and singleton globe labels use the same compact 30-character +presentation: a credible explicit or leading-decimal frequency is rendered +first (`93.9 FM — Station`), otherwise the station name is ellipsized. Full +upstream names remain unchanged in the directory, player, and search state. +Fresh Radio sessions open on All stations while enable and restoration remain +silent. Directory refreshes retain cluster overlay identity for unchanged +represented membership and discard only identities containing removed stations. +When the user explicitly clicks Enable inside the expanded Radio section, the +Context panel performs one internal post-render scroll to reveal the station +filter and primary transport together without moving keyboard focus, the page, +or the globe. Compact controls, voice/tools, restoration, Data Layers, and +programmatic activation do not trigger this reveal. + +Radio directory admission is atomic on both sides of the proxy boundary. A +refresh is healthy only when it reaches the minimum accepted-query and station +coverage; schema-valid responses with zero normalized stations count as failed +queries rather than inflating refresh health. Each specialist query also needs +an accepted station whose normalized tags match that requested category; rows +tagged only for another category remain usable catalog data but do not earn +specialist health credit. A partial cold result remains +usable but is explicitly `DEGRADED` and has no accepted catalog generation, while a +partial or malformed refresh cannot replace a warm catalog. The client likewise +rejects stale/future freshness metadata, incomplete rows, and empty catalogs as +a whole, preserving its last usable stations with `STALE`/`DEGRADED` state. +Every healthy admission publishes a monotonically increasing generation scoped +to a restart-stable `catalogInstance` token (a new server process starts a +fresh sequence — never read as a repeat or a regression) with a deeply +immutable station snapshot; stale/degraded warm responses retain the same +generation so tuner and cluster consumers can preserve exact station identity. +Generation semantics assume the app's actual single-process dev-server +deployment; concurrent replicas behind one origin are out of scope. +The client snapshot contains only the normalized station-field allowlist, +preserves object identity for an idempotent repeat of the same generation, and +degrades without replacement if a fresh response presents an older generation. +Snapshot records mark community metadata as untrusted, and Radio tool results +omit station names so directory text never becomes model instruction context. +One bounded country parser maps recognized ISO codes and English/common names +through proxy metadata and final station selection, while malformed, non-ISO, +control-containing, and oversized inputs fail closed. Literal or resolved +non-global IPv4/IPv6 targets are refused. Destroy fully releases the Radio audio +session, voice ducking/restoration, request state, filter, selection, volume, +accepted snapshot, and feed telemetry before re-initialization; monotonic +ownership tokens plus the session boundary keep late callbacks from a retired +session inert. +A tuner drag resolves previews against the single accepted snapshot captured at +pointer-down, even while a newer catalog is admitted. Release starts playback +only when the current same-ID record still exactly matches the frozen +presentation and stream metadata; removal or replacement reports the channel as +unavailable and never silently retargets the drag. A cold degraded fallback may +still populate the directory and globe, but its null accepted generation cannot +populate or begin the tuner. + +Context entry and exit consider both rejected lifecycle promises and resolved +`false` manager results to be transaction failures. Entry awaits isolation and +restores the exact prior layer snapshot when isolation or activation fails; +when a direct Context shell fails after lifecycle work starts, reconciliation +waits outside the manager notification until that shell's queue settles, then +restores the complete snapshot rather than treating the in-flight layer as an +exclusion. An uncertain shell is retried and incomplete cleanup retains the +snapshot for a later restore. Direct-shell isolation and its compensating +rollback share one operation-scoped notification token, so an inner lifecycle +failure cannot announce separately from the outer blocked action. Context exit +waits for every sibling transition, retains the exact pending snapshot +after failed compensation, and can retry it later instead of silently reporting +a partial restore. User-facing Context chooser, direct Data Layer shell, +mapped-installation Search, rollback/exit, and Radio chip routes settle those +failures through the existing toast surface, release busy controls, and avoid +unhandled promises. A wrapped operation owns exactly one failure or blocked +notification; its synchronous manager event is suppressed only for that +operation, while unwrapped lifecycle failures retain the manager-level fallback. +The toast is a polite atomic status announcement. +When no tracked entity owns the follow camera, Radio Previous/Next and tuner +previews rotate to the requested broadcaster without changing zoom. A local +view whose optical center remains safely over the Earth, or a full globe already +contained in the viewport keyhole, uses one direct station flight and preserves +its initiating view angle. If a fit-capable Earth disc is clipped/off-center, or +a closer oblique view leaves the viewport center outside the Earth disc, Radio +first animates a centered north-up nadir composition, then focuses the latest +station from that canonical frame. Closer views keep their initiating altitude; +an extreme zoom-out is capped at 13,000 km so recovery returns to a useful +whole-globe scale instead of preserving an empty-space view. The two-stage navigation has one generation: +a playback fallback retargets it to the broadcaster that will play, while a newer +Radio action—including direct globe or non-moving voice selection—supersedes +older callbacks, and layer disable/destroy invalidates +the pending stages. While Flights, Military Flights, or any other +`viewer.trackedEntity` is active, Previous/Next and tuner previews continue to +change station and playback without cancelling or flying the camera. Tracking +acquired between the recenter and focus stages suppresses the later stage, and +a delayed fallback rechecks the same live ownership. Voice Radio navigation +remains non-moving; explicit station focus remains a separate user-requested +route and also yields to a live tracked entity. +The Radio tuner exposes the complete current filtered directory, up to 750 +stations, in stable catalog and filter order. Needle progress is absolute across +that directory: the left, center, and right of the control resolve to the first, +middle, and last available station. Every position snaps to a real station, so +there are no selectable static gaps. During a drag, the bounded virtual tape +moves left as the needle moves right and travels faster than the needle without +creating DOM nodes for the complete directory. Camera movement never re-ranks or +rebuilds this order. Tuner-owned preview flights remain monotonic on the captured +strip, and release commits the frozen station before selection and playback +settle. That exact release clears older fallback ownership, so a broadcaster +failure cannot silently play a station left over from an earlier non-playing +cycle; the failed target retains its static/error handoff until Stop or another +explicit choice. Pointer cancellation instead cancels the active preview flight and +restores the exact pre-drag station ordering, absolute position, and frozen +presentation-only marker without starting a replacement camera flight, +committing, or autoplaying. That marker is restored even when an +accepted concurrent catalog removes the station or replaces its metadata, but +it never becomes current playback authority. The next accepted healthy catalog +refresh clears that presentation-only marker, including when the accepted +generation repeats and the immutable directory snapshot is intentionally kept +by identity. Every accepted filter action also clears and rebuilds the marker, +including a request for the already-active filter; a lifecycle-rejected filter +event leaves the restored marker and tuner band untouched. An accepted filter +action synchronously rebuilds the complete navigation pool after the layer state +notification, so Previous/Next and the tuner cannot observe an empty interim +directory. This restoration +also holds at either directory endpoint. Previous/Next traverses the same stable +pool and updates the absolute tuner position. +Cluster refresh identity follows the prior cluster contributing the greatest +absolute number of stations to the new cluster. This preserves majority identity +during merges instead of allowing a fully retained minority to win; deterministic +similarity and stable-ID tie breakers cover equal contributors and splits. +Every positive overlap participates in greatest-contributor discovery, so ratio +thresholds cannot discard a diffuse or one-station maximum. Inheritance is +bilateral mutual-best: a current cluster accepts only a greatest contributor and +a prior identity transfers only to a strongest split child. If that identity is +already claimed by an equal or stronger child, the later cluster receives fresh +identity instead of falling through to a historical minority. Disjoint clusters +also receive fresh identity; sequential fresh IDs are allocated in canonical +membership/station order so input permutations do not rename them. + +Voice interprets “turn on/start the radio” as Radio Play, including when it is +combined with a camera action. Explicit “show/enable the Radio layer/markers” +remains a silent layer-only action and does not close the voice session. + +Successful explicit user playback from Play/Resume, Previous/Next, the tuner, +or a globe station closes an active voice session only after Radio reaches +`playing`; a failed stream leaves voice active. +An interrupted or superseded voice turn aborts pending Radio location resolution +before it can enable the layer or select a station. Radio enable and disable are +abort-aware manager transactions: cancellation restores the authoritative +pre-transaction state only when the compensating lifecycle call succeeds and +emits no settled explicit-intent event for Context persistence. Failed +activation and teardown expose explicit `enabling` and `disabling` lifecycle +states while the manager retains the last authoritative visibility. The public +lifecycle vocabulary is `enabling`, `enabled`, `disabling`, and `disabled`, plus +a separate uncertainty bit. Radio controls present that phase and remain +non-interactive until the lifecycle is certain `enabled`; only a successful +transaction publishes the new `enabled` or `disabled` settlement. Radio's data +layer state, player message, compact status, launch controls, and generic Data +Layers row explicitly show `UNCERTAIN` when cleanup cannot establish authority; +their accessible labels name the uncertainty while Enable/Disable remains +available to reconcile it. +The Radio source, shared overlays, selected marker, and pick handler use the same +manager-owned presentation gate: +they remain hidden and inert throughout enabling, disabling, cancellation, +failure, and uncertain reconciliation, and activate only for certain `enabled`. +The same gate rejects direct station selection, Previous/Next cycling and its +camera/fallback preparation, tuner-static and category-filter mutation, volume +mutation, and every non-Pause playback toggle before fallback, selection, or +audio state changes. Voice volume and station-starting actions require a fresh +manager lifecycle read. +Every returned `control_radio` result, including status, failure, +cancellation, and a missing Radio module, exposes that same authoritative +`lifecycleState` plus `lifecycleUncertain`; the manager lifecycle record takes +precedence over the stable enabled fallback. Status only reads this state and +does not enqueue lifecycle or player work. +Generic `set_layer_visibility` results expose the same atomic `enabled`, +`lifecycleState`, and `lifecycleUncertain` summary for Radio on success, +fulfilled-false failure, rejection, cancellation, reversal, and missing-module +outcomes. Realtime suppression and settlement refresh all three fields together, +so dedicated and generic Radio routes cannot publish mixed lifecycle snapshots. +`disabled` is inert, while a transitional or uncertain shell is reconciled +through the manager and may proceed only after a fresh read confirms certain +`enabled`; false, rejection, or cancellation preserves the prior player state. +The existing immediate Stop/Pause authority is unchanged. +Cancelled-disable compensation reports failure and records whether lifecycle +state remains uncertain. Any same-target request made while state is uncertain +performs the real lifecycle work instead of taking the stable-state no-op, then +clears that reconciliation debt only after a confirmed enable or disable. A module-local +`AbortError` is a cancellation even while the caller signal remains live, while +that settled transaction releases its abort listener so the old caller cannot +disable a later successful retry. A resolved `false` from init, enable, first +update, or disable is a lifecycle +failure. Unrelated sibling tools remain independent, while each Radio control +captures the current playback-handoff epoch. A later Radio +Pause or Stop provisionally freezes prepared or already-started playback +handoff work without aborting active Select/Play auto-enable or an independent +explicit dedicated or generic Radio visibility ON. After semantic success it +cancels the active Select/Play lane and clears the frozen handoff. Semantic +failure leaves active work live and releases and resumes the frozen handoff. +Disable and generic OFF own visibility as well as playback and may cancel both. +These controls commit their authority only after semantic success, so a failed +stronger control cannot suppress a valid completed sibling. Pause uses the +production player's synchronous boolean contract; Stop and Disable additionally +handle their awaited failure paths. The control's function output is sent before a failed reservation releases, +so resumed playback cannot close the voice channel before the failure is +reported. Resumed handoffs own their attempt-scoped cleanup, so a stale predecessor +cannot clear the successor's in-flight result or block a later failed +reservation from resuming it again. +Generic `set_layer_visibility` Radio disable participates in the same ownership +domain as dedicated Radio controls, so an older Select cannot reverse it. Direct OFF +from either Radio control or the Data Layers row publishes intent before joining +the lifecycle queue and aborts in-flight voice work before an intermediate ON +event can settle. Every absolute manager visibility request also advances a +per-layer intent epoch and aborts the older absolute lifecycle transaction, +including a same-target request whose newer origin must own persistence. An +obsolete queued request never starts; obsolete in-flight cleanup keeps +presentation transitional and hidden, and only the latest request may adopt or +reconcile that state and publish settled visibility. The epoch is rechecked +after synchronous lifecycle-presentation callbacks, so a re-entrant newer +request prevents the older transaction from arming a timer or publishing a +settled visibility event. Superseding an uncertain same-target retry also keeps +the last authoritative enabled boolean until cleanup confirms the real module +state. Direct OFF also freezes +prepared or already-started playback handoff +until the manager queue settles; confirmed OFF discards that handoff, while a +failed OFF releases the reservation and resumes the valid prepared result. A +successful Stop cancels stale Radio work across older response ids as well as +its own response. If a Radio action has already completed its station mutation +before the later control succeeds, only its playback handoff is suppressed; its +result reads enabled state from the manager and audio state from the Radio +module. Voice Pause is a playback-only no-op while the layer is disabled; it +never enables Radio and reports a fulfilled-false pause as failure. A +newer-response or user-origin Stop still cancels stale handoff work. +Only a newer user turn or session teardown aborts the complete active-tool set. +A cancelled turn cannot publish a late Radio playback request. Dedicated Radio +controls and generic layer-visibility commands both forward cancellation. +Each explicit Radio play attempt owns one active audio element; replacement +retires the prior element, making its queued callbacks inert. Pause retires the +current stream and fallback attempt, so delayed callbacks from a replaced or +paused stream cannot change current state or start another station. +Pause and Stop settle attempt ownership and authoritative audio state before +synchronous playback-control observers run, so reentrant voice cleanup cannot +overwrite a released stream with a stale paused state. Layer +destruction likewise retains an enabled manager entry when module disable or +destroy fails semantically, preventing an active orphan and allowing retry. +When Context is collapsed, explicitly activating its Radio header icon reveals +the compact transport; hover and focus alone do not open it. When Context is +expanded, the icon instead expands and scrolls to the embedded Radio section. +Its accessible label, controlled region, and expanded state keep describing the +current route throughout enabling, enabled, disabling, and uncertain lifecycle +renders. Both routes preserve Radio power, playback, station, filter, and volume +state. + +Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojson.js` (Vite `?url` assets) when toggled on. + +A bundled dataset that fails to load is a broken install, not a blip, so it is +never swallowed: `localGeojson.js` guards `response.ok`, reports `error` + +`lastUpdate` through `getStats()` (UNAVAILABLE chip, not a green ON over an +empty globe), and commits its Cesium data source only after setup completes so +a partial failure retries on the next enable. The two non-layer packs +(`naturalEarthRegions.js`, `neighborhoodPolygons.js`) have no stats contract to +report into, so they instead refuse to memoize a failure — +`src/data/retryableLoad.js` caches success permanently and retries a failed +load after a doubling cooldown (5 s → 5 min), which keeps one bad load from +silently demoting every later lookup for the session. + +### Context / Contacts coordinator (July 2026) + +- The internal Context coordinator is available in every visual style. Its dedicated right-side `CONTEXT` chooser exposes the neutral shell; the coordinator is not duplicated in Data Layers and does not enable a live-data dependency until a mode is selected. +- The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies. +- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft. +- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. +- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. +- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. +- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. +- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. +- 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. +- 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. +- 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. + +### Motion & Symbology Correctness (June 10, 2026) + +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. +- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. +- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. +- **Satellite classes** (`src/data/satelliteClass.js`): every satellite is classified from the CelesTrak group it was ingested with — no extra fetch and no heuristics — into STATION (warm white), NAV (cyan; GPS/GLONASS/Galileo deliberately share one color so the GNSS family reads as one thing), GEO (violet), VISUAL (muted blue-gray catch-all), and COMMS (dim slate, dense Starlink shell only). That module is the single source of truth for class, label, and color, so the dot, the card, and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and COMMS stays far below VISUAL in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATION color — it carries a permanent name label and its card still reads `STATION · ISS`. `satelliteClassOf` is the single owner of that ISS rule, so the card label and the legend tally can never disagree: during a stations-feed outage the ISS is ingested as `visual`, and both surfaces still file it under STATION. +- Class is also a **text field**, not just a color: `NAV · GPS` / `GEO` / `COMMS · STARLINK` / `STATION · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. +- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): a **DENSE** chip exposing the existing `catalog` param, and a swatch legend with live per-class counts. Default is the sparse core catalog. The chip is stateless — it declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. +- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. +- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. +- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. +- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. +- **CCTV v2 foundation:** a pitched + frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's + far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only — + auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction + probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains; + ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to + `godsEyeView.cctv.calibration.v2` (wiped clean, no v1 import); a panel-only CAL badge shows + `CALIBRATED`/`CURATED`/`RAW PRIOR` (no in-world tint). Panel is titled "CCTV" (not "CCTV + MESH"). Staggered geometry/frame loading is active-first and uses 4 records per 120 ms normally, + or 2 per 250 ms while tracking/cockpit owns the view (re-evaluated each batch), with coalesced progress + notifications (roughly 300 ms or ten batches; natural completion and disable each publish their + terminal state through their own completion paths) and a LOADING FRAMES + chip and the preview-first auto-expanding panel are unchanged. Coverage polylines are created + lazily instead of inserting five entities for every catalog camera during initialization: default + COVERAGE ON enable creates the active/visible 14-camera cohort, and activation always creates the + selected frustum even with COVERAGE OFF. **Field validation passed + 2026-07-04** (core look + downtown no-clip confirmed); that round fixed three findings: the + ground clamp now lifts the cap *center* only so the wireframe stays a true pyramid welded to + the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active + camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a + periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES` + default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of + 1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego, + Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default + sources — ~800 cameras total, all RAW PRIOR poses, stills-first. +- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field + validated 2026-07-21): the COVERAGE toggle is a + tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum + as a translucent **color-coded volume** (golden-angle hue per camera, `cctvViewshed.js`) + welded to the same 5 points as the wireframe — zero new scene queries or update cadences. + The 7 calibration sliders are **deleted**: ADJUST mode puts a direct-manipulation **gizmo** + on the active camera (`cctvGizmo.js` — heading/pitch rings, E/N/U arrows, range handle at + the cap center, FOV handles on the cap edges; all 7 offset DOF), plus a click-to-edit + **effective-pose readout** (HDG/PITCH/FOV/RANGE/HGT/ΔN/ΔE, absolute values). Persistence is + now **save-gated**: edits are live but unsaved (`CAL · EDITED` chip) until SAVE CAL writes + the v2 store. Do not add + Translation arrows use a depth-test-free pickable tip so E/N/U ownership remains unambiguous even where shafts overlap + other handles. Avoid hover effects that mutate gizmo polyline geometry (width) — the primitive rebuild blanks the pick buffer and eats the + following click (root-caused 2026-07-05). Gizmo input checks the topmost, depth-test-free + handle with `scene.pick` first and uses `drillPick` only as an overlap fallback; this keeps + hover and press responsive on software GL without changing the real-GPU interaction. Frame serving is bounded independently from the + 10-second active refresh: upstream and Street View attempts abort after 8 seconds, and the + panel/monitor plane keep at most one same-camera image request in flight. This prevents a + slow provider from being cancelled and restarted forever while stale `SNAPSHOT · OK` health + remains beside a pending preview. Grounding is shared with every other height consumer: + CCTV warms/resolves `groundFloor.js` cells, reads `cachedGroundFloor()`, and delegates optional + Google 3D refinement to the unchanged `meshFloorSampler.js`. During E/N gizmo movement the + prior floor is frozen (constant elevation and zero transient samples); release or reset makes + one resolution request at the committed anchor. U edits remain pure geometry and enforce the + 2 m minimum mount height above whichever shared floor wins, including a rooftop. Public camera + state exposes `groundPriorM` as the immutable Re:Earth ellipsoidal datum reference; it is kept + separate from live frustum geometry because Google-3D can refine the rendered ground to the + photogrammetric mesh. +- **CCTV citywide ambient cards** (built 2026-07-29; shared-host migration + 2026-08-02): the LOD-selected nearby static cameras (20/28/40 by zoom, + `cctvLod.js`) get **screen-space thumbnail cards** through the shared world-overlay host + showing paced static frames — reselection on `camera.moveEnd` only, at most one frame fetch + per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min). Zero-flicker: + a card renders nothing until its first frame, a drawn frame persists through failed fetches, + and eviction grace (2-pass/5 s) stops budget-edge churn. Camera icons stay visible at every + zoom. Eligible candidates are filtered to in-view stills with valid IDs, + finite distances, and one deterministic representative per camera before + ranking; videos, hidden/malformed rows, and duplicate outliers cannot alter + the density scale or displace a valid winner. They are ranked with a + deterministic 50/50 blend of eye distance and normalized screen-center offset + before the existing 5×4 distribution pass, so the center wins contested + density without removing peripheral coverage or changing the bounded count. + The active camera keeps the v3 monitor plane and is excluded from the + 40-card ambient ring with no thumbnail by default. An explicit + `activeCameraCardEnabled` presentation option can publish the retained + protected-card path. Disable tears the tier down completely. + Coverage/viewshed semantics unchanged. +- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. +- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time. + When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration. + Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. + 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). +- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. + +### Share-link v2 layer state (August 2026) + +- Generated share links use a deterministic v2 hash. Existing camera, visual, + HUD, detection, post-processing, celestial, scope, and map-stack fields remain, + 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 + settles independently per layer so one failed or unavailable source cannot + block its siblings. +- 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, + calibration, caches, lifecycle state, temporary Context ownership, and derived + effects are deliberately excluded. Radio restore never selects or plays a + station. +- Normal loads restore the last successful explicit UI, voice, or tool choice + from versioned local storage. Any valid camera share wins for the current load + without overwriting recipient preferences. Restore ownership is split by + visibility, option/selection, camera, visual, map, and individual panel lane: + a newer explicit action supersedes only the field it owns. In particular, + navigation cannot turn unrelated layers off, and an option change cannot + cancel the same layer's visibility transition. Every explicit HUD, detection, + post-processing, scope, or celestial action from the UI, keyboard, voice, or + public tool facade claims the visual lane before mutation. Invalid requests do + not claim that lane or partially change controls. + Direct globe pointer and wheel gestures supersede the delayed shared camera + and selected-subject Follow without aborting unrelated layer visibility or + display-option restoration. +- The initial restore has one terminal promise spanning the camera flight, + visual/map/panel callback work, every production layer result, and the + destination-scoped selected-subject Follow result. Hash writes remain + suppressed and the startup screen continues to read `Restoring shared view...` + until that aggregate settles. Destroy settles it as destroyed rather than + permitting late mutation. A superseded shared visibility intent follows + the authoritative successor chain to a terminal lifecycle result, including a + same-target re-enable or opposite-target disable, before releasing the layer + barrier. Flights, Military, and Satellite first-update + fetches consume the manager AbortSignal; disable and destroy also abort their + module-owned feed or dense-catalog requests. +- Only one Flights, Military, or Satellite tracking ID can be durable at once. + Explicit selection clears the other families, Stop Tracking clears active and + pending IDs, and ambiguous incoming multi-family selections fail closed rather + than letting feed arrival order choose the camera owner. +- An explicit aircraft selection made inside Contacts promotes the owning + Flights or Military layer from a mode-owned dependency into durable state, so + leaving Context, reloading, or opening the link can restore it. Passive + Contacts autofocus does not revoke a pending selected aircraft; the exact + shared/local target wins when its feed row arrives. +- A shared Flights, Military, or Satellite subject that has not arrived yet + publishes a persistent top-center `ACQUIRING` progress state while the + existing deferred-restore latch and source-specific deadline remain active. + Success, expiry/failure, cancellation, superseding intent, owner-layer + disable, explicit navigation, and teardown all settle and clear that state; + caller abort remains authoritative after the pending handoff. A latch that + rejects its deferred selection emits only the terminal failure and never a + false acquisition state. An unrelated manager failure preempts `ACQUIRING` + for its full visible dwell; if acquisition is still owned afterward, the + progress state resumes. A share-specific terminal failure that arrives while + another failure is visible is queued, and its own fixed dwell starts only + when that message reaches the screen. Only terminal failures use the existing + fixed-dwell error presentation. +- Radio category persistence shares the live directory's bounded normalizer, + including generated genre identifiers with spaces or `&` such as `Hip Hop` + and `R&B`. +- Shared panel state starts from deterministic defaults and excludes responsive + auto-collapse. Partial or malformed panel fields cannot import or overwrite + recipient-local layout preferences. +- A fresh Cockpit entry temporarily collapses the standard left/right map + panels and opens Cockpit's own Contact and Live Signals rails. This runs only + on entry: Previous/Next preserves any panel the operator opens while already + inside. Exit restores the exact standard-panel open/collapsed snapshot from + before entry; Cockpit-only disclosure changes do not replace that map layout. + Opening Data Layers while inside Cockpit temporarily collapses the Contact + panel to prevent overlap. Closing Data Layers restores Contact only when that + accordion action collapsed it; an operator's own Contact collapse remains + authoritative. + Voice selection of the nearest aircraft near a named place follows + the requested-layer enable → location arrival → destination refresh → nearest + airborne lookup → aircraft selection path, excludes on-ground records, and + never enters Contacts or Cockpit unless either mode is named explicitly. The + destination refresh also runs when the requested layer was already enabled. + The lookup inspects the full loaded fleet and tracks by stable ICAO identity. + That complete route is one atomic voice action, so Realtime sibling calls + cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is + queried normally and its source is returned with the selection; fallback with + no airborne records remains an honest no-data result, not an enable failure. +- Voice treats the parent Context panel and Contacts as separate intents. An + explicit request to open Context expands only `global-context-panel`; it does + not choose a mode. An explicit request to open Contacts expands that parent + 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. + +### 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. +- 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 + definitive transport/credential failure reports `UNAVAILABLE`. Accepted + warm vessels survive later zero-position refreshes as stale/degraded data, + while disable/re-enable owns a new timer and superseded responses remain + inert. +- Client render cap `VITE_AIS_LIVE_MAX_ROWS` (default 12,000); type-colored ship icons (tanker/cargo/passenger/fishing/tug); screen-space label clustering caps active labels at `VITE_AIS_LIVE_LABEL_MAX_ROWS` (default 900). +- Click-to-inspect wired into the voice context store. + +### Voice Control (June 2026) + +`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC: + +- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware holds `OPENAI_API_KEY` and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. +- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn. +- **Twenty-eight tools** (schemas defined server-side in `vite.config.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, and `next_iss_pass`. +> **Reading `npm test` totals:** the count depends on the Node major. The two +> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` +> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the +> calibrated Node 24 runtime; on any other major the runner skips both files and +> their 14 tests are absent from the total. A branch total quoted without its +> Node version is therefore not reproducible. As of the fly_route cinematic +> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on +> `main` + 26 route pins; the same tree on Node 24 reports 2,295. + +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. + - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. + - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. + - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). +- **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`. +- **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight. + - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. + - **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload. + - **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`). + - **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. + - **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance. + - **One aircraft-proximity engine** (2026-08-22). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) is the single computation behind both the Contacts panel window and the voice analyst's entity-centred "how many nearby", so the panel readout and the spoken count for one centre are identical by construction. **Invariant: do not re-derive a proximity count anywhere else.** They diverged before because the panel read live billboard positions (20,000 cap) while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions and arbitrary points deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft}`. + - **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel. + - **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded. +- **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction. +- **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration. +- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`). + - **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`. + - **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker. + - **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun. + - **Metering.** Token usage from each `response.done` is folded into a per-session estimate. Cached tokens are subtracted from their modality totals; any aggregate-minus-details residual (and any payload with no detail at all) is attributed to audio rates, so uncertainty always resolves *upward*. + - **Thresholds** (one object, persisted at `godsEyeView.voiceCost.limits`): soft warning at **$2** — amber readout plus exactly one console line; hard cap at **$5** — the session ends through the ordinary stop path (data channel and peer connection closed, mic tracks stopped) and the readout reads `Session ended — cost cap`. `0`/negative disables a threshold and round-trips through storage as an `'off'` sentinel (raw `Infinity` would JSON-serialize to `null` and silently restore the default); a corrupt entry falls back to the defaults rather than disarming the cap. + - **Cap semantics — in-flight tools COMPLETE and are NOT rolled back.** A session-ending latch (`isSessionEnding()`) is checked at the tool-dispatch site, so no *new* tool is dispatched once the cap trips. `extractFunctionCalls` yields at most one call per event, so that single check covers the whole batch. A tool already executing may still finish its map mutation (a camera flight, a layer toggle, an annotation). This is deliberate: unwinding a partially applied map change has no safe general implementation, and a half-reverted camera/layer/annotation state is worse than a completed one. + - **In-flight response at teardown → the accounting is INCOMPLETE.** Usage only arrives with `response.done`, which never comes for a response cut off by teardown (`stop()` closes the peer connection, and the server cancels rather than completes it). Rather than invent a token count for it, the tracker is marked `incomplete`: the chip shows a trailing `*` (`~$1.00*`) as a see-note mark and the tooltip carries the reason. Deliberately **not** presented as a lower bound — the estimate can also run high (residuals and unrecognised models bill at worst-case rates, and sub-cent totals round up), so it is partial rather than directional. (A bounded teardown drain was tried and removed: `pc.close()` closes the data channels a drain would listen on, so it was structurally dead.) + - **⚠️ Model ids and rates are external facts** read from OpenAI's model + pricing pages on 2026-08-18 and marked VERIFY-AT-RELEASE in `voiceCost.js`. +- **Reliability**: tool-call dedupe (2.5s window across call/item/args keys); response-create queueing that respects active responses and defers follow-ups when the user starts speaking; per-tool follow-up instructions so the agent confirms only what actually happened (zoom confirms only on `ok=true`). +- **Aircraft identity honesty:** “What is this aircraft?” reads callsign, operator, registration, type, and route only from the selected contact context. Missing operator, route, or type enrichment is named explicitly rather than silently omitted or inferred from the callsign. +- **Diagnostics**: every client/server event is posted to `/api/realtime/debug-log` and appended to `.gev-logs/realtime-conversations.jsonl` (gitignored) with secret/image redaction; last 30 errors persist in `localStorage` (`gev-realtime-errors`); `window.__gevVoiceCommands.getDiagnostics()` in the console. +- **Counting semantics ("near")** — a CONTRACT; new count-bearing tool work inherits it. Three honest numbers exist for one question: the Contacts cohort (250 km around the subject, what the panel shows), `analyst_query`'s count of *currently-loaded* records for the requested scope, and the layer-wide loaded total in `coverage.layersQueried[].records`. They diverge legitimately — the flights layer loads by viewport, so after a camera dive the loaded set can hold a fraction of the cohort (field case: panel 42, analyst 8). The contract: + 1. **Contacts ACTIVE** → "near / nearby / how many aircraft" means the **Contacts window** — the panel's numbers, spoken verbatim. Mechanism: `contactsWindow` (`{centeredOn, radiusKm, flights, military, vessels}`), carried by both `analyst_query` and `get_current_view_state`, derived by `contactsWindowFromSnapshot()` from the same snapshot the panel renders so the two cannot drift. A cohort whose feed cannot answer reports `'unknown'`, never a confident zero. + 2. **Contacts OFF** → "nearby" means **in view**; "near \" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera. + 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` so this is mechanical. Two different numbers with named scopes are not a contradiction. + 4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers). +- **Degradation**: without `OPENAI_API_KEY`, `/api/realtime/token` returns 503 and the mic button surfaces the error; the rest of the app is unaffected. + +### AI HUD Summary (June 2026) + +- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). +- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. +- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. + +### Map Stack Switcher (June 2026) + +- `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, default), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. +- The bottom Visual Presets tray presents a **four-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The four tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline. +- The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`). +- Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token. +- Stack choice participates in share links (`src/sharelink.js`) and falls back to OSM when Google 3D tiles fail to load. Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path. + +### Voice Map Whiteboard / Annotations (June 2026) + +- 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. +- 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. +- Current known resolver gap: mall/lifestyle districts such as "The Domain, Austin" can prefer a named building over the broader retail envelope. Product decision is that districts should become envelope + key buildings, but the scoring change still needs a careful multi-case validation pass. + +### 3D Aircraft + Tracking (June 2026) + +- **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. +- **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, always flat amber). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. +- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. +- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. +- Civilian and military 2D aircraft use the established distance scale: `3×` near + the camera and a `0.5×` floor from 8,000 km outward. A standard 20 px ambient + icon therefore remains about 10 px at globe altitude while retaining the + established close-range silhouette. Any compact alternative requires + before/after visual evidence. +- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker. +- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere. +- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks. +- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. + +### TR-3B conversion Easter egg (August 2026) + +- With a contact tracked, CONTEXT ▸ CONTACTS shows a small 🛸 chip beside COCKPIT (`#tr3b-toggle`, gated by `CockpitView.syncTr3bToggle()` on a tracked contact — not on the cockpit entry policy). Pressing it converts that contact into a TR-3B and pressing it again restores the real aircraft. State lives in `src/data/tr3bRegistry.js`: a session-scoped module-level `Set` keyed by ICAO 24-bit address, shared across both flight layers the same way `militaryRegistry.js` is, so a conversion holds through a civil↔military handoff. It is deliberately NOT persisted (no localStorage, no share-link param, no schema change) and layer teardown deliberately leaves it intact — only a page reload clears conversions. +- The sprite is two hidden kinds (`tr3b`, `tr3bHot`) in `src/data/aircraftIcons.js`, authored in the same 96×96 nose-up pipeline as the eight class silhouettes, so the triangle points along the display course through the existing screen-projected rotation path with `alignedAxis` still `ZERO`. They are unreachable from `classifyAircraft()`. Variant selection rides the existing `irBoost` layer param, so under NVG/FLIR/surveillance the hull stays cold and the four emitters render hot; a style switch re-images only converted contacts, never the rest of the fleet. +- Both layers resolve every `aircraftIcon()` call through a local `_iconKind()` shim (identity for unconverted contacts), so no refresh path — poll reconciler, two-tier raster swap, presentation pass, tracked entity — can revert a conversion. +- The class label follows the conversion across every surface that reports one: tracked card, cockpit/`getTrackedInfo`, Contacts, and the analyst record's `aircraftClass` (`tr3b`, the style-independent id, so a query answers the same in FLIR as in Normal). Callsign, flight level, speed, and route stay live-feed truth. +- A converted contact is billboard-only. It is excluded from model eligibility at SELECTION time, so it never consumes a `MODEL_MAX` cap slot, with the handoff guard and the tracked-model regime guard kept as defence. The billboard stays shown, so the contact keeps satisfying the `getNearby` / `getDetectableObjects` visibility guards and still works in Contacts and Cockpit. +- Regression surface: `src/data/tr3bRegistry.test.mjs`. + +### Split-flap status chips (August 2026) + +- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). All three route through `setSplitFlapText()` in `src/splitFlap.js`; there is no other writer of those three elements. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine. +- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: `ensureHost()` re-validates the shell on every call, and the long-lived `Text` node is never replaced. +- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The first call upgrades a chip label into a permanent shell (`ensureHost`): a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling. After that the ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest and lets `_updateTrafficSyncChip`'s own `textContent !==` guard keep working. The shell is built on a tick where the text is NOT changing, so no real label change ever carries a structural mutation. +- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. `setSplitFlapText` is a no-op on unchanged text, which is required — the chips are repainted by a 60 ms and a 500 ms ticker. +- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css. +- **Columns never renumber mid-cascade — do not "optimise" this away.** For the whole cascade the board keeps one column per index of the LONGER string, each holding its own width; a column the new string does not reach flaps to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left". +- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT. +- Accessibility: the cells sit in an `aria-hidden` wrapper and the settled string is real text in the a11y tree, so the `aria-live` chips announce the label once per change rather than character fragments. No `aria-label` is used — ARIA prohibits naming a generic ``. Because the text node is permanent and only its data changes, a label update is a single `characterData` mutation and settlement is none — node churn in a live region can double-announce. +- A chip hidden by clean-UI, recording mode, or an un-`.visible` (`opacity: 0`) traffic/CCTV chip swaps instantly instead of animating where nobody can see it; `prefers-reduced-motion: reduce` does the same. +- Kill switch: `SPLIT_FLAP_ENABLED` in `src/splitFlap.js`. Set it `false` and every chip returns to a plain instant swap with no other change. +- Regression surface: `src/splitFlap.test.mjs`. + +### Panoptic Detection + Tracked Readout (June 2026) + +- `src/data/detection.js` samples enabled layers through each layer's `getDetectableObjects()` contract and renders bounding boxes/labels from the shared host's sole Cesium post-render callback so boxes align with the final camera frame. +- Detection diagnostics count fading labels from the arbiter rows that are + actually rendered. The label QA harness uses time-weighted label exposure for + churn and requires conclusive solve/frame samples at both its 12,000-object + pathological field and 5,200-object normal field without relaxing budgets. +- `src/data/detectionDraw.js` performs the batched, DPI-crisp canvas drawing for tier-colored labels, corner brackets, callouts, and distance-scaled tracked boxes. Unit tests cover label measurement and draw geometry. +- `src/data/trackedReadout.js` publishes a protected shared-host callout above tracked aircraft and satellites or selected mapped installations. It reads only each layer's cached display position—never a fresh entity position evaluation—preventing readout jitter against the rendered target. AIS selection remains in the vessel source's protected card path. + +### Not Currently in Runtime + +- Weather radar (removed before OSS v1 after QA; no reliable visible payoff) +- General replay/timeline systems outside the Space Missions experience +- LiDAR explorer and paired-point CCTV calibration experiments + +## Auth + Launch + +- Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) +- Build gate: `npm run build` +- Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) +- OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) +- Google key expected in Keychain service `google-maps-api` (or `GOOGLE_MAPS_API_KEY`, or `.env`) +- OpenSky credentials expected in Keychain service `opensky-network` (or env, or `.env`); `OPENSKY_AUTH_MODE` and `OPENSKY_CREDENTIALS_FILE` read from `.env` too +- Optional-key precedence in `dev-fresh.sh` is uniform — explicit shell env, then `.env`, then Keychain: `OPENAI_API_KEY` (Keychain `openai-api`/`api-key` — voice + HUD summary), `AISSTREAM_API_KEY` (`aisstream-api`/`api-key` — live vessels), `CESIUM_ION_TOKEN` (`cesium-ion`/`token` — Bing stacks), `TOMTOM_API_KEY` (`tomtom-api`/`api-key` — live traffic flow), `FIRMS_MAP_KEY` (`firms-map`/`map-key` — live fires), `LL2_API_TOKEN` (`.env` only) +- An empty string is not "unset" on either side of the launcher, and both sides are handled. `scripts/read-dotenv-value.mjs` hides the requested key from `process.env` for the duration of the read (Vite's `loadEnv` otherwise lets an inherited empty export win over the parsed files) and restores it after. A key the launcher resolves to nothing is then removed from the dev server's environment outright (`env -u`), not merely omitted — the child inherits this shell's environment, and Vite backfills `.env` only over undefined variables, so an empty export in either place would shadow a configured key. `CCTV_CALTRANS_DISTRICTS` is the deliberate exception: empty is its documented Caltrans kill switch and is passed through as-is +- `.env` supported via `.env.example` template + +### Proxy/Security Baseline + +- CCTV proxy rejects client-specified upstream URLs (server-side source allowlist only). +- CCTV upstream still-image fetches use an explicit abort controller with an + eight-second timeout; the timer is cleared on every success or failure path. +- OpenSky response cache stores successful upstream responses only; OAuth token refresh calls are coalesced. +- A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot. +- GBFS response size is capped; CCTV health map is bounded. +- Proxy error payloads are sanitized (no internal error details returned to clients). +- `OPENAI_API_KEY` is server-side only; the browser receives 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. +- `/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/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). +- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. + +## UI/UX Runtime Defaults + +- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300. +- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail. +- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip. +- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a ` + + + + +
+ syncing road network + 0% +
+
+ loading frames + 0/0 +
+ + +
+ + +
+
+
+ + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+
+ +

GOD'S EYE VIEW

+

Initializing photorealistic world...

+
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..108e2dc2b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3782 @@ +{ + "name": "gods-eye-view", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gods-eye-view", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@mapbox/vector-tile": "^3.0.0", + "cesium": "^1.124.0", + "egm96-universal": "^1.1.1", + "mgrs": "^2.1.0", + "pbf": "^5.1.2", + "satellite.js": "^6.0.2" + }, + "devDependencies": { + "puppeteer": "^24.37.5", + "sharp": "^0.34.5", + "vite": "^6.0.0", + "vite-plugin-cesium": "^1.2.23", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=24.14.0 <25 || >=26 <27" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cesium/engine": { + "version": "22.3.0", + "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz", + "integrity": "sha512-oDl+nWX/qfHYQ0lEdGxLqZoKEtTMghvJDzZKTycYfiIuDYDh8Kh0Oy45wr3mSJse3PuTj1e6hDmbw8vbycCOxw==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/wasm-splats": "^0.1.0-alpha.2", + "@spz-loader/core": "0.3.0", + "@tweenjs/tween.js": "^25.0.0", + "@zip.js/zip.js": "^2.8.1", + "autolinker": "^4.0.0", + "bitmap-sdf": "^1.0.3", + "dompurify": "^3.3.0", + "draco3d": "^1.5.1", + "earcut": "^3.0.0", + "grapheme-splitter": "^1.0.4", + "jsep": "^1.3.8", + "kdbush": "^4.0.1", + "ktx-parse": "^1.0.0", + "lerc": "^2.0.0", + "mersenne-twister": "^1.1.0", + "meshoptimizer": "^1.0.1", + "pako": "^2.0.4", + "protobufjs": "^8.0.0", + "rbush": "^4.0.1", + "topojson-client": "^3.1.0", + "urijs": "^1.19.7" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@cesium/wasm-splats": { + "version": "0.1.0-alpha.2", + "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", + "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==", + "license": "Apache-2.0" + }, + "node_modules/@cesium/widgets": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-14.3.0.tgz", + "integrity": "sha512-1bS+Nv/uXwP0/NV0o4XeUA5nLCWttjTmKwl+pHnbZXp0ZwDmClb0xVDruDyVtLrUuRhsk84JZ4rXI/IT7HXOvA==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/engine": "^22.3.0", + "nosleep.js": "^0.12.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@spz-loader/core": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.0.tgz", + "integrity": "sha512-sbStwMHb/MIE29st7rRuMYWqhX1UmLSFzdpyGtUZUXLkFNIuYKblzjQdtiet8bau8sUf21uL1DQ451zuySGmcA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16", + "pnpm": ">=8" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", + "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", + "integrity": "sha512-sCs5DpNyFVcckh789zZHa+7vk5XBmzCOjyCcfVp1Ozehq6kDCqgHimZReZTzcYJHJiNmN1vN9xHNT0N3S8SIcw==", + "license": "BSD-3-Clause", + "dependencies": { + "web-worker": "^1.5.0" + }, + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autolinker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", + "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "pnpm": ">=10.10.0" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", + "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cesium": { + "version": "1.138.0", + "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.138.0.tgz", + "integrity": "sha512-YX7Ttd4LzAxunuzcKPyOCQa+BPc2RmenqnkM5uZkk/GVwor724bd+F3kdVP4IyMbTgxFkchXuX2Aa8L1Y0/ZxA==", + "license": "Apache-2.0", + "workspaces": [ + "packages/engine", + "packages/widgets", + "packages/sandcastle" + ], + "dependencies": { + "@cesium/engine": "^22.3.0", + "@cesium/widgets": "^14.3.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1566079", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", + "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/egm96-universal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/egm96-universal/-/egm96-universal-1.1.1.tgz", + "integrity": "sha512-rHOKjWBcT2SQ1SqATOt3FdVVEkd6+n4Q+j0IDivVc/yqS0SmQ+LLUbNdCRuS6C0aibv9UXxqOyAGIyyg4E22Qg==", + "license": "MIT", + "dependencies": { + "rfc4648": "^1.5.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/nicolas-van" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/ktx-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", + "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", + "license": "MIT" + }, + "node_modules/lerc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", + "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", + "license": "Apache-2.0" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mersenne-twister": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", + "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, + "node_modules/mgrs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-2.1.0.tgz", + "integrity": "sha512-/pjuM02PUAhp4dazfccTSKBGTGPFkRz9LKwGJKusc/B20apcGG/CYnRHx0kXmLXy4m5O3p6y6nOVxEnErPpFvQ==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/nosleep.js": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", + "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/protobufjs": { + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.2.tgz", + "integrity": "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", + "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1566079", + "puppeteer-core": "24.37.5", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", + "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1566079", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/satellite.js": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-6.0.2.tgz", + "integrity": "sha512-XWKxtqVF5xiJ1xAeiYeT/oSSzsukoCLWvk6nO/WFy4un0M3g4djAU9TAtOCqJLtYW9vxx9pkPJ1L9ITOc607GA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-cesium": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/vite-plugin-cesium/-/vite-plugin-cesium-1.2.23.tgz", + "integrity": "sha512-x9A8ZCEoegceXg/E+LnxKr0XBsI9CR4cgYWQ2Dd3cUEYwKcTnHQ3kBfpol7BUcGtgQnQos/mtVrRmuVQBXFjHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^9.1.0", + "rollup-plugin-external-globals": "^0.6.1", + "serve-static": "^1.14.1" + }, + "peerDependencies": { + "cesium": "^1.95.0", + "vite": ">=2.7.1" + } + }, + "node_modules/vite-plugin-cesium/node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-cesium/node_modules/rollup-plugin-external-globals": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-external-globals/-/rollup-plugin-external-globals-0.6.1.tgz", + "integrity": "sha512-mlp3KNa5sE4Sp9UUR2rjBrxjG79OyZAh/QC18RHIjM+iYkbBwNXSo8DHRMZWtzJTrH8GxQ+SJvCTN3i14uMXIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^4.0.0", + "estree-walker": "^2.0.1", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^2.25.0" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..86d1099a1 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "gods-eye-view", + "private": true, + "version": "0.1.0", + "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", + "author": "Bilawal Sidhu", + "homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme", + "repository": { + "type": "git", + "url": "https://github.com/bilawalsidhu/gods-eye-view.git" + }, + "bugs": { + "url": "https://github.com/bilawalsidhu/gods-eye-view/issues" + }, + "engines": { + "node": ">=24.14.0 <25 || >=26 <27" + }, + "keywords": [ + "cesium", + "3d-globe", + "geospatial", + "osint", + "spatial-intelligence", + "photorealistic-3d-tiles", + "adsb", + "ais", + "satellites", + "voice-control", + "openai-realtime", + "visualization" + ], + "scripts": { + "dev": "vite", + "dev:secure": "./scripts/dev-secure.sh", + "opensky:import": "./scripts/opensky-import-client.sh", + "build": "vite build", + "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" + }, + "dependencies": { + "@mapbox/vector-tile": "^3.0.0", + "cesium": "^1.124.0", + "egm96-universal": "^1.1.1", + "mgrs": "^2.1.0", + "pbf": "^5.1.2", + "satellite.js": "^6.0.2" + }, + "devDependencies": { + "puppeteer": "^24.37.5", + "sharp": "^0.34.5", + "vite": "^6.0.0", + "vite-plugin-cesium": "^1.2.23", + "ws": "^8.21.0" + } +} diff --git a/public/location.svg b/public/location.svg new file mode 100644 index 000000000..5f77aa3f6 --- /dev/null +++ b/public/location.svg @@ -0,0 +1,4 @@ + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 000000000..c2606eaf7 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,43 @@ + + God's Eye View + + + + + + + + diff --git a/public/mic.svg b/public/mic.svg new file mode 100644 index 000000000..8fb438e99 --- /dev/null +++ b/public/mic.svg @@ -0,0 +1,9 @@ + + Microphone + + + + + + + diff --git a/public/models/README.md b/public/models/README.md new file mode 100644 index 000000000..7e9c089be --- /dev/null +++ b/public/models/README.md @@ -0,0 +1,21 @@ +# Bundled 3D Model Attribution + +The model files in this directory are third-party visual assets. They are not +covered by the repository's MIT source-code license; each remains available +under the license listed below. + +| File | Original work and creator | Source | License | Project modifications | +|---|---|---|---|---| +| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | +| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | +| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. | +| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | + +CC BY 4.0 permits sharing and adaptation, including commercial use, provided +appropriate credit is retained, the license is linked, and modifications are +identified. These credits do not imply endorsement by the original creators. diff --git a/public/models/airplane.glb b/public/models/airplane.glb new file mode 100644 index 000000000..016a56f1d Binary files /dev/null and b/public/models/airplane.glb differ diff --git a/public/models/atr72.glb b/public/models/atr72.glb new file mode 100644 index 000000000..8946dc39c Binary files /dev/null and b/public/models/atr72.glb differ diff --git a/public/models/b789.glb b/public/models/b789.glb new file mode 100644 index 000000000..be9849d74 Binary files /dev/null and b/public/models/b789.glb differ diff --git a/public/models/bell206.glb b/public/models/bell206.glb new file mode 100644 index 000000000..029fe1339 Binary files /dev/null and b/public/models/bell206.glb differ diff --git a/public/models/c172.glb b/public/models/c172.glb new file mode 100644 index 000000000..3c5155433 Binary files /dev/null and b/public/models/c172.glb differ diff --git a/public/models/citation2.glb b/public/models/citation2.glb new file mode 100644 index 000000000..2ec47d6dc Binary files /dev/null and b/public/models/citation2.glb differ diff --git a/public/models/jet.glb b/public/models/jet.glb new file mode 100644 index 000000000..bb2f99f9f Binary files /dev/null and b/public/models/jet.glb differ diff --git a/public/models/mq9.glb b/public/models/mq9.glb new file mode 100644 index 000000000..47bc21f91 Binary files /dev/null and b/public/models/mq9.glb differ diff --git a/public/models/ship.glb b/public/models/ship.glb new file mode 100644 index 000000000..e52d13b2c Binary files /dev/null and b/public/models/ship.glb differ diff --git a/public/pin.svg b/public/pin.svg new file mode 100644 index 000000000..06408fc32 --- /dev/null +++ b/public/pin.svg @@ -0,0 +1,5 @@ + + Pin panel + + + diff --git a/public/visual-presets.svg b/public/visual-presets.svg new file mode 100644 index 000000000..192c961b0 --- /dev/null +++ b/public/visual-presets.svg @@ -0,0 +1,5 @@ + diff --git a/scripts/build-sf-neighborhoods.mjs b/scripts/build-sf-neighborhoods.mjs new file mode 100644 index 000000000..49d17eb51 --- /dev/null +++ b/scripts/build-sf-neighborhoods.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Build src/data/local_data/neighborhoods/san-francisco.json from the official + * public-domain DataSF "Analysis Neighborhoods" dataset. + * + * Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h + * Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood) + * Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100 + * License: Open Data Commons Public Domain Dedication and License (PDDL 1.0) + * (dataset metadata `licenseId: "PDDL"` at + * https://data.sfgov.org/api/views/j2bu-swwd.json) + * + * Transform (deterministic): + * 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim). + * 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves + * hyper-detailed shoreline vertices without visibly moving boundaries. + * 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped; + * rings re-closed; rings that collapse below 4 points dropped. + * 4. Single-part MultiPolygons collapsed to Polygon. + * 5. Features sorted by name for a stable diff. + * + * Usage: + * node scripts/build-sf-neighborhoods.mjs [raw.geojson] + * With no argument it downloads the live dataset; with an argument it reads the + * given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100'; +const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)), + '..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json'); +const TOLERANCE = 2e-5; // degrees, ~2 m +const DECIMALS = 6; + +/** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */ +function segDist(p, a, b) { + let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b; + let dx = x2 - x1; let 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 on 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]); +} + +const round = (v) => Number(v.toFixed(DECIMALS)); + +/** Simplify + round one ring; returns null if it degenerates. */ +function processRing(ring) { + // GeoJSON rings are closed (first == last); simplify the open part. + const open = ring.length > 1 + && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1] + ? ring.slice(0, -1) : ring.slice(); + const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]); + const out = []; + for (const pt of simplified) { + const prev = out[out.length - 1]; + if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue; + out.push(pt); + } + if (out.length < 3) return null; + out.push([out[0][0], out[0][1]]); // re-close + return out; +} + +async function main() { + let rawText; + if (process.argv[2]) { + rawText = fs.readFileSync(process.argv[2], 'utf8'); + console.log(`read ${process.argv[2]}`); + } else { + console.log(`fetching ${SOURCE_URL}`); + const res = await fetch(SOURCE_URL); + if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`); + rawText = await res.text(); + } + const raw = JSON.parse(rawText); + if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) { + throw new Error('unexpected payload: not a FeatureCollection'); + } + + let inVerts = 0; let outVerts = 0; + const features = []; + for (const f of raw.features) { + const name = f.properties && f.properties.nhood; + if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`); + const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates + : f.geometry.type === 'Polygon' ? [f.geometry.coordinates] + : null; + if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`); + const polys = []; + for (const poly of polysIn) { + const rings = []; + for (const ring of poly) { + inVerts += ring.length; + const r = processRing(ring); + if (r) { rings.push(r); outVerts += r.length; } + } + if (rings.length && poly[0] && rings[0]) polys.push(rings); + } + if (!polys.length) throw new Error(`geometry collapsed for ${name}`); + const geometry = polys.length === 1 + ? { type: 'Polygon', coordinates: polys[0] } + : { type: 'MultiPolygon', coordinates: polys }; + features.push({ type: 'Feature', properties: { name }, geometry }); + } + features.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + const out = { + type: 'FeatureCollection', + city: 'San Francisco', + source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md', + features, + }; + fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`); + const kb = (fs.statSync(OUT).size / 1024).toFixed(0); + console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/dev-cctv.sh b/scripts/dev-cctv.sh new file mode 100755 index 000000000..b69d84a14 --- /dev/null +++ b/scripts/dev-cctv.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PORT="${PORT:-4173}" +HOST="${HOST:-0.0.0.0}" +CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" +CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" +CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" + +if ! command -v npm >/dev/null 2>&1; then + echo "error: npm not found" + exit 1 +fi + +GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}" +GOOGLE_MAPS_API_KEY_KEYCHAIN="" +GOOGLE_MAPS_API_KEY_SOURCE="" +if command -v security >/dev/null 2>&1; then + for acct in "api-key" "default" "key"; do + GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)" + if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}" + break + fi + done +fi + +if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}" +elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}" + GOOGLE_MAPS_API_KEY_SOURCE="env" +else + GOOGLE_MAPS_API_KEY="" +fi +if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then + echo "error: Google Maps API key missing." + echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key" + exit 1 +fi + +read_keychain_secret() { + local service="$1" + local account="$2" + security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true +} + +load_opensky_oauth_from_file() { + local file_path="$1" + if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then + return + fi + if ! command -v node >/dev/null 2>&1; then + echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE" + return + fi + + local parsed + parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)" + if [[ "${parsed}" != *$'\t'* ]]; then + return + fi + local parsed_client_id="${parsed%%$'\t'*}" + local parsed_client_secret="${parsed#*$'\t'}" + + if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then + OPENSKY_CLIENT_ID="${parsed_client_id}" + fi + if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then + OPENSKY_CLIENT_SECRET="${parsed_client_secret}" + fi +} + +resolve_opensky_credentials() { + OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')" + OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}" + case "${OPENSKY_AUTH_MODE}" in + basic|oauth|auto|anon) ;; + *) + echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'" + OPENSKY_AUTH_MODE="oauth" + ;; + esac + + OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}" + OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}" + OPENSKY_USERNAME="${OPENSKY_USERNAME:-}" + OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}" + + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "username" "user" "login" "email" "default"; do + OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_USERNAME}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "password" "pass" "token" "secret" "default"; do + OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_PASSWORD}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}" + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_id" "client-id" "client" "api-key"; do + OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_secret" "client-secret" "secret"; do + OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then + break 2 + fi + done + done + fi + + case "${OPENSKY_AUTH_MODE}" in + basic) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + ;; + oauth) + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + anon) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + esac +} + +resolve_opensky_credentials + +echo "Starting God's Eye View with CCTV source pack..." +echo "URL: http://localhost:${PORT}/" +echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" +if [[ -f "${CCTV_SOURCES_FILE}" ]]; then + echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}" +else + echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)" +fi +echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" +if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}" + else + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)" + fi +fi +case "${OPENSKY_AUTH_MODE}" in + basic) + if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky basic auth: configured" + else + echo "OpenSky basic auth: missing credentials" + fi + ;; + oauth) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky OAuth: configured" + else + echo "OpenSky OAuth: missing client credentials" + fi + ;; + auto) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky auto auth: OAuth configured" + elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky auto auth: basic configured" + else + echo "OpenSky auto auth: no credentials found" + fi + ;; + anon) + echo "OpenSky auth: disabled (anonymous mode)" + ;; +esac + +GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \ +CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \ +CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \ +CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \ +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \ +OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \ +OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \ +OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \ +OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \ +OPENSKY_USERNAME="${OPENSKY_USERNAME}" \ +OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \ +npm run dev -- --host "${HOST}" --port "${PORT}" --force diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh new file mode 100755 index 000000000..e50be2e37 --- /dev/null +++ b/scripts/dev-fresh.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PORT="${PORT:-4173}" +# Local-only by default: the dev server brokers configured API keys, so it +# should not be reachable from the network unless explicitly requested. +# Set HOST=0.0.0.0 to opt in to LAN exposure (a warning is printed). +HOST="${HOST:-localhost}" +# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans +# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream), +# TfL London JamCams (~870 live upstream). Caps keep the densest cores per +# pack; override per-run for lighter/heavier loads. Kill switches: +# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0. +CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}" +# Use `-` not `:-` so an explicit empty string (the documented kill switch) +# is preserved rather than replaced by the default. Still set-u-safe when unset. +CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}" +CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}" +CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}" +CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}" +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}" + +if command -v npm >/dev/null 2>&1; then + DEV_COMMAND=(npm run dev --) +elif command -v pnpm >/dev/null 2>&1; then + DEV_COMMAND=(pnpm run dev) +else + echo "error: neither npm nor pnpm found" + exit 1 +fi + +read_dotenv_value() { + local variable_name="$1" + if [[ ! -f ".env" ]]; then + return + fi + if ! command -v node >/dev/null 2>&1; then + echo "warning: node not found; cannot parse .env" >&2 + return + fi + node scripts/read-dotenv-value.mjs "${variable_name}" +} + +# Vite loads .env for browser build-time configuration, but this launcher needs +# the Maps key before Vite starts. Preserve a shell-provided value; otherwise +# read the project-local .env without executing it as shell code. +GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}" +GOOGLE_MAPS_API_KEY_ENV_SOURCE="env" +if [[ -z "${GOOGLE_MAPS_API_KEY_ENV}" && -f ".env" ]]; then + GOOGLE_MAPS_API_KEY_ENV="$(read_dotenv_value "GOOGLE_MAPS_API_KEY")" + GOOGLE_MAPS_API_KEY_ENV_SOURCE=".env" +fi +GOOGLE_MAPS_API_KEY_KEYCHAIN="" +GOOGLE_MAPS_API_KEY_SOURCE="" +if command -v security >/dev/null 2>&1; then + for acct in "api-key" "default" "key"; do + GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)" + if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}" + break + fi + done +fi + +if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}" +elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}" + GOOGLE_MAPS_API_KEY_SOURCE="${GOOGLE_MAPS_API_KEY_ENV_SOURCE}" +else + GOOGLE_MAPS_API_KEY="" +fi +if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then + echo "error: Google Maps API key missing." + echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key" + exit 1 +fi + +read_keychain_secret() { + local service="$1" + local account="$2" + security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true +} + +load_opensky_oauth_from_file() { + local file_path="$1" + if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then + return + fi + if ! command -v node >/dev/null 2>&1; then + echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE" + return + fi + + local parsed + parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" -- "${file_path}" 2>/dev/null || true)" + if [[ "${parsed}" != *$'\t'* ]]; then + return + fi + local parsed_client_id="${parsed%%$'\t'*}" + local parsed_client_secret="${parsed#*$'\t'}" + + if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then + OPENSKY_CLIENT_ID="${parsed_client_id}" + fi + if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then + OPENSKY_CLIENT_SECRET="${parsed_client_secret}" + fi +} + +resolve_opensky_credentials() { + OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE:-$(read_dotenv_value "OPENSKY_AUTH_MODE")}" + OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')" + OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-$(read_dotenv_value "OPENSKY_CREDENTIALS_FILE")}" + case "${OPENSKY_AUTH_MODE}" in + basic|oauth|auto|anon) ;; + *) + echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'" + OPENSKY_AUTH_MODE="oauth" + ;; + esac + + # Explicit env wins, then .env, then the credentials file / Keychain below. + OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-$(read_dotenv_value "OPENSKY_CLIENT_ID")}" + OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-$(read_dotenv_value "OPENSKY_CLIENT_SECRET")}" + OPENSKY_USERNAME="${OPENSKY_USERNAME:-$(read_dotenv_value "OPENSKY_USERNAME")}" + OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-$(read_dotenv_value "OPENSKY_PASSWORD")}" + + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "username" "user" "login" "email" "default"; do + OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_USERNAME}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "password" "pass" "token" "secret" "default"; do + OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_PASSWORD}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}" + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_id" "client-id" "client" "api-key"; do + OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_secret" "client-secret" "secret"; do + OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then + break 2 + fi + done + done + fi + + case "${OPENSKY_AUTH_MODE}" in + basic) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + ;; + oauth) + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + anon) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + esac +} + +resolve_opensky_credentials + +# Optional keys: explicit env wins, followed by .env and Keychain fallback. +# Add to Keychain with e.g.: +# security add-generic-password -U -s "openai-api" -a "api-key" -w +OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}" +AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}" +CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}" +LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}" +TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_dotenv_value "TOMTOM_API_KEY")}" +FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_dotenv_value "FIRMS_MAP_KEY")}" +OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_keychain_secret "openai-api" "api-key")}" +AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_keychain_secret "aisstream-api" "api-key")}" +CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token")}" +TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}" +FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}" + +if [[ ! -f "src/data/cctv.js" ]]; then + echo "error: expected CCTV layer file missing: src/data/cctv.js" + exit 1 +fi + +if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then + echo "error: CCTV layer not wired in src/main.js" + exit 1 +fi + +echo "Stopping all existing God's Eye View dev servers..." +pkill -f "${ROOT_DIR}/node_modules/.bin/vite" >/dev/null 2>&1 || true +pkill -f "${ROOT_DIR}/node_modules/vite/bin/vite.js" >/dev/null 2>&1 || true + +# Also clear the requested port in case it is held by a stale wrapper or a +# server started through a different package-manager command. +if command -v lsof >/dev/null 2>&1; then + PIDS="$(lsof -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null || true)" + if [[ -n "${PIDS}" ]]; then + echo "${PIDS}" | xargs kill -9 >/dev/null 2>&1 || true + fi +fi + +echo "Clearing Vite cache..." +rm -rf node_modules/.vite + +echo "Starting fresh God's Eye View dev server..." +case "${HOST}" in + localhost|127.0.0.1|::1) + echo "Local-only mode: reachable at http://localhost:${PORT}/ (set HOST=0.0.0.0 for LAN)" + ;; + *) + LAN_IP="" + if command -v ipconfig >/dev/null 2>&1; then + # macOS: first active interface wins + for iface in en0 en1; do + LAN_IP="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)" + [[ -n "${LAN_IP}" ]] && break + done + elif command -v hostname >/dev/null 2>&1; then + # Linux: hostname -I lists addresses; take the first + LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + fi + echo "" + echo "!! ==============================================================" + echo "!! WARNING: HOST=${HOST} — network-exposed mode." + echo "!! This dev server brokers your configured API keys (OpenAI," + echo "!! OpenSky, AISStream, TomTom, FIRMS, LL2, Google) to ANYONE who can" + echo "!! reach it on the network. Use only on networks you trust." + echo "!! Consider the opt-in per-IP throttles GEV_RATELIMIT_OPENAI_PER_MIN" + echo "!! and GEV_RATELIMIT_GOOGLE_PER_MIN (see .env.example) — and note" + echo "!! they are NOT billing caps; set provider-side budget alerts too." + if [[ -n "${LAN_IP}" ]]; then + echo "!! LAN URL: http://${LAN_IP}:${PORT}/" + else + echo "!! LAN URL: http://:${PORT}/" + fi + echo "!! ==============================================================" + echo "" + echo "URL (this machine): http://localhost:${PORT}/" + ;; +esac +echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" +echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)." +echo "If panels are still missing, run this once in browser console:" +echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();" +echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" +if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}" + else + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)" + fi +fi +case "${OPENSKY_AUTH_MODE}" in + basic) + if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky basic auth: configured" + else + echo "OpenSky basic auth: missing credentials" + fi + ;; + oauth) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky OAuth: configured" + else + echo "OpenSky OAuth: missing client credentials" + fi + ;; + auto) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky auto auth: OAuth configured" + elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky auto auth: basic configured" + else + echo "OpenSky auto auth: no credentials found" + fi + ;; + anon) + echo "OpenSky auth: disabled (anonymous mode)" + ;; +esac +[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled" +[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty" +[[ -n "${CESIUM_ION_TOKEN}" ]] && echo "Cesium ion token (Bing map stacks): configured" || echo "Cesium ion token (Bing map stacks): not set — Google 3D/OSM only" +[[ -n "${TOMTOM_API_KEY}" ]] && echo "TomTom key (live traffic flow): configured" || echo "TomTom key (live traffic flow): not set — simulated traffic" +[[ -n "${FIRMS_MAP_KEY}" ]] && echo "NASA FIRMS key (live fires): configured" || echo "NASA FIRMS key (live fires): not set — fires layer requires a key" +[[ -n "${LL2_API_TOKEN}" ]] && echo "Launch Library 2 token: configured" || echo "Launch Library 2 token: not set — using public access" + +# Build the dev server environment explicitly. A value that resolved to +# nothing is left UNSET instead of being exported empty: Vite backfills its +# own .env values only for variables it finds undefined, so an empty export +# would shadow a key the user did configure in .env. +DEV_ENV=() +DEV_UNSET=() +put_env() { + DEV_ENV+=("$1=$2") +} +put_env_if_set() { + if [[ -n "$2" ]]; then + DEV_ENV+=("$1=$2") + else + # Leaving it out is not enough: the child inherits this shell's + # environment, so an empty export made in the PARENT would pass straight + # through and shadow .env just the same. Remove it from the child outright. + DEV_UNSET+=(-u "$1") + fi +} + +put_env GOOGLE_MAPS_API_KEY "${GOOGLE_MAPS_API_KEY}" +put_env CCTV_AUSTIN_MAX_SOURCES "${CCTV_AUSTIN_MAX_SOURCES}" +# Empty is the documented Caltrans kill switch, so this one is passed as-is. +put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}" +put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}" +put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}" +put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}" +put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}" +put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}" +put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}" +put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}" +put_env_if_set OPENSKY_CLIENT_ID "${OPENSKY_CLIENT_ID}" +put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}" +put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}" +put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}" +put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}" +put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}" +put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}" +put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}" +put_env_if_set FIRMS_MAP_KEY "${FIRMS_MAP_KEY}" +put_env_if_set LL2_API_TOKEN "${LL2_API_TOKEN}" + +env ${DEV_UNSET[@]+"${DEV_UNSET[@]}"} "${DEV_ENV[@]}" "${DEV_COMMAND[@]}" --host "${HOST}" --port "${PORT}" --force diff --git a/scripts/dev-secure.sh b/scripts/dev-secure.sh new file mode 100755 index 000000000..6ac6c1599 --- /dev/null +++ b/scripts/dev-secure.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PORT="${PORT:-4173}" +# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN. +HOST="${HOST:-localhost}" + +GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}" +GOOGLE_MAPS_API_KEY_KEYCHAIN="" +GOOGLE_MAPS_API_KEY_SOURCE="" +if command -v security >/dev/null 2>&1; then + for acct in "api-key" "default" "key"; do + GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)" + if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}" + break + fi + done +fi + +if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}" +elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then + GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}" + GOOGLE_MAPS_API_KEY_SOURCE="env" +else + GOOGLE_MAPS_API_KEY="" +fi +if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then + echo "error: Google Maps API key missing." + echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key" + exit 1 +fi + +read_keychain_secret() { + local service="$1" + local account="$2" + security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true +} + +load_opensky_oauth_from_file() { + local file_path="$1" + if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then + return + fi + if ! command -v node >/dev/null 2>&1; then + echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE" + return + fi + + local parsed + parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)" + if [[ "${parsed}" != *$'\t'* ]]; then + return + fi + local parsed_client_id="${parsed%%$'\t'*}" + local parsed_client_secret="${parsed#*$'\t'}" + + if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then + OPENSKY_CLIENT_ID="${parsed_client_id}" + fi + if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then + OPENSKY_CLIENT_SECRET="${parsed_client_secret}" + fi +} + +resolve_opensky_credentials() { + OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')" + OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}" + case "${OPENSKY_AUTH_MODE}" in + basic|oauth|auto|anon) ;; + *) + echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'" + OPENSKY_AUTH_MODE="oauth" + ;; + esac + + OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}" + OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}" + OPENSKY_USERNAME="${OPENSKY_USERNAME:-}" + OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}" + + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "username" "user" "login" "email" "default"; do + OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_USERNAME}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "password" "pass" "token" "secret" "default"; do + OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_PASSWORD}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}" + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_id" "client-id" "client" "api-key"; do + OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then + break 2 + fi + done + done + fi + if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then + for svc in "opensky-network" "opensky"; do + for acct in "client_secret" "client-secret" "secret"; do + OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")" + if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then + break 2 + fi + done + done + fi + + case "${OPENSKY_AUTH_MODE}" in + basic) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + ;; + oauth) + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + anon) + OPENSKY_CLIENT_ID="" + OPENSKY_CLIENT_SECRET="" + OPENSKY_USERNAME="" + OPENSKY_PASSWORD="" + ;; + esac +} + +resolve_opensky_credentials + +CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" + +echo "Starting God's Eye View dev server..." +echo "URL: http://localhost:${PORT}/" +echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" +echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" +if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then + if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}" + else + echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)" + fi +fi +case "${OPENSKY_AUTH_MODE}" in + basic) + if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky basic auth: configured" + else + echo "OpenSky basic auth: missing credentials" + fi + ;; + oauth) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky OAuth: configured" + else + echo "OpenSky OAuth: missing client credentials" + fi + ;; + auto) + if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then + echo "OpenSky auto auth: OAuth configured" + elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then + echo "OpenSky auto auth: basic configured" + else + echo "OpenSky auto auth: no credentials found" + fi + ;; + anon) + echo "OpenSky auth: disabled (anonymous mode)" + ;; +esac + +GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \ +CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \ +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \ +OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \ +OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \ +OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \ +OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \ +OPENSKY_USERNAME="${OPENSKY_USERNAME}" \ +OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \ +npm run dev -- --host "${HOST}" --port "${PORT}" diff --git a/scripts/fixtures/voice/README.md b/scripts/fixtures/voice/README.md new file mode 100644 index 000000000..afd5f9dc1 --- /dev/null +++ b/scripts/fixtures/voice/README.md @@ -0,0 +1,7 @@ +# Voice QA fixture + +`full-globe-turn-on-radio.wav` is an owner-generated fake-microphone fixture for the credentialed AI-to-Radio acceptance test. Its embedded WAV metadata is the provenance record for the generator, model, voice, spoken text, usage terms, and audio settings; inspect it with `ffprobe -show_entries format_tags -of json scripts/fixtures/voice/full-globe-turn-on-radio.wav`. + +- Provided and approved for repository QA use by the user on 2026-08-05. +- SHA-256: `b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb` +- Run: `node scripts/qa-voice-wav.mjs http://localhost:4189` diff --git a/scripts/fixtures/voice/full-globe-turn-on-radio.wav b/scripts/fixtures/voice/full-globe-turn-on-radio.wav new file mode 100644 index 000000000..1314fcb19 Binary files /dev/null and b/scripts/fixtures/voice/full-globe-turn-on-radio.wav differ diff --git a/scripts/focus-allocation-check.mjs b/scripts/focus-allocation-check.mjs new file mode 100644 index 000000000..e15538a2f --- /dev/null +++ b/scripts/focus-allocation-check.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + advanceSpriteFocus, +} from '../src/data/focusDeemphasis.js'; +import { + applyAircraftBillboardTreatment, +} from '../src/data/aircraftRecession.js'; + +if (typeof global.gc !== 'function') { + throw new Error('focus-allocation-check.mjs requires node --expose-gc'); +} + +const CALLS = 100_000; +const TRIALS = 9; +const WARMUP_CALLS = 200_000; +const PURE_CALL_BUDGET = 16; +const PRODUCTION_SPRITE_TICK_BUDGET = 212; + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function bytesPerCall(fn) { + for (let index = 0; index < WARMUP_CALLS; index += 1) fn(); + const samples = []; + for (let trial = 0; trial < TRIALS; trial += 1) { + global.gc(); + const before = process.memoryUsage().heapUsed; + let checksum = 0; + for (let index = 0; index < CALLS; index += 1) checksum += fn(); + const after = process.memoryUsage().heapUsed; + // Keep the returned scalar observable so V8 cannot delete the calls. + assert.ok(Number.isFinite(checksum)); + samples.push((after - before) / CALLS); + } + const rawMedian = median(samples); + return { rawMedian, samples }; +} + +function netOfLoopBaseline(observed, baseline) { + // V8's callback/checksum loop itself retains roughly one boxed-number word + // per call until the next collection. Subtract the identically measured + // no-op loop so this gate reports allocation attributable to the treatment. + const rawMedian = Math.max(0, observed.rawMedian - baseline.rawMedian); + return { + rawMedian, + roundedMedian: Math.round(rawMedian), + observedRawMedian: observed.rawMedian, + baselineRawMedian: baseline.rawMedian, + samples: observed.samples, + }; +} + +const target = { + screenRect: { left: 0, top: 0, right: 10, bottom: 10 }, + paddingPx: 18, + cameraDistance: 1000, +}; +const sprite = {}; +const focusInput = { + screenPosition: { x: 5, y: 5 }, + cameraDistance: 1200, + nowMs: 1000, + target, +}; + +const baseColor = { withAlpha: (alpha) => ({ alpha }) }; +const billboard = { scale: 1, color: { alpha: 1 } }; +const aircraftInput = { + billboard, + baseScale: 1, + baseAlpha: 1, + baseColor, + focusFactor: 1, + cameraDistanceM: 100, + cameraHeightM: 1000, +}; + +const baseline = bytesPerCall(() => focusInput.nowMs); +const focus = netOfLoopBaseline( + bytesPerCall(() => advanceSpriteFocus(sprite, focusInput).factor), + baseline, +); +const aircraft = netOfLoopBaseline( + bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha), + baseline, +); +const productionSpriteTick = netOfLoopBaseline( + bytesPerCall(() => { + // Match the hot civilian/military sprite path: the caller constructs fresh + // option literals, consumes the shared focus result immediately, and then + // constructs the composed aircraft-treatment options for the same sprite. + const focusResult = advanceSpriteFocus(sprite, { + screenPosition: focusInput.screenPosition, + cameraDistance: focusInput.cameraDistance, + nowMs: focusInput.nowMs, + target, + }); + const treatment = applyAircraftBillboardTreatment({ + billboard, + baseScale: aircraftInput.baseScale, + baseAlpha: aircraftInput.baseAlpha, + baseColor, + focusFactor: focusResult.factor, + cameraDistanceM: aircraftInput.cameraDistanceM, + cameraHeightM: aircraftInput.cameraHeightM, + }); + return focusResult.factor + treatment.alpha; + }), + baseline, +); + +assert.ok( + focus.roundedMedian <= PURE_CALL_BUDGET, + `advanceSpriteFocus allocated ${focus.rawMedian.toFixed(3)} B/call median`, +); +assert.ok( + aircraft.roundedMedian <= PURE_CALL_BUDGET, + `applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`, +); +assert.ok( + productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET, + `production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`, +); + +console.log(JSON.stringify({ + callsPerTrial: CALLS, + trials: TRIALS, + budgets: { + pureCall: PURE_CALL_BUDGET, + productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET, + }, + loopBaseline: baseline, + advanceSpriteFocus: focus, + applyAircraftBillboardTreatment: aircraft, + productionSpriteTick, +})); diff --git a/scripts/opensky-import-client.sh b/scripts/opensky-import-client.sh new file mode 100755 index 000000000..021539331 --- /dev/null +++ b/scripts/opensky-import-client.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +if ! command -v security >/dev/null 2>&1; then + echo "error: macOS 'security' command not found" + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "error: node is required to parse JSON credentials" + exit 1 +fi + +CREDENTIALS_FILE="${1:-${OPENSKY_CREDENTIALS_FILE:-}}" +if [[ -z "${CREDENTIALS_FILE}" ]]; then + echo "usage: ./scripts/opensky-import-client.sh /path/to/credentials.json" + echo "or set OPENSKY_CREDENTIALS_FILE and run ./scripts/opensky-import-client.sh" + exit 1 +fi + +if [[ ! -f "${CREDENTIALS_FILE}" ]]; then + echo "error: credentials file not found: ${CREDENTIALS_FILE}" + exit 1 +fi + +PARSED="$(node -e "const fs=require('fs');const p=process.argv[1];const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();if(!id||!secret){process.exit(2)};process.stdout.write(id+'\t'+secret);" "${CREDENTIALS_FILE}" 2>/dev/null || true)" +if [[ "${PARSED}" != *$'\t'* ]]; then + echo "error: could not parse clientId/clientSecret from ${CREDENTIALS_FILE}" + exit 1 +fi + +CLIENT_ID="${PARSED%%$'\t'*}" +CLIENT_SECRET="${PARSED#*$'\t'}" +if [[ -z "${CLIENT_ID}" || -z "${CLIENT_SECRET}" ]]; then + echo "error: credentials file is missing client id or client secret" + exit 1 +fi + +security add-generic-password -U -s "opensky-network" -a "client_id" -w "${CLIENT_ID}" >/dev/null +security add-generic-password -U -s "opensky-network" -a "client_secret" -w "${CLIENT_SECRET}" >/dev/null + +echo "OpenSky OAuth client credentials stored in Keychain:" +echo " service=opensky-network account=client_id" +echo " service=opensky-network account=client_secret" diff --git a/scripts/qa-attribution-b12.mjs b/scripts/qa-attribution-b12.mjs new file mode 100644 index 000000000..3232bbb0c --- /dev/null +++ b/scripts/qa-attribution-b12.mjs @@ -0,0 +1,398 @@ +/** + * qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution). + * + * Public attribution checks: + * H10 — the Google/Cesium credit MUST stay visible in clean-view AND + * recording modes (those are the modes used to record demos). + * H11 — every data layer's required attribution must surface in the + * expandable bottom-left "Data attribution" lightbox. + * + * This script drives the REAL app headless and proves: + * (i) per-layer credits are registered in viewer.creditDisplay (H11), + * and appear in the "Data attribution" lightbox when opened; + * (ii) enabling datacenters + submarine cables keeps their credits present; + * (iii) toggling clean-view keeps #cesium-credits visible (screenshot); + * (iv) toggling recording-mode keeps #cesium-credits visible (screenshot). + * + * Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300 + */ +import puppeteer from 'puppeteer'; +import { existsSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +function getOpt(flag, def) { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : def; +} +const APP_URL = getOpt('--url', 'http://localhost:4300'); +const APP_ORIGIN = new URL(APP_URL).origin; +const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12'); +mkdirSync(SHOT_DIR, { recursive: true }); + +const CHROME_EXECUTABLE_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean); + +function findChromeExecutable() { + return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => { + try { return existsSync(candidate); } catch { return false; } + }) || null; +} + +let passed = 0; +let failed = 0; +function check(name, ok, detail) { + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); + if (ok) passed++; + else failed++; +} + +// Substrings that MUST be present across the registered per-layer credits. +const REQUIRED_CREDIT_SUBSTRINGS = [ + 'OpenStreetMap contributors', // ODbL — datacenters/dams/roads + 'adsb.lol', // ODbL — military traces + 'TeleGeography', // CC BY-NC-SA — cables + 'NASA FIRMS', // fires + 'CelesTrak', // satellites + 'U.S. Geological Survey', // earthquakes + 'OpenSky Network', // flights + 'AISStream', // vessels + 'City of Austin', // CCTV + 'Radio Browser', // internet-radio directory +]; + +async function main() { + const chromeExecutable = findChromeExecutable(); + const browser = await puppeteer.launch({ + headless: 'new', + ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + await page.setRequestInterception(true); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ summary: 'QA globe ready' }), + }); + return; + } + request.continue(); + }); + + const consoleErrors = []; + const failedResponses = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + const sourceUrl = msg.location()?.url || ''; + consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text()); + } + }); + page.on('response', (response) => { + if (response.status() >= 500) { + failedResponses.push(`HTTP ${response.status()} ${response.url()}`); + } + }); + + console.log(`\n qa-attribution-b12 → ${APP_URL}\n`); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + + // Wait for the app + viewer.creditDisplay to be live. + await page.waitForFunction( + () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay, + { timeout: 60000 }, + ); + // Give Cesium a few frames to render the on-screen credit line (logo + link). + await new Promise((r) => setTimeout(r, 2500)); + + // ── (i) H11: static credits registered ──────────────────────────── + console.log('H11 — per-layer credits registered in viewer.creditDisplay'); + const registeredHtml = await page.evaluate(() => { + const cd = window.__godsEyeView.viewer.creditDisplay; + const statics = cd._staticCredits || []; + return statics.map((c) => c.html); + }); + check( + 'creditDisplay has static per-layer credits', + registeredHtml.length >= 10, + `${registeredHtml.length} static credits registered`, + ); + for (const sub of REQUIRED_CREDIT_SUBSTRINGS) { + const found = registeredHtml.some((h) => h.includes(sub)); + check(`credit present: "${sub}"`, found, found ? '' : 'MISSING'); + } + + // ── (i) H11: credits render in the "Data attribution" lightbox ───── + console.log('\nH11 — "Data attribution" lightbox surfaces the credits'); + // Force a render frame so the credit display flushes, then open the lightbox. + const lightboxState = await page.evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + viewer.scene.requestRender(); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const cd = viewer.creditDisplay; + cd.showLightbox(); + viewer.scene.requestRender(); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const box = document.querySelector('.cesium-credit-lightbox'); + const list = box?.querySelector(':scope > ul'); + const title = box?.querySelector('.cesium-credit-lightbox-title'); + const close = box?.querySelector('.cesium-credit-lightbox-close'); + const boxRect = box?.getBoundingClientRect(); + const listRect = list?.getBoundingClientRect(); + const overlay = document.querySelector('.cesium-credit-lightbox-overlay'); + const worldOverlay = document.getElementById('world-overlay-root'); + document.querySelector('[data-qa-attribution-stack-probe]')?.remove(); + const stackProbe = document.createElement('div'); + stackProbe.dataset.qaAttributionStackProbe = 'true'; + stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE'; + Object.assign(stackProbe.style, { + position: 'absolute', + left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`, + top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`, + width: '180px', + padding: '5px 8px', + color: '#8ffcff', + background: 'rgba(4, 18, 28, 0.94)', + borderLeft: '3px solid #19d9e8', + font: '11px monospace', + whiteSpace: 'nowrap', + pointerEvents: 'none', + }); + worldOverlay?.appendChild(stackProbe); + const stackProbeRect = stackProbe.getBoundingClientRect(); + const lastItem = list?.lastElementChild; + if (list) list.scrollTop = list.scrollHeight; + const lastRect = lastItem?.getBoundingClientRect(); + return { + html: box?.innerHTML || '', + itemCount: list?.children.length || 0, + linkCount: list?.querySelectorAll('a[href]').length || 0, + everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])] + .every((link) => Boolean(link.getAttribute('href'))), + boxHeight: boxRect?.height || 0, + viewportHeight: window.innerHeight, + listClientHeight: list?.clientHeight || 0, + listScrollHeight: list?.scrollHeight || 0, + listClientWidth: list?.clientWidth || 0, + listScrollWidth: list?.scrollWidth || 0, + listOverflowY: list ? getComputedStyle(list).overflowY : '', + titleVisible: Boolean(title?.getBoundingClientRect().height), + closeVisible: Boolean(close?.getBoundingClientRect().height), + overlayZIndex: Number(getComputedStyle(overlay).zIndex), + worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex), + stackProbeIntersectsModal: Boolean(boxRect + && stackProbeRect.left < boxRect.left + && stackProbeRect.right > boxRect.left + && stackProbeRect.top < boxRect.bottom + && stackProbeRect.bottom > boxRect.top), + stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left), + lastItemReachable: Boolean(lastRect && listRect + && lastRect.bottom <= listRect.bottom + 1 + && lastRect.top >= listRect.top - 1), + }; + }); + const lightboxHtml = lightboxState.html; + const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s)); + check( + 'lightbox renders the per-layer credits', + lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1, + `${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`, + ); + check( + 'desktop lightbox stays compact with a fixed title and close control', + lightboxState.boxHeight > 0 + && lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71 + && lightboxState.titleVisible + && lightboxState.closeVisible, + `box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`, + ); + check( + 'complete credit list scrolls vertically without horizontal overflow', + lightboxState.itemCount >= registeredHtml.length + && lightboxState.linkCount > 0 + && lightboxState.everyLinkHasDestination + && lightboxState.listScrollHeight > lightboxState.listClientHeight + && /auto|scroll/.test(lightboxState.listOverflowY) + && lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1 + && lightboxState.lastItemReachable, + `${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`, + ); + check( + 'attribution modal stacks above shared world labels', + Number.isFinite(lightboxState.overlayZIndex) + && Number.isFinite(lightboxState.worldOverlayZIndex) + && lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex + && lightboxState.stackProbeIntersectsModal + && lightboxState.stackProbeExtendsOutsideModal, + `modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`, + ); + await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox()); + await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') }); + await page.evaluate(() => { + window.__godsEyeView.viewer.creditDisplay.showLightbox(); + const list = document.querySelector('.cesium-credit-lightbox > ul'); + if (list) list.scrollTop = 0; + }); + await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') }); + await page.evaluate(() => { + window.__godsEyeView.viewer.creditDisplay.hideLightbox(); + document.querySelector('[data-qa-attribution-stack-probe]')?.remove(); + }); + + await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 }); + await page.evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + viewer.resize(); + viewer.scene.requestRender(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + const mobileLightbox = await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.creditDisplay.showLightbox(); + const box = document.querySelector('.cesium-credit-lightbox'); + const list = box?.querySelector(':scope > ul'); + const rect = box?.getBoundingClientRect(); + if (list) list.scrollTop = list.scrollHeight; + const listRect = list?.getBoundingClientRect(); + const lastRect = list?.lastElementChild?.getBoundingClientRect(); + return { + left: rect?.left || 0, + right: rect?.right || 0, + top: rect?.top || 0, + bottom: rect?.bottom || 0, + viewportWidth: innerWidth, + viewportHeight: innerHeight, + lastItemReachable: Boolean(lastRect && listRect + && lastRect.bottom <= listRect.bottom + 1), + }; + }); + check( + 'mobile lightbox remains full-screen edge-to-edge and reaches the final credit', + Math.abs(mobileLightbox.left) <= 1 + && Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1 + && Math.abs(mobileLightbox.top) <= 1 + && Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1 + && mobileLightbox.lastItemReachable, + `box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}–${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`, + ); + await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') }); + await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox()); + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + + // ── (ii) enable datacenters + cables; credits still present ──────── + console.log('\nH11 — enabling datacenters + submarine cables'); + const layerIds = await page.evaluate(() => { + const dm = window.__godsEyeView.dataManager; + return [...dm.layers.keys()]; + }); + // Find the datacenter + cable layer ids by fuzzy match on the id string. + const dcId = layerIds.find((id) => /datacenter/i.test(id)); + const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id)); + check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`); + if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId); + if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId); + await new Promise((r) => setTimeout(r, 800)); + const afterEnableHtml = await page.evaluate(() => + (window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html), + ); + check( + 'datacenter credit (OSM/ODbL) still present after enable', + afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')), + '', + ); + check( + 'cable credit (TeleGeography) still present after enable', + afterEnableHtml.some((h) => h.includes('TeleGeography')), + '', + ); + + // helper: is #cesium-credits visible (line rendered, not display:none)? + const creditVisibility = async () => + page.evaluate(() => { + const el = document.getElementById('cesium-credits'); + if (!el) return { present: false }; + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return { + present: true, + display: cs.display, + visibility: cs.visibility, + opacity: cs.opacity, + width: rect.width, + height: rect.height, + text: (el.textContent || '').trim().slice(0, 120), + hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'), + }; + }); + + // ── (iii) clean-view: credit line STILL visible ─────────────────── + console.log('\nH10 — clean-view keeps the credit line visible'); + await page.evaluate(() => document.body.classList.add('ui-clean-view')); + await new Promise((r) => setTimeout(r, 400)); + const cleanVis = await creditVisibility(); + await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') }); + check( + 'clean-view: #cesium-credits not display:none', + cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden', + `display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`, + ); + check( + 'clean-view: "Data attribution" link to per-layer popover still present', + !!cleanVis.text && /Data attribution/i.test(cleanVis.text), + `credit text = "${cleanVis.text}"`, + ); + await page.evaluate(() => document.body.classList.remove('ui-clean-view')); + + // ── (iv) recording-mode: credit line STILL visible ──────────────── + console.log('\nH10 — recording-mode keeps the credit line visible'); + await page.evaluate(() => document.body.classList.add('recording-mode')); + await new Promise((r) => setTimeout(r, 400)); + const recVis = await creditVisibility(); + await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') }); + check( + 'recording-mode: #cesium-credits not display:none', + recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden', + `display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`, + ); + await page.evaluate(() => document.body.classList.remove('recording-mode')); + + // baseline (normal) screenshot for comparison + await new Promise((r) => setTimeout(r, 300)); + await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') }); + + check( + 'no console errors during QA', + consoleErrors.length === 0, + consoleErrors.slice(0, 3).join(' | ') || 'clean', + ); + check( + 'no HTTP 5xx responses during QA', + failedResponses.length === 0, + failedResponses.slice(0, 3).join(' | ') || 'clean', + ); + + await browser.close(); + + console.log('\n────────────────────────────────────────────────────────────'); + console.log(` RESULT: ${passed} passed, ${failed} failed`); + console.log(` Screenshots: ${SHOT_DIR}`); + console.log('────────────────────────────────────────────────────────────\n'); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error('qa-attribution-b12 crashed:', e); + process.exit(1); +}); diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs new file mode 100644 index 000000000..a27dd57c3 --- /dev/null +++ b/scripts/qa-cables-overlay.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +/** + * qa-cables-overlay — submarine-cable label path measurement + contract gate. + * + * Two jobs: + * 1. MEASURE (both builds, `--legacy` on the pre-migration build): with the + * cables layer ON at a fixed mid-Atlantic camera, capture + * a. per-frame `scene.render` cost over a ~10 s driven-orbit window + * (mean / p50 / p95 / max — relative numbers, SwiftShader surface), + * b. toggle-ON → first-labels-visible latency, + * c. entity / native-label / host-entry counts, + * d. parked-idle postRender fires over 5 s (render-governor honesty). + * 2. GATE (unified build only): assert the migration contract — + * zero native `LabelGraphics` on cable reference entities, host entries + * present for the source, idle stays near-zero, and a clean OFF→ON cycle + * (no orphan host entries after disable). + * + * Usage: node scripts/qa-cables-overlay.mjs [--url http://localhost:4214] [--legacy] + * Requires a running dev server. Headless; rAF throttling disabled so the + * frame clock is honest (hidden-pane gotcha). + */ +import puppeteer from 'puppeteer'; + +const argv = process.argv; +const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; +const legacy = argv.includes('--legacy'); +// --control: never enable the layer; measure the empty-scene orbit cost so the +// cables layer's share of frame time is attributable. +const control = argv.includes('--control'); +const LAYER_ID = 'telegeography-submarine-cables'; + +const results = []; +function check(name, pass, detail) { + results.push({ name, pass }); + const tag = pass ? 'PASS' : 'FAIL'; + console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`); +} +function report(name, detail) { + console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); +} + +const browser = await puppeteer.launch({ + headless: 'new', + protocolTimeout: 300_000, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--window-size=1440,900', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-background-timer-throttling', + ], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 860 }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); + await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init + + // Park mid-Atlantic (many cables + both coasts' landings in range) and + // disable every layer so the cables layer is measured in isolation. + await page.evaluate(async () => { + const gev = window.__godsEyeView; + const v = gev.viewer; + v.camera.cancelFlight(); + const ell = v.scene.globe.ellipsoid; + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + for (const [id, entry] of gev.dataManager.layers) { + if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } } + } + }); + await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle + + // ── b. toggle-ON → labels visible ───────────────────────────────────── + if (!control) { + const labelLatency = await page.evaluate(async (layerId, isLegacy) => { + const gev = window.__godsEyeView; + const t0 = performance.now(); + await gev.dataManager.setEnabled(layerId, true, { origin: 'user' }); + const deadline = t0 + 60_000; + const labelsUp = () => { + if (isLegacy) { + const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {}; + return (stats.referenceLabelCount || 0) > 0; + } + const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; + return (diag.paintedBySource?.[layerId] || 0) > 0; + }; + while (performance.now() < deadline) { + if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false }; + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 100)); + } + return { ms: 60_000, timedOut: true }; + }, LAYER_ID, legacy); + report('toggle-ON -> labels visible (ms)', labelLatency); + check('labels became visible after enable', labelLatency.timedOut === false, labelLatency); + await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle + } + + // ── c. entity / label / host counts ─────────────────────────────────── + const counts = await page.evaluate((layerId) => { + const gev = window.__godsEyeView; + const v = gev.viewer; + const out = { + dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0, + referenceEntities: 0, hostEntries: 0, hostPainted: 0, + }; + for (let i = 0; i < v.dataSources.length; i++) { + const ds = v.dataSources.get(i); + if (!/TeleGeography/.test(ds.name || '')) continue; + const list = ds.entities.values; + out.dataSources[ds.name] = list.length; + for (const e of list) { + if (e.label !== undefined) out.nativeLabels++; + if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++; + if (e.polyline?.positions && e.polyline.positions.isConstant === false) { + out.dynamicPositionEntities++; + } + } + if (/References/.test(ds.name)) out.referenceEntities = list.length; + } + const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; + out.hostEntries = diag.entriesBySource?.[layerId] || 0; + out.hostPainted = diag.paintedBySource?.[layerId] || 0; + const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {}; + out.referenceLabelCount = stats.referenceLabelCount ?? null; + return out; + }, LAYER_ID); + report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts); + + // ── a. per-frame scene.render cost over a ~10 s driven orbit ───────── + const frameCost = await page.evaluate(() => new Promise((resolve) => { + const v = window.__godsEyeView.viewer; + const scene = v.scene; + const durations = []; + const originalRender = scene.render; + scene.render = function patchedRender(...args) { + const started = performance.now(); + const result = originalRender.apply(this, args); + durations.push(performance.now() - started); + return result; + }; + const t0 = performance.now(); + const tick = () => { + // Slow orbit: every frame is a camera change, so Cesium renders every + // rAF — the honest "user is interacting" cost of the layer. + v.camera.rotateRight(0.0004); + if (performance.now() - t0 < 10_000) requestAnimationFrame(tick); + else { + scene.render = originalRender; + durations.sort((a, b) => a - b); + const n = durations.length; + const sum = durations.reduce((s, d) => s + d, 0); + resolve({ + frames: n, + meanMs: +(sum / Math.max(1, n)).toFixed(2), + p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2), + p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2), + maxMs: +(durations[n - 1] || 0).toFixed(2), + effectiveFps: +(n / 10).toFixed(1), + }); + } + }; + requestAnimationFrame(tick); + })); + report(control + ? 'scene.render cost, control (cables OFF), 10s orbit' + : 'scene.render cost, cables ON, 10s orbit', frameCost); + + // ── d. parked idle honesty (labels must not force continuous render) ── + await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle + const idle = await page.evaluate(() => new Promise((resolve) => { + const scene = window.__godsEyeView.viewer.scene; + let renders = 0; + const remove = scene.postRender.addEventListener(() => { renders += 1; }); + setTimeout(() => { remove(); resolve({ renders }); }, 5_000); + })); + report(control + ? 'parked idle, control (postRender fires / 5s)' + : 'parked idle with cables ON (postRender fires / 5s)', idle); + + if (!legacy && !control) { + // ── unified-build contract gates ──────────────────────────────────── + check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels); + check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities); + check('host has cable entries', counts.hostEntries > 0, counts.hostEntries); + check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted); + // The cables source never republishes an unchanged cohort (unit pin), and + // since the 2026-08-18 host fix the occluder observer is scoped (body + // childList filtered to inventory chrome; attribute observation + // element-only) and the right-rail allocator writes-if-changed — so a + // parked camera measures 0 postRender fires / 5 s with cables ON, equal + // to the empty-scene control (pre-fix: ~56-61). The headroom below covers + // an occasional genuine chrome transition (loading-chip flip + its 100 ms + // occluder-refresh echo) landing inside the window; ticking churn (~10) + // or the old observer leak (~56+) must fail. + check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle); + + // OFF must clear the host source (no orphan labels), ON must restore. + const cycle = await page.evaluate(async (layerId) => { + const gev = window.__godsEyeView; + await gev.dataManager.setEnabled(layerId, false, { origin: 'user' }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1_200)); + const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; + const offEntries = offDiag.entriesBySource?.[layerId] || 0; + const offPainted = offDiag.paintedBySource?.[layerId] || 0; + await gev.dataManager.setEnabled(layerId, true, { origin: 'user' }); + const t0 = performance.now(); + let onPainted = 0; + while (performance.now() - t0 < 20_000) { + const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; + onPainted = diag.paintedBySource?.[layerId] || 0; + if (onPainted > 0) break; + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 100)); + } + return { offEntries, offPainted, onPainted }; + }, LAYER_ID); + check('disable clears host entries (no orphans)', cycle.offEntries === 0 && cycle.offPainted === 0, cycle); + check('re-enable restores painted labels', cycle.onPainted > 0, cycle); + } +} finally { + await browser.close(); +} + +const passed = results.filter((r) => r.pass).length; +console.log(`\nqa-cables-overlay: ${passed}/${results.length} passed${legacy ? ' (legacy measurement mode)' : ''}${control ? ' (control mode)' : ''}`); +console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`); +process.exit(passed === results.length ? 0 : 1); diff --git a/scripts/qa-cables-render-probe.mjs b/scripts/qa-cables-render-probe.mjs new file mode 100644 index 000000000..babef945d --- /dev/null +++ b/scripts/qa-cables-render-probe.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * qa-cables-render-probe — replicates the owner-side perf probe methodology + * for the cables layer (relative numbers; SwiftShader when run headless): + * + * 1. PARKED: timed full `viewer.render()` loop (40 calls) with cables ON, + * then OFF, at the fixed mid-Atlantic camera. Reports mean/max. + * 2. PAN-STOP: 3 cycles of { setView to an offset, raise camera.moveEnd, + * wait 650 ms, 20 timed `viewer.render()` calls }. Reports per-cycle + * mean/max and the worst frame across cycles, cables ON vs OFF. + * + * Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216] + */ +import puppeteer from 'puppeteer'; + +const argv = process.argv; +const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216'; +const LAYER_ID = 'telegeography-submarine-cables'; + +const browser = await puppeteer.launch({ + headless: 'new', + protocolTimeout: 300_000, + args: [ + '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', + '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', + '--disable-background-timer-throttling', + ], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 860 }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); + await new Promise((r) => setTimeout(r, 12_000)); + + await page.evaluate(async () => { + const gev = window.__godsEyeView; + gev.viewer.camera.cancelFlight(); + const ell = gev.viewer.scene.globe.ellipsoid; + gev.viewer.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + for (const [id, entry] of gev.dataManager.layers) { + if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } } + } + }); + await new Promise((r) => setTimeout(r, 5_000)); + + const stats = (values) => ({ + mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2), + max: +Math.max(...values).toFixed(2), + }); + + const measure = async (label, enabled) => { + await page.evaluate(async (id, on) => { + const gev = window.__godsEyeView; + await gev.dataManager.setEnabled(id, on, { origin: 'user' }); + }, LAYER_ID, enabled); + await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle + + // 1. PARKED timed renders. + const parked = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const durations = []; + for (let i = 0; i < 40; i++) { + const t0 = performance.now(); + v.render(); + durations.push(performance.now() - t0); + } + return durations; + }); + + // 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders. + const cycles = []; + for (let cycle = 0; cycle < 3; cycle++) { + const frames = await page.evaluate(async (step) => { + const v = window.__godsEyeView.viewer; + const ell = v.scene.globe.ellipsoid; + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: (-40 + step * 3) * Math.PI / 180, + latitude: 35 * Math.PI / 180, + height: 4_500_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + v.camera.moveEnd.raiseEvent(); + await new Promise((resolve) => setTimeout(resolve, 650)); + const durations = []; + for (let i = 0; i < 20; i++) { + const t0 = performance.now(); + v.render(); + durations.push(performance.now() - t0); + } + return durations; + }, cycle + 1); + cycles.push(stats(frames)); + } + + const worst = Math.max(...cycles.map((c) => c.max)); + console.log(` [${label}] parked render: ${JSON.stringify(stats(parked))}`); + console.log(` [${label}] pan-stop cycles: ${JSON.stringify(cycles)} worstFrame=${worst}`); + return { parked: stats(parked), cycles, worst }; + }; + + const on = await measure('cables ON ', true); + const off = await measure('cables OFF', false); + console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`); + console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`); +} finally { + await browser.close(); +} diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs new file mode 100644 index 000000000..12af9beb8 --- /dev/null +++ b/scripts/qa-cables-shot.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * qa-cables-shot — capture the cables layer at two fixed cameras for + * before/after visual-identity comparison. Writes to gitignored qa-shots/. + * Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before] + */ +import puppeteer from 'puppeteer'; +import { mkdirSync } from 'node:fs'; + +const argv = process.argv; +const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; +const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot'; +const LAYER_ID = 'telegeography-submarine-cables'; +mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true }); + +const browser = await puppeteer.launch({ + headless: 'new', + protocolTimeout: 300_000, + args: [ + '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', + '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', + '--disable-background-timer-throttling', + ], +}); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 860 }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); + await new Promise((r) => setTimeout(r, 12_000)); + await page.evaluate(async (layerId) => { + const gev = window.__godsEyeView; + gev.viewer.camera.cancelFlight(); + for (const [id, entry] of gev.dataManager.layers) { + if (entry.enabled && id !== layerId) { + try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ } + } + } + await gev.dataManager.setEnabled(layerId, true, { origin: 'user' }); + }, LAYER_ID); + + const views = [ + { name: 'atlantic', lon: -40, lat: 35, height: 4_500_000 }, + { name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 }, + ]; + for (const view of views) { + await page.evaluate((v) => { + const viewer = window.__godsEyeView.viewer; + const ell = viewer.scene.globe.ellipsoid; + viewer.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, view); + // Let the sweep + labels settle with frames flowing. + await page.evaluate(() => new Promise((resolve) => { + const v = window.__godsEyeView.viewer; + let ticks = 0; + const tick = () => { + v.scene.requestRender?.(); + if (++ticks < 240) requestAnimationFrame(tick); else resolve(); + }; + requestAnimationFrame(tick); + })); + await new Promise((r) => setTimeout(r, 1_000)); + const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname; + await page.screenshot({ path }); + console.log(`saved ${path}`); + } +} finally { + await browser.close(); +} diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs new file mode 100644 index 000000000..d1ac4dabd --- /dev/null +++ b/scripts/qa-cctv-v2.mjs @@ -0,0 +1,1506 @@ +/** + * qa-cctv-v2.mjs — CCTV v2 subsystem proof harness + * + * Replaces qa-cctv-b9.mjs + qa-cctv-drift-b9b.mjs, which proved fixes in + * systems v2 deleted outright (chunked auto-cal raycast storms, periodic + * ground re-sync deadband/oscillation). v2 has no per-frame scene queries at + * all, no auto-calibration, and no periodic re-grounding — so this harness + * asserts the NEW invariants instead of re-proving retired ones: + * + * 1. Raycast invariant (design §9.1-amended): scene.pickFromRay fires + * exactly once per camera ACTIVATION (the §9.1 obstruction probe) and + * never again in steady state; shared mesh-floor sampling fires at most + * once per coarse cell during the staggered geometry-load drain, and + * actually COMPLETES once tiles load (>=1 real sample — the + * ceiling alone would pass trivially at Δ=0 if the snap never finished), + * stays flat while idle, and stays flat for non-positional pose edits. + * 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane + * exist for the active camera, the plane's corners coincide with the + * 4 corner-ray endpoints, and the whole geometry is byte-stable over a + * 30s idle window (nothing resamples/rewrites without a reason to). + * 3. Calibration round-trip: a manual patch writes the v2 localStorage key + * with source:'manual', rotates the geometry, flips the panel calBadge + * to 'calibrated'; reset removes the entry and restores base geometry. + * 4. Frame-loop health: the plane's projection runtime starts on a + * placeholder and reaches a "frame considered fresh" state, and the + * panel + the plane's underlying fetch share the same + * /api/cctv/frame/ URL family. + * 5. Empty-space ownership: real canvas gestures prove ADJUST and sibling + * picks preserve the active camera, while a true empty-space click + * publishes exactly one null transition without moving the camera or + * emitting a focus request; a repeat click is idempotent. + * 6. Viewshed mode (v3, 2026-07-05): coverage tri-state round-trip + + * boolean compat, color-coded volume primitives exist only in + * 'viewshed' for the visible set, idle-stable by object identity, + * raycast counters flat across cycling. + * 7. Calibration gizmo (v3): 8 handle entities in ADJUST mode, synthetic + * E-arrow drag edits the live pose while freezing mount elevation and + * issuing zero transient samples, without touching the save-gated store; + * camera-controller inputs restore and parts hide on mode off. + * + * SwiftShader caveat (proven in Task 3): under headless software GL, plane + * image materials do not visually FILL — screenshotting for a pixel-based + * pass/fail is not viable here. All four assertion groups therefore check + * entity/counter/localStorage/network STATE (GL-independent), never rendered + * pixels. Screenshots are still captured for human visual review but do not + * gate the exit code. + * + * No `cesium` package import for in-page math: the app does not expose a + * `window.Cesium` global, so every in-page evaluate() either (a) reads plain + * numbers off live Cesium objects (Cartesian3 -> {x,y,z}, Quaternion -> + * {x,y,z,w}) and does its own vector/quaternion math in plain JS, or + * (b) borrows static methods off the *constructor* of a live instance, which + * works because Cesium classes' statics live on the constructor, not a + * namespace object. Separately, this harness DOES import + * `computeFrustumGeometry` directly from `src/data/cctv.js` (a pure, + * side-effect-free export with no Cesium/scene dependency) to use as a + * Node-side ground-truth oracle — see the "safe pose" search below. + * + * Ground-clamp caveat (discovered writing this harness, see task report): + * Austin's fabricated pose priors (pitch -18/-24°, FOV 44/56°, range 145/210m, + * mount 8/10m — vite.config.js's two "personalities") routinely put the far + * cap's bottom edge below ground truth. `computeFrustumGeometry` clamps each + * WIREFRAME corner independently to `groundAlt + FRUSTUM_GROUND_CLEARANCE_M`, + * but the PLANE entity (`createProjectionRuntime`/`updatePlanePlacement`) is + * always an undistorted rectangle (fixed halfW/halfH, unclamped tilt) sitting + * at the (possibly-clamped) capCenter. When the pose clamps asymmetrically, + * the plane and the wireframe corners genuinely do NOT coincide — this is a + * real property of the shipped geometry, not a harness bug. To test the + * intended "coincide" contract deterministically (independent of which + * camera activates), this harness first applies a temporary calibration + * patch (large upward pitch offset + shrunk FOV + minimum range) computed + * via a Node-side search against the SAME `computeFrustumGeometry` oracle, + * chosen to clear the ground clamp with margin, then resets it afterward. + * + * Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173 + * + * Exit 0 = all asserts passed. Non-zero = a hard failure. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { computeFrustumGeometry, FRUSTUM_GROUND_CLEARANCE_M } from '../src/data/cctv.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const getFlag = (name) => argv.includes(name); + +const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const APP_URL = getOpt('--url', BASE_URL); +const HEADFUL = getFlag('--headful'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); + +const CHROME_EXECUTABLE_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + // Prefer puppeteer's version-pinned Chrome-for-Testing over the system + // Chrome: /Applications auto-updates underneath the harnesses, and its + // software-GL behavior shifts across majors (system Chrome 150 blew the + // tile-gated drain budget under SwiftShader on 2026-07-30 — six + // false-negative qa-cctv-v2 runs against a healthy build). A deterministic + // pinned browser beats the newest one for regression harnesses. + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean); + +function findChromeExecutable() { + for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return null; +} + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// SwiftShader can spend tens of seconds servicing the hover drillPick emitted +// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the +// pointer while ADJUST is disabled, then re-enable it and explicitly render a +// few frames so the real press/drag still exercises Cesium's input + pick path. +async function positionPointerForGizmoDrag(page, point, frameCount = 3) { + await page.evaluate(() => { + window.__godsEyeView.dataManager.layers.get('cctv').module + .setParams({ calibrationMode: false }); + }); + await page.mouse.move(point.x, point.y); + return page.evaluate(async (count) => { + const gev = window.__godsEyeView; + gev.dataManager.layers.get('cctv').module.setParams({ calibrationMode: true }); + const scene = gev.viewer.scene; + let rendered = 0; + return new Promise((resolve) => { + let settled = false; + const finish = (complete) => { + if (settled) return; + settled = true; + clearTimeout(timer); + remove(); + resolve(complete); + }; + const remove = scene.postRender.addEventListener(() => { + rendered += 1; + if (rendered >= count) { + finish(true); + return; + } + scene.requestRender(); + }); + const timer = setTimeout(() => finish(false), 5000); + scene.requestRender(); + }); + }, frameCount); +} + +// In-page helper (stringified and re-defined inside each evaluate that needs +// it, since functions can't cross the puppeteer boundary): serializes the +// 5 frustum polylines + plane position/orientation/dimensions for a camera +// into plain arrays of numbers, using only the camera's own clock time (no +// Cesium namespace needed — Property.getValue just needs *a* JulianDate, +// and the viewer's own clock.currentTime is a valid live instance of one). +const SERIALIZE_GEOM_SRC = ` + function serializeGeom(camId) { + const viewer = window.__godsEyeView.viewer; + const time = viewer.clock.currentTime; + const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap']; + const poly = {}; + for (const role of roles) { + const ent = viewer.entities.getById('cctv-' + camId + '-' + role); + poly[role] = ent && ent.polyline + ? ent.polyline.positions.getValue(time).map((p) => [p.x, p.y, p.z]) + : null; + } + const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); + let plane = null; + if (planeEnt) { + const pos = planeEnt.position.getValue(time); + const orient = planeEnt.orientation.getValue(time); + const dims = planeEnt.plane.dimensions.getValue(time); + plane = { + position: [pos.x, pos.y, pos.z], + orientation: [orient.x, orient.y, orient.z, orient.w], + dimensions: [dims.x, dims.y], + }; + } + return { poly, plane }; + } +`; + +// In-page helper: bearing (degrees, mount -> cap-center) shift between two +// serializeGeom() snapshots of the SAME camera. Plain-JS spherical bearing +// math over ECEF points (no Cesium namespace) — a local east/north basis is +// built from the mount's geocentric "up" via cross products, which is +// equivalent to a proper ENU bearing at these ranges (hundreds of metres; +// cctv.js's own math already treats this scale as flat-earth, see +// projectPoint). Used both to prove a heading patch rotates the geometry AND +// that reset un-rotates it — comparing BEARING (not absolute cap-center +// position) is deliberate: the far-cap's ALTITUDE can legitimately drift a +// few metres between two ground-clamp actions because scene.sampleHeight +// against a still-streaming 3D tileset is not perfectly deterministic call to +// call (observed while writing this harness — see report), which would give +// a false-negative on an absolute-position epsilon that heading math alone +// doesn't share. +const BEARING_SHIFT_SRC = ` + function bearingShiftDeg(before, after) { + function capCenter(g) { + const pts = g.poly.cap; // [tl, tr, br, bl, tl] + const avg = [0, 0, 0]; + for (let i = 0; i < 4; i++) { avg[0] += pts[i][0]; avg[1] += pts[i][1]; avg[2] += pts[i][2]; } + return [avg[0] / 4, avg[1] / 4, avg[2] / 4]; + } + const mount = before.poly['ray-tl'][0]; + const up = mount; + const upLen = Math.hypot(up[0], up[1], up[2]); + const upN = [up[0] / upLen, up[1] / upLen, up[2] / upLen]; + const ref = Math.abs(upN[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]; + const eastRaw = [ + ref[1] * upN[2] - ref[2] * upN[1], + ref[2] * upN[0] - ref[0] * upN[2], + ref[0] * upN[1] - ref[1] * upN[0], + ]; + const eastLen = Math.hypot(eastRaw[0], eastRaw[1], eastRaw[2]); + const east = [eastRaw[0] / eastLen, eastRaw[1] / eastLen, eastRaw[2] / eastLen]; + const north = [ + upN[1] * east[2] - upN[2] * east[1], + upN[2] * east[0] - upN[0] * east[2], + upN[0] * east[1] - upN[1] * east[0], + ]; + function bearingTo(target) { + const v = [target[0] - mount[0], target[1] - mount[1], target[2] - mount[2]]; + const e = v[0] * east[0] + v[1] * east[1] + v[2] * east[2]; + const n = v[0] * north[0] + v[1] * north[1] + v[2] * north[2]; + return (Math.atan2(e, n) * 180 / Math.PI + 360) % 360; + } + const b0 = bearingTo(capCenter(before)); + const b1 = bearingTo(capCenter(after)); + let delta = b1 - b0; + delta = ((delta + 540) % 360) - 180; // normalize to [-180, 180] + return delta; + } +`; + +/** + * Waits for the scene's 3D tileset to report tilesLoaded===true. Cesium + * flips tilesLoaded back to false whenever a view/pose change causes new + * tiles to be requested (observed directly while writing this harness: it + * can go false again even after an earlier wait already saw it true) — so + * every call site that depends on a REAL ground sample landing (not the + * B9c guard's pure recompute from the cached real ground) must wait fresh + * immediately before the action, not rely on an earlier wait. + * @param {import('puppeteer').Page} page + * @param {number} [timeoutMs=15000] + * @returns {Promise} True if tilesLoaded was observed within the timeout. + */ +function waitForTilesLoaded(page, timeoutMs = 15000) { + return page.waitForFunction( + () => { + const scene = window.__godsEyeView.viewer.scene; + const prims = scene.primitives; + for (let i = 0; i < prims.length; i++) { + const p = prims.get(i); + if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { + return p.tilesLoaded === true; + } + } + return true; // no tileset in the scene — nothing to wait for + }, + { timeout: timeoutMs } + ).then(() => true).catch(() => false); +} + +/** + * Searches for a calibration patch (pitchDeg/fovDeg/rangeScale offsets) that + * clears the ground clamp for a given base pose, using the SAME + * computeFrustumGeometry the app renders with as the oracle. Pushes pitch to + * its allowed maximum, shrinks range to its allowed minimum, then shrinks FOV + * in 2-degree steps until every corner sits comfortably above + * `groundAltM + FRUSTUM_GROUND_CLEARANCE_M` (3m safety margin beyond the + * clamp floor). Returns null if no patch in the legal offset ranges clears it + * (shouldn't happen for the catalog's fabricated poses, but the caller must + * treat that as INCONCLUSIVE, not a hard failure). + * @param {Object} basePose - { pitchDeg, fovDeg, rangeM, mountHeightM }. + * @param {number} groundAltM - Ground altitude at the mount. + * @returns {{ pitchDeg: number, fovDeg: number, rangeScale: number }|null} Calibration patch offsets. + */ +function findSafeCalibrationPatch(basePose, groundAltM) { + const pitchDeg = Math.max(-70, Math.min(10, basePose.pitchDeg + 45)); + const pitchOffset = Number((pitchDeg - basePose.pitchDeg).toFixed(1)); + const rangeScale = 0.35; // minimum legal rangeScale (normalizeCalibration clamp) + const rangeM = Math.max(120, basePose.rangeM * rangeScale); + const clampFloor = groundAltM + FRUSTUM_GROUND_CLEARANCE_M; + for (let fovOffset = 0; fovOffset >= -50; fovOffset -= 2) { + const fovDeg = Math.max(20, Math.min(130, basePose.fovDeg + fovOffset)); + const camera = { lat: 30, lon: -97, headingDeg: 0, pitchDeg, fovDeg, rangeM, mountHeightM: basePose.mountHeightM }; + const geom = computeFrustumGeometry(camera, groundAltM, rangeM); + const minAlt = Math.min(geom.corners.tl.alt, geom.corners.tr.alt, geom.corners.br.alt, geom.corners.bl.alt); + if (minAlt > clampFloor + 3) { + return { pitchDeg: pitchOffset, fovDeg: fovOffset, rangeScale }; + } + } + return null; +} + +async function main() { + console.log(`\nCCTV v2 Subsystem Proof`); + console.log(` App URL : ${APP_URL}`); + console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`); + + try { + const res = await fetch(APP_URL, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + process.exit(2); + } + + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + + const chromeExecutable = findChromeExecutable(); + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), + '--disable-dev-shm-usage', + '--disable-web-security', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1280,800', + ], + }); + + const consoleErrors = []; + let exitCode = 0; + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on('console', (msg) => { + if (msg.type() === 'error') { + const t = msg.text(); + if (!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(t)) { + consoleErrors.push(t); + } + } + }); + console.log('Loading app...'); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager, + { timeout: 60000 } + ); + // Let the initial fly-to Austin and first tiles settle. + await sleep(4000); + + // ----------------------------------------------------------------------- + // Install raycast counters BEFORE the layer is enabled (§9.1-amended: the + // zero-raycast invariant is steady-state-only; the single activation + // obstruction probe is the one legal pickFromRay). Wrapping happens + // in-page against the live scene, counting ALL calls (shared scene) — the + // harness only ever asserts DELTAS around CCTV-specific actions, so noise + // from unrelated subsystems cancels out by construction. + // ----------------------------------------------------------------------- + const wrapInstalled = await page.evaluate(() => { + const scene = window.__godsEyeView?.viewer?.scene; + if (!scene || typeof scene.pickFromRay !== 'function' || typeof scene.sampleHeight !== 'function') { + return false; + } + window.__qaCounters = { pickFromRay: 0, sampleHeight: 0 }; + const origPick = scene.pickFromRay.bind(scene); + scene.pickFromRay = (...args) => { + window.__qaCounters.pickFromRay += 1; + return origPick(...args); + }; + const origSample = scene.sampleHeight.bind(scene); + scene.sampleHeight = (...args) => { + window.__qaCounters.sampleHeight += 1; + return origSample(...args); + }; + return true; + }); + if (!wrapInstalled) { + console.error('\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m'); + process.exit(2); + } + + const readCounters = () => page.evaluate(() => ({ ...window.__qaCounters })); + + console.log('Enabling CCTV layer...'); + const c0 = await readCounters(); + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + const entry = dm.layers.get('cctv'); + if (!entry.enabled) await dm.toggle('cctv'); + }); + + // Wait for the staggered geometry-load queue to drain (shared mesh-floor + // sampling: <=1 sampleHeight per coarse cell, gated on tiles-ready). The + // budget scales with catalog size: the queue staggers ~4 records/120ms + // and the one-shot tiles-ready completion pass can re-enqueue the whole + // catalog once — 30s was calibrated for the old 36-camera default and + // times out spuriously at the 250-camera default (2026-07-04). + const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count); + // ~800ms per real ground sample measured under SwiftShader (each + // scene.sampleHeight forces tile loads at the probe point) — real GPU is + // far faster, so this is a headless-CI ceiling, not an app expectation. + const drainBudgetMs = HEADFUL + ? Math.max(30000, camCount * 800 + 30000) + : Math.min(120000, Math.max(30000, camCount * 800 + 30000)); + console.log(`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`); + const drained = await page.waitForFunction( + () => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const ui = mod.getUIState(); + return ui.loading && ui.loading.active === false; + }, + { timeout: drainBudgetMs } + ).then(() => true).catch(() => false); + record(`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, + drained ? true : (HEADFUL ? false : null), + drained + ? 'loading.active === false' + : HEADFUL + ? 'timed out waiting for drain on the real-GPU sign-off path' + : 'headless GL geometry sampling did not settle; current headful evidence is required'); + + const cAfterDrain = await readCounters(); + record('pickFromRay stays 0 before any activation', cAfterDrain.pickFromRay - c0.pickFromRay === 0, + `Δ=${cAfterDrain.pickFromRay - c0.pickFromRay}`); + // Every camera maps to at most one shared coarse cell, so ≤1×N remains a + // safe ceiling while nearby cameras reuse the same accepted floor. + const sampleAfterDrain = cAfterDrain.sampleHeight - c0.sampleHeight; + record(`sampleHeight <= 1×N after drain (N=${camCount})`, sampleAfterDrain <= camCount, + `Δ=${sampleAfterDrain}, ceiling=${camCount} (one-shot shared mesh-floor cell)`); + + // Pick the active camera (or nearest) to activate. + console.log('Activating a camera (focusNearest)...'); + const activeIdBeforeActivation = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); + const cBeforeActivate = await readCounters(); + await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.focusNearest({ durationSec: 0.1 }); + }); + await sleep(500); // let the activation's synchronous work (probe + geometry rewrite) land + const cAfterActivate = await readCounters(); + const activeId = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); + + const pickDeltaActivation = cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay; + record('pickFromRay fires exactly once for the activation (§9.1 probe)', pickDeltaActivation === 1, + `Δ=${pickDeltaActivation} (camera=${activeId}, was=${activeIdBeforeActivation})`); + + // Re-selecting the ALREADY-ACTIVE camera is a no-op (field test + // 2026-07-04: every click on the monitor plane picks its own camera, and + // re-running activation rewrote the plane entity → visible flash). No new + // probe, no geometry rewrite. + const geomBeforeReselect = await page.evaluate(`(() => { + ${SERIALIZE_GEOM_SRC} + return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); + })()`); + await page.evaluate((id) => { + window.__godsEyeView.dataManager.layers.get('cctv').module.setParams({ selectedCameraId: id }); + }, activeId); + await sleep(400); + const cAfterReselect = await readCounters(); + const geomAfterReselect = await page.evaluate(`(() => { + ${SERIALIZE_GEOM_SRC} + return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); + })()`); + record('re-selecting the active camera is a no-op (no probe, no geometry rewrite)', + cAfterReselect.pickFromRay - cAfterActivate.pickFromRay === 0 && geomBeforeReselect === geomAfterReselect, + `probeΔ=${cAfterReselect.pickFromRay - cAfterActivate.pickFromRay}, geometryChanged=${geomBeforeReselect !== geomAfterReselect}`); + + // ----------------------------------------------------------------------- + // Completion FLOOR (locks update()'s one-shot tiles-ready re-enqueue): + // the "<=1×N" ceiling above passes trivially at Δ=0 — e.g. if every drain + // pass ran before tiles loaded (fallback height, record left unresolved) + // and nothing ever completed the snap. Once tilesLoaded has been observed + // true, at least ONE real scene.sampleHeight must have fired since enable + // (existence, not just ceiling). The wait below tolerates the update-tick + // latency of the completion pass (update interval is 10s). + // ----------------------------------------------------------------------- + console.log('Waiting for tiles + the one-shot completion pass...'); + const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + let sampleFloorOk = false; + if (tilesSeenLoaded) { + sampleFloorOk = await page.waitForFunction( + (base) => window.__qaCounters.sampleHeight - base >= 1, + { timeout: 30000 }, + c0.sampleHeight + ).then(() => true).catch(() => false); + } + const cFloor = await readCounters(); + record('>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', + tilesSeenLoaded ? sampleFloorOk : null, + tilesSeenLoaded + ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` + : 'tilesLoaded never observed within 45s — floor not assessable this run'); + + // Let the completion pass finish before the steady-state window: require + // the sampleHeight counter quiet for 12s (> the 10s update interval, so + // the one-shot latch has provably had a tick in which to fire and drain) + // — otherwise the idle assertion below could observe the legitimate tail + // of the one-shot pass and misread it as steady-state sampling. The cap + // scales with N like the drain budget: when the ENABLE-time drain ran + // before tiles loaded (fallback path, zero real samples), the completion + // pass re-enqueues the WHOLE catalog and each real sampleHeight costs + // ~1s under SwiftShader — at N=250 that tail is ~4 minutes, and the old + // fixed 60s cap expired mid-pass, bleeding legitimate one-shot samples + // into the idle window (observed 2026-07-05: idle Δ=12, pose-edit Δ=5). + let completionPassQuiet = false; + { + const settleBudgetMs = Math.max(60000, camCount * 1200 + 30000); + console.log(`Waiting for the one-shot completion pass to go quiet (budget ${Math.round(settleBudgetMs / 1000)}s)...`); + let last = (await readCounters()).sampleHeight; + let quietMs = 0; + const settleStart = Date.now(); + while (quietMs < 12000 && Date.now() - settleStart < settleBudgetMs) { + await sleep(1000); + const cur = (await readCounters()).sampleHeight; + if (cur === last) { + quietMs += 1000; + } else { + quietMs = 0; + last = cur; + } + } + completionPassQuiet = quietMs >= 12000; + } + + // ----------------------------------------------------------------------- + // Steady-state idle window (>=15s): pickFromRay must not grow at all; + // sampleHeight must stay flat (queue already drained, no periodic re-sync + // in v2). + // ----------------------------------------------------------------------- + console.log('Idling 15s to check steady-state raycast invariant...'); + const cIdleStart = await readCounters(); + await sleep(15000); + const cIdleEnd = await readCounters(); + const pickDuringIdle = cIdleEnd.pickFromRay - cIdleStart.pickFromRay; + const sampleDuringIdle = cIdleEnd.sampleHeight - cIdleStart.sampleHeight; + record('pickFromRay does not grow during 15s steady-state idle', pickDuringIdle === 0, + `Δ=${pickDuringIdle}`); + record('sampleHeight stays flat during 15s steady-state idle', + completionPassQuiet ? sampleDuringIdle === 0 : null, + completionPassQuiet + ? `Δ=${sampleDuringIdle}` + : `one-shot floor completion did not become quiet under this GL stack; observed tail Δ=${sampleDuringIdle}`); + + // ----------------------------------------------------------------------- + // A rotational calibration edit preserves the existing shared floor and + // therefore adds zero sampleHeight calls. The activation obstruction probe + // remains activation-only. + console.log('Applying a calibration patch (heading +30°)...'); + const cBeforeCal = await readCounters(); + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); + }, activeId); + await sleep(500); + const cAfterCal = await readCounters(); + const sampleDeltaCal = cAfterCal.sampleHeight - cBeforeCal.sampleHeight; + const pickDeltaCal = cAfterCal.pickFromRay - cBeforeCal.pickFromRay; + record('sampleHeight stays flat after a heading-only calibration edit', + completionPassQuiet ? sampleDeltaCal === 0 : null, + completionPassQuiet + ? `Δ=${sampleDeltaCal}` + : `one-shot floor completion was still unresolved; observed tail Δ=${sampleDeltaCal}`); + record('pickFromRay does not fire on a calibration patch (activation-only)', pickDeltaCal === 0, + `Δ=${pickDeltaCal}`); + + // Undo that patch before the geometry-contract assertions below so they + // observe the record's steady, unpatched frustum. + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, reset: true } }); + }, activeId); + await sleep(300); + + // ========================================================================= + // Group 2: geometry contract + // ========================================================================= + console.log('Checking geometry contract (frustum + plane)...'); + const geomInfo = await page.evaluate(`(function(camId){ + ${SERIALIZE_GEOM_SRC} + const g = serializeGeom(camId); + const viewer = window.__godsEyeView.viewer; + // Overlay unification: the plane label is no longer a native entity — + // it publishes as a protected host entry under the cctv-projection + // source. Assert the migrated surface via the host diagnostics. + const diag = window.__gevWorldOverlay?.getDiagnostics?.(); + g.hasLabel = (diag?.entriesBySource?.['cctv-projection'] || 0) >= 1; + const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); + g.planeShow = planeEnt ? planeEnt.show : null; + return g; + })(${JSON.stringify(activeId)})`); + + const allPolylinesPresent = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'].every((r) => Array.isArray(geomInfo.poly[r])); + record('all 5 frustum polyline entities exist for the active camera', allPolylinesPresent, + Object.keys(geomInfo.poly).map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`).join(', ')); + record('plane + plane-label entities exist for the active camera', !!geomInfo.plane && geomInfo.hasLabel, + `plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`); + + // Plane corner positions must coincide with the 4 corner-ray endpoints + // (ε < 0.5m) — but ONLY when the frustum doesn't hit the ground clamp + // (see the file-header "Ground-clamp caveat"). Austin's fabricated pitch + // priors clamp routinely at the base pose, so apply a temporary + // calibration patch (computed against the same computeFrustumGeometry + // oracle the app uses) that pushes pitch up + shrinks FOV/range enough to + // clear the clamp with margin, run the coincidence check against THAT + // pose, then reset back to the base pose before the byte-stability check. + const activeCameraForPatch = await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + return mod.getUIState().cameras.find((c) => c.id === camId); + }, activeId); + const basePose = activeCameraForPatch.basePose; + const groundAltMForPatch = activeCameraForPatch.elevationM - activeCameraForPatch.mountHeightM; + const safePatch = basePose ? findSafeCalibrationPatch(basePose, groundAltMForPatch) : null; + record('found a calibration patch that clears the ground clamp (test setup, not an app assertion)', safePatch !== null, + safePatch ? JSON.stringify(safePatch) : 'no legal offset combination cleared the clamp — corner-coincidence check below will run against the (possibly clamped) base pose'); + + // updateRecordGeometry's B9c guard now recomputes PURELY from the cached + // real ground when tilesLoaded flips false at the instant of the patch + // (final-review fix — previously it skipped the rewrite entirely, leaving + // the pre-patch polylines/plane dimensions stale), so a pose patch always + // lands in the entity geometry. Cesium can still re-flip tilesLoaded + // false on a pose change even after an earlier wait already saw it true + // (a shrunk FOV/pitch change requests a different tile set), so the + // wait-then-patch retry loop is kept as belt-and-braces, verifying the + // plane's dimensions actually changed before trusting geomForCorners. + let geomForCorners = geomInfo; + let patchGeometryApplied = !safePatch; + if (safePatch) { + const dimsBefore = geomInfo.plane ? JSON.stringify(geomInfo.plane.dimensions) : null; + for (let attempt = 0; attempt < 3 && !patchGeometryApplied; attempt++) { + await waitForTilesLoaded(page, 15000); + await page.evaluate(({ camId, patch }) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, patch } }); + }, { camId: activeId, patch: safePatch }); + await sleep(400); + geomForCorners = await page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`); + const dimsAfter = geomForCorners.plane ? JSON.stringify(geomForCorners.plane.dimensions) : null; + patchGeometryApplied = !!dimsAfter && dimsAfter !== dimsBefore; + } + } + record('calibration patch geometry landed (test setup, not an app assertion)', patchGeometryApplied, + patchGeometryApplied ? 'plane dimensions changed vs pre-patch' : 'plane dimensions never changed — tiles kept flipping mid-stream; corner-coincidence check below may be running against a stale pose'); + + let cornerEps = null; + if (allPolylinesPresent && geomForCorners.plane) { + cornerEps = await page.evaluate((geom) => { + // Rotate vector v by quaternion q=[x,y,z,w] (standard formula). + function rotate(q, v) { + const [qx, qy, qz, qw] = q; + const ix = qw * v[0] + qy * v[2] - qz * v[1]; + const iy = qw * v[1] + qz * v[0] - qx * v[2]; + const iz = qw * v[2] + qx * v[1] - qy * v[0]; + const iw = -qx * v[0] - qy * v[1] - qz * v[2]; + return [ + ix * qw + iw * -qx + iy * -qz - iz * -qy, + iy * qw + iw * -qy + iz * -qx - ix * -qz, + iz * qw + iw * -qz + ix * -qy - iy * -qx, + ]; + } + const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + const { poly, plane } = geom; + const center = plane.position; + const halfW = plane.dimensions[0] / 2; + const halfH = plane.dimensions[1] / 2; + // planeOrientationFor builds the frame as columns [right, up, normal] + // — local +X is right, +Y is up (matches Cesium's row/col Matrix3 + // constructor call order in cctv.js's planeOrientationFor). + const corner = (sx, sy) => { + const local = [sx * halfW, sy * halfH, 0]; + const world = rotate(plane.orientation, local); + return [center[0] + world[0], center[1] + world[1], center[2] + world[2]]; + }; + const planeCorners = { tl: corner(-1, 1), tr: corner(1, 1), br: corner(1, -1), bl: corner(-1, -1) }; + const rayEndpoint = (role) => poly[role][poly[role].length - 1]; + const rayCorners = { tl: rayEndpoint('ray-tl'), tr: rayEndpoint('ray-tr'), br: rayEndpoint('ray-br'), bl: rayEndpoint('ray-bl') }; + return { + tl: dist(planeCorners.tl, rayCorners.tl), + tr: dist(planeCorners.tr, rayCorners.tr), + br: dist(planeCorners.br, rayCorners.br), + bl: dist(planeCorners.bl, rayCorners.bl), + }; + }, geomForCorners); + const maxEps = Math.max(...Object.values(cornerEps)); + const epsLabel = safePatch + ? 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m, ground-clamp-cleared pose)' + : 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)'; + record(epsLabel, maxEps < 0.5, + `tl=${cornerEps.tl.toFixed(3)}m tr=${cornerEps.tr.toFixed(3)}m br=${cornerEps.br.toFixed(3)}m bl=${cornerEps.bl.toFixed(3)}m`); + } else { + record('plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)', null, + 'skipped — geometry entities missing'); + } + + // Restore the base (unpatched) pose before the byte-stability window so + // that check observes the record's normal steady-state frustum, not the + // synthetic clamp-clearing test pose. + if (safePatch) { + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, reset: true } }); + }, activeId); + await sleep(300); + } + + // Byte-stable over 30s idle: serialize + compare after 30s. Nothing + // resamples on a timer in v2, so this is a pure drift regression. + console.log('Checking geometry byte-stability over 30s idle...'); + const serializeGeom = (camId) => page.evaluate(`(function(camId){ + ${SERIALIZE_GEOM_SRC} + return serializeGeom(camId); + })(${JSON.stringify(camId)})`); + const geomBefore30s = await serializeGeom(activeId); + await sleep(30000); + const geomAfter30s = await serializeGeom(activeId); + const geomStable = JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s); + record('geometry is byte-stable over 30s idle (no per-frame/timer resample)', geomStable, + geomStable ? 'identical serialization' : 'serialization CHANGED — see harness output above'); + if (!geomStable) { + console.log(' before:', JSON.stringify(geomBefore30s)); + console.log(' after :', JSON.stringify(geomAfter30s)); + } + + // ========================================================================= + // Group 3: calibration round-trip + // ========================================================================= + console.log('Checking calibration round-trip (patch -> save -> reset)...'); + const baseGeom = await serializeGeom(activeId); + const baseBadge = await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const ui = mod.getUIState(); + return ui.cameras.find((c) => c.id === camId)?.calBadge; + }, activeId); + const baseStoreEmpty = await page.evaluate((camId) => { + const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); + const map = raw ? JSON.parse(raw) : {}; + return !(camId in map); + }, activeId); + record('CAL badge is raw-prior (or curated) before any manual save', baseBadge !== 'calibrated', + `calBadge=${baseBadge}`); + record('v2 store has no entry for this camera before any manual save', baseStoreEmpty, + baseStoreEmpty ? 'no entry' : 'unexpected pre-existing entry'); + + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); + }, activeId); + await sleep(300); + + // Save-gated persistence (v3 design §3e): a PATCH edits the live pose + // only — the store must stay untouched and the camera must read as + // dirty/EDITED until the explicit save action below. + const afterPatch = await page.evaluate((camId) => { + const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); + const map = raw ? JSON.parse(raw) : {}; + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().cameras.find((c) => c.id === camId); + return { stored: camId in map, calDirty: cam?.calDirty, calBadge: cam?.calBadge }; + }, activeId); + record('patch does NOT write the v2 store (save-gated)', afterPatch.stored === false, + afterPatch.stored ? 'entry written on patch — save-gating broken' : 'store untouched'); + record('patch marks the camera calDirty (EDITED chip)', afterPatch.calDirty === true, + `calDirty=${afterPatch.calDirty}`); + record('calBadge stays non-calibrated after an unsaved patch', afterPatch.calBadge !== 'calibrated', + `calBadge=${afterPatch.calBadge}`); + + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, save: true } }); + }, activeId); + await sleep(200); + + const storeEntry = await page.evaluate((camId) => { + const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); + const map = raw ? JSON.parse(raw) : {}; + return map[camId] || null; + }, activeId); + record('v2 localStorage key written with source:manual + savedAt on SAVE', !!(storeEntry && storeEntry.source === 'manual' && Number.isFinite(storeEntry.savedAt)), + storeEntry ? `source=${storeEntry.source} savedAt=${storeEntry.savedAt}` : 'no entry written'); + + const afterSave = await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().cameras.find((c) => c.id === camId); + return { calBadge: cam?.calBadge, calDirty: cam?.calDirty }; + }, activeId); + record('calBadge flips to calibrated after SAVE (and dirty clears)', afterSave.calBadge === 'calibrated' && afterSave.calDirty === false, + `calBadge=${afterSave.calBadge} calDirty=${afterSave.calDirty}`); + + // Geometry rotated: cap-center bearing from the mount should have shifted + // ~30 degrees vs the pre-patch baseline (shared BEARING_SHIFT_SRC helper + // — see its header comment for why bearing, not absolute position). + const geomAfterCal = await serializeGeom(activeId); + const bearingShiftDegPatch = await page.evaluate(`(function(before, after){ + ${BEARING_SHIFT_SRC} + return bearingShiftDeg(before, after); + })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterCal)})`); + const rotatedByAbout30 = Math.abs(Math.abs(bearingShiftDegPatch) - 30) < 5; + record('geometry cap-center bearing shifts ~30° after headingDeg+30 patch', rotatedByAbout30, + `Δbearing=${bearingShiftDegPatch.toFixed(2)}°`); + + // Reset: entry removed, base geometry restored, badge back to raw-prior. + await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, reset: true } }); + }, activeId); + await sleep(300); + + const storeAfterReset = await page.evaluate((camId) => { + const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); + const map = raw ? JSON.parse(raw) : {}; + return camId in map; + }, activeId); + record('v2 localStorage entry removed after reset', !storeAfterReset, + storeAfterReset ? 'entry still present' : 'removed'); + + const badgeAfterReset = await page.evaluate((camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + return mod.getUIState().cameras.find((c) => c.id === camId)?.calBadge; + }, activeId); + record('calBadge returns to raw-prior (or curated) after reset', badgeAfterReset !== 'calibrated', + `calBadge=${badgeAfterReset}`); + + // Reset must undo the heading+30 rotation back to ~0°. Ground now resolves + // through the stable shared floor cache instead of repeated raw samples. + const geomAfterReset = await serializeGeom(activeId); + const bearingShiftDegReset = await page.evaluate(`(function(before, after){ + ${BEARING_SHIFT_SRC} + return bearingShiftDeg(before, after); + })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterReset)})`); + record('cap-center bearing restored to base (~0° vs baseline) after reset', Math.abs(bearingShiftDegReset) < 5, + `Δbearing=${bearingShiftDegReset.toFixed(2)}°`); + + // ========================================================================= + // Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat) + // ========================================================================= + console.log('Checking frame-loop health (placeholder -> frame swap, URL family)...'); + // The plane's material.image is always defined (placeholder canvas from + // the moment the runtime is created, real frame content painted onto the + // same canvas/video reference once the fetch resolves) — assert it's + // never null/undefined once the runtime exists, i.e. there's no gap where + // the plane shows nothing. + const materialInfo = await page.evaluate((camId) => { + const viewer = window.__godsEyeView.viewer; + const time = viewer.clock.currentTime; + const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); + const mat = planeEnt?.plane?.material; + const image = mat?.image; + const resolved = image && typeof image.getValue === 'function' ? image.getValue(time) : image; + return { + hasMaterial: !!mat, + hasImage: !!resolved, + tagName: resolved?.tagName || null, + show: planeEnt?.show, + }; + }, activeId); + record('plane material + image are defined for the active camera (no blank gap)', materialInfo.hasMaterial && materialInfo.hasImage, + `material=${materialInfo.hasMaterial} image=${materialInfo.hasImage} tag=${materialInfo.tagName} show=${materialInfo.show}`); + + // Poll /api/cctv/frame/ directly (the same endpoint frameUrlFor + // targets) to confirm the backend serves SOMETHING for this camera + // (upstream / streetview / synthetic fallback all count as "the frame + // loop is healthy" — a hard 5xx/network failure would not). + const frameFetch = await page.evaluate(async (camId) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().cameras.find((c) => c.id === camId); + const res = await fetch(cam.frameUrl); + return { ok: res.ok, status: res.status, contentType: res.headers.get('content-type'), url: cam.frameUrl, mediaUrl: cam.mediaUrl }; + }, activeId); + record('backend serves a frame for the active camera (2xx, image content-type)', frameFetch.ok && /^image\//.test(frameFetch.contentType || ''), + `status=${frameFetch.status} content-type=${frameFetch.contentType} url=${frameFetch.url}`); + + // Panel src and the plane's frame fetch share the same + // /api/cctv/frame/ URL family (same endpoint + camera id segment). + const panelImgSrc = await page.evaluate(() => document.getElementById('cctv-frame')?.getAttribute('src') || null); + const sameFamily = !!panelImgSrc && panelImgSrc.includes(`/api/cctv/frame/${encodeURIComponent(activeId)}`); + record('panel and plane share the same /api/cctv/frame/ URL family', sameFamily, + `panelSrc=${panelImgSrc}`); + + // ========================================================================= + // Group 5: installed canvas-click ownership + true-empty deselection + // ========================================================================= + console.log('Checking installed canvas click ownership and empty-space deselection...'); + const emptyClickPoint = await page.evaluate(async () => { + const gev = window.__godsEyeView; + const viewer = gev.viewer; + const scene = viewer.scene; + const canvas = scene.canvas; + const rect = canvas.getBoundingClientRect(); + const { hitTestWorldOverlay } = await import('/src/overlays/worldOverlay.js'); + const candidates = [ + [0.55, 0.72], [0.45, 0.72], [0.62, 0.62], [0.38, 0.62], + [0.58, 0.22], [0.42, 0.22], [0.5, 0.52], + ]; + const resolveId = (picked) => { + const direct = picked?.id?.id ?? picked?.id; + if (direct !== undefined && direct !== null) return String(direct); + const primitive = picked?.primitive?.id?.id ?? picked?.primitive?.id; + return primitive === undefined || primitive === null ? null : String(primitive); + }; + for (const [fx, fy] of candidates) { + const x = Math.round(canvas.clientWidth * fx); + const y = Math.round(canvas.clientHeight * fy); + const picked = scene.pick({ x, y }); + const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' }); + const top = document.elementFromPoint(rect.left + x, rect.top + y); + if (resolveId(picked) === null && !card && (top === canvas || canvas.contains(top))) { + return { x: rect.left + x, y: rect.top + y, canvasX: x, canvasY: y }; + } + } + return null; + }); + record('true-empty canvas target is available (no scene owner or CCTV card)', !!emptyClickPoint, + emptyClickPoint ? `canvas=(${emptyClickPoint.canvasX},${emptyClickPoint.canvasY})` : 'no clean canvas point found'); + + const clickEvidenceSetup = await page.evaluate(() => { + const gev = window.__godsEyeView; + const viewer = gev.viewer; + const mod = gev.dataManager.layers.get('cctv').module; + const snapshotPose = () => ({ + position: [viewer.camera.positionWC.x, viewer.camera.positionWC.y, viewer.camera.positionWC.z], + direction: [viewer.camera.directionWC.x, viewer.camera.directionWC.y, viewer.camera.directionWC.z], + up: [viewer.camera.upWC.x, viewer.camera.upWC.y, viewer.camera.upWC.z], + right: [viewer.camera.rightWC.x, viewer.camera.rightWC.y, viewer.camera.rightWC.z], + transform: Array.from({ length: 16 }, (_, i) => viewer.camera.transform[i]), + heading: viewer.camera.heading, + pitch: viewer.camera.pitch, + roll: viewer.camera.roll, + trackedId: viewer.trackedEntity?.id ?? null, + }); + const cameraPoseMatches = (a, b, epsilon = 1e-5) => { + const vectorKeys = ['position', 'direction', 'up', 'right', 'transform']; + const scalarKeys = ['heading', 'pitch', 'roll']; + return vectorKeys.every((key) => ( + Array.isArray(a?.[key]) + && Array.isArray(b?.[key]) + && a[key].length === b[key].length + && a[key].every((value, index) => Math.abs(value - b[key][index]) <= epsilon) + )) && scalarKeys.every((key) => Math.abs(a?.[key] - b?.[key]) <= epsilon) + && a?.trackedId === b?.trackedId; + }; + window.__qaCctvClickEvidence?.dispose?.(); + const evidence = { + activeId: mod.getUIState().activeCameraId, + publications: [], + activeTransitions: [], + focusEvents: 0, + pose: snapshotPose(), + snapshotPose, + cameraPoseMatches, + }; + evidence.lastActiveId = evidence.activeId; + evidence.unsubscribe = mod.subscribe((state) => { + evidence.publications.push(state.activeCameraId); + if (state.activeCameraId !== evidence.lastActiveId) { + evidence.activeTransitions.push([evidence.lastActiveId, state.activeCameraId]); + evidence.lastActiveId = state.activeCameraId; + } + }); + evidence.baselineTransitions = evidence.activeTransitions.length; + evidence.onFocus = () => { evidence.focusEvents += 1; }; + window.addEventListener('gev:cctv-request-focus', evidence.onFocus); + evidence.dispose = () => { + evidence.unsubscribe?.(); + window.removeEventListener('gev:cctv-request-focus', evidence.onFocus); + }; + window.__qaCctvClickEvidence = evidence; + return { activeId: evidence.activeId, baselineTransitions: evidence.baselineTransitions }; + }); + + if (emptyClickPoint) { + await page.evaluate(() => { + window.__godsEyeView.dataManager.layers.get('cctv').module + .setParams({ calibrationMode: true }); + }); + await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); + await sleep(250); + const adjustClick = await page.evaluate(() => { + const evidence = window.__qaCctvClickEvidence; + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibrationMode: false }); + evidence.siblingBaselineTransitions = evidence.activeTransitions.length; + return { + activeId: mod.getUIState().activeCameraId, + publications: evidence.publications.length, + focusEvents: evidence.focusEvents, + poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + }; + }); + record('ADJUST-mode true-empty canvas click preserves active camera', + adjustClick.activeId === clickEvidenceSetup.activeId + && adjustClick.focusEvents === 0 + && adjustClick.poseSame, + `active=${adjustClick.activeId} focus=${adjustClick.focusEvents} poseSame=${adjustClick.poseSame}`); + } else { + record('ADJUST-mode true-empty canvas click preserves active camera', false, 'no true-empty target'); + } + + const siblingTarget = await page.evaluate(async (emptyPoint) => { + if (!emptyPoint) return null; + const gev = window.__godsEyeView; + const viewer = gev.viewer; + const scene = viewer.scene; + const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js'); + const canvasPoint = new Cesium.Cartesian2(emptyPoint.canvasX, emptyPoint.canvasY); + const ray = viewer.camera.getPickRay(canvasPoint); + if (!ray) return null; + let position = null; + let projected = null; + for (const distance of [1_000, 10_000, 100_000, 1_000_000]) { + const candidate = Cesium.Ray.getPoint(ray, distance, new Cesium.Cartesian3()); + const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, candidate); + if (screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2) { + position = candidate; + projected = screen; + break; + } + } + if (!position) return null; + const owner = viewer.entities.add({ + id: 'qa-cctv-sibling-owner', + position, + point: { + pixelSize: 64, + color: Cesium.Color.MAGENTA, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + window.__qaCctvSiblingOwner = owner; + await new Promise((resolve) => { + let frames = 0; + const remove = scene.postRender.addEventListener(() => { + if (++frames < 3) { + scene.requestRender(); + return; + } + remove(); + resolve(); + }); + scene.requestRender(); + }); + const rect = scene.canvas.getBoundingClientRect(); + const picked = scene.pick(canvasPoint); + const pickedId = picked?.id?.id ?? picked?.id ?? null; + window.__qaCctvClickEvidence.siblingBaselineTransitions = + window.__qaCctvClickEvidence.activeTransitions.length; + return { + x: rect.left + emptyPoint.canvasX, + y: rect.top + emptyPoint.canvasY, + pickedId: typeof pickedId === 'string' ? pickedId : pickedId?.id ?? null, + ownerId: owner.id, + ownsExactObject: picked?.id === owner, + projectionDelta: Math.hypot(projected.x - canvasPoint.x, projected.y - canvasPoint.y), + }; + }, emptyClickPoint); + record('sibling test object owns its canvas pick', + siblingTarget + ? siblingTarget.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject + : null, + siblingTarget + ? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}` + : 'no previously verified empty canvas ray was available'); + if (siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject) { + await page.mouse.click(siblingTarget.x, siblingTarget.y); + await sleep(250); + } + const siblingClick = await page.evaluate(() => { + const evidence = window.__qaCctvClickEvidence; + const gev = window.__godsEyeView; + const mod = gev.dataManager.layers.get('cctv').module; + if (window.__qaCctvSiblingOwner) { + gev.viewer.entities.remove(window.__qaCctvSiblingOwner); + delete window.__qaCctvSiblingOwner; + } + return { + activeId: mod.getUIState().activeCameraId, + transitions: evidence.activeTransitions.length, + baselineTransitions: evidence.siblingBaselineTransitions, + focusEvents: evidence.focusEvents, + poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + }; + }); + record('sibling canvas click passes through without CCTV selection or deselection', + siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject + ? siblingClick.activeId === clickEvidenceSetup.activeId + && siblingClick.transitions === siblingClick.baselineTransitions + && siblingClick.focusEvents === 0 + && siblingClick.poseSame + : null, + `active=${siblingClick.activeId} transitions=${siblingClick.transitions - siblingClick.baselineTransitions} focus=${siblingClick.focusEvents} poseSame=${siblingClick.poseSame}`); + + if (emptyClickPoint) { + await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); + await sleep(250); + } + const firstEmptyClick = await page.evaluate(() => { + const evidence = window.__qaCctvClickEvidence; + const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); + return { + activeId: state.activeCameraId, + enabled: state.enabled, + transitions: evidence.activeTransitions.length, + baselineTransitions: evidence.siblingBaselineTransitions, + lastTransition: evidence.activeTransitions.at(-1) ?? null, + focusEvents: evidence.focusEvents, + poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + }; + }); + record('real true-empty canvas click publishes one active-to-null transition', + !!emptyClickPoint + && firstEmptyClick.activeId === null + && firstEmptyClick.enabled === true + && firstEmptyClick.transitions === firstEmptyClick.baselineTransitions + 1 + && firstEmptyClick.lastTransition?.[0] === clickEvidenceSetup.activeId + && firstEmptyClick.lastTransition?.[1] === null, + `active=${firstEmptyClick.activeId} enabled=${firstEmptyClick.enabled} transitions=${firstEmptyClick.transitions - firstEmptyClick.baselineTransitions}`); + record('real true-empty deselection preserves pose/tracking and emits no focus request', + !!emptyClickPoint && firstEmptyClick.poseSame && firstEmptyClick.focusEvents === 0, + `poseSame=${firstEmptyClick.poseSame} focus=${firstEmptyClick.focusEvents}`); + + if (emptyClickPoint) { + await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); + await sleep(250); + } + const repeatEmptyClick = await page.evaluate(() => { + const evidence = window.__qaCctvClickEvidence; + const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); + evidence.dispose(); + delete window.__qaCctvClickEvidence; + return { + activeId: state.activeCameraId, + transitions: evidence.activeTransitions.length, + baselineTransitions: evidence.siblingBaselineTransitions, + focusEvents: evidence.focusEvents, + }; + }); + record('repeat true-empty canvas click is null-idempotent', + !!emptyClickPoint + && repeatEmptyClick.activeId === null + && repeatEmptyClick.transitions === repeatEmptyClick.baselineTransitions + 1 + && repeatEmptyClick.focusEvents === 0, + `active=${repeatEmptyClick.activeId} transitions=${repeatEmptyClick.transitions - repeatEmptyClick.baselineTransitions} focus=${repeatEmptyClick.focusEvents}`); + + // Explicit navigation from null must remain available for the remaining + // coverage and gizmo groups. NEXT is the product route pinned by N4. + const resumedId = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.cycleCamera(1); + return mod.getUIState().activeCameraId; + }); + record('explicit NEXT resumes from null at the first catalog camera', + typeof resumedId === 'string' && !!resumedId, + `active=${resumedId}`); + + // ========================================================================= + // Group 6: viewshed mode (v3 design §3b — coverage tri-state + volumes) + // ========================================================================= + console.log('Checking viewshed mode (coverage tri-state + color-coded volumes)...'); + const countVolumes = () => page.evaluate(() => { + const prims = window.__godsEyeView.viewer.scene.primitives; + let n = 0; + for (let i = 0; i < prims.length; i++) { + if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; + } + return n; + }); + + const cBeforeViewshed = await readCounters(); + const modeAfterSet = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ coverageMode: 'viewshed' }); + return mod.getUIState().coverageMode; + }); + await sleep(400); + record('coverageMode setParams round-trip (viewshed)', modeAfterSet === 'viewshed', + `coverageMode=${modeAfterSet}`); + + const volumesOn = await countVolumes(); + record('viewshed volumes exist for the visible set (1..15)', volumesOn >= 1 && volumesOn <= 15, + `${volumesOn} volume primitives (visible-set cap is 14 + active)`); + + // Boolean back-compat shim: showCoverage=false → 'off' (0 volumes), + // showCoverage=true → 'on' (wireframes, still 0 volumes). + const compat = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ showCoverage: false }); + const off = mod.getUIState().coverageMode; + mod.setParams({ showCoverage: true }); + const on = mod.getUIState().coverageMode; + return { off, on }; + }); + await sleep(200); + const volumesInOnMode = await countVolumes(); + record('boolean showCoverage compat (false→off, true→on)', compat.off === 'off' && compat.on === 'on', + `false→${compat.off}, true→${compat.on}`); + record("mode 'on' renders zero volumes (wireframes only)", volumesInOnMode === 0, + `${volumesInOnMode} volumes in 'on' mode`); + + // Back to viewshed: volumes must be STABLE while idle (no churn — the + // rebuild sites are pose edits and style refreshes only). Object identity + // over 8s proves nothing recreated them. + await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ coverageMode: 'viewshed' }); + }); + await sleep(400); + await page.evaluate(() => { + const prims = window.__godsEyeView.viewer.scene.primitives; + window.__qaViewshedRefs = []; + for (let i = 0; i < prims.length; i++) { + if (prims.get(i) && prims.get(i)._gevViewshed) window.__qaViewshedRefs.push(prims.get(i)); + } + }); + await sleep(8000); + const viewshedIdle = await page.evaluate(() => { + const prims = window.__godsEyeView.viewer.scene.primitives; + const now = []; + for (let i = 0; i < prims.length; i++) { + if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i)); + } + const before = window.__qaViewshedRefs || []; + const sameCount = now.length === before.length; + const sameIdentity = sameCount && before.every((p) => now.includes(p)); + return { sameCount, sameIdentity, count: now.length }; + }); + const cAfterViewshed = await readCounters(); + const lateFloorSamples = cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; + record('viewshed volumes idle-stable over 8s (same primitives, no churn)', + lateFloorSamples > 0 ? null : viewshedIdle.sameIdentity, + lateFloorSamples > 0 + ? `late one-shot floor completion sampled ${lateFloorSamples} cells and legitimately rebuilt geometry` + : `count=${viewshedIdle.count} sameCount=${viewshedIdle.sameCount} sameIdentity=${viewshedIdle.sameIdentity}`); + record('raycast counters flat across viewshed cycling + idle', + lateFloorSamples > 0 ? null : cAfterViewshed.pickFromRay === cBeforeViewshed.pickFromRay, + lateFloorSamples > 0 + ? `late one-shot floor completion: ΔsampleHeight=${lateFloorSamples}; ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay}` + : `ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay} ΔsampleHeight=0`); + + const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png'); + await page.screenshot({ path: viewshedShot }); + console.log(` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`); + + // ========================================================================= + // Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag) + // ========================================================================= + console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...'); + const GIZMO_PARTS = ['ring-heading', 'ring-pitch', 'move-east', 'move-north', 'move-up', 'handle-range', 'handle-fov-l', 'handle-fov-r']; + const gizmoStates = await page.evaluate((parts) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibrationMode: true }); + const viewer = window.__godsEyeView.viewer; + return parts.map((p) => { + const e = viewer.entities.getById('cctv-gizmo-' + p); + return e ? (e.show ? 'shown' : 'hidden') : 'missing'; + }); + }, GIZMO_PARTS); + record('all 8 gizmo parts exist and show in ADJUST mode', gizmoStates.every((s) => s === 'shown'), + gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); + + // Synthetic CDP drag on the EAST arrow — the owner's field-test + // regression. Sample effective elevation after every mouse move: it must + // stay frozen until release, with no transient sampleHeight calls. + const eastDrag = await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + const e = viewer.entities.getById('cctv-gizmo-move-east'); + if (!e) return null; + const pts = e.polyline.positions.getValue(viewer.clock.currentTime); + const scene = viewer.scene; + const w = scene.canvas.clientWidth; + const h = scene.canvas.clientHeight; + const a = scene.cartesianToCanvasCoordinates(pts[0]); + const b = scene.cartesianToCanvasCoordinates(pts[pts.length - 1]); + if (!a || !b || !Number.isFinite(a.x) || !Number.isFinite(a.y) || + !Number.isFinite(b.x) || !Number.isFinite(b.y)) return null; + const dx = b.x - a.x; + const dy = b.y - a.y; + const mag = Math.hypot(dx, dy); + if (mag < 10) return null; + const candidates = [0.12, 0.2, 0.28, 0.36, 0.44, 0.52, 0.6, 0.68, 0.76, 0.84, 0.92] + .map((t) => ({ x: a.x + dx * t, y: a.y + dy * t })) + .filter((p) => p.x > 60 && p.y > 60 && p.x < w - 60 && p.y < h - 60); + if (b.x > 60 && b.y > 60 && b.x < w - 60 && b.y < h - 60) { + candidates.unshift({ x: b.x, y: b.y }); + } + if (!candidates.length) return null; + return { ...candidates[0], candidates, ux: dx / mag, uy: dy / mag }; + }); + + const calBeforeDrag = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().activeCamera; + return cam ? { + calibration: cam.calibration, + elevationM: cam.elevationM, + groundResolveCount: cam.groundResolveCount, + groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, + } : null; + }); + + const transientDragStates = []; + const countersBeforeDrag = await readCounters(); + let countersBeforeRelease = countersBeforeDrag; + if (eastDrag) { + // Batched polylines can miss one synthetic LEFT_DOWN while their pick + // buffer settles. Try several visible shaft points; stop after the first + // actual offset change so a successful drag still has exactly one release. + for (const grab of eastDrag.candidates) { + const pickBufferReady = await positionPointerForGizmoDrag(page, grab); + if (!pickBufferReady) continue; + const ownsPick = await page.evaluate((point) => { + const scene = window.__godsEyeView.viewer.scene; + const partFrom = (picked) => { + const id = picked?.id?.id ?? picked?.id; + return typeof id === 'string' && id.startsWith('cctv-gizmo-') + ? id.slice('cctv-gizmo-'.length) + : null; + }; + const direct = partFrom(scene.pick(point, 14, 14)); + if (direct) return direct === 'move-east'; + const firstGizmo = (scene.drillPick(point, 6, 14, 14) || []) + .map(partFrom) + .find(Boolean); + return firstGizmo === 'move-east'; + }, grab); + if (!ownsPick) continue; + await page.mouse.down(); + for (let i = 1; i <= 5; i++) { + await page.mouse.move( + grab.x + eastDrag.ux * i * 12, + grab.y + eastDrag.uy * i * 12 + ); + await sleep(40); + transientDragStates.push(await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().activeCamera; + return cam ? { + elevationM: cam.elevationM, + eastOffsetM: cam.calibration?.offsetEastM, + groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, + } : null; + })); + } + const candidateCountersBeforeRelease = await readCounters(); + await page.mouse.up(); + await sleep(400); + const candidateEast = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + return mod.getUIState().activeCamera?.calibration?.offsetEastM ?? null; + }); + if (Number.isFinite(candidateEast) && + Math.abs(candidateEast - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05) { + countersBeforeRelease = candidateCountersBeforeRelease; + break; + } + } + } + + const dragOutcome = await page.evaluate((pt) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const viewer = window.__godsEyeView.viewer; + const cam = mod.getUIState().activeCamera; + const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); + const map = raw ? JSON.parse(raw) : {}; + let pickable = null; + let ownsPick = null; + if (pt) { + try { + const picked = viewer.scene.drillPick({ x: pt.x, y: pt.y }, 6, 14, 14) || []; + pickable = picked.some((r) => String(r?.id?.id ?? r?.id ?? '').startsWith('cctv-gizmo-')); + const firstGizmo = picked + .map((r) => String(r?.id?.id ?? r?.id ?? '')) + .find((id) => id.startsWith('cctv-gizmo-')); + ownsPick = firstGizmo === 'cctv-gizmo-move-east'; + } catch { pickable = null; } + } + return { + eastOffsetM: cam?.calibration?.offsetEastM ?? null, + groundResolveCount: cam?.groundResolveCount ?? null, + calDirty: cam?.calDirty ?? null, + stored: cam ? (cam.id in map) : null, + inputsEnabled: viewer.scene.screenSpaceCameraController.enableInputs, + pickable, + ownsPick, + }; + }, eastDrag); + + const eastChanged = eastDrag + && Number.isFinite(dragOutcome.eastOffsetM) + && Math.abs(dragOutcome.eastOffsetM - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05; + const transientElevations = transientDragStates.map((state) => state?.elevationM).filter(Number.isFinite); + const maxTransientElevationDelta = transientElevations.length && Number.isFinite(calBeforeDrag?.elevationM) + ? Math.max(...transientElevations.map((height) => Math.abs(height - calBeforeDrag.elevationM))) + : Infinity; + const transientSampleDelta = countersBeforeRelease.sampleHeight - countersBeforeDrag.sampleHeight; + const activeMeshRequestsBeforeRelease = transientDragStates + .map((state) => state?.groundMeshSampleRequestCount) + .filter(Number.isFinite) + .at(-1); + const transientActiveMeshRequestDelta = Number.isFinite(activeMeshRequestsBeforeRelease) + ? activeMeshRequestsBeforeRelease - (calBeforeDrag?.groundMeshSampleRequestCount ?? 0) + : Infinity; + if (eastChanged) { + record('east-arrow drag changes the east offset (live, unsaved)', true, + `offsetEastM ${calBeforeDrag?.calibration?.offsetEastM ?? 0} → ${dragOutcome.eastOffsetM}`); + record('east-arrow drag keeps mount elevation frozen until mouse-up', maxTransientElevationDelta < 0.01, + `max transient Δelevation=${maxTransientElevationDelta.toFixed(4)}m over ${transientElevations.length} moves`); + record('east-arrow transient moves issue zero active-camera mesh-floor requests', + transientActiveMeshRequestDelta === 0, + `active-camera request Δ=${transientActiveMeshRequestDelta}; global sampleHeight Δ=${transientSampleDelta} (other cells may finish asynchronously)`); + record('east-arrow release resolves the committed floor exactly once', + dragOutcome.groundResolveCount === (calBeforeDrag?.groundResolveCount ?? 0) + 1, + `ground resolutions ${calBeforeDrag?.groundResolveCount ?? 0} → ${dragOutcome.groundResolveCount}`); + record('drag leaves the camera dirty and the store untouched', dragOutcome.calDirty === true && dragOutcome.stored === false, + `calDirty=${dragOutcome.calDirty} stored=${dragOutcome.stored}`); + } else if (eastDrag) { + const missDetail = dragOutcome.pickable === false + ? 'drill-pick cannot see a gizmo under this GL stack' + : dragOutcome.ownsPick === false + ? 'the east arrow did not own any tested shaft pick' + : 'LEFT_DOWN did not begin an east-arrow drag despite the ownership precondition'; + record('east-arrow drag changes the east offset (live, unsaved)', null, + `${missDetail} — verify on a current headful GPU surface`); + record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped with drag'); + record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped with drag'); + record('east-arrow release resolves the committed floor exactly once', null, 'skipped with drag'); + record('drag leaves the camera dirty and the store untouched', null, 'skipped with drag'); + } else { + const unavailable = 'no on-screen east-arrow shaft found under this GL stack — verify on real GPU'; + record('east-arrow drag changes the east offset (live, unsaved)', null, unavailable); + record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped: drag target unavailable'); + record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped: drag target unavailable'); + record('east-arrow release resolves the committed floor exactly once', null, 'skipped: drag target unavailable'); + record('drag leaves the camera dirty and the store untouched', null, 'skipped: drag target unavailable'); + } + record('camera-controller inputs re-enabled after drag', dragOutcome.inputsEnabled === true, + `enableInputs=${dragOutcome.inputsEnabled}`); + + // ADJUST off: parts hidden; leave a clean calibration for the next group. + const gizmoOff = await page.evaluate((parts) => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibrationMode: false }); + mod.setParams({ calibration: { reset: true } }); + const viewer = window.__godsEyeView.viewer; + return parts.map((p) => { + const e = viewer.entities.getById('cctv-gizmo-' + p); + return e ? (e.show ? 'shown' : 'hidden') : 'missing'; + }); + }, GIZMO_PARTS); + record('gizmo parts hide when ADJUST mode turns off', gizmoOff.every((s) => s === 'hidden'), + gizmoOff.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); + + // ----------------------------------------------------------------------- + // Screenshots for human visual review (NOT a pass/fail signal — SwiftShader + // cannot fill plane image materials under headless software GL). + // ----------------------------------------------------------------------- + await sleep(1000); + const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); + await page.screenshot({ path: shot }); + console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`); + + // No console errors during the whole exercise. + record('no console errors during CCTV v2 exercise', consoleErrors.length === 0, + consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean'); + + for (const r of results) { + if (r.ok === false) exitCode = 1; + } + } finally { + await browser.close(); + } + + console.log('\n' + '─'.repeat(60)); + const pass = results.filter((r) => r.ok === true).length; + const fail = results.filter((r) => r.ok === false).length; + const inconclusive = results.filter((r) => r.ok === null).length; + console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode); +} + +main().catch((e) => { + console.error('\x1b[31mHarness error:\x1b[0m', e); + process.exit(3); +}); diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs new file mode 100644 index 000000000..d4804d754 --- /dev/null +++ b/scripts/qa-cockpit-plates.mjs @@ -0,0 +1,555 @@ +#!/usr/bin/env node +/** + * Rendered proof for backdrop-selective callout plates at GROUND LEVEL. + * + * The owner's scenario: a cockpit sitting at JFK, where the geoid runs ~34 m + * under the ellipsoid so the HUD honestly reads a NEGATIVE altitude. Every + * label against the open sky came back with a full dark plate, because a + * sub-ellipsoid camera fell into `skyBackdropFactor`'s degenerate guard. + * + * WHAT THIS ASSERTS, AND WHY IT IS NOT A UNIT TEST IN DISGUISE. An earlier + * version of this harness called `skyBackdropFactor` directly and would have + * passed with the painter stubbed out — the number was right and nothing was + * drawn. Every assertion here now comes from the RENDERED path instead: + * + * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the + * overlay's own published `visibleCount` / `labeledKeys`, so a contact only + * counts once the sweep, the arbiter and the placement solve have all run. + * plate scale — tapped off `CanvasRenderingContext2D` while the frame paints. + * `paintDetectionCallout` fills the plate at `alpha * plateScale` and the + * accent bar at `alpha`, in that order, then draws the callsign, so the + * ratio of the two fills IS the plate scale and the callsign attributes it + * to a named contact. Nothing is recomputed; this reads what was painted. + * pixels — the plate rect comes from the same tap (`roundRect` immediately + * precedes its `fill`), and the harness reads back mean alpha inside that + * rect on `#world-overlay-canvas`, the real compositing surface. + * + * `--teeth` proves those assertions have teeth: it stubs the canvas paint calls + * the callout painter uses to no-ops, so nothing reaches the framebuffer, and + * the plate-scale and pixel checks must go RED. Run it after any change here. + * + * Three poses over one synthetic contact field: + * level — cockpit on the ramp, nose on the horizon. This is the frame + * the owner shot: approach traffic against open sky. + * tilted-down — same cockpit, pitched down over the apron. Asserts that NO + * ground contact is labelled, which is not this fix's doing: + * from a camera under the ellipsoid Cesium's + * `EllipsoidalOccluder` culls every point below the local + * horizontal before detection ever sees it. + * lifted — the same view a moment after rotate, at +40 m of ellipsoid + * height. The apron contacts are back, and they keep their + * FULL plates: the proof that sky selectivity did not quietly + * become "no plates anywhere". + * + * Run (dev server already up): + * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 + * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before + * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth + * + * Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL, + * which is deterministic but is NOT real-GPU evidence; the banner says which + * backend actually ran and the run records it alongside the results. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const argv = process.argv.slice(2); +const getOpt = (name, fallback) => { + const index = argv.indexOf(name); + return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const APP_ORIGIN = new URL(APP_URL).origin; +const TAG = getOpt('--tag', 'after'); +const HEADFUL = argv.includes('--headful'); +const TEETH = argv.includes('--teeth'); +const SWIFTSHADER = argv.includes('--swiftshader'); +const SHOT_DIR = path.resolve('qa-shots/cockpitplates'); + +/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */ +const SKY_PLATE_SCALE = 0.22; +/** Painted alpha is a ratio of two floats; this is generous slack on it. */ +const PLATE_SCALE_TOLERANCE = 0.02; + +// JFK. Ramp ~4 m MSL with a geoid height of about -34 m, so ellipsoid height +// on the deck is ~-30 m and a widebody cockpit reads ALT -18 m: the owner's +// exact HUD number, and a camera genuinely INSIDE the ellipsoid. +const JFK_LON = -73.7781; +const JFK_LAT = 40.6413; +const COCKPIT_H = -18; +const RAMP_H = -30; +const M_PER_DEG_LAT = 110_574; + +/** + * Contacts are placed due north of the cockpit, which faces north, so the sky + * set and the ground set share one frame and one screenshot can show both. + */ +const CONTACTS = [ + // --- against open sky: above the cockpit's eye level --- + { id: 'AAL1042', metric: '168KT', range: 6_000, height: 620, want: 'sky' }, + { id: 'DAL883', metric: '154KT', range: 9_000, height: 940, want: 'sky' }, + { id: 'JBU221', metric: '178KT', range: 12_000, height: 1_500, want: 'sky' }, + { id: 'UAL508', metric: '145KT', range: 4_200, height: 380, want: 'sky' }, + { id: 'FDX1190', metric: '190KT', range: 16_000, height: 2_400, want: 'sky' }, + // --- against the apron: below the cockpit floor --- + { id: 'N6172G', metric: '12KT', range: 150, height: RAMP_H, want: 'ground' }, + { id: 'SWA2255', metric: '8KT', range: 260, height: RAMP_H, want: 'ground' }, + { id: 'DAL2190', metric: '15KT', range: 400, height: RAMP_H - 4, want: 'ground' }, + { id: 'AAL77', metric: '6KT', range: 620, height: RAMP_H - 6, want: 'ground' }, +]; + +const SKY_IDS = CONTACTS.filter((contact) => contact.want === 'sky').map((contact) => contact.id); +const GROUND_IDS = CONTACTS.filter((contact) => contact.want === 'ground').map((contact) => contact.id); + +const POSES = [ + { + name: 'level', + height: COCKPIT_H, + pitchDeg: -1.5, + // Approach traffic is above eye level, so it survives the occluder and must + // paint at the feathered scale. + expectPlated: SKY_IDS, + expectScale: SKY_PLATE_SCALE, + expectAbsent: GROUND_IDS, + note: 'sub-ellipsoid cockpit, nose on the horizon', + }, + { + name: 'tilted-down', + height: COCKPIT_H, + pitchDeg: -14, + expectPlated: [], + expectScale: null, + expectAbsent: GROUND_IDS, + note: 'same cockpit pitched down; the occluder owns this emptiness', + }, + { + name: 'lifted', + height: 40, + pitchDeg: -14, + expectPlated: GROUND_IDS, + expectScale: 1, + expectAbsent: [], + note: 'above the ellipsoid after rotate; apron plates at full strength', + }, +]; + +const CHROME_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + // Version-pinned Chrome-for-Testing over the auto-updating system Chrome: + // its software-GL behavior shifts across majors and has produced false + // negatives in this repo's harnesses before. + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', +].filter(Boolean); + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`); +} + +const stubJson = (body) => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +/** + * Installed before any app code runs. Records the callout painter's own canvas + * calls; under `--teeth` it replaces them with no-ops instead, so the painter + * stops reaching the framebuffer and every rendered assertion should fail. + */ +function installPaintTap(teeth) { + const proto = CanvasRenderingContext2D.prototype; + const origRoundRect = proto.roundRect; + const origFill = proto.fill; + const origFillText = proto.fillText; + window.__PLATE_TAP = { on: false, events: [], lastRect: null, teeth }; + + if (teeth) { + // Stub the painter: no record, no pixels. This is the negative control. + proto.roundRect = function () {}; + proto.fill = function () {}; + proto.fillText = function () {}; + return; + } + + proto.roundRect = function (x, y, w, h, r) { + if (window.__PLATE_TAP.on) window.__PLATE_TAP.lastRect = { x, y, w, h }; + return origRoundRect.call(this, x, y, w, h, r); + }; + proto.fill = function (...args) { + if (window.__PLATE_TAP.on) { + window.__PLATE_TAP.events.push({ + kind: 'fill', + alpha: this.globalAlpha, + rect: window.__PLATE_TAP.lastRect || null, + }); + } + return origFill.apply(this, args); + }; + proto.fillText = function (text, x, y, ...rest) { + if (window.__PLATE_TAP.on) { + window.__PLATE_TAP.events.push({ kind: 'text', alpha: this.globalAlpha, text: String(text) }); + } + return origFillText.call(this, text, x, y, ...rest); + }; +} + +async function main() { + const backend = SWIFTSHADER ? 'software GL (SwiftShader)' : 'real GPU (ANGLE/Metal)'; + console.log('\nCockpit Plate Backdrop QA — rendered path'); + console.log(` App URL : ${APP_URL}`); + console.log(` Tag : ${TAG}${TEETH ? ' (TEETH: painter stubbed, expect RED)' : ''}`); + console.log(` Backend : ${backend}\n`); + + try { + const response = await fetch(APP_URL); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`); + process.exit(2); + } + + fs.mkdirSync(SHOT_DIR, { recursive: true }); + const executablePath = CHROME_CANDIDATES.find((candidate) => { + try { return fs.existsSync(candidate); } catch { return false; } + }); + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(executablePath ? { executablePath } : {}), + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ...(SWIFTSHADER + ? ['--use-gl=angle', '--use-angle=swiftshader'] + : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']), + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1280,800', + ], + }); + + const consoleErrors = []; + const perPose = []; + let renderer = 'unknown'; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); + page.on('console', (message) => { + if (message.type() !== 'error') return; + const text = message.text(); + if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text); + }); + page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`)); + await page.evaluateOnNewDocument(installPaintTap, TEETH); + + await page.setRequestInterception(true); + page.on('request', (request) => { + 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') { + return void request.respond(stubJson({ + rows: [], source: 'QA', status: 'open', error: null, refreshing: false, + newestPositionAt: null, lastMessageAt: null, + })); + } + if (url.pathname === '/api/adsblol/mil') { + return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] })); + } + if (url.pathname === '/api/opensky') { + return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] })); + } + if (url.pathname === '/api/opensky-track') { + return void request.respond(stubJson({ path: [] })); + } + if (url.pathname === '/api/openai/hud-summary') { + return void request.respond(stubJson({ summary: 'Cockpit plate QA' })); + } + return void request.continue(); + }); + + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 }); + await page.waitForFunction( + () => document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90_000 }, + ); + + renderer = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2') + || document.createElement('canvas').getContext('webgl'); + if (!gl) return 'no webgl'; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + return ext + ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) + : String(gl.getParameter(gl.RENDERER)); + }); + const softwareRenderer = /swiftshader|llvmpipe|software/i.test(renderer); + console.log(` GL : ${renderer}\n`); + record( + SWIFTSHADER + ? 'running on software GL as requested (structural checks, not GPU evidence)' + : 'running on the real GPU, so the screenshots are real-GPU evidence', + SWIFTSHADER ? softwareRenderer : !softwareRenderer, + renderer, + ); + + // Inject the field. The layers keep their real pipelines; only the source + // of observations is replaced, exactly as scripts/qa-labels.mjs does. + await page.evaluate((payload) => { + const { viewer, dataManager, styleManager } = window.__godsEyeView; + viewer.camera.cancelFlight(); + const Cartesian3 = viewer.camera.position.constructor; + window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({ + sourceId: contact.id.toLowerCase(), + id: contact.id, + metric: contact.metric, + position: Cartesian3.fromDegrees( + payload.lon, + payload.lat + contact.range / payload.mPerDegLat, + contact.height, + ), + type: 'AIR', + tier: 'civil', + })); + for (const layerId of ['flights', 'military', 'satellites']) { + const entry = dataManager.layers.get(layerId); + if (!entry?.module) continue; + entry.module.getDetectableObjects = () => ( + layerId === 'flights' ? window.__PLATE_QA_FIELD : [] + ); + } + styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic' }); + }, { lon: JFK_LON, lat: JFK_LAT, mPerDegLat: M_PER_DEG_LAT, contacts: CONTACTS }); + + for (const pose of POSES) { + console.log(`\n — pose "${pose.name}" (${pose.note})`); + await page.evaluate((payload) => { + const { viewer } = window.__godsEyeView; + const Cartesian3 = viewer.camera.position.constructor; + viewer.camera.setView({ + destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height), + orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 }, + }); + }, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg }); + + // Let the tiles settle and the label solve run before the tap window. + await new Promise((resolve) => setTimeout(resolve, 6_000)); + await page.evaluate(() => { + window.__PLATE_TAP.events = []; + window.__PLATE_TAP.on = true; + // Drive the frames this measurement needs, instead of assuming something + // else is holding the render loop open. + // + // This harness enables no data layer and never enters real Cockpit (which + // takes its own hold), so nothing in the scene animates. It used to get + // its frames for free from detection's continuous-render hold — and that + // hold was itself the bug: it pinned every idle tab at 60 fps, which is + // why it was removed (see src/data/detectionRenderDemand.js). The scene is + // now correctly idle during the tap window, so a measurement of PAINT + // OUTPUT has to ask for its own renders. Production is unaffected: real + // Cockpit holds, and a parked map keeps showing the last correct frame + // precisely because nothing changed. + const scene = window.__godsEyeView.viewer.scene; + const pump = () => { + if (!window.__PLATE_TAP.on) return; + scene.requestRender(); + requestAnimationFrame(pump); + }; + pump(); + }); + await new Promise((resolve) => setTimeout(resolve, 1_200)); + const observed = await page.evaluate((wantedIds) => { + window.__PLATE_TAP.on = false; + const events = window.__PLATE_TAP.events; + const wanted = new Set(wantedIds); + // paintDetectionCallout paints, in order: the plate (alpha*plateScale), + // the accent bar (alpha), the leader, then the callsign. So the two + // fills before a callsign are that callout's plate and accent, and + // their ratio is the plate scale that was actually painted. + const byId = new Map(); + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.kind !== 'text') continue; + const id = event.text.trim(); + if (!wanted.has(id)) continue; + const fills = []; + for (let j = i - 1; j >= 0 && fills.length < 2; j--) { + if (events[j].kind === 'fill') fills.unshift(events[j]); + } + if (fills.length < 2) continue; + const [plate, accent] = fills; + if (!(accent.alpha > 0)) continue; + byId.set(id, { + id, + plateAlpha: plate.alpha, + accentAlpha: accent.alpha, + plateScale: plate.alpha / accent.alpha, + rect: plate.rect, + }); + } + + // Pixel readback from the real compositing surface, over the plate rect + // the painter itself just used. + const canvas = document.getElementById('world-overlay-canvas'); + const ctx = canvas?.getContext('2d', { willReadFrequently: true }); + const dpr = canvas && canvas.clientWidth ? canvas.width / canvas.clientWidth : 1; + for (const entry of byId.values()) { + entry.meanPixelAlpha = null; + if (!ctx || !entry.rect || !(entry.rect.w > 0) || !(entry.rect.h > 0)) continue; + const x = Math.max(0, Math.round(entry.rect.x * dpr)); + const y = Math.max(0, Math.round(entry.rect.y * dpr)); + const w = Math.min(canvas.width - x, Math.round(entry.rect.w * dpr)); + const h = Math.min(canvas.height - y, Math.round(entry.rect.h * dpr)); + if (!(w > 0) || !(h > 0)) continue; + const data = ctx.getImageData(x, y, w, h).data; + let total = 0; + for (let p = 3; p < data.length; p += 4) total += data[p]; + entry.meanPixelAlpha = total / (data.length / 4) / 255; + } + + const diagnostics = window.__godsEyeView.styleManager.getDetectionDiagnostics(); + return { + plated: [...byId.values()], + visibleCount: diagnostics?.visibleCount ?? null, + labeledKeys: diagnostics?.labeledKeys ?? null, + profile: diagnostics?.profile ?? null, + fillEvents: events.filter((event) => event.kind === 'fill').length, + }; + }, [...pose.expectPlated, ...pose.expectAbsent]); + + const labelledIds = (observed.labeledKeys || []) + .filter((key) => key.startsWith('flights:')) + .map((key) => key.slice('flights:'.length).toUpperCase()); + console.log( + ` diagnostics: profile=${observed.profile} visibleCount=${observed.visibleCount}` + + ` labelled=[${labelledIds.join(' ')}] fills=${observed.fillEvents}`, + ); + for (const entry of observed.plated) { + console.log( + ` ${entry.id.padEnd(8)} plateScale=${entry.plateScale.toFixed(4)}` + + ` (plate ${entry.plateAlpha.toFixed(4)} / accent ${entry.accentAlpha.toFixed(4)})` + + ` meanPixelAlpha=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(4)}`, + ); + } + + // 1. The overlay actually rendered something this pose. + record( + `${pose.name}: the overlay reports live rendered contacts`, + Number.isFinite(observed.visibleCount) && observed.visibleCount > 0, + `visibleCount=${observed.visibleCount}`, + ); + // 2. The contacts we expect to be labelled are labelled, by id. + const missingLabels = pose.expectPlated.filter((id) => !labelledIds.includes(id)); + record( + `${pose.name}: every expected contact reaches the label solve`, + missingLabels.length === 0, + missingLabels.length + ? `missing ${missingLabels.join(',')}` + : `${pose.expectPlated.length} labelled`, + ); + // 3. The contacts that must NOT be labelled are absent. + const unexpected = pose.expectAbsent.filter((id) => labelledIds.includes(id)); + record( + `${pose.name}: below-eye-level contacts stay out of the solve`, + unexpected.length === 0, + unexpected.length + ? `unexpectedly labelled ${unexpected.join(',')}` + : 'none labelled, as expected', + ); + // 4/5/6. Painted plate scale and pixels, per named contact. + if (pose.expectPlated.length > 0) { + const platedIds = observed.plated.map((entry) => entry.id); + const missingPaint = pose.expectPlated.filter((id) => !platedIds.includes(id)); + record( + `${pose.name}: the callout painter ran for every expected contact`, + missingPaint.length === 0, + missingPaint.length + ? `no paint recorded for ${missingPaint.join(',')}` + : `${platedIds.length} painted`, + ); + const offScale = observed.plated.filter( + (entry) => Math.abs(entry.plateScale - pose.expectScale) > PLATE_SCALE_TOLERANCE, + ); + record( + `${pose.name}: painted plate scale is ${pose.expectScale}`, + observed.plated.length > 0 && offScale.length === 0, + offScale.length + ? offScale.map((entry) => `${entry.id}=${entry.plateScale.toFixed(3)}`).join(' ') + : observed.plated.map((entry) => entry.plateScale.toFixed(3)).join(' '), + ); + const inked = observed.plated.filter((entry) => (entry.meanPixelAlpha ?? 0) > 0.01); + record( + `${pose.name}: the plate rect carries real pixels on the overlay canvas`, + observed.plated.length > 0 && inked.length === observed.plated.length, + observed.plated + .map((entry) => `${entry.id}=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(3)}`) + .join(' '), + ); + } + + perPose.push({ pose: pose.name, ...observed, labelledIds }); + const shot = path.join(SHOT_DIR, `${TAG}-${pose.name}.png`); + await page.screenshot({ path: shot }); + console.log(` shot: ${shot}`); + } + + // Cross-pose pixel comparison: the same theme, the same canvas, the same + // painter — only the backdrop differs, so sky plates must be lighter. + const skyInk = perPose.find((entry) => entry.pose === 'level')?.plated || []; + const groundInk = perPose.find((entry) => entry.pose === 'lifted')?.plated || []; + const meanOf = (rows) => (rows.length + ? rows.reduce((sum, entry) => sum + (entry.meanPixelAlpha ?? 0), 0) / rows.length + : null); + const skyMean = meanOf(skyInk); + const groundMean = meanOf(groundInk); + record( + 'sky-backed plates are measurably lighter on the canvas than ground-backed ones', + Number.isFinite(skyMean) && Number.isFinite(groundMean) && skyMean < groundMean, + `sky mean alpha ${skyMean === null ? 'n/a' : skyMean.toFixed(4)}` + + ` vs ground ${groundMean === null ? 'n/a' : groundMean.toFixed(4)}`, + ); + + record( + 'no page errors while the overlay ran at ground level', + consoleErrors.length === 0, + consoleErrors.slice(0, 3).join(' | ') || 'clean', + ); + } finally { + await browser.close(); + } + + fs.writeFileSync( + path.join(SHOT_DIR, `${TAG}-rendered.json`), + `${JSON.stringify({ + tag: TAG, + teeth: TEETH, + backend, + renderer, + cockpitHeight: COCKPIT_H, + poses: perPose, + }, null, 2)}\n`, + ); + + const failed = results.filter((entry) => !entry.ok); + console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`); + if (TEETH) { + // Under --teeth the run is EXPECTED to fail; a green run would mean the + // assertions do not depend on the painter and the harness is decorative. + const ok = failed.length > 0; + console.log(ok + ? ` \x1b[32mTEETH OK\x1b[0m — ${failed.length} check(s) went red with the painter stubbed:\n ${failed.map((entry) => entry.name).join('\n ')}\n` + : ' \x1b[31mTEETH FAILED\x1b[0m — every check passed with the painter stubbed; the harness proves nothing.\n'); + process.exit(ok ? 0 : 1); + } + process.exit(failed.length === 0 ? 0 : 1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs new file mode 100644 index 000000000..fa74b287e --- /dev/null +++ b/scripts/qa-cockpit-utility.mjs @@ -0,0 +1,2080 @@ +#!/usr/bin/env node +/** Focused rendered proof for adaptive Cockpit Display/Radio layout. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility'); +const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; +const headful = process.argv.includes('--headful'); +fs.mkdirSync(shotsDir, { recursive: true }); + +const chromeCandidates = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', +].filter(Boolean); +const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); +const browser = await puppeteer.launch({ + headless: headful ? false : 'new', + ...(executablePath ? { executablePath } : {}), + args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], +}); +const page = await browser.newPage(); +const failures = []; +const consoleErrors = []; +const localHttpErrors = []; +page.on('console', (message) => { + if (message.type() === 'error' && !/Failed to load resource.*404/i.test(message.text())) { + const source = message.location()?.url; + consoleErrors.push(source ? `${message.text()} [${source}]` : message.text()); + } +}); +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'; + if ( + url.origin === new URL(appUrl).origin + && response.status() >= 400 + && !expectedOptionalTrackMiss + ) { + localHttpErrors.push(`${response.status()} ${url.pathname}`); + } +}); + +const check = (name, passed, detail) => { + console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`); + if (!passed) failures.push(name); +}; + +try { + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + await page.setRequestInterception(true); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rows: [], + source: 'AISStream QA', + status: 'open', + error: null, + refreshing: false, + newestPositionAt: null, + lastMessageAt: null, + }), + }); + return; + } + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ msg: 'No error', now: Date.now(), ac: [] }), + }); + return; + } + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ path: [] }), + }); + return; + } + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military-installations') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + elements: [], + saturated: false, + elementCap: 1_500, + retrievedAt: new Date().toISOString(), + status: 'ready', + }), + }); + return; + } + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/openai/hud-summary') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ summary: 'Cockpit utility QA' }), + }); + return; + } + request.continue(); + }); + await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); + await page.waitForFunction( + () => document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60_000 }, + ); + await page.waitForFunction(() => ( + typeof window.__gevQaRegisterLayer === 'function' + && typeof window.__gevQaUnregisterLayer === 'function' + )); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const hud = document.getElementById('cockpit-hud'); + const signal = document.getElementById('cockpit-signal-stream'); + window.__qaCockpitUtilityPrior = { + cockpit: document.body.classList.contains('cockpit-mode'), + active: manager.cockpitView.active, + hudHidden: hud.hidden, + signalHidden: signal.hidden, + hudVisible: manager.hud.visible, + hudVariant: manager.hud.getVariant(), + utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), + }; + }); + const contactMissionHandoff = await page.evaluate(async () => { + const { styleManager, dataManager } = window.__godsEyeView; + const originalShowToast = styleManager._showToast; + const toasts = []; + styleManager._showToast = (message) => { toasts.push(String(message)); }; + try { + if (styleManager._contextMode) await styleManager._selectContextMode(null); + const contactsResult = await styleManager._selectContextMode('flights'); + const missionResult = await styleManager._selectContextMode('space-missions'); + const switched = { + contactsResult, + missionResult, + mode: styleManager._contextMode, + missionsEnabled: dataManager.isEnabled('rocket-launches'), + toasts: [...toasts], + }; + const exitResult = await styleManager._selectContextMode(null); + return { + ...switched, + exitResult, + modeAfterExit: styleManager._contextMode, + missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'), + }; + } finally { + styleManager._showToast = originalShowToast; + } + }); + check( + 'Contacts hands off to Space Missions on the first request without a failure toast', + contactMissionHandoff.contactsResult === true + && contactMissionHandoff.missionResult === true + && contactMissionHandoff.mode === 'space-missions' + && contactMissionHandoff.missionsEnabled + && contactMissionHandoff.toasts?.length === 0 + && contactMissionHandoff.exitResult === true + && contactMissionHandoff.modeAfterExit === null + && contactMissionHandoff.missionsOffAfterExit, + JSON.stringify(contactMissionHandoff), + ); + const cancelledMissionEntry = await page.evaluate(async () => { + const styleManager = window.__godsEyeView.styleManager; + const dataManager = window.__godsEyeView.dataManager; + const rocketEntry = dataManager.layers.get('rocket-launches'); + if (!rocketEntry || dataManager.isEffectivelyEnabled('rocket-launches')) { + return { exercised: false, reason: 'Space Missions was not in a clean OFF state' }; + } + + const siblingId = 'qa-cockpit-cancel-sibling'; + const siblingModule = { + id: siblingId, + name: 'QA cancellation sibling', + icon: 'science', + source: 'QA', + updateInterval: -1, + async init() { return true; }, + async enable() { return true; }, + async update() { return true; }, + async disable() { return true; }, + async destroy() {}, + getStats() { return { count: 1, status: 'live' }; }, + }; + window.__gevQaRegisterLayer(dataManager, siblingModule); + await dataManager.setEnabled(siblingId, true, { origin: 'programmatic' }); + + const originalInitialized = rocketEntry.initialized; + const originalLifecycle = rocketEntry.lifecycleState; + const originalMethods = { + init: rocketEntry.module.init, + enable: rocketEntry.module.enable, + update: rocketEntry.module.update, + disable: rocketEntry.module.disable, + }; + const originalShowToast = styleManager._showToast; + const toasts = []; + let releaseEnable; + let markEnableStarted; + const enableStarted = new Promise((resolve) => { markEnableStarted = resolve; }); + const enableGate = new Promise((resolve) => { releaseEnable = resolve; }); + rocketEntry.initialized = true; + rocketEntry.module.enable = async () => { + markEnableStarted(); + await enableGate; + return true; + }; + rocketEntry.module.update = async () => true; + rocketEntry.module.disable = async () => true; + styleManager._showToast = (message) => { toasts.push(String(message)); }; + + let transitionResult = null; + let timedOut = false; + try { + const controller = new AbortController(); + const transition = dataManager.setEnabled('rocket-launches', true, { + origin: 'user', + signal: controller.signal, + }); + await enableStarted; + controller.abort(new DOMException('QA caller cancellation', 'AbortError')); + releaseEnable(); + transitionResult = await transition; + const deadline = performance.now() + 5_000; + while (performance.now() < deadline) { + if ( + styleManager._contextModeEntering === null + && styleManager._contextSessionSnapshot === null + && dataManager.isEnabled(siblingId) + && !dataManager.isEffectivelyEnabled('rocket-launches') + ) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + timedOut = !( + styleManager._contextModeEntering === null + && styleManager._contextSessionSnapshot === null + && dataManager.isEnabled(siblingId) + && !dataManager.isEffectivelyEnabled('rocket-launches') + ); + return { + exercised: true, + transitionResult, + timedOut, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + siblingRestored: dataManager.isEnabled(siblingId), + missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), + toasts, + }; + } finally { + styleManager._showToast = originalShowToast; + rocketEntry.module.init = originalMethods.init; + rocketEntry.module.enable = originalMethods.enable; + rocketEntry.module.update = originalMethods.update; + rocketEntry.module.disable = originalMethods.disable; + rocketEntry.initialized = originalInitialized; + rocketEntry.lifecycleState = originalLifecycle; + await dataManager.setEnabled(siblingId, false, { origin: 'programmatic' }); + await window.__gevQaUnregisterLayer(dataManager, siblingId); + } + }); + check( + 'cancelled direct Space Missions entry restores its exact isolated sibling state', + cancelledMissionEntry.exercised + && cancelledMissionEntry.transitionResult === false + && !cancelledMissionEntry.timedOut + && cancelledMissionEntry.entering === null + && !cancelledMissionEntry.snapshotRetained + && cancelledMissionEntry.siblingRestored + && !cancelledMissionEntry.missionEffective, + JSON.stringify(cancelledMissionEntry), + ); + check( + 'successful Space Missions cancellation rollback stays silent', + cancelledMissionEntry.exercised && cancelledMissionEntry.toasts?.length === 0, + JSON.stringify(cancelledMissionEntry.toasts || []), + ); + const replacementMissionEntry = await page.evaluate(async () => { + const styleManager = window.__godsEyeView.styleManager; + const dataManager = window.__godsEyeView.dataManager; + const rocketEntry = dataManager.layers.get('rocket-launches'); + if (!rocketEntry || dataManager.isEffectivelyEnabled('rocket-launches')) { + return { exercised: false, reason: 'Space Missions was not in a clean OFF state' }; + } + const siblingId = 'qa-cockpit-replacement-sibling'; + window.__gevQaRegisterLayer(dataManager, { + id: siblingId, + name: 'QA replacement sibling', + icon: 'science', + source: 'QA', + updateInterval: -1, + async init() { return true; }, + async enable() { return true; }, + async update() { return true; }, + async disable() { return true; }, + async destroy() {}, + getStats() { return { count: 1, status: 'live' }; }, + }); + await dataManager.setEnabled(siblingId, true, { origin: 'programmatic' }); + const original = { + siblingInitialized: dataManager.layers.get(siblingId)?.initialized, + initialized: rocketEntry.initialized, + lifecycleState: rocketEntry.lifecycleState, + init: rocketEntry.module.init, + enable: rocketEntry.module.enable, + update: rocketEntry.module.update, + disable: rocketEntry.module.disable, + showToast: styleManager._showToast, + }; + let firstUpdate = true; + let releaseUpdate; + let markUpdateStarted; + const updateStarted = new Promise((resolve) => { markUpdateStarted = resolve; }); + const updateGate = new Promise((resolve) => { releaseUpdate = resolve; }); + const toasts = []; + rocketEntry.initialized = true; + rocketEntry.module.enable = async () => true; + rocketEntry.module.update = async () => { + if (!firstUpdate) return true; + firstUpdate = false; + markUpdateStarted(); + await updateGate; + return true; + }; + rocketEntry.module.disable = async () => true; + styleManager._showToast = (message) => { toasts.push(String(message)); }; + try { + const entry = styleManager._selectContextMode('space-missions'); + await updateStarted; + const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); + releaseUpdate(); + const [entryResult, replacementResult] = await Promise.all([entry, replacement]); + const committed = { + entryResult, + replacementResult, + mode: styleManager._contextMode, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), + missionEnabled: dataManager.isEnabled('rocket-launches'), + missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), + toasts: [...toasts], + }; + const exitResult = await styleManager._selectContextMode(null); + return { + exercised: true, + ...committed, + exitResult, + siblingRestored: dataManager.isEnabled(siblingId), + missionRestoredOff: !dataManager.isEffectivelyEnabled('rocket-launches'), + }; + } finally { + styleManager._showToast = original.showToast; + rocketEntry.module.init = original.init; + rocketEntry.module.enable = original.enable; + rocketEntry.module.update = original.update; + rocketEntry.module.disable = original.disable; + rocketEntry.initialized = original.initialized; + rocketEntry.lifecycleState = original.lifecycleState; + await dataManager.setEnabled(siblingId, false, { origin: 'programmatic' }); + await window.__gevQaUnregisterLayer(dataManager, siblingId); + } + }); + check( + 'right-rail Space Missions adopts a newer authoritative ON without stale restoration', + replacementMissionEntry.exercised + && replacementMissionEntry.entryResult === true + && replacementMissionEntry.replacementResult === true + && replacementMissionEntry.mode === 'space-missions' + && replacementMissionEntry.entering === null + && replacementMissionEntry.snapshotRetained + && replacementMissionEntry.siblingIsolated + && replacementMissionEntry.missionEnabled + && replacementMissionEntry.missionEffective + && replacementMissionEntry.toasts?.length === 0 + && replacementMissionEntry.exitResult === true + && replacementMissionEntry.siblingRestored + && replacementMissionEntry.missionRestoredOff, + JSON.stringify(replacementMissionEntry), + ); + const supersededMissionEntry = await page.evaluate(async () => { + const styleManager = window.__godsEyeView.styleManager; + const dataManager = window.__godsEyeView.dataManager; + const rocketEntry = dataManager.layers.get('rocket-launches'); + if (!rocketEntry || dataManager.isEffectivelyEnabled('rocket-launches')) { + return { exercised: false, reason: 'Space Missions was not in a clean OFF state' }; + } + const siblingId = 'qa-cockpit-superseded-sibling'; + window.__gevQaRegisterLayer(dataManager, { + id: siblingId, + name: 'QA superseded sibling', + icon: 'science', + source: 'QA', + updateInterval: -1, + async init() { return true; }, + async enable() { return true; }, + async update() { return true; }, + async disable() { return true; }, + async destroy() {}, + getStats() { return { count: 1, status: 'live' }; }, + }); + await dataManager.setEnabled(siblingId, true, { origin: 'programmatic' }); + const original = { + initialized: rocketEntry.initialized, + lifecycleState: rocketEntry.lifecycleState, + init: rocketEntry.module.init, + enable: rocketEntry.module.enable, + update: rocketEntry.module.update, + disable: rocketEntry.module.disable, + showToast: styleManager._showToast, + }; + let releaseUpdate; + let markUpdateStarted; + const updateStarted = new Promise((resolve) => { markUpdateStarted = resolve; }); + const updateGate = new Promise((resolve) => { releaseUpdate = resolve; }); + const toasts = []; + rocketEntry.initialized = true; + rocketEntry.module.enable = async () => true; + rocketEntry.module.update = async () => { + markUpdateStarted(); + await updateGate; + return true; + }; + rocketEntry.module.disable = async () => true; + styleManager._showToast = (message) => { toasts.push(String(message)); }; + try { + const entry = styleManager._runUserFacingContextAction( + (notificationToken) => styleManager._selectContextMode( + 'space-missions', + { notificationToken }, + ), + 'Space Missions could not complete the requested transition; try again', + ); + await updateStarted; + const supersedingOff = dataManager.setEnabled('rocket-launches', false, { + origin: 'programmatic', + }); + releaseUpdate(); + const [entryResult, offResult] = await Promise.all([entry, supersedingOff]); + return { + exercised: true, + entryResult, + offResult, + mode: styleManager._contextMode, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + siblingRestored: dataManager.isEnabled(siblingId), + missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), + toasts, + }; + } finally { + styleManager._showToast = original.showToast; + rocketEntry.module.init = original.init; + rocketEntry.module.enable = original.enable; + rocketEntry.module.update = original.update; + rocketEntry.module.disable = original.disable; + rocketEntry.initialized = original.initialized; + rocketEntry.lifecycleState = original.lifecycleState; + await dataManager.setEnabled(siblingId, false, { origin: 'programmatic' }); + await window.__gevQaUnregisterLayer(dataManager, siblingId); + } + }); + check( + 'right-rail Space Missions superseded by OFF restores exactly without a failure toast', + supersededMissionEntry.exercised + && supersededMissionEntry.entryResult === null + && supersededMissionEntry.mode === null + && supersededMissionEntry.entering === null + && !supersededMissionEntry.snapshotRetained + && supersededMissionEntry.siblingRestored + && !supersededMissionEntry.missionEffective + && supersededMissionEntry.toasts?.length === 0, + JSON.stringify(supersededMissionEntry), + ); + const voiceMissionEntry = await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + const siblingId = '__qa_voice_context_sibling__'; + const rocketEntry = dataManager.layers.get('rocket-launches'); + window.__gevQaRegisterLayer(dataManager, { + id: siblingId, + name: 'QA voice Context sibling', + icon: 'science', + source: 'QA', + updateInterval: -1, + async init() { return true; }, + async enable() { return true; }, + async update() { return true; }, + async disable() { return true; }, + async destroy() {}, + getStats() { return { count: 1, status: 'live' }; }, + }); + const original = { + initialized: rocketEntry.initialized, + lifecycleState: rocketEntry.lifecycleState, + init: rocketEntry.module.init, + enable: rocketEntry.module.enable, + update: rocketEntry.module.update, + disable: rocketEntry.module.disable, + }; + const siblingLayerEntry = dataManager.layers.get(siblingId); + siblingLayerEntry.initialized = true; + await dataManager.setEnabled(siblingId, true, { origin: 'programmatic' }); + rocketEntry.initialized = true; + rocketEntry.module.init = async () => true; + rocketEntry.module.enable = async () => true; + rocketEntry.module.update = async () => true; + rocketEntry.module.disable = async () => true; + try { + const voiceOn = await dataManager.setEnabled('rocket-launches', true, { origin: 'voice' }); + await new Promise((resolve) => setTimeout(resolve, 80)); + const voiceState = { + voiceOn, + mode: styleManager._contextMode, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), + }; + const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])]; + const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' }); + const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size; + await styleManager._waitForContextLayerSettlement(); + const restoredSiblingEntry = dataManager.layers.get(siblingId); + const voiceExit = { + voiceOff, + snapshotBeforeOff, + reactionCountAfterOff, + mode: styleManager._contextMode, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + siblingRestored: dataManager.isEffectivelyEnabled(siblingId), + siblingEnabled: restoredSiblingEntry?.enabled, + siblingLifecycle: restoredSiblingEntry?.lifecycleState, + restoreActive: Boolean(styleManager._contextRestoreState), + }; + + await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); + await new Promise((resolve) => setTimeout(resolve, 40)); + const internalState = { + mode: styleManager._contextMode, + entering: styleManager._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'), + }; + await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' }); + return { exercised: true, voiceState, voiceExit, internalState }; + } finally { + rocketEntry.module.init = original.init; + rocketEntry.module.enable = original.enable; + rocketEntry.module.update = original.update; + rocketEntry.module.disable = original.disable; + rocketEntry.initialized = original.initialized; + rocketEntry.lifecycleState = original.lifecycleState; + await dataManager.setEnabled(siblingId, false, { origin: 'programmatic' }); + siblingLayerEntry.initialized = original.siblingInitialized; + await window.__gevQaUnregisterLayer(dataManager, siblingId); + } + }); + check( + 'voice Space Missions uses the same Context snapshot and exact-restore transaction as UI entry', + voiceMissionEntry.exercised + && voiceMissionEntry.voiceState?.voiceOn + && voiceMissionEntry.voiceState.mode === 'space-missions' + && voiceMissionEntry.voiceState.entering === null + && voiceMissionEntry.voiceState.snapshotRetained + && voiceMissionEntry.voiceState.siblingIsolated + && voiceMissionEntry.voiceExit?.voiceOff + && voiceMissionEntry.voiceExit.mode === null + && voiceMissionEntry.voiceExit.entering === null + && !voiceMissionEntry.voiceExit.snapshotRetained + && voiceMissionEntry.voiceExit.siblingRestored, + JSON.stringify(voiceMissionEntry), + ); + check( + 'internal programmatic Space Missions activation does not create a user Context session', + voiceMissionEntry.internalState?.missionEnabled + && voiceMissionEntry.internalState.mode === null + && voiceMissionEntry.internalState.entering === null + && !voiceMissionEntry.internalState.snapshotRetained, + JSON.stringify(voiceMissionEntry), + ); + await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled( + 'flights', + true, + { origin: 'user' }, + )); + await page.waitForFunction(() => { + const layer = window.__godsEyeView?.dataManager?.layers?.get('flights')?.module; + return (layer?.getAllPositions?.(500) || []).some( + (candidate) => Number(candidate.altitudeM) > 1_000, + ); + }, { timeout: 60_000 }); + const tracked = await page.evaluate(() => { + const layer = window.__godsEyeView.dataManager.layers.get('flights')?.module; + const candidates = layer?.getAllPositions?.(500) || []; + const airborne = candidates.find((candidate) => Number(candidate.altitudeM) > 1_000); + return { + id: airborne?.id || null, + tracked: Boolean(airborne && layer.trackById?.(airborne.id)), + }; + }); + check('real airborne flight is tracked before Contacts activation', tracked.tracked, JSON.stringify(tracked)); + await page.waitForFunction( + () => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), + { timeout: 10_000 }, + ); + const preselectedContactAdoption = await page.evaluate(async () => { + const { styleManager, dataManager, viewer } = window.__godsEyeView; + const flights = dataManager.layers.get('flights')?.module; + await dataManager.setEnabled('military', true, { origin: 'programmatic' }); + const before = flights?.getTrackedInfo?.() || null; + const trackedEntityBefore = viewer.trackedEntity; + const blockerId = '__qa_slow_contacts_sibling__'; + const gateTimeoutMs = 5_000; + // The transition settles behind real dependency enables (network-bound), so its + // guard matches the harness's other network waits — a hang stop, not a budget. + const settleTimeoutMs = 60_000; + let releaseDisable = () => {}; + let reportDisableStarted = () => {}; + const disableGate = new Promise((resolve) => { releaseDisable = resolve; }); + const disableStarted = new Promise((resolve) => { reportDisableStarted = resolve; }); + // Every await in this probe is bounded: the gated sibling teardown is the + // behaviour under test, so a no-show has to fail the check with evidence + // rather than hang the whole harness on an unresolved promise. + const settleWithin = (promise, ms) => Promise.race([ + promise.then( + (value) => ({ settled: true, value }), + (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }), + ), + new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }), + ]); + window.__gevQaRegisterLayer(dataManager, { + id: blockerId, + name: 'QA slow Contacts sibling', + icon: 'science', + source: 'QA', + updateInterval: -1, + async init() { return true; }, + async enable() { return true; }, + async update() { return true; }, + async disable() { + reportDisableStarted(); + await disableGate; + return true; + }, + async destroy() {}, + getStats() { return { count: 1, status: 'live' }; }, + }); + let deferCleanup = false; + try { + const blockerEntry = dataManager.layers.get(blockerId); + blockerEntry.initialized = true; + await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' }); + const transition = styleManager._selectContextMode('flights'); + const disableStart = await settleWithin(disableStarted, gateTimeoutMs); + if (!disableStart.settled) { + releaseDisable(); + const abandoned = await settleWithin(transition, settleTimeoutMs); + return { + disableObserved: false, + beforeId: before?.icao24 || null, + timeoutReason: `QA blocker disable() never started within ${gateTimeoutMs}ms — ` + + 'the Contacts transition never tore its sibling down, so the pending-window ' + + 'probe could not be exercised', + transitionSettled: abandoned.settled, + transitionResult: abandoned.value, + }; + } + styleManager.cockpitView.syncEntry(); + const entry = document.getElementById('cockpit-entry'); + const pending = { + changing: styleManager._contextModeChanging, + entryHidden: Boolean(entry?.hidden), + enterResult: styleManager.cockpitView.enter(), + trackerPreserved: viewer.trackedEntity === trackedEntityBefore, + }; + releaseDisable(); + const settleStartedAt = performance.now(); + const settledTransition = await settleWithin(transition, settleTimeoutMs); + const settleMs = Math.round(performance.now() - settleStartedAt); + styleManager.cockpitView.syncEntry(); + const awareness = dataManager.layers.get('military-awareness')?.module; + const snapshot = awareness?.getContextSnapshot?.() || null; + const trackedInfo = flights?.getTrackedInfo?.() || null; + deferCleanup = true; + return { + disableObserved: true, + beforeId: before?.icao24 || null, + pending, + transitionSettled: settledTransition.settled, + transitionResult: settledTransition.value, + settleMs, + subject: snapshot?.subject ? { + layerId: snapshot.subject.layerId, + id: snapshot.subject.id, + } : null, + navigation: snapshot?.navigation || null, + afterId: trackedInfo?.icao24 || null, + viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null, + contextVisible: !document.getElementById('military-awareness-panel')?.hidden, + entryAvailableAfterSettlement: Boolean(entry && !entry.hidden), + blockerStillRegistered: dataManager.layers.has(blockerId), + blockerEffectivelyOff: !dataManager.isEffectivelyEnabled(blockerId), + }; + } finally { + // Never leave the injected layer (or a gated disable) behind — a failed + // probe must not poison the checks that run after it. + releaseDisable(); + if (!deferCleanup && dataManager.layers.has(blockerId)) { + if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null); + await window.__gevQaUnregisterLayer(dataManager, blockerId); + styleManager.cockpitView.syncEntry(); + } + } + }); + check( + 'Contacts blocks early Cockpit entry, then adopts the already tracked flight after settlement', + preselectedContactAdoption.disableObserved + && preselectedContactAdoption.transitionSettled + && preselectedContactAdoption.beforeId + && preselectedContactAdoption.pending?.changing + && preselectedContactAdoption.pending.entryHidden + && preselectedContactAdoption.pending.enterResult === false + && preselectedContactAdoption.pending.trackerPreserved + && preselectedContactAdoption.transitionResult === true + && preselectedContactAdoption.subject?.layerId === 'flights' + && String(preselectedContactAdoption.subject.id).toLowerCase() + === String(preselectedContactAdoption.beforeId).toLowerCase() + && String(preselectedContactAdoption.afterId).toLowerCase() + === String(preselectedContactAdoption.beforeId).toLowerCase() + && String(preselectedContactAdoption.viewerTrackedId).toLowerCase() + === `flights:${String(preselectedContactAdoption.beforeId).toLowerCase()}` + && preselectedContactAdoption.navigation?.canPrevious === false + && typeof preselectedContactAdoption.navigation?.canNext === 'boolean' + && preselectedContactAdoption.contextVisible + && preselectedContactAdoption.entryAvailableAfterSettlement + && preselectedContactAdoption.blockerStillRegistered + && preselectedContactAdoption.blockerEffectivelyOff, + JSON.stringify(preselectedContactAdoption), + ); + // The slow thing in the field is the FETCH, so that is what this holds — the + // REAL militaryInstallations module runs throughout and reports its own + // getStats(). An earlier cut stubbed `enable` and `getStats` instead. Both + // were wrong, and provably so: + // * `enable()` is SYNCHRONOUS in production (the module's own comment says + // "DataLayerManager invokes update() immediately after enable(), which + // owns the first fetch"), so gating it blocked the Contacts activation + // that brings the PANEL up. The scenario then sampled markup an earlier + // scenario had left behind, which is why it read a stale `0`. + // * The panel does not observe a `getStats` stub in this scenario at all — + // verified by making the stub report an ANSWERED source mid-window and + // watching every assertion stay green. A check written against a stub the + // panel cannot see has no teeth, which is the same defect this scenario + // was created to fix. + // Holding the network reproduces the field exactly and the panel tracks it, + // which is what gives these assertions teeth. + const deferredInstallationReadiness = await page.evaluate(async () => { + const { styleManager, dataManager } = window.__godsEyeView; + const entry = dataManager.layers.get('military-installations'); + if (!entry?.module) return { exercised: false, reason: 'military-installations missing' }; + const settleWithin = (promise, ms) => Promise.race([ + promise.then( + (value) => ({ settled: true, value }), + (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }), + ), + new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }), + ]); + const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')] + .find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations'); + const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null; + /** The REASON text, which is what tracks availability: the cohort engine + * answers 'feed unavailable' / 'feed stale' when it refuses to count. */ + const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null; + + const realFetch = window.fetch; + let released = false; + let requestSeen = false; + let transition = null; + try { + await styleManager._selectContextMode(null); + await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' }); + + // Hold the first Overpass request open, honouring the module's own + // AbortSignal so its camera-settle abort/refetch behaves as it does live. + window.fetch = (input, init) => { + const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url; + let url = null; + try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); } + if (url.pathname !== '/api/military-installations') return realFetch(input, init); + requestSeen = true; + const signal = init?.signal; + return new Promise((resolve, reject) => { + let done = false; + const fail = () => { + if (done) return; + done = true; + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }; + if (signal) { + if (signal.aborted) return fail(); + signal.addEventListener('abort', fail, { once: true }); + } + const tick = () => { + if (done) return; + if (released) { + done = true; + resolve(new Response( + JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )); + return; + } + setTimeout(tick, 100); + }; + tick(); + }); + }; + + transition = styleManager._selectContextMode('flights'); + // The activation promise stays PENDING while the first fetch is out, and + // the Contacts panel comes up behind it — that is what a deferred + // dependency is for. Verified live on :4272: the panel renders and reads + // a non-numeric count for the whole of a 17 s first fetch. + const started = await settleWithin((async () => { + for (let i = 0; i < 100 && !(requestSeen && row()); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return requestSeen && Boolean(row()); + })(), 12_000); + styleManager.cockpitView.syncEntry(); + const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations'); + const pendingCount = installationCount(); + const pendingReason = installationReason(); + + // THE WINDOW: first request outstanding, panel live. Sampled more than + // once — the field report was a panel sitting on a fabricated 0 for ~13 s, + // so a single lucky read is not evidence. + const fetchingCounts = []; + const fetchingReasons = []; + for (let i = 0; i < 6; i++) { + fetchingCounts.push(installationCount()); + fetchingReasons.push(installationReason()); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + released = true; + const contacts = await settleWithin(transition, 20_000); + styleManager.cockpitView.syncEntry(); + // Measured AFTER activation settles, not during it. The sibling pin above + // ("Contacts blocks early Cockpit entry, then adopts the already tracked + // flight after settlement") establishes that Contacts HIDES the entry + // while the transition is changing — that is the designed contract, and + // asserting the opposite (as this scenario used to) could never pass. + // What matters is that a slow dependency does not lock it away for good. + const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden; + const installed = await settleWithin((async () => { + while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + return true; + })(), 20_000); + const answered = await settleWithin((async () => { + for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + return true; + })(), 12_000); + + return { + exercised: true, + started, + contacts, + pendingLifecycle, + pendingCount, + pendingReason, + cockpitAvailableAfterActivation, + installed, + settledLifecycle: dataManager.getLayerLifecycleState('military-installations'), + fetchingCounts, + fetchingReasons, + answered, + settledCount: installationCount(), + settledReason: installationReason(), + }; + } finally { + released = true; + window.fetch = realFetch; + if (transition) await settleWithin(transition, 20_000); + if (styleManager._contextMode !== 'flights') { + await settleWithin(styleManager._selectContextMode('flights'), 15_000); + } + } + }); + // The panel has two truthful ways to say "nothing has answered yet" — the + // cohort count renders `?`, and an UNKNOWN relationship renders as that word. + // Which one is on screen depends on which panel surface is mounted, and the + // property under test is not the token: it is that the operator is NEVER + // shown a fabricated NUMBER for a source that has not answered. So these + // assert numeric-vs-not, which is exactly the field report. + const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value); + const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value); + check( + 'mapped installations load behind Contacts without a false all-clear or a locked Cockpit', + deferredInstallationReadiness.exercised + && deferredInstallationReadiness.started?.settled + && deferredInstallationReadiness.contacts?.settled + && deferredInstallationReadiness.contacts.value === true + && deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling' + && readsUnknown(deferredInstallationReadiness.pendingCount) + && /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason)) + && deferredInstallationReadiness.cockpitAvailableAfterActivation + && deferredInstallationReadiness.installed?.settled, + JSON.stringify(deferredInstallationReadiness), + ); + check( + 'installations still fetching behind a live Contacts panel never reads a number', + deferredInstallationReadiness.exercised + && deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling' + && Array.isArray(deferredInstallationReadiness.fetchingCounts) + && deferredInstallationReadiness.fetchingCounts.length > 0 + && deferredInstallationReadiness.fetchingCounts.every(readsUnknown) + && Array.isArray(deferredInstallationReadiness.fetchingReasons) + && deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))), + JSON.stringify({ + settledLifecycle: deferredInstallationReadiness.settledLifecycle, + fetchingCounts: deferredInstallationReadiness.fetchingCounts, + }), + ); + check( + 'the mapped-installations count appears once the first fetch actually answers', + deferredInstallationReadiness.exercised + && deferredInstallationReadiness.answered?.settled + && readsNumber(deferredInstallationReadiness.settledCount) + && !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)), + JSON.stringify({ + answered: deferredInstallationReadiness.answered, + settledCount: deferredInstallationReadiness.settledCount, + }), + ); + const locationContactHandoff = await page.evaluate(async () => { + const { styleManager, dataManager, viewer } = window.__godsEyeView; + const awareness = dataManager.layers.get('military-awareness')?.module; + const before = awareness?.getContextSnapshot?.()?.subject || null; + document.querySelector('#location-pills .location-pill')?.click(); + await new Promise((resolve) => setTimeout(resolve, 3600)); + const after = awareness?.getContextSnapshot?.()?.subject || null; + const released = !viewer.trackedEntity; + const refocused = awareness?.focusCurrent?.() === true; + await new Promise((resolve) => setTimeout(resolve, 120)); + const owningLayer = after?.layerId === 'militaryFlights' ? 'military-flights' : after?.layerId; + const trackedInfo = dataManager.layers.get(owningLayer)?.module?.getTrackedInfo?.() || null; + return { + before: before ? { layerId: before.layerId, id: before.id } : null, + after: after ? { layerId: after.layerId, id: after.id } : null, + mode: styleManager._contextMode, + released, + refocused, + trackedId: trackedInfo?.icao24 || trackedInfo?.id || null, + }; + }); + check( + 'Location moves independently while Contact selection persists and Focus restores it', + locationContactHandoff.before?.id + && locationContactHandoff.after?.id === locationContactHandoff.before.id + && locationContactHandoff.after?.layerId === locationContactHandoff.before.layerId + && locationContactHandoff.mode === 'flights' + && locationContactHandoff.released + && locationContactHandoff.refocused + && String(locationContactHandoff.trackedId).toLowerCase() + === String(locationContactHandoff.before.id).toLowerCase(), + JSON.stringify(locationContactHandoff), + ); + const zoomedOutContactRefocus = await page.evaluate(async () => { + const { dataManager, viewer } = window.__godsEyeView; + const awareness = dataManager.layers.get('military-awareness')?.module; + const before = awareness?.getContextSnapshot?.()?.subject || null; + const entityBefore = viewer.trackedEntity; + viewer.camera.zoomOut(1_500_000); + const cameraRange = () => Math.hypot( + viewer.camera.position.x, + viewer.camera.position.y, + viewer.camera.position.z, + ); + const zoomedRange = cameraRange(); + const refocused = awareness?.focusCurrent?.() === true; + await new Promise((resolve) => setTimeout(resolve, 180)); + const focusedRange = cameraRange(); + const after = awareness?.getContextSnapshot?.()?.subject || null; + return { + before: before ? { layerId: before.layerId, id: before.id } : null, + after: after ? { layerId: after.layerId, id: after.id } : null, + sameEntity: viewer.trackedEntity === entityBefore, + refocused, + zoomedRange: Math.round(zoomedRange), + focusedRange: Math.round(focusedRange), + }; + }); + check( + 'Contact Focus restores the canonical follow frame after a manual zoom-out', + zoomedOutContactRefocus.before?.id + && zoomedOutContactRefocus.after?.id === zoomedOutContactRefocus.before.id + && zoomedOutContactRefocus.after?.layerId === zoomedOutContactRefocus.before.layerId + && zoomedOutContactRefocus.sameEntity + && zoomedOutContactRefocus.refocused + && zoomedOutContactRefocus.zoomedRange > 1_000_000 + && zoomedOutContactRefocus.focusedRange < 50_000, + JSON.stringify(zoomedOutContactRefocus), + ); + await page.waitForFunction(() => { + const entry = document.getElementById('cockpit-entry'); + return entry && !entry.hidden && !entry.disabled; + }, { timeout: 10_000 }); + await page.$eval('#cockpit-entry', (entry) => entry.click()); + await page.waitForFunction( + () => document.body.classList.contains('cockpit-mode') + && window.__godsEyeView.styleManager.cockpitView.active + && !document.getElementById('cockpit-hud').hidden, + { timeout: 10_000 }, + ); + await page.waitForFunction( + () => { + // Require the node: `!missing?.hidden` is vacuously true, so a dropped + // #cockpit-context would sail past this gate instead of failing here. + const context = document.getElementById('cockpit-context'); + return Boolean(context) && !context.hidden; + }, + { timeout: 10_000 }, + ); + const firstCockpitContact = await page.evaluate(() => { + const awareness = window.__godsEyeView.dataManager.layers + .get('military-awareness')?.module; + const snapshot = awareness?.getContextSnapshot?.() || null; + const context = document.getElementById('cockpit-context'); + const subjectText = document.getElementById('cockpit-context-subject')?.textContent || ''; + const previous = document.getElementById('cockpit-context-previous'); + const next = document.getElementById('cockpit-context-next'); + const signalText = document.getElementById('cockpit-signal-list')?.textContent || ''; + return { + subject: snapshot?.subject ? { + layerId: snapshot.subject.layerId, + id: snapshot.subject.id, + label: snapshot.subject.label, + } : null, + contextVisible: Boolean(context && !context.hidden + && getComputedStyle(context).display !== 'none'), + subjectText, + previousDisabled: previous?.disabled, + nextDisabled: next?.disabled, + navigation: snapshot?.navigation || null, + contextStandby: signalText.includes('CONTEXT STANDBY'), + }; + }); + check( + 'first Cockpit entry shows matching Contact Previous/Next controls', + firstCockpitContact.contextVisible + && firstCockpitContact.subject?.layerId === 'flights' + && String(firstCockpitContact.subject.id).toLowerCase() + === String(tracked.id).toLowerCase() + && firstCockpitContact.subjectText.startsWith( + `${firstCockpitContact.subject.label} ·`, + ) + && firstCockpitContact.previousDisabled + === !firstCockpitContact.navigation?.canPrevious + && firstCockpitContact.nextDisabled + === !firstCockpitContact.navigation?.canNext + && !firstCockpitContact.contextStandby, + JSON.stringify(firstCockpitContact), + ); + // Field test 2026-08-18: "when you click on Contacts, detections should + // just turn on, and they should stay on in Cockpit or in third-person + // tracking inside Contacts or inside Cockpit, both… when I leave the Cockpit, + // detections go off" — that last part being the bug. Driven through the REAL + // context-mode transaction and the REAL CockpitViewController with a live + // tracked flight: the one path a Node unit test cannot boot. + const contactsDetection = await page.evaluate(async () => { + const { styleManager } = window.__godsEyeView; + const cockpit = styleManager.cockpitView; + const mode = () => styleManager.getDetectionState().detectionMode; + const density = () => styleManager.getDetectionState().densityPct; + const settle = (ms = 160) => new Promise((resolve) => setTimeout(resolve, ms)); + // Cockpit entry needs an auto-focused subject, which arrives a beat after + // the Contacts transaction settles. Bounded so a stall fails the check + // rather than hanging the harness. + const enterCockpit = async () => { + for (let attempt = 0; attempt < 40; attempt += 1) { + if (cockpit.enter() === true) return true; + await settle(200); + } + return false; + }; + const originalOverridden = styleManager._detectionUserOverridden; + + // Start from outside both, so activation is a real edge. + cockpit.exit({ restoreTracking: true }); + await settle(); + await styleManager._selectContextMode(null); + await settle(400); + const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null; + const cleanupBlockerId = '__qa_slow_contacts_sibling__'; + const cleanupUnregistered = await window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + cleanupBlockerId, + ); + const cleanupBlockerAbsent = !window.__godsEyeView.dataManager.layers.has(cleanupBlockerId); + // Adversarial precondition: detection explicitly OFF at a NON-tactical + // density, and the operator already flagged as having overridden detection + // this session — the flag that suppresses the military-style auto-enable. + styleManager.setDetection({ enabled: false, densityPct: 25 }); + await settle(); + const beforeContacts = mode(); + const overriddenBeforeContacts = styleManager._detectionUserOverridden; + + // 1. Activating Contacts forces the tactical preset on. + const contactsOn = await styleManager._selectContextMode('flights'); + await settle(400); + const afterContactsOn = mode(); + const afterContactsOnDensity = density(); + + // 2. Cockpit enter and exit are moves WITHIN the session: detection unchanged. + const entered = await enterCockpit(); + await settle(); + const insideCockpit = mode(); + cockpit.cycleVisionMode(1); + await settle(); + const afterVisionCycle = mode(); + cockpit.setVisionMode('optical'); + await settle(); + const exited = cockpit.exit({ restoreTracking: true }) === true; + await settle(); + const afterCockpitExit = mode(); + const afterCockpitExitDensity = density(); + + // 3. A manual off during the session holds — even across a cockpit re-entry. + document.getElementById('detection-toggle')?.click(); + await settle(); + const afterManualOff = mode(); + const reentered = await enterCockpit(); + await settle(); + const afterCockpitReentry = mode(); + cockpit.exit({ restoreTracking: true }); + await settle(); + + // 4. Deactivating Contacts restores the pre-Contacts state. + await styleManager._selectContextMode(null); + await settle(400); + const afterContactsOff = mode(); + const afterContactsOffDensity = density(); + // The operator's next manual enable must come back at THEIR profile, not + // the tactical density Contacts stamped on. + document.getElementById('detection-toggle')?.click(); + await settle(); + const manualEnableMode = mode(); + const manualEnableDensity = density(); + styleManager.setDetection({ enabled: false }); + await settle(); + + // Leave the session exactly as the following checks expect it. + const restoredContacts = await styleManager._selectContextMode('flights'); + await settle(400); + const restoredCockpit = await enterCockpit(); + styleManager._detectionUserOverridden = originalOverridden; + await settle(); + return { + beforeContacts, + overriddenBeforeContacts, + contactsOn, + afterContactsOn, + afterContactsOnDensity, + entered, + insideCockpit, + afterVisionCycle, + exited, + afterCockpitExit, + afterCockpitExitDensity, + afterManualOff, + reentered, + afterCockpitReentry, + afterContactsOff, + afterContactsOffDensity, + manualEnableMode, + manualEnableDensity, + restoredContacts, + restoredCockpit, + cockpitActive: cockpit.active, + cleanupSnapshotCleared, + cleanupUnregistered, + cleanupBlockerAbsent, + }; + }); + check( + 'Contacts owns detection: activation forces the tactical preset, cockpit transitions leave it alone, deactivation restores', + contactsDetection.contactsOn === true + && contactsDetection.entered + && contactsDetection.exited + && contactsDetection.reentered + && contactsDetection.restoredContacts === true + && contactsDetection.restoredCockpit + && contactsDetection.cockpitActive + && contactsDetection.cleanupSnapshotCleared + && contactsDetection.cleanupUnregistered + && contactsDetection.cleanupBlockerAbsent + && contactsDetection.beforeContacts === 'OFF' + && contactsDetection.overriddenBeforeContacts === true + // Activation forces the military look — Dense @ 75%, not the 25% the + // operator was sitting at, and regardless of the override flag. + && contactsDetection.afterContactsOn === 'DENSE' + && contactsDetection.afterContactsOnDensity === 75 + // Cockpit is a move WITHIN Contacts: enter, vision cycle and — the actual + // bug — EXIT must all leave detection exactly where it was. + && contactsDetection.insideCockpit === 'DENSE' + && contactsDetection.afterVisionCycle === 'DENSE' + && contactsDetection.afterCockpitExit === 'DENSE' + && contactsDetection.afterCockpitExitDensity === 75 + // A manual off holds for the session, including across a cockpit re-entry. + && contactsDetection.afterManualOff === 'OFF' + && contactsDetection.afterCockpitReentry === 'OFF' + // Leaving Contacts restores the pre-Contacts state — mode AND density, so + // the operator's next manual enable returns their own 25% profile rather + // than the tactical 75% Contacts stamped on. + && contactsDetection.afterContactsOff === 'OFF' + && contactsDetection.afterContactsOffDensity === 25 + && contactsDetection.manualEnableMode === 'SPARSE' + && contactsDetection.manualEnableDensity === 25, + JSON.stringify(contactsDetection), + ); + const densityNavigation = await page.evaluate(async () => { + const { styleManager, dataManager, viewer } = window.__godsEyeView; + const cockpit = styleManager.cockpitView; + const awareness = dataManager.layers.get('military-awareness')?.module; + const settle = (ms = 260) => new Promise((resolve) => setTimeout(resolve, ms)); + const snapshot = (step) => { + const context = awareness?.getContextSnapshot?.() || null; + const tracker = viewer.trackedEntity || cockpit.trackedEntity; + return { + step, + cockpitActive: cockpit.active, + bodyCockpit: document.body.classList.contains('cockpit-mode'), + contextMode: styleManager._contextMode, + contextChanging: styleManager._contextModeChanging, + subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, + tracked: tracker?.gevTrackedId || null, + density: styleManager.getDetectionState().densityPct, + detectionMode: styleManager.getDetectionState().detectionMode, + installations: dataManager.getLayerLifecycleState('military-installations'), + }; + }; + const setDensityFromUi = (value) => { + const slider = document.getElementById('detection-density-slider'); + slider.value = String(value); + slider.dispatchEvent(new Event('input', { bubbles: true })); + }; + + const states = [snapshot('before')]; + setDensityFromUi(25); + states.push(snapshot('sparse')); + document.getElementById('cockpit-context-next')?.click(); + await settle(1700); + states.push(snapshot('ui-next')); + setDensityFromUi(75); + document.getElementById('cockpit-context-previous')?.click(); + await settle(1700); + states.push(snapshot('ui-previous')); + const voiceNext = styleManager.controlCockpit('next'); + await settle(1700); + states.push(snapshot('voice-next')); + const voicePrevious = styleManager.controlCockpit('previous'); + await settle(1700); + states.push(snapshot('voice-previous')); + + const exited = cockpit.exit({ restoreTracking: true }) === true; + await settle(100); + const mapBefore = snapshot('map-before'); + const mapNext = awareness?.navigateNext?.({ origin: 'user' }) === true; + await settle(); + const mapAfterNext = snapshot('map-next'); + const mapPrevious = awareness?.navigatePrevious?.({ origin: 'user' }) === true; + await settle(); + const mapAfterPrevious = snapshot('map-previous'); + const reentered = cockpit.enter() === true; + await settle(100); + states.push(snapshot('reentered')); + return { + states, + voiceNext: voiceNext.ok, + voicePrevious: voicePrevious.ok, + exited, + mapBefore, + mapNext, + mapAfterNext, + mapPrevious, + mapAfterPrevious, + reentered, + }; + }); + const cockpitStatesStable = densityNavigation.states.every((state) => ( + state.cockpitActive + && state.bodyCockpit + && state.contextMode === 'flights' + && !state.contextChanging + && state.subject + && state.tracked + )); + check( + 'Dense/Sparse UI and voice Next/Previous keep Cockpit active and standard Flights tracking stable', + cockpitStatesStable + && densityNavigation.states.find(({ step }) => step === 'sparse')?.density === 25 + && densityNavigation.states.find(({ step }) => step === 'ui-next')?.density === 25 + && densityNavigation.states.find(({ step }) => step === 'ui-previous')?.density === 75 + && densityNavigation.voiceNext + && densityNavigation.voicePrevious + && densityNavigation.exited + && !densityNavigation.mapBefore.cockpitActive + && densityNavigation.mapBefore.contextMode === 'flights' + && densityNavigation.mapNext + && densityNavigation.mapAfterNext.tracked + && densityNavigation.mapPrevious + && densityNavigation.mapAfterPrevious.tracked + && densityNavigation.reentered, + JSON.stringify(densityNavigation), + ); + const cockpitExitOwnership = await page.evaluate(async () => { + const { styleManager, dataManager, viewer } = window.__godsEyeView; + const awareness = dataManager.layers.get('military-awareness')?.module; + const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity; + const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; + const exited = styleManager.cockpitView.exit() === true; + await new Promise((resolve) => setTimeout(resolve, 80)); + const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; + const refocused = awareness?.focusCurrent?.() === true; + await new Promise((resolve) => setTimeout(resolve, 80)); + return { + exited, + refocused, + sameEntity: viewer.trackedEntity === entity, + listenersBeforeExit, + listenersAfterExit, + listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners, + }; + }); + check( + 'Cockpit exit and later Contact Focus retain one source-owned camera-frame listener', + cockpitExitOwnership.exited + && cockpitExitOwnership.refocused + && cockpitExitOwnership.sameEntity + && cockpitExitOwnership.listenersAfterExit === cockpitExitOwnership.listenersBeforeExit + 1 + && cockpitExitOwnership.listenersAfterFocus === cockpitExitOwnership.listenersAfterExit, + JSON.stringify(cockpitExitOwnership), + ); + const cockpitPanelRoundTrip = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const panelIds = [ + 'data-panel', + 'cctv-panel', + 'scene-panel', + 'pp-toggles', + 'global-context-panel', + 'radio-panel', + ]; + const read = () => Object.fromEntries(panelIds.map((panelId) => [ + panelId, + document.getElementById(panelId)?.classList.contains('collapsed'), + ])); + const desired = new Map([ + ['data-panel', false], + ['cctv-panel', true], + ['scene-panel', true], + ['pp-toggles', true], + ['global-context-panel', false], + ['radio-panel', true], + ]); + for (const [panelId, collapsed] of desired) { + manager.setPanelCollapsed(panelId, collapsed, { persist: false, syncShare: false }); + } + const before = read(); + document.getElementById('cockpit-entry')?.click(); + await new Promise((resolve) => setTimeout(resolve, 120)); + const during = read(); + manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const dataRect = document.getElementById('data-panel')?.getBoundingClientRect(); + const contactRect = document.getElementById('cockpit-context')?.getBoundingClientRect(); + const clearancePx = dataRect && contactRect ? contactRect.top - dataRect.bottom : null; + const contactAutoCollapsed = manager.cockpitView.contextCollapsed === true; + manager.setPanelCollapsed('data-panel', true, { persist: false, syncShare: false }); + const contactAutoRestored = manager.cockpitView.contextCollapsed === false; + manager.cockpitView.setContextCollapsed(true); + manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }); + manager.setPanelCollapsed('data-panel', true, { persist: false, syncShare: false }); + const manualContactCollapseRetained = manager.cockpitView.contextCollapsed === true; + manager.cockpitView.setContextCollapsed(false); + const exited = manager.cockpitView.exit() === true; + await new Promise((resolve) => setTimeout(resolve, 80)); + return { + before, + during, + after: read(), + exited, + clearancePx, + contactAutoCollapsed, + contactAutoRestored, + manualContactCollapseRetained, + }; + }); + check( + 'Cockpit temporarily collapses standard panels and restores the exact map layout on exit', + cockpitPanelRoundTrip.exited + && Object.values(cockpitPanelRoundTrip.during).every(Boolean) + && JSON.stringify(cockpitPanelRoundTrip.after) + === JSON.stringify(cockpitPanelRoundTrip.before) + && Number.isFinite(cockpitPanelRoundTrip.clearancePx) + && cockpitPanelRoundTrip.clearancePx >= 0 + && cockpitPanelRoundTrip.contactAutoCollapsed + && cockpitPanelRoundTrip.contactAutoRestored + && cockpitPanelRoundTrip.manualContactCollapseRetained, + JSON.stringify(cockpitPanelRoundTrip), + ); + await page.waitForFunction(() => { + const entry = document.getElementById('cockpit-entry'); + return entry && !entry.hidden && !entry.disabled; + }, { timeout: 10_000 }); + await page.$eval('#cockpit-entry', (entry) => entry.click()); + await page.waitForFunction( + () => document.body.classList.contains('cockpit-mode') + && window.__godsEyeView.styleManager.cockpitView.active, + { timeout: 10_000 }, + ); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', false); + }); + + const visionCycle = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const cockpit = manager.cockpitView; + const current = document.getElementById('cockpit-vision-current'); + const label = document.getElementById('cockpit-vision-current-label'); + const read = () => ({ + mode: current?.dataset.cockpitVision, + label: label?.textContent?.trim(), + aria: current?.getAttribute('aria-label'), + displayOpen: document.getElementById('cockpit-display-toggle-btn')?.getAttribute('aria-expanded'), + radioOpen: document.getElementById('cockpit-radio-toggle-btn')?.getAttribute('aria-expanded'), + parametersActive: document.getElementById('param-slider-panel')?.classList.contains('active'), + parametersCollapsed: document.getElementById('param-slider-panel')?.classList.contains('collapsed'), + signalCollapsed: cockpit.signalCollapsed, + focusRetained: document.activeElement === current, + }); + 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) { + current.click(); + states.push(read()); + } + const originalStyle = manager.activeStyle; + manager.setStyle('noir'); + await new Promise((resolve) => setTimeout(resolve, 900)); + 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, + }; + manager.setStyle(originalStyle); + await new Promise((resolve) => setTimeout(resolve, 900)); + cockpit.setVisionMode('optical'); + return { states, inheritedNoir, explicitNoir, restoredNoir }; + }); + 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), + ); + 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.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' + && visionCycle.restoredNoir?.activeStyle === 'noir' + && visionCycle.restoredNoir?.intensity === 1, + JSON.stringify(visionCycle), + ); + await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('noir')); + await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') }); + await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical')); + + const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => { + const manager = window.__godsEyeView.styleManager; + const hud = document.getElementById('cockpit-hud'); + const signal = document.getElementById('cockpit-signal-stream'); + const utility = document.getElementById('cockpit-utility-controls'); + const display = document.getElementById('cockpit-display-toggle-btn'); + const radio = document.getElementById('cockpit-radio-toggle-btn'); + const displayControl = display.closest('.cockpit-utility-control'); + const radioControl = radio.closest('.cockpit-utility-control'); + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', false); + if (variantName === 'off') manager.hud.setMode('off'); + else { + manager._setHudVariant(variantName); + manager.hud.setMode('on'); + } + manager._updateHudButtonState(); + await new Promise((resolve) => setTimeout(resolve, 360)); + document.body.classList.add('cockpit-mode'); + manager.cockpitView.active = true; + hud.hidden = false; + signal.hidden = false; + // Cockpit owns --cockpit-utility-top and republishes it every layout tick, + // so the corridor is driven through its real input: how far up the briefing + // card reaches. A short card leaves the strip a roomy corridor. + const priorSignalMaxHeight = signal.style.maxHeight; + signal.style.maxHeight = '150px'; + manager._setCockpitDisclosure(kind, true); + // One pass must be enough. Record the result of the FIRST layout call, then + // confirm a second changes nothing, so a convergence regression cannot hide + // behind a repeated call. + manager.cockpitView.syncSignalLayout(); + const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top'); + const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only'); + manager.cockpitView.syncSignalLayout(); + const signalTop = signal.getBoundingClientRect().top; + const expanded = kind === 'display' ? displayControl : radioControl; + const sibling = kind === 'display' ? radioControl : displayControl; + const state = { + variant: variantName, + kind, + signalTop: Math.round(signalTop), + firstPassTop, + idempotent: firstPassTop === hud.style.getPropertyValue('--cockpit-utility-top') + && firstPassPrimaryOnly === utility.classList.contains('layout-primary-only'), + utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), + primaryOnly: utility.classList.contains('layout-primary-only'), + expandedRect: expanded.getBoundingClientRect().toJSON(), + siblingRect: sibling.getBoundingClientRect().toJSON(), + siblingDisplay: getComputedStyle(sibling).display, + siblingAriaHidden: sibling.getAttribute('aria-hidden'), + siblingExpanded: sibling.querySelector('[aria-expanded]')?.getAttribute('aria-expanded'), + contained: expanded.getBoundingClientRect().bottom <= signalTop - 7, + }; + signal.style.maxHeight = priorSignalMaxHeight; + manager.cockpitView.syncSignalLayout(); + return state; + }, { variantName: variant, kind: openKind }); + + for (const variant of ['off', 'minimal', 'tactical', 'operator']) { + for (const kind of ['display', 'radio']) { + const state = await desktopState(variant, kind); + check( + `${variant} ${kind} keeps its collapsed sibling visible in a roomy corridor`, + !state.primaryOnly + && state.expandedRect.width > 0 + && state.siblingRect.width > 0 + && state.siblingRect.height > 0 + && state.siblingDisplay !== 'none' + && state.siblingAriaHidden === 'false' + && state.siblingExpanded === 'false' + && state.contained, + JSON.stringify(state), + ); + check( + `${variant} ${kind} settles the strip anchor in one layout pass`, + state.idempotent && state.firstPassTop === state.utilityTop, + `first=${state.firstPassTop} settled=${state.utilityTop}`, + ); + } + } + await page.screenshot({ path: path.join(shotsDir, 'roomy-desktop.png') }); + check( + 'roomy screenshot remains in a real Cockpit session', + await page.evaluate(() => document.body.classList.contains('cockpit-mode') + && window.__godsEyeView.styleManager.cockpitView.active + && getComputedStyle(document.getElementById('cockpit-utility-controls')).display !== 'none'), + ); + + const portalScroll = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const standard = document.getElementById('pp-toggles'); + const cockpit = document.getElementById('cockpit-display-panel'); + const waitFrames = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const priorStandardMaxHeight = standard.style.maxHeight; + const priorStandardHeight = standard.style.height; + const priorStandardOverflow = standard.style.overflowY; + const priorCockpitMaxHeight = cockpit.style.maxHeight; + const priorCockpitHeight = cockpit.style.height; + const priorCockpitOverflow = cockpit.style.overflowY; + const standardWasCollapsed = standard.classList.contains('collapsed'); + manager._setCockpitDisplayPortalActive(false); + document.body.classList.remove('cockpit-mode'); + await waitFrames(); + standard.classList.remove('collapsed'); + standard.style.height = '120px'; + standard.style.maxHeight = '120px'; + standard.style.overflowY = 'auto'; + standard.scrollTop = Math.min(80, standard.scrollHeight - standard.clientHeight); + await waitFrames(); + const standardBefore = standard.scrollTop; + document.body.classList.add('cockpit-mode'); + manager._setCockpitDisplayPortalActive(true); + manager._setCockpitDisclosure('display', true); + await waitFrames(); + cockpit.style.height = '120px'; + cockpit.style.maxHeight = '120px'; + cockpit.style.overflowY = 'auto'; + cockpit.scrollTop = Math.min(60, cockpit.scrollHeight - cockpit.clientHeight); + await waitFrames(); + const cockpitBefore = cockpit.scrollTop; + document.body.classList.remove('cockpit-mode'); + manager._setCockpitDisplayPortalActive(false); + await waitFrames(); + const standardAfter = standard.scrollTop; + const standardSaved = manager._standardDisplayScrollTop; + const standardClientHeight = standard.clientHeight; + const standardScrollHeight = standard.scrollHeight; + document.body.classList.add('cockpit-mode'); + manager._setCockpitDisplayPortalActive(true); + manager._setCockpitDisclosure('display', true); + await waitFrames(); + const cockpitAfter = cockpit.scrollTop; + standard.style.height = priorStandardHeight; + standard.style.maxHeight = priorStandardMaxHeight; + standard.style.overflowY = priorStandardOverflow; + cockpit.style.height = priorCockpitHeight; + cockpit.style.maxHeight = priorCockpitMaxHeight; + cockpit.style.overflowY = priorCockpitOverflow; + standard.classList.toggle('collapsed', standardWasCollapsed); + return { + standardBefore, + standardAfter, + standardSaved, + standardClientHeight, + standardScrollHeight, + cockpitBefore, + cockpitAfter, + }; + }); + check( + 'Display portal round trip preserves standard and Cockpit scroll owners', + portalScroll.standardBefore > 0 + && portalScroll.cockpitBefore > 0 + && portalScroll.standardAfter === portalScroll.standardBefore + && portalScroll.cockpitAfter === portalScroll.cockpitBefore, + JSON.stringify(portalScroll), + ); + + const boundary = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const hud = document.getElementById('cockpit-hud'); + const signal = document.getElementById('cockpit-signal-stream'); + const utility = document.getElementById('cockpit-utility-controls'); + const display = document.getElementById('cockpit-display-toggle-btn'); + const radio = document.getElementById('cockpit-radio-toggle-btn'); + const displayControl = display.closest('.cockpit-utility-control'); + const radioControl = radio.closest('.cockpit-utility-control'); + document.body.classList.add('cockpit-mode'); + manager.cockpitView.active = true; + hud.hidden = false; + signal.hidden = false; + manager._setCockpitDisclosure('radio', false); + manager._setCockpitDisclosure('display', true); + const priorSignalMaxHeight = signal.style.maxHeight; + const priorSignalHeight = signal.style.height; + // The strip is pinned to the viewport ceiling once the briefing card grows + // into its lane, so the corridor is sized by moving that card's top edge — + // the wall Cockpit actually solves against — not by injecting an anchor. + // The card is bottom-anchored, so an explicit height moves its top edge. + const minTop = Math.max(96, window.innerHeight * 0.12); + const moveSignalTopTo = (wantTop) => { + for (let pass = 0; pass < 8; pass += 1) { + const rect = signal.getBoundingClientRect(); + const delta = wantTop - rect.top; + if (Math.abs(delta) < 0.2) break; + const current = parseFloat(signal.style.height) || rect.height; + signal.style.height = `${Math.max(40, current - delta)}px`; + } + // One layout pass owns the decision; the second only proves it settled. + // (A pass that newly hides the collapsed sibling legitimately re-anchors + // the now-shorter strip, so idempotence is asserted where the strip's + // composition is unchanged.) + manager.cockpitView.syncSignalLayout(); + const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top'); + const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only'); + manager.cockpitView.syncSignalLayout(); + return { + firstPassTop, + firstPassPrimaryOnly, + idempotent: firstPassTop === hud.style.getPropertyValue('--cockpit-utility-top') + && firstPassPrimaryOnly === utility.classList.contains('layout-primary-only'), + }; + }; + signal.style.maxHeight = 'none'; + signal.style.height = '150px'; + manager.cockpitView.syncSignalLayout(); + const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height); + const collapsedHeight = Math.max(50, radioControl.scrollHeight); + const stripHeight = expandedHeight + collapsedHeight + 7; + // Straddle the fit by a pixel instead of aiming AT it. `moveSignalTopTo` only + // converges to within 0.2 px and the production decision is a strict `>` + // against exactly that budget, so a corridor sized to the boundary itself + // resolves on sub-pixel residue — a coin flip, not a behaviour. It happened to + // land heads until Display grew 36 px on 2026-08-22 (the 3D Proximity/All row + // now ships open), which is what surfaced it. One pixel inside the fit and one + // pixel outside proves the same transition, deterministically. + const BOUNDARY_EPSILON_PX = 1; + const exactSignalTop = minTop + 8 + stripHeight + BOUNDARY_EPSILON_PX; + // The boundary is only meaningful when approached from the TWO-control + // composition. The anchor is solved against the strip's currently RENDERED + // height, so arriving here already collapsed to primary-only makes the strip + // short, pushes the resolved top above minTop, and the "exact" corridor is + // then never evaluated at the boundary at all — the check would fail while + // production is behaving correctly. + // + // The fixed `height: 150px` card seed above used to guarantee that; it stopped + // once Display grew (the 3D Proximity/All row ships open from 2026-08-22, +36 px), + // because 150 px of card leaves a corridor sized for the OLD strip. Seed from + // the measured requirement instead, which holds for any future panel height, + // and assert the precondition rather than assuming it. + const roomySeed = moveSignalTopTo(exactSignalTop + 80); + const roomySeedExpanded = !utility.classList.contains('layout-primary-only') + && roomySeed.firstPassPrimaryOnly === false; + const exact = { + ...moveSignalTopTo(exactSignalTop), + utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), + primaryOnly: utility.classList.contains('layout-primary-only'), + siblingVisible: getComputedStyle(radioControl).display !== 'none', + siblingAriaHidden: radioControl.getAttribute('aria-hidden'), + }; + radio.focus({ preventScroll: true }); + const constrained = { + // One pixel on the far side of the same fit (`exactSignalTop` already sits + // one pixel inside it), so this is still the one-pixel constraint it claims. + ...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX), + utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), + primaryOnly: utility.classList.contains('layout-primary-only'), + siblingDisplay: getComputedStyle(radioControl).display, + siblingRect: radioControl.getBoundingClientRect().toJSON(), + siblingAriaHidden: radioControl.getAttribute('aria-hidden'), + focusReturned: document.activeElement === display, + contained: displayControl.getBoundingClientRect().bottom + <= signal.getBoundingClientRect().top - 7, + }; + const restored = { + ...moveSignalTopTo(exactSignalTop + 80), + utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), + primaryOnly: utility.classList.contains('layout-primary-only'), + siblingVisible: getComputedStyle(radioControl).display !== 'none', + siblingAriaHidden: radioControl.getAttribute('aria-hidden'), + }; + signal.style.maxHeight = priorSignalMaxHeight; + signal.style.height = priorSignalHeight; + manager.cockpitView.syncSignalLayout(); + return { minTop, stripHeight, exactSignalTop, roomySeedExpanded, exact, constrained, restored }; + }); + check( + 'exact fit keeps the sibling; one-pixel constraint hides it, transfers focus, and restores it', + boundary.roomySeedExpanded + && !boundary.exact.primaryOnly + && boundary.exact.siblingVisible + && boundary.exact.siblingAriaHidden === 'false' + && boundary.constrained.primaryOnly + && boundary.constrained.siblingDisplay === 'none' + && boundary.constrained.siblingRect.width === 0 + && boundary.constrained.siblingAriaHidden === 'true' + && boundary.constrained.focusReturned + && boundary.constrained.contained + && !boundary.restored.primaryOnly + && boundary.restored.siblingVisible + && boundary.restored.siblingAriaHidden === 'false', + JSON.stringify(boundary), + ); + check( + 'each corridor change is decided by the first layout pass, not a repeated one', + boundary.exact.idempotent + && boundary.restored.idempotent + && boundary.constrained.firstPassPrimaryOnly, + JSON.stringify({ + exact: { first: boundary.exact.firstPassTop, idempotent: boundary.exact.idempotent }, + constrained: { + first: boundary.constrained.firstPassTop, + firstPassPrimaryOnly: boundary.constrained.firstPassPrimaryOnly, + }, + restored: { first: boundary.restored.firstPassTop, idempotent: boundary.restored.idempotent }, + }), + ); + + const signalTransition = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const cockpit = manager.cockpitView; + const signalToggle = document.getElementById('cockpit-signal-toggle'); + let expandedEvents = 0; + const onExpanded = () => { expandedEvents += 1; }; + cockpit.signalUserCollapsed = false; + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', false); + cockpit.setSignalCollapsed(true); + window.addEventListener('gev:cockpit-signal-expanded', onExpanded); + try { + cockpit.setSignalCollapsed(false); + const afterExpansion = expandedEvents; + cockpit.setSignalCollapsed(false); + const afterRepeatedExpansion = expandedEvents; + + manager._setCockpitDisclosure('display', true); + const displayCollapsedSignals = cockpit.signalCollapsed; + manager._setCockpitDisclosure('display', false); + const afterUtilityClose = expandedEvents; + const utilityCloseReopenedSignals = !cockpit.signalCollapsed; + + cockpit.setSignalCollapsed(true, { user: true }); + manager._setCockpitDisclosure('radio', true); + manager._setCockpitDisclosure('radio', false); + const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed; + return { + afterExpansion, + afterRepeatedExpansion, + afterUtilityClose, + displayCollapsedSignals, + utilityCloseReopenedSignals, + manualCollapseRetained, + signalAriaExpanded: signalToggle?.getAttribute('aria-expanded'), + }; + } finally { + window.removeEventListener('gev:cockpit-signal-expanded', onExpanded); + cockpit.signalUserCollapsed = false; + cockpit.setSignalCollapsed(false); + } + }); + check( + 'Live Signals expansion is edge-triggered and manual collapse remains authoritative', + signalTransition.afterExpansion === 1 + && signalTransition.afterRepeatedExpansion === 1 + && signalTransition.afterUtilityClose === 2 + && signalTransition.displayCollapsedSignals + && signalTransition.utilityCloseReopenedSignals + && signalTransition.manualCollapseRetained + && signalTransition.signalAriaExpanded === 'false', + JSON.stringify(signalTransition), + ); + const contextTransition = await page.evaluate(() => { + const cockpit = window.__godsEyeView.styleManager.cockpitView; + const priorCollapsed = cockpit.contextCollapsed; + const contextToggle = document.getElementById('cockpit-context-toggle'); + let expandedEvents = 0; + const onExpanded = () => { expandedEvents += 1; }; + cockpit.setContextCollapsed(true); + window.addEventListener('gev:cockpit-context-expanded', onExpanded); + try { + cockpit.setContextCollapsed(false); + const afterExpansion = expandedEvents; + cockpit.setContextCollapsed(false); + return { + afterExpansion, + afterRepeatedExpansion: expandedEvents, + ariaExpanded: contextToggle?.getAttribute('aria-expanded'), + label: contextToggle?.getAttribute('aria-label'), + }; + } finally { + window.removeEventListener('gev:cockpit-context-expanded', onExpanded); + cockpit.setContextCollapsed(priorCollapsed); + } + }); + check( + 'Contact expansion is edge-triggered and retains its disclosure semantics', + contextTransition.afterExpansion === 1 + && contextTransition.afterRepeatedExpansion === 1 + && contextTransition.ariaExpanded === 'true' + && contextTransition.label === 'Collapse Contact panel', + JSON.stringify(contextTransition), + ); + await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') }); + check( + 'restored screenshot remains in a real Cockpit session', + await page.evaluate(() => document.body.classList.contains('cockpit-mode') + && window.__godsEyeView.styleManager.cockpitView.active + && getComputedStyle(document.getElementById('cockpit-utility-controls')).display !== 'none'), + ); + const desktopViewActions = await page.evaluate(() => { + const reset = document.getElementById('cockpit-reset-globe')?.getBoundingClientRect(); + const exit = document.getElementById('map-view-switch')?.getBoundingClientRect(); + return { + reset: reset?.toJSON() || null, + exit: exit?.toJSON() || null, + viewport: { width: innerWidth, height: innerHeight }, + }; + }); + check( + 'desktop Cockpit Reset and Exit remain readable without overlap', + desktopViewActions.reset?.width > 44 + && desktopViewActions.exit?.width > 70 + && desktopViewActions.reset.right <= desktopViewActions.exit.left + && desktopViewActions.reset.left >= 0 + && desktopViewActions.exit.right <= desktopViewActions.viewport.width, + JSON.stringify(desktopViewActions), + ); + + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); + const mobile = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const hud = document.getElementById('cockpit-hud'); + const signal = document.getElementById('cockpit-signal-stream'); + const display = document.getElementById('cockpit-display-toggle-btn'); + const radio = document.getElementById('cockpit-radio-toggle-btn'); + const displayControl = display.closest('.cockpit-utility-control'); + const radioControl = radio.closest('.cockpit-utility-control'); + document.body.classList.add('cockpit-mode'); + manager.cockpitView.active = true; + hud.hidden = false; + signal.hidden = false; + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', false); + manager.cockpitView.syncSignalLayout(); + const collapsed = { + display: displayControl.getBoundingClientRect().toJSON(), + radio: radioControl.getBoundingClientRect().toJSON(), + }; + display.click(); + manager.cockpitView.syncSignalLayout(); + return { + collapsed, + expandedDisplay: displayControl.getBoundingClientRect().toJSON(), + radioDisplay: getComputedStyle(radioControl).display, + radioAriaHidden: radioControl.getAttribute('aria-hidden'), + }; + }); + check( + 'mobile keeps both collapsed launchers and the existing fixed expanded behavior', + mobile.collapsed.display.width > 0 + && mobile.collapsed.radio.width > 0 + && mobile.expandedDisplay.width > 0 + && mobile.expandedDisplay.width <= 366 + && mobile.radioDisplay === 'none' + && mobile.radioAriaHidden === 'true', + JSON.stringify(mobile), + ); + await page.screenshot({ path: path.join(shotsDir, 'mobile.png') }); + check( + 'mobile screenshot remains in a real Cockpit session', + await page.evaluate(() => document.body.classList.contains('cockpit-mode') + && window.__godsEyeView.styleManager.cockpitView.active + && getComputedStyle(document.getElementById('cockpit-utility-controls')).display !== 'none'), + ); + const narrowViewActions = await page.evaluate(() => { + const reset = document.getElementById('cockpit-reset-globe')?.getBoundingClientRect(); + const exit = document.getElementById('map-view-switch')?.getBoundingClientRect(); + return { + reset: reset?.toJSON() || null, + exit: exit?.toJSON() || null, + viewport: { width: innerWidth, height: innerHeight }, + }; + }); + check( + 'narrow Cockpit Reset and Exit remain readable without overlap', + narrowViewActions.reset?.width > 44 + && narrowViewActions.exit?.width > 70 + && narrowViewActions.reset.right <= narrowViewActions.exit.left + && narrowViewActions.reset.left >= 0 + && narrowViewActions.exit.right <= narrowViewActions.viewport.width, + JSON.stringify(narrowViewActions), + ); + + const resetResult = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module; + window.__qaCockpitReset = { + calls: 0, + original: manager.resetToGlobeView, + subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null, + }; + manager.resetToGlobeView = function qaCountedCockpitReset(...args) { + window.__qaCockpitReset.calls += 1; + return window.__qaCockpitReset.original.apply(this, args); + }; + return Boolean(document.getElementById('cockpit-reset-globe')); + }); + check('Cockpit Reset is present before keyboard activation', resetResult); + await page.focus('#cockpit-reset-globe'); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => !window.__godsEyeView.styleManager.cockpitView.active, + { timeout: 6_000 }, + ); + await page.waitForFunction(() => { + const viewer = window.__godsEyeView.viewer; + if (!viewer) return false; + const height = viewer.camera.positionCartographic?.height; + return Math.abs(height - 18_000_000) < 150_000; + }, { timeout: 6_000 }); + const resetState = await page.evaluate(() => { + const gev = window.__godsEyeView; + const qa = window.__qaCockpitReset; + const awareness = gev.dataManager.layers.get('military-awareness')?.module; + const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null; + const height = gev.viewer.camera.positionCartographic?.height; + gev.styleManager.resetToGlobeView = qa.original; + delete window.__qaCockpitReset; + return { + calls: qa.calls, + cockpitActive: gev.styleManager.cockpitView.active, + trackedEntity: Boolean(gev.viewer.trackedEntity), + resetHidden: document.getElementById('cockpit-reset-globe')?.hidden, + height, + subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId), + }; + }); + check( + 'keyboard Cockpit Reset uses one canonical route and preserves Contact selection', + resetState.calls === 1 + && !resetState.cockpitActive + && !resetState.trackedEntity + && resetState.resetHidden + && Math.abs(resetState.height - 18_000_000) < 150_000 + && resetState.subjectPreserved, + JSON.stringify(resetState), + ); + check('runtime console remains clean', consoleErrors.length === 0 && localHttpErrors.length === 0, + [...localHttpErrors, ...consoleErrors].slice(0, 6).join(' | ')); +} finally { + await page.evaluate(() => { + const manager = window.__godsEyeView?.styleManager; + const prior = window.__qaCockpitUtilityPrior; + if (!manager || !prior) return; + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', false); + if (manager.cockpitView.active && !prior.active) manager.cockpitView.exit(); + else manager.cockpitView.active = prior.active; + const hud = document.getElementById('cockpit-hud'); + const signal = document.getElementById('cockpit-signal-stream'); + hud.hidden = prior.hudHidden; + signal.hidden = prior.signalHidden; + manager._setHudVariant(prior.hudVariant); + manager.hud.setMode(prior.hudVisible ? 'on' : 'off'); + if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); + else hud.style.removeProperty('--cockpit-utility-top'); + if (!prior.cockpit) document.body.classList.remove('cockpit-mode'); + }).catch(() => {}); + await browser.close(); +} + +console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`); +process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs new file mode 100644 index 000000000..5b9d89816 --- /dev/null +++ b/scripts/qa-enrich-ambient.mjs @@ -0,0 +1,582 @@ +#!/usr/bin/env node +/** + * qa-enrich-ambient.mjs — headless proof for AMBIENT fleet type enrichment + * (fleet-enrichment widening of skylight Task 14). + * + * Field data (2026-07-02): OpenSky's live category field is 0/"no info" for + * ~94% of planes, so ambient classification defaulted nearly the whole fleet + * to the airliner silhouette. This harness reproduces exactly that case — + * 12 synthetic planes, ALL category 0 — and proves that the per-poll + * on-screen enrichment sweep (`_sweepAmbientEnrichment` in flights.js) gives + * them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while + * 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/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 + * harness releases them (deterministic baseline), + * then served with 400 ms latency; every request + * start/inflight count is logged for the bounds + * assertions. + * + * Machine assertions: + * E1 ingest : all 12 synthetic planes in the flights layer + * E2 baseline : BEFORE any enrichment answer, all 12 billboards carry the + * default airliner glyph at scale 1 (the real-world problem) + * E3 requests : the sweep requested all 12 hexes, each exactly once + * E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after + * enrichment (expected: 8) + * E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode)) + * E6 scales : every billboard's scale === CLASS_SCALE_2D[klass] + * E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s + * drip), total ≤300 (budget ceiling) + * E8 next poll : another poll neither reverts glyphs nor re-requests hexes + * E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes + * stop being requested once the bucket is empty (shrunk via + * the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't + * be waited out headlessly) + * E11 resume : after a refill window passes, ambient enrichment RESUMES + * for the still-unrequested planes (the 2026-07-03 field bug: + * the old one-shot session cap never refilled, so hours-long + * sessions showed airliner monoculture in every new region) + * E12 clean : no console errors + * + * Visual output (qa-shots/enrich-ambient/, untracked): before/after top-down + * spreads of the row. + * + * Run: node scripts/qa-enrich-ambient.mjs --url http://localhost:4304 + * Exits non-zero if any assertion fails. Never commits anything. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js'; +import { aircraftIcon } from '../src/data/aircraftIcons.js'; + +// --------------------------------------------------------------------------- +// Args (same shape as qa-sprites-b5.mjs) +// --------------------------------------------------------------------------- +const argv = process.argv.slice(2); +const getFlag = (name) => argv.includes(name); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; + +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = getFlag('--headful'); +const SHOT_DIR = path.resolve('qa-shots/enrich-ambient'); + +// Client-side bounds under test (flights.js constants): +const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT +const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s) +const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check +const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling) + +// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js): +// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts +// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can +// land mid-phase); E11 then shortens it in-page so refill windows have +// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens +// after the 12 baseline planes — E10's batch exhausts those 8 and stalls. +const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 }; +const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills + +const CHROME_EXECUTABLE_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + // Prefer puppeteer's version-pinned Chrome-for-Testing over the system + // Chrome: /Applications auto-updates underneath the harnesses, and its + // software-GL behavior shifts across majors (system Chrome 150 blew the + // tile-gated drain budget under SwiftShader on 2026-07-30 — six + // false-negative qa-cctv-v2 runs against a healthy build). A deterministic + // pinned browser beats the newest one for regression harnesses. + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean); + +function findChromeExecutable() { + for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { + try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ } + } + return null; +} + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +// --------------------------------------------------------------------------- +// Synthetic fleet: one east-west row near Austin, ALL category 0 ("no info" — +// the 94% real-world case). adsbdb type answers cover 8 distinct classes; +// aa000c answers found:false (negative-cache case → stays airliner). +// --------------------------------------------------------------------------- +const ROW_LAT = 30.30; +const ROW_LON0 = -97.82; +const ROW_STEP_DEG = 0.012; // ~1.15 km — 12 planes span ~12.7 km, fits a 20 km-up frame + +/** hex → adsbdb icao_type (null = adsbdb miss / negative cache). */ +const TYPES = { + aa0001: 'C172', // light + aa0002: 'B744', // quadjet + aa0003: 'DH8D', // turboprop + aa0004: 'H60', // helicopter + aa0005: 'F16', // fastjet + aa0006: 'GLID', // glider + aa0007: 'B77W', // widebody + aa0008: 'A320', // airliner + aa0009: 'B738', // airliner (known code outside the special sets) + aa000a: 'PC12', // turboprop + aa000b: 'R44', // helicopter + aa000c: null, // adsbdb 404 → found:false → stays airliner, never re-asked +}; + +const SPEC = { + timeOffsetSec: 0, + responseDelayMs: 400, // adsbdb latency so concurrency is actually observable + budgetQa: BUDGET_QA, + types: TYPES, + planes: Object.keys(TYPES).map((hex, i) => ({ + icao: hex, + callsign: `AMB${String(i + 1).padStart(3, '0')}`, + lon0: ROW_LON0 + i * ROW_STEP_DEG, + lat0: ROW_LAT, + courseDeg: 0, + speedMps: 70, + altM: 2400 + i * 40, + })), +}; + +// Node-side expectations (same classifier inputs the layer sees). +const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [ + hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }), +])); +const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default + +// --------------------------------------------------------------------------- +async function main() { + console.log(`\nAmbient fleet type-enrichment QA (fleet-enrichment widening)`); + console.log(` App URL : ${APP_URL}\n`); + console.log(` Expected classes: ${[...expected.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`); + + try { + const res = await fetch(APP_URL, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + process.exit(2); + } + + fs.mkdirSync(SHOT_DIR, { recursive: true }); + + const chromeExecutable = findChromeExecutable(); + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-web-security', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1280,800', + ], + }); + + const consoleErrors = []; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on('console', (msg) => { + if (msg.type() === 'error') { + const text = msg.text(); + if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text); + } + }); + page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`)); + + // ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb ------- + await page.evaluateOnNewDocument((spec) => { + window.__ENR = spec; + window.__ENR.epochMs = Date.now(); + // Rolling-budget QA seam — must exist BEFORE flights.js initializes so + // the shrunk knobs are read from the very first sweep (see BUDGET_QA). + window.__GEV_ENRICH_AMBIENT_QA = { ...spec.budgetQa }; + // Request log: starts (hex + performance.now), live/max concurrency, + // hold gate (responses park until the harness releases them so the + // pre-enrichment baseline is deterministic). + window.__ENRICH_LOG = { starts: [], inflight: 0, maxInflight: 0, held: true, holds: [] }; + window.__ENRICH_RELEASE = () => { + window.__ENRICH_LOG.held = false; + window.__ENRICH_LOG.holds.splice(0).forEach((fn) => fn()); + }; + + // Straight line from (lon0, lat0) along courseDeg at speedMps. + window.__ENR.stateAt = (p, tSec) => { + const cRad = (p.courseDeg * Math.PI) / 180; + const dist = p.speedMps * tSec; + const east = dist * Math.sin(cRad); + const north = dist * Math.cos(cRad); + const lat = p.lat0 + north / 111320; + const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180)); + return { lon, lat, course: p.courseDeg, speedMps: p.speedMps }; + }; + + const realFetch = window.fetch.bind(window); + const jsonResponse = (obj) => new Response(JSON.stringify(obj), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : (input && input.url) || ''; + const S = window.__ENR; + const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0); + const tRel = nowSec - S.epochMs / 1000; + + if (url.includes('/api/adsbdb/type/')) { + const hex = url.split('/').pop().split('?')[0].toLowerCase(); + const L = window.__ENRICH_LOG; + L.starts.push({ hex, t: performance.now() }); + L.inflight += 1; + L.maxInflight = Math.max(L.maxInflight, L.inflight); + const tc = S.types[hex]; + const body = tc + ? { found: true, typeCode: tc, typeName: `Synthetic ${tc}`, registration: `N${hex.slice(-3).toUpperCase()}` } + : { found: false }; + return new Promise((resolve) => { + const finish = () => setTimeout(() => { L.inflight -= 1; resolve(jsonResponse(body)); }, S.responseDelayMs); + if (L.held) L.holds.push(finish); else finish(); + }); + } + 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')) { + return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); + } + if (url.includes('/api/adsblol/mil')) { + return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] })); + } + if (url.includes('/api/opensky')) { + const states = S.planes.map((f) => { + const s = S.stateAt(f, tRel); + return [ + f.icao, f.callsign, 'Synthetica', + Math.floor(nowSec), Math.floor(nowSec), + s.lon, s.lat, f.altM, + false, // on_ground + s.speedMps, s.course, + 0, null, null, null, false, 0, + 0, // state[17] — category 0 = "no info" (the 94% case) + ]; + }); + return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states })); + } + return realFetch(input, init); + }; + }, SPEC); + + console.log('Loading app...'); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager, + { timeout: 60000, polling: 200 } + ); + console.log(' App globals ready.'); + + // ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ---------- + await page.evaluate(() => { + window.__collectBillboards = function () { + const v = window.__godsEyeView.viewer; + const out = []; + const walk = (coll) => { + const n = coll.length; + for (let i = 0; i < n; i++) { + let p; + try { p = coll.get(i); } catch { continue; } + if (!p) continue; + if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } + if (p.image !== undefined && p.alignedAxis !== undefined) { + out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show }); + } + } + }; + walk(v.scene.primitives); + return out; + }; + }); + + // ---- Frame the row FIRST (top-down, all 12 on-screen), then prime ------ + // The ambient sweep runs inside update(), so the camera must already be + // framing the fleet when the layer polls — otherwise the frustum test + // (correctly) skips off-screen planes. + const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG; + await page.evaluate(({ lat, lon, height }) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView + const C3 = v.camera.position.constructor; + v.camera.setView({ + destination: C3.fromDegrees(lon, lat, height), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, { lat: ROW_LAT, lon: centerLon, height: 20000 }); + + console.log('Priming straight-flight history through the render delay (30 s)...'); + const primed = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + const v = window.__godsEyeView.viewer; + window.__ENR.timeOffsetSec = -32; + await dm.setEnabled('flights', true); + const fl = dm.layers.get('flights').module; + for (const off of [-24, -16, -8, 0]) { + window.__ENR.timeOffsetSec = off; + await fl.update(v); + } + window.__ENR.timeOffsetSec = 0; + window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000); + return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length }; + }); + console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`); + record('E1 ingest: all synthetic category-0 planes in the flights layer', + primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`); + if (primed.count === 0) { finish(); return; } + + // ======================================================================== + // E2 — deterministic pre-enrichment baseline (responses still held) + // ======================================================================== + console.log('\nE2 — pre-enrichment baseline (adsbdb responses held)'); + const before = await page.evaluate(() => window.__collectBillboards()); + const beforeById = new Map(before.map((b) => [b.id, b])); + const baselineBad = []; + for (const hex of Object.keys(TYPES)) { + const bb = beforeById.get(hex); + if (!bb) baselineBad.push(`${hex}:missing`); + else if (bb.image !== AIRLINER_ICON) baselineBad.push(`${hex}:not-default-glyph`); + else if (Math.abs(bb.scale - (CLASS_SCALE_2D.airliner || 1)) > 1e-9) baselineBad.push(`${hex}:scale=${bb.scale}`); + } + record('E2 baseline: all 12 category-0 planes default to the airliner glyph', + baselineBad.length === 0, + baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)'); + await sleep(1200); // let a fleet tick land rotations before the "before" shot + await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') }); + console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)'); + + // ======================================================================== + // Release adsbdb + wait for the drip to finish, then for glyph swaps + // ======================================================================== + console.log('\nReleasing adsbdb responses; waiting for the bounded drip to drain...'); + await page.evaluate(() => window.__ENRICH_RELEASE()); + const drained = await page.waitForFunction( + (want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0, + { timeout: 30000, polling: 100 }, SPEC.planes.length + ).then(() => true).catch(() => false); + if (!drained) console.log(' \x1b[33mdrip did not fully drain within 30 s\x1b[0m'); + // Give the enrichment callbacks a beat to apply billboard swaps. + await page.waitForFunction(() => { + const images = new Set(window.__collectBillboards().map((b) => b.image)); + return images.size >= 5; + }, { timeout: 10000, polling: 200 }).catch(() => { /* asserted below */ }); + + // ======================================================================== + // E3–E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off) + // ======================================================================== + console.log('\nE3–E6 — ambient enrichment results'); + const log1 = await page.evaluate(() => ({ + starts: window.__ENRICH_LOG.starts.map((s) => ({ hex: s.hex, t: s.t })), + maxInflight: window.__ENRICH_LOG.maxInflight, + })); + const startHexes = log1.starts.map((s) => s.hex); + const uniqueHexes = new Set(startHexes); + const allRequested = Object.keys(TYPES).every((h) => uniqueHexes.has(h)); + record('E3 requests: sweep requested all 12 hexes, each exactly once', + allRequested && startHexes.length === Object.keys(TYPES).length, + `${startHexes.length} requests, ${uniqueHexes.size} unique`); + + const after = await page.evaluate(() => window.__collectBillboards()); + const afterById = new Map(after.map((b) => [b.id, b])); + const syntheticImages = new Set( + Object.keys(TYPES).map((hex) => afterById.get(hex)?.image).filter(Boolean) + ); + record('E4 diversity: ≥5 distinct glyph data-URIs displayed ambiently', + syntheticImages.size >= 5, `${syntheticImages.size} distinct glyphs (expected 8)`); + + const glyphBad = []; + const scaleBad = []; + for (const [hex, klass] of expected) { + const bb = afterById.get(hex); + const wantImg = aircraftIcon(klass); + const wantScale = CLASS_SCALE_2D[klass] || 1; + if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`); + if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`); + } + record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))', + glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`); + record('E6 scales: every billboard has its per-class CLASS_SCALE_2D', + scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)'); + + await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-after-diverse.png') }); + console.log(' saved enrich-after-diverse.png (type-diverse row, still untracked)'); + + // ======================================================================== + // E7 — politeness bounds (concurrency, drip rate, session cap) + // ======================================================================== + console.log('\nE7 — client-side bounds'); + const gaps = []; + for (let i = 1; i < log1.starts.length; i++) gaps.push(log1.starts[i].t - log1.starts[i - 1].t); + const minGap = gaps.length ? Math.min(...gaps) : Infinity; + // Max dispatches in any sliding 1 s window (reported for context). + let maxPerSec = 0; + for (let i = 0; i < log1.starts.length; i++) { + let n = 0; + for (let j = i; j < log1.starts.length && log1.starts[j].t - log1.starts[i].t < 1000; j++) n++; + maxPerSec = Math.max(maxPerSec, n); + } + const boundsOk = log1.maxInflight <= MAX_INFLIGHT + && minGap >= (DISPATCH_GAP_MS - GAP_TOLERANCE_MS) + && log1.starts.length <= SESSION_CAP; + record(`E7 bounds: ≤${MAX_INFLIGHT} concurrent, ≥${DISPATCH_GAP_MS}ms drip gap, ≤${SESSION_CAP}/session`, + boundsOk, + `maxConcurrent=${log1.maxInflight} minGap=${Number.isFinite(minGap) ? minGap.toFixed(1) : 'n/a'}ms ` + + `maxIn1s=${maxPerSec} total=${log1.starts.length}`); + + // ======================================================================== + // E8 — next poll: klass persists (poll re-derive keeps enriched types) and + // the session dedupe never re-requests an answered/negative hex + // ======================================================================== + console.log('\nE8 — next poll after enrichment'); + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + const v = window.__godsEyeView.viewer; + await dm.layers.get('flights').module.update(v); + }); + await sleep(500); + const afterPoll = await page.evaluate(() => ({ + billboards: window.__collectBillboards(), + starts: window.__ENRICH_LOG.starts.length, + })); + const afterPollById = new Map(afterPoll.billboards.map((b) => [b.id, b])); + const pollBad = []; + for (const [hex, klass] of expected) { + const bb = afterPollById.get(hex); + if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`); + else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`); + } + const noReRequests = afterPoll.starts === Object.keys(TYPES).length; + record('E8 next poll: enriched glyphs persist and no hex is re-requested', + pollBad.length === 0 && noReRequests, + pollBad.length ? pollBad.join(' ') : `glyphs stable, requests still ${afterPoll.starts}`); + + // ======================================================================== + // E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the + // old ONE-SHOT session cap burned out and never refilled, so every new + // region after the first polls showed airliner monoculture. With the seam + // ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh + // on-screen planes must stall at exactly 8 new requests, and further polls + // must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so + // no refill can land mid-phase). Pre-fix code fails this phase: there is + // no rolling bucket to exhaust (the seam is unread and the 300 cap simply + // admits the whole batch). + // ======================================================================== + console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)'); + const BATCH_COUNT = 26; + const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`); + const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length); + await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => { + const dm = window.__godsEyeView.dataManager; + const v = window.__godsEyeView.viewer; + // Two fresh rows just north of the original one — still inside the + // top-down 20 km frame, so the sweep's frustum test keeps them. + // STATIONARY (speed 0): moving planes drift north out of the frustum at + // run-time-dependent rates, which made this phase timing-sensitive. + hexes.forEach((hex, i) => { + window.__ENR.planes.push({ + icao: hex, + callsign: `BGT${String(i + 1).padStart(3, '0')}`, + lon0: rowLon0 + (i % 13) * stepDeg, + lat0: rowLat + (i < 13 ? 0.02 : 0.04), + courseDeg: 0, + speedMps: 0, + altM: 2600 + i * 15, + }); + }); + const fl = dm.layers.get('flights').module; + await fl.update(v); // sweep enqueues the last 8 tokens + await fl.update(v); // bucket empty — must enqueue nothing more + }, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG }); + // Wait for the drip to drain whatever was enqueued, then settle. + await page.waitForFunction( + (min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0, + { timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch) + ).catch(() => { /* asserted below */ }); + await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms + // One more poll while exhausted — still nothing new. + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.layers.get('flights').module.update(window.__godsEyeView.viewer); + }); + await sleep(1000); + const exhausted = await page.evaluate(() => ({ + starts: window.__ENRICH_LOG.starts.length, + inflight: window.__ENRICH_LOG.inflight, + })); + record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`, + exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0, + `starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; ` + + `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`); + + // ======================================================================== + // E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the + // time since the bucket's refill anchor now spans whole windows; the next + // sweep refills (clamped at ceil) and the waiting planes get requested. + // ======================================================================== + console.log('\nE11 — refill window passes; ambient enrichment resumes'); + await page.evaluate((winMs) => { window.__GEV_ENRICH_AMBIENT_QA.windowMs = winMs; }, RESUME_WINDOW_MS); + await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.layers.get('flights').module.update(window.__godsEyeView.viewer); + }); + const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested + const resumed = await page.waitForFunction( + (want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0, + { timeout: 25000, polling: 100 }, expectedTotal + ).then(() => true).catch(() => false); + const resumeLog = await page.evaluate(() => window.__ENRICH_LOG.starts.map((s) => s.hex)); + const batchRequested = batchHexes.filter((h) => resumeLog.includes(h)).length; + const dupes = resumeLog.length !== new Set(resumeLog).size; + record('E11 resume: enrichment resumes after a refill window (all waiting planes requested, once each)', + resumed && batchRequested === BATCH_COUNT && resumeLog.length === expectedTotal && !dupes, + `starts=${resumeLog.length} (want ${expectedTotal}) batchRequested=${batchRequested}/${BATCH_COUNT} dupes=${dupes}`); + + record('E12: no console errors during QA run', consoleErrors.length === 0, + consoleErrors.length ? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}` : 'clean'); + + finish(); + } finally { + await browser.close(); + } + + function finish() { + const failed = results.filter((r) => r.ok === false); + const passed = results.filter((r) => r.ok === true); + console.log(`\n${'─'.repeat(60)}`); + console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`); + console.log(`${'─'.repeat(60)}\n`); + process.exitCode = failed.length > 0 ? 1 : 0; + } +} + +main().catch((err) => { + console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err); + process.exit(2); +}); diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs new file mode 100644 index 000000000..3b4582b9c --- /dev/null +++ b/scripts/qa-failstate-b10.mjs @@ -0,0 +1,570 @@ +#!/usr/bin/env node +/** + * qa-failstate-b10.mjs + * + * Targeted proof for Batch 10 — the "silent-failure" theme (finding H3 + H9). + * Drives the REAL app in headless Chromium and asserts that dead/failed feeds + * are SURFACED to the user instead of a healthy-looking empty state. + * + * Node-side request interception (page.setRequestInterception) fabricates the + * 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 + * {rows:[],status:'error',error:'invalid key'}. Assert the layer's + * getStats().error is set (feed down) — NOT a clean 'just now · 0'. + * + * (ii) CelesTrak outage — first serve a good catalog so there's something to + * preserve, then flip ALL /api/celestrak/* groups to HTTP 503. Toggle + * satellites off/on. Assert the catalog is NOT wiped to 0 AND + * getStats().error is set. + * + * (iii) DETECT with no data — enable panoptic detection with NO data layers. + * Assert the shared #world-overlay-canvas is non-empty (mode banner drawn), + * so DETECT reads "armed, nothing in view" instead of a blank canvas. + * The banner is developer telemetry, hidden from users by default, so this + * harness loads the app with ?detectDebug=1 — without it the pixel check + * would pass on scanlines alone and no longer assert what it names. + * (Absent-by-default is pinned in src/data/detectionHost.test.mjs.) + * + * Run: node scripts/qa-failstate-b10.mjs --url http://localhost:4300 + * Exits non-zero if any assertion fails. DOES NOT COMMIT anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const getFlag = (name) => argv.includes(name); + +const APP_URL = getOpt('--url', 'http://localhost:4300'); + +/** + * Check (iii) asserts the detection MODE BANNER is drawn on a zero-object frame. + * That banner is developer telemetry and is hidden from users by default, so the + * harness must ask for it explicitly — otherwise the pixel check silently passes + * on scanlines alone and the assertion name stops meaning anything. + */ +function withDetectDebug(url) { + const parsed = new URL(url); + parsed.searchParams.set('detectDebug', '1'); + return parsed.toString(); +} +const HEADFUL = getFlag('--headful'); +const ARTIFACT_DIR = getOpt('--artifact-dir', null); + +const CHROME_EXECUTABLE_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + // Prefer puppeteer's version-pinned Chrome-for-Testing over the system + // Chrome: /Applications auto-updates underneath the harnesses, and its + // software-GL behavior shifts across majors (system Chrome 150 blew the + // tile-gated drain budget under SwiftShader on 2026-07-30 — six + // false-negative qa-cctv-v2 runs against a healthy build). A deterministic + // pinned browser beats the newest one for regression harnesses. + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean); + +function findChromeExecutable() { + for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { /* ignore */ } + } + return null; +} + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function readLayerControl(page, layerId, expectedLabel) { + await page.waitForFunction( + (id, label) => { + const button = document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`); + return button?.textContent?.trim() === label; + }, + { timeout: 5000 }, + layerId, + expectedLabel, + ); + return page.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const button = row?.querySelector('.data-toggle-btn'); + const meta = row?.querySelector('.data-toggle-meta'); + return { + label: button?.textContent?.trim() || '', + feedState: button?.dataset?.feedState || '', + ariaLabel: button?.getAttribute('aria-label') || '', + meta: meta?.textContent?.trim() || '', + }; + }, layerId); +} + +async function captureLayerControl(page, layerId, filename) { + if (!ARTIFACT_DIR) return; + fs.mkdirSync(ARTIFACT_DIR, { recursive: true }); + const dataPanel = await page.$('#data-panel'); + const panelCollapsed = await dataPanel?.evaluate((element) => element.classList.contains('collapsed')); + if (panelCollapsed) await page.click('#data-panel .panel-collapse-btn'); + const row = await page.$(`[data-layer-id="${layerId}"]`); + if (!row) throw new Error(`Layer control not found for screenshot: ${layerId}`); + await row.evaluate((element) => element.scrollIntoView({ block: 'center' })); + await page.waitForFunction((id) => { + const element = document.querySelector(`[data-layer-id="${id}"]`); + const rect = element?.getBoundingClientRect(); + return !!rect && rect.width > 0 && rect.height > 0; + }, { timeout: 5_000 }, layerId); + await sleep(100); + await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) }); +} + +// A minimal but valid TLE for one satellite (ISS), so the "good catalog" pass +// builds a non-zero catalog we can later prove is preserved across the outage. +// parseTLE in satellites.js expects `NAME\n1 ...\n2 ...` triplets. +const GOOD_TLE = [ + 'ISS (ZARYA)', + '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927', + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537', + 'NOAA 19', + '1 33591U 09005A 08264.51782528 -.00000045 00000-0 35116-4 0 9999', + '2 33591 99.1949 123.4567 0013000 200.0000 160.0000 14.12345678123456', +].join('\n'); + +async function main() { + console.log('\nSilent-Failure Proof (Batch 10 — H3 + H9)'); + console.log(` App URL : ${APP_URL}`); + console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`); + + try { + const res = await fetch(APP_URL, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + process.exit(2); + } + + const chromeExecutable = findChromeExecutable(); + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-web-security', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1280,800', + ], + }); + + let exitCode = 0; + + // Node-side controllable interception mode, flipped mid-run. + // celestrak: 'good' → serve GOOD_TLE for every group + // celestrak: 'partial' → fail one group while preserving a usable catalog + // celestrak: 'down' → 503 for every group (total outage) + const mode = { celestrak: 'good' }; + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + + await page.setRequestInterception(true); + 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')) { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }), + }); + return; + } + // (ii) CelesTrak proxy — /api/celestrak/ + if (url.includes('/api/celestrak/')) { + if (mode.celestrak === 'down' + || (mode.celestrak === 'partial' && url.includes('/api/celestrak/stations'))) { + req.respond({ status: 503, contentType: 'text/plain', body: 'upstream unavailable' }); + } else { + req.respond({ status: 200, contentType: 'text/plain', body: GOOD_TLE }); + } + return; + } + req.continue(); + }); + + console.log('Loading app...'); + await page.goto(withDetectDebug(APP_URL), { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__godsEyeView + && window.__godsEyeView.viewer + && window.__godsEyeView.dataManager + && window.__godsEyeView.styleManager, + { timeout: 60000 }, + ); + await sleep(1500); + + // ── (0) Manager-owned periodic refresh → shared work/failure/recovery ── + console.log('\n(0) Universal periodic refresh feedback...'); + const refreshFeedback = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + const ids = ['flights', 'military', 'satellites']; + const styleManager = window.__godsEyeView.styleManager; + const outcomes = []; + const readBanner = () => ({ + hidden: document.getElementById('global-loading-status')?.hidden, + state: document.getElementById('global-loading-status')?.dataset?.state || '', + label: document.getElementById('global-loading-label')?.textContent?.trim() || '', + detail: document.getElementById('global-loading-detail')?.textContent?.trim() || '', + }); + + for (const id of ids) { + const entry = dm.layers.get(id); + if (!entry) { + outcomes.push({ id, missing: true }); + continue; + } + const original = { + enabled: entry.enabled, + initialized: entry.initialized, + lifecycleState: entry.lifecycleState, + update: entry.module.update, + getStats: entry.module.getStats, + managerRefreshError: entry.managerRefreshError, + }; + const baseStats = typeof original.getStats === 'function' + ? original.getStats.call(entry.module) + : { count: 0, lastUpdate: null }; + let rejectRefresh; + try { + entry.enabled = true; + entry.initialized = true; + entry.lifecycleState = 'enabled'; + entry.managerRefreshError = null; + entry.module.getStats = () => ({ + ...baseStats, + error: null, + lastError: null, + available: true, + unavailable: false, + loading: false, + refreshing: false, + }); + entry.module.update = () => new Promise((resolve, reject) => { + rejectRefresh = reject; + }); + const pendingFailure = dm._runPeriodicUpdate(id, entry); + styleManager._updateGlobalLoadingFeedback(performance.now()); + await new Promise((resolve) => setTimeout(resolve, 220)); + styleManager._updateGlobalLoadingFeedback(performance.now()); + const working = readBanner(); + rejectRefresh(new Error(`${id} QA refresh failure`)); + await pendingFailure; + styleManager._updateGlobalLoadingFeedback(performance.now()); + const failed = readBanner(); + const managerError = entry.managerRefreshError; + + entry.module.update = async () => true; + const pendingRecovery = dm._runPeriodicUpdate(id, entry); + styleManager._updateGlobalLoadingFeedback(performance.now()); + await pendingRecovery; + styleManager._updateGlobalLoadingFeedback(performance.now()); + outcomes.push({ + id, + name: entry.module.name, + working, + failed, + managerError, + recoveredError: entry.managerRefreshError, + lifecycleState: entry.lifecycleState, + enabled: entry.enabled, + }); + } finally { + entry.module.update = original.update; + entry.module.getStats = original.getStats; + entry.enabled = original.enabled; + entry.initialized = original.initialized; + entry.lifecycleState = original.lifecycleState; + entry.managerRefreshError = original.managerRefreshError; + } + } + return outcomes; + }); + for (const outcome of refreshFeedback) { + const passed = !outcome.missing + && outcome.working.hidden === false + && outcome.working.label === 'REFRESHING LIVE DATA' + && outcome.working.detail.includes(outcome.name) + && outcome.failed.hidden === false + && outcome.failed.label === 'LOAD FAILED' + && outcome.failed.state === 'error' + && outcome.managerError?.includes('QA refresh failure') + && outcome.recoveredError === null + && outcome.lifecycleState === 'enabled' + && outcome.enabled === true; + record( + `${outcome.name || outcome.id}: periodic refresh reports work, failure, and recovery`, + passed, + JSON.stringify(outcome), + ); + if (!passed) exitCode = 1; + } + + // ── (i) AIS invalid key → surfaced error, not a clean empty ────────────── + console.log('\n(i) AIS invalid-key: enabling ais-live-vessels...'); + const aisStats = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('ais-live-vessels', true); + const mod = dm.layers.get('ais-live-vessels').module; + // DataLayerManager intentionally treats enable() as a synchronous + // lifecycle hook, while this layer starts its first poll asynchronously + // from enable(). Wait on the layer's public loading state instead of a + // machine-speed-dependent fixed delay so the assertion always observes + // the injected response, not an in-flight request. + const deadline = Date.now() + 5000; + while (mod.getStats().loading && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + return mod.getStats(); + }); + { + const hasError = typeof aisStats.error === 'string' && aisStats.error.length > 0; + const cleanEmpty = !hasError && (aisStats.count === 0); + record( + 'AIS: dead feed surfaces an error (not clean empty)', + hasError && !cleanEmpty, + `stats=${JSON.stringify({ error: aisStats.error, count: aisStats.count, lastUpdate: aisStats.lastUpdate })}`, + ); + if (!hasError) exitCode = 1; + } + const aisControl = await readLayerControl(page, 'ais-live-vessels', 'UNAVAILABLE'); + const aisChipHonest = aisControl.feedState === 'unavailable' + && aisControl.ariaLabel === 'Live AIS Vessels: UNAVAILABLE'; + const aisMetaHonest = /^UNAVAILABLE · AISStream · /i.test(aisControl.meta) + && aisStats.error + && aisControl.meta.includes(aisStats.error); + record( + 'AIS: layer control reads UNAVAILABLE', + aisChipHonest, + JSON.stringify(aisControl), + ); + record( + 'AIS: layer metadata names the upstream error', + aisMetaHonest, + `meta=${JSON.stringify(aisControl.meta)}`, + ); + if (!aisChipHonest || !aisMetaHonest) exitCode = 1; + await captureLayerControl(page, 'ais-live-vessels', 'failstate-ais-unavailable.png'); + + // ── (ii) CelesTrak outage on re-enable → catalog NOT wiped, error set ──── + console.log('\n(ii) Satellites: building a good catalog first...'); + const goodStats = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('satellites', true); + await new Promise((r) => setTimeout(r, 800)); + const mod = dm.layers.get('satellites').module; + return mod.getStats(); + }); + record( + 'Satellites: good catalog loaded before outage (baseline)', + goodStats.count > 0, + `count=${goodStats.count} error=${goodStats.error ?? null}`, + ); + if (!(goodStats.count > 0)) { + // Without a baseline the preservation assertion is meaningless. + record('Satellites: catalog preserved across total outage', null, 'no baseline catalog to preserve'); + } else { + console.log(' Failing one CelesTrak group and verifying the visible degraded state...'); + mode.celestrak = 'partial'; + const partialStats = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('satellites', false); + await new Promise((r) => setTimeout(r, 200)); + await dm.setEnabled('satellites', true); + await new Promise((r) => setTimeout(r, 800)); + return dm.layers.get('satellites').module.getStats(); + }); + const partialControl = await readLayerControl(page, 'satellites', 'DEGRADED'); + const partialChipHonest = partialStats.count > 0 + && /1 CelesTrak group unavailable/i.test(partialStats.error || '') + && partialControl.feedState === 'degraded' + && partialControl.ariaLabel === 'Satellites: DEGRADED'; + const partialMetaHonest = /^DEGRADED · CelesTrak · /i.test(partialControl.meta) + && /1 CelesTrak group unavailable/i.test(partialControl.meta); + record( + 'Satellites: partial outage renders a DEGRADED chip', + partialChipHonest, + JSON.stringify({ stats: partialStats, control: partialControl }), + ); + record( + 'Satellites: degraded metadata names partial coverage', + partialMetaHonest, + `meta=${JSON.stringify(partialControl.meta)}`, + ); + if (!partialChipHonest || !partialMetaHonest) exitCode = 1; + await captureLayerControl(page, 'satellites', 'failstate-satellites-degraded.png'); + + console.log(' Flipping ALL CelesTrak groups to 503 and toggling satellites off→on...'); + mode.celestrak = 'down'; + const outageStats = await page.evaluate(async (baseline) => { + const dm = window.__godsEyeView.dataManager; + // Toggle off then on — the re-enable's update() re-fetches (now all 503). + await dm.setEnabled('satellites', false); + await new Promise((r) => setTimeout(r, 200)); + await dm.setEnabled('satellites', true); + await new Promise((r) => setTimeout(r, 800)); + const mod = dm.layers.get('satellites').module; + return { stats: mod.getStats(), baseline }; + }, goodStats.count); + + const s = outageStats.stats; + const notWiped = s.count > 0; // catalog preserved, not blanked to 0 + const errorSet = typeof s.error === 'string' && s.error.length > 0; + const outageControl = await readLayerControl(page, 'satellites', 'UNAVAILABLE'); + const outageChipHonest = outageControl.feedState === 'unavailable' + && outageControl.ariaLabel === 'Satellites: UNAVAILABLE'; + const outageMetaHonest = /^UNAVAILABLE · CelesTrak · /i.test(outageControl.meta) + && outageControl.meta.includes(s.error || ''); + record( + 'Satellites: catalog NOT wiped to 0 on total outage', + notWiped, + `count=${s.count} (baseline=${outageStats.baseline})`, + ); + record( + 'Satellites: outage surfaces stats.error', + errorSet, + `error=${JSON.stringify(s.error)} lastUpdate=${s.lastUpdate}`, + ); + record( + 'Satellites: total outage renders an UNAVAILABLE chip', + outageChipHonest, + JSON.stringify(outageControl), + ); + record( + 'Satellites: unavailable metadata names the outage', + outageMetaHonest, + `meta=${JSON.stringify(outageControl.meta)}`, + ); + if (!notWiped || !errorSet || !outageChipHonest || !outageMetaHonest) exitCode = 1; + await captureLayerControl(page, 'satellites', 'failstate-satellites-unavailable.png'); + } + + // ── (iii) DETECT with no data layers → mode banner drawn (non-blank) ───── + console.log('\n(iii) DETECT with no data layers: disabling data layers, enabling panoptic...'); + const detect = await page.evaluate(async () => { + const gev = window.__godsEyeView; + const dm = gev.dataManager; + // Turn OFF every data layer so detection collects zero objects. + for (const [id, entry] of dm.layers) { + if (entry.enabled) { try { await dm.setEnabled(id, false); } catch { /* ignore */ } } + } + // Enable panoptic detection via the styleManager facade (the UI path). + gev.styleManager.setDetection({ enabled: true, mode: 'panoptic' }); + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return { present: false }; + const viewer = gev.viewer; + const tileset = gev.tileset; + const priorDefaultLoop = viewer.useDefaultRenderLoop; + const priorTilesetShow = tileset?.show; + let nonEmpty = 0; + let solid = 0; + let sampled = 0; + viewer.useDefaultRenderLoop = false; + if (tileset) tileset.show = false; + try { + const deadline = performance.now() + 5000; + while (performance.now() < deadline && solid <= 20) { + viewer.render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const w = canvas.width, h = canvas.height; + const off = document.createElement('canvas'); + off.width = w; off.height = h; + const octx = off.getContext('2d'); + // The detection pass (banner included) paints on the host-owned + // blend-isolation surface beneath the shared canvas — composite + // both so the sample sees whichever surface carries the banner. + const detSurface = document.getElementById('world-overlay-detection-surface'); + if (detSurface) octx.drawImage(detSurface, 0, 0); + octx.drawImage(canvas, 0, 0); + const dpr = window.devicePixelRatio || 1; + const bw = Math.min(w, Math.round(260 * dpr)); + const bh = Math.min(h, Math.round(48 * dpr)); + const data = octx.getImageData(0, 0, bw, bh).data; + sampled = bw * bh; + nonEmpty = 0; + solid = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 4) nonEmpty++; + // The banner paints labelBg at full opacity (alpha 0.66-0.78 => 168+). + // Scanlines paint the SAME color but scaled by _theme.scanline, and + // only on every fourth row, so they never reach this threshold. This + // is what separates "the banner is drawn" from "something is drawn". + if (data[i] > 128) solid++; + } + } + } finally { + if (tileset) tileset.show = priorTilesetShow; + viewer.useDefaultRenderLoop = priorDefaultLoop; + viewer.scene.requestRender(); + } + const detState = gev.styleManager.getDetectionState?.() || null; + return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null }; + }); + + if (!detect.present) { + record('DETECT: mode banner drawn on 0 objects', false, 'world-overlay canvas absent'); + exitCode = 1; + } else { + // Solid pixels only: a non-empty canvas proves SOMETHING painted, which + // faint scanlines alone satisfy. The banner's opaque background is what + // this check is named for, so that is what it asserts. + const bannerDrawn = detect.solid > 20; + record( + 'DETECT: mode banner drawn on 0 objects (opaque banner fill present)', + bannerDrawn, + `solidPx=${detect.solid} nonEmptyPx=${detect.nonEmpty}/${detect.sampled} mode=${detect.mode}`, + ); + if (!bannerDrawn) exitCode = 1; + } + + } catch (e) { + console.error('\x1b[31mHarness error:\x1b[0m', e); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok === true).length; + const fail = results.filter((r) => r.ok === false).length; + const inconclusive = results.filter((r) => r.ok === null).length; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs new file mode 100644 index 000000000..ca5f23525 --- /dev/null +++ b/scripts/qa-firms.mjs @@ -0,0 +1,481 @@ +#!/usr/bin/env node +/** + * qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer. + * + * Drives the REAL app in headless Chromium against a dev server with a + * FIRMS key (default :4420). The live section rides the proxy's 30-min disk + * cache (zero upstream quota risk per run); the degraded states are + * fabricated with request interception so they're deterministic. + * + * (i) LIVE — layer loads >1000 detections, no error, label 'LIVE ·', + * /api/firms/status reports the key + transaction telemetry. + * (ii) CARDS — the shared world-overlay canvas reports painted FIRMS + * entries and is non-blank at a fire cluster (detections LOD) AND at + * global zoom (cell cards); aggregate cells expose no action target. + * (iii) KEYLESS — /api/firms intercepted to 503 {error:'no_key'}: + * stats.error 'KEY REQUIRED', zero fires, no crash. + * (iv) STALE — /api/firms intercepted to a stale:true payload with + * fresh-enough detections: stats.stale true, error 'STALE · …', + * fires still render (old data beats a wiped map). + * (v) ACTION (synthetic) — one deterministic intercepted detection's + * accessible card selects exactly that stable fire and transfers the + * camera through the shared UI navigation policy. + * + * Visual proof saved to qa-shots/ (gitignored). + * + * Run: node scripts/qa-firms.mjs --url http://localhost:4420 + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4420'); +const HEADFUL = argv.includes('--headful'); + +const CHROME_EXECUTABLE_CANDIDATES = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + // Prefer puppeteer's version-pinned Chrome-for-Testing over the system + // Chrome: /Applications auto-updates underneath the harnesses, and its + // software-GL behavior shifts across majors (system Chrome 150 blew the + // tile-gated drain budget under SwiftShader on 2026-07-30 — six + // false-negative qa-cctv-v2 runs against a healthy build). A deterministic + // pinned browser beats the newest one for regression harnesses. + (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean); + +function findChromeExecutable() { + for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { + try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } + } + return null; +} + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Non-transparent pixels plus source-scoped host diagnostics. */ +async function cardCanvasInk(page) { + return page.evaluate(() => { + const canvas = document.getElementById('world-overlay-canvas'); + const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.(); + const painted = diagnostics?.paintedBySource?.firms || 0; + const entries = diagnostics?.entriesBySource?.firms || 0; + if (!canvas) return { + present: false, ink: 0, painted, entries, + candidates: diagnostics?.candidateCount || 0, + projected: diagnostics?.projectedCount || 0, + }; + const ctx = canvas.getContext('2d'); + const { width, height } = canvas; + if (!width || !height) return { + present: true, ink: 0, painted, entries, + candidates: diagnostics?.candidateCount || 0, + projected: diagnostics?.projectedCount || 0, + }; + const data = ctx.getImageData(0, 0, width, height).data; + let ink = 0; + for (let i = 3; i < data.length; i += 4) if (data[i] > 8) ink++; + return { + present: true, ink, painted, entries, + candidates: diagnostics?.candidateCount || 0, + projected: diagnostics?.projectedCount || 0, + }; + }); +} + +/** Wait for two consecutive painted frames instead of sampling a render gap. */ +async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) { + const deadline = Date.now() + timeoutMs; + let consecutive = 0; + let sample = null; + while (Date.now() < deadline) { + await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await sleep(150); + sample = await cardCanvasInk(page); + if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) { + consecutive += 1; + if (consecutive >= 2) return sample; + } else { + consecutive = 0; + } + } + return sample || { + present: false, ink: 0, painted: 0, entries: 0, candidates: 0, projected: 0, + }; +} + +/** Currently painted FIRMS actions mirrored for keyboard/assistive input. */ +async function firmsActionSnapshot(page) { + return page.evaluate(() => { + const buttons = [...document.querySelectorAll( + '#world-overlay-action-list button[data-overlay-action-key]', + )].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000')); + return { + count: buttons.length, + selectedCount: buttons.filter((button) => button.getAttribute('aria-pressed') === 'true').length, + labels: buttons.map((button) => button.getAttribute('aria-label') || button.textContent || ''), + }; + }); +} + +/** Wait until the accessible mirror reflects the requested FIRMS action count. */ +async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {}) { + const deadline = Date.now() + timeoutMs; + let snapshot = await firmsActionSnapshot(page); + while (Date.now() < deadline && snapshot.count !== expected) { + await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await sleep(175); + snapshot = await firmsActionSnapshot(page); + } + return snapshot; +} + +/** Boot the app and enable the fires layer, polling until settled. */ +async function bootAndEnable(page, { timeoutS = 45 } = {}) { + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: 60000 }, + ); + await sleep(1500); + return page.evaluate(async (tS) => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('local-firms', true); + const mod = dm.layers.get('local-firms').module; + let s = null; + for (let i = 0; i < tS; i++) { + await new Promise((r) => setTimeout(r, 1000)); + s = mod.getStats(); + if ((s.count > 0 || s.error) && !s.loading) break; + } + return s; + }, timeoutS); +} + +/** Teleport the camera (duck-typed cartographic — no Cesium global). */ +async function setView(page, lon, lat, height) { + await page.evaluate((lo, la, h) => { + const gev = window.__godsEyeView; + const ell = gev.viewer.scene.globe.ellipsoid; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } + gev.viewer.camera.setView({ + destination: ell.cartographicToCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, lon, lat, height); +} + +/** Fabricated stale-but-recent proxy payload for section (iv). */ +function stalePayload() { + const now = Date.now(); + const acq = new Date(now - 2 * 3600000); + const acqDate = acq.toISOString().slice(0, 10); + const acqTime = `${String(acq.getUTCHours()).padStart(2, '0')}${String(acq.getUTCMinutes()).padStart(2, '0')}`; + const mk = (lat, lon, frp, confidence) => ({ + lat, lon, frp, confidence, brightness: 330, brightnessTi5: 290, + daynight: 'N', acqDate, acqTime, satellite: 'N20', instrument: 'VIIRS', + }); + return { + fetchedAt: now - 2 * 3600000, + stale: true, + ttlMs: 1800000, + sources: [{ source: 'VIIRS_NOAA20_NRT', count: 3, ok: false }], + count: 3, + fires: [mk(61.9, -122.9, 900, 'h'), mk(61.95, -122.8, 45, 'n'), mk(61.85, -123.0, 4, 'l')], + }; +} + +async function main() { + console.log('\nLive NASA FIRMS Proof (qa-firms)'); + console.log(` App URL : ${APP_URL}\n`); + + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + process.exit(2); + } + + const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null); + if (!status?.hasKey) { + console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m'); + process.exit(2); + } + + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), + args: [ + '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', + '--disable-dev-shm-usage', '--disable-web-security', + '--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], + }); + + let exitCode = 0; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + + // ── (i) LIVE feed ──────────────────────────────────────────────────────── + console.log('(i) LIVE — loading the fires layer through the cached proxy...'); + const live = await bootAndEnable(page); + { + const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE'); + record('LIVE: >1000 detections, no error, LIVE label', ok, + `count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`); + record('LIVE: /api/firms/status has key + transaction telemetry', + status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)), + `transactions=${JSON.stringify(status.transactions)} count=${status.count}`); + if (!ok) exitCode = 1; + } + + // ── (ii) CARDS — canvas overlay draws at both LODs ────────────────────── + console.log('\n(ii) CARDS — tactical card overlay ink at both LODs...'); + { + const strongest = await page.evaluate(() => { + const mod = window.__godsEyeView.dataManager.layers.get('local-firms').module; + return mod.getStrongestFire(); + }); + if (!strongest) { + record('CARDS: detections-LOD cards drawn', false, 'no strongest fire available'); + exitCode = 1; + } else { + await setView(page, strongest.longitude, strongest.latitude, 60000); + const det = await waitForCardCanvasInk(page); + record('CARDS: detections-LOD cards drawn (host source + canvas ink)', + det.present && det.painted > 0 && det.ink > 500, + `entries=${det.entries} candidates=${det.candidates} projected=${det.projected} ` + + `painted=${det.painted} inkPx=${det.ink} @ strongest fire FRP ${strongest.frp}`); + if (!(det.present && det.painted > 0 && det.ink > 500)) exitCode = 1; + await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-cards-detections.png') }); + + // Global LOD band starts at 9,000 km; its labelDistance is 12,000 km + // (cards fade to zero beyond it, matching the legacy labels) — so the + // cell-card proof must sit inside that window. + await setView(page, strongest.longitude, strongest.latitude, 10000000); + // Do not let the previous detections frame satisfy the ink predicate + // before the 650 ms LOD watcher has swapped in aggregate cell cards. + const aggregateActions = await waitForFirmsActionCount(page, 0); + const cells = await waitForCardCanvasInk(page); + record('CARDS: global-LOD cell cards drawn (host source + canvas ink)', + cells.present && cells.painted > 0 && cells.ink > 500, + `entries=${cells.entries} candidates=${cells.candidates} projected=${cells.projected} ` + + `painted=${cells.painted} inkPx=${cells.ink}`); + if (!(cells.present && cells.painted > 0 && cells.ink > 500)) exitCode = 1; + record('CARDS: aggregate cells expose no FIRMS action target', + aggregateActions.count === 0, + `actionButtons=${aggregateActions.count}`); + if (aggregateActions.count !== 0) exitCode = 1; + await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-global-cells.png') }); + } + } + + // ── (iii) KEYLESS (intercepted) ────────────────────────────────────────── + console.log('\n(iii) KEYLESS — intercepted 503 no_key...'); + await page.setRequestInterception(true); + let interceptMode = 'keyless'; + page.on('request', (req) => { + const url = req.url(); + if (url.includes('/api/firms/status')) { + req.respond({ + status: 200, contentType: 'application/json', + body: JSON.stringify(interceptMode === 'keyless' + ? { hasKey: false } + : { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }), + }); + return; + } + if (url.includes('/api/firms')) { + if (interceptMode === 'keyless') { + req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); + } else { + req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) }); + } + return; + } + try { req.continue(); } catch { /* already handled */ } + }); + + const keyless = await bootAndEnable(page, { timeoutS: 20 }); + { + const ok = keyless.error === 'KEY REQUIRED' && keyless.count === 0; + record('KEYLESS: error "KEY REQUIRED", zero fires, no crash', ok, + `error=${JSON.stringify(keyless.error)} count=${keyless.count} label=${JSON.stringify(keyless.loadingLabel)}`); + if (!ok) exitCode = 1; + await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-keyless.png') }); + } + + // ── (iv) STALE (intercepted) ───────────────────────────────────────────── + console.log('\n(iv) STALE — intercepted stale:true payload...'); + interceptMode = 'stale'; + const stale = await bootAndEnable(page, { timeoutS: 20 }); + { + const ok = stale.stale === true + && String(stale.error || '').startsWith('STALE') + && stale.count === 3; + record('STALE: stats.stale, "STALE · cached" error, fires still render', ok, + `stale=${stale.stale} error=${JSON.stringify(stale.error)} count=${stale.count}`); + if (!ok) exitCode = 1; + await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-stale.png') }); + } + + // ── (v) ACTION (deterministic intercepted detection) ──────────────────────── + console.log('\n(v) ACTION — synthetic intercepted fire card through shared camera policy...'); + await setView(page, -122.9, 61.9, 60000); + const actionCards = await waitForCardCanvasInk(page); + const beforeActions = await firmsActionSnapshot(page); + if (!(actionCards.present && actionCards.painted > 0 && beforeActions.count > 0)) { + record('ACTION (synthetic): individual fire action is available', false, + `painted=${actionCards.painted} actionButtons=${beforeActions.count}`); + exitCode = 1; + } else { + const prepared = await page.evaluate(() => { + const gev = window.__godsEyeView; + const mod = gev.dataManager.layers.get('local-firms').module; + const target = mod.getDetectableObjects({ maxCount: 1 })[0]; + if (!target?.position) return null; + + const sentinel = gev.viewer.entities.add({ + id: 'qa-firms-prior-camera-owner', + position: target.position, + }); + gev.viewer.trackedEntity = sentinel; + + const camera = gev.viewer.camera; + const originalFly = camera.flyToBoundingSphere; + const proof = { + sentinel, + originalFly, + flightCount: 0, + trackedAtFlight: 'not-called', + request: null, + generationBefore: gev.styleManager._navigationGeneration, + }; + proof.onRequest = (event) => { + proof.request = { + kind: event.detail?.kind || null, + id: event.detail?.id || null, + }; + }; + window.addEventListener('gev:world-request-focus', proof.onRequest); + camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) { + proof.flightCount += 1; + proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null; + return originalFly.apply(this, args); + }; + window.__qaFirmsActionProof = proof; + return { generationBefore: proof.generationBefore }; + }); + + if (!prepared) { + record('ACTION (synthetic): individual fire action is available', false, + 'no deterministic fire position'); + exitCode = 1; + } else { + const clicked = await page.evaluate(() => { + const button = [...document.querySelectorAll( + '#world-overlay-action-list button[data-overlay-action-key]', + )].find((candidate) => String(candidate.dataset.overlayActionKey || '').startsWith('firms\u0000')); + button?.click(); + return Boolean(button); + }); + + for (let i = 0; i < 30; i += 1) { + const settled = await page.evaluate(() => { + window.__godsEyeView?.viewer?.scene?.requestRender?.(); + return (window.__qaFirmsActionProof?.flightCount || 0) > 0; + }); + if (settled) break; + await sleep(100); + } + await sleep(250); + + const proof = await page.evaluate(() => { + const gev = window.__godsEyeView; + const state = window.__qaFirmsActionProof; + const actionButtons = [...document.querySelectorAll( + '#world-overlay-action-list button[data-overlay-action-key]', + )].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000')); + const selectedEntityId = window.__gevContextStore?.selectedEntityId || null; + const result = { + flightCount: state?.flightCount || 0, + trackedAtFlight: state?.trackedAtFlight, + trackingReleased: gev.viewer.trackedEntity == null, + generationBefore: state?.generationBefore, + generationAfter: gev.styleManager._navigationGeneration, + request: state?.request || null, + selectedEntityId, + selectedActionCount: actionButtons.filter( + (button) => button.getAttribute('aria-pressed') === 'true', + ).length, + }; + if (state) { + gev.viewer.camera.flyToBoundingSphere = state.originalFly; + window.removeEventListener('gev:world-request-focus', state.onRequest); + if (state.sentinel) gev.viewer.entities.remove(state.sentinel); + } + delete window.__qaFirmsActionProof; + return result; + }); + + const actionOk = clicked + && proof.flightCount === 1 + && proof.trackedAtFlight == null + && proof.trackingReleased + && proof.generationAfter === proof.generationBefore + 1 + && proof.request?.kind === 'fire' + && proof.request?.id === proof.selectedEntityId + && proof.selectedActionCount === 1; + record('ACTION (synthetic): stable fire selects once and shared policy owns flight', actionOk, + `clicked=${clicked} flights=${proof.flightCount} trackedAtFlight=${JSON.stringify(proof.trackedAtFlight)} ` + + `generation=${proof.generationBefore}->${proof.generationAfter} ` + + `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} ` + + `selectedActions=${proof.selectedActionCount}`); + if (!actionOk) exitCode = 1; + await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') }); + } + } + } catch (e) { + console.error('\x1b[31mHarness error:\x1b[0m', e); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok === true).length; + const fail = results.filter((r) => r.ok === false).length; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/firms-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { console.error(e); process.exit(3); }); diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs new file mode 100644 index 000000000..0efc867f2 --- /dev/null +++ b/scripts/qa-firstrun-mutations.mjs @@ -0,0 +1,519 @@ +#!/usr/bin/env node +/** + * Are the first-run launcher's pins actually load-bearing? + * + * A pin that only goes red when you delete the whole feature proves very little. + * This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces + * the original defect or contradicts the product rule — and requires + * src/firstRunExperience.test.mjs to go red for it. Every entry names what it + * restores, so the count is reproducible rather than asserted in a commit + * message. + * + * node scripts/qa-firstrun-mutations.mjs + * + * Every touched file is restored on exit, including on failure. + * + * NOTE: two mutations edit vite.config.js, and a running dev server watches that + * file and restarts on every write. Writes are therefore content-guarded below + * so the file is touched exactly twice per mutation instead of on every + * iteration — enough that a dev server survives, but expect it to restart. If + * you are mid-QA on a live server, run this before or after, not during. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TESTS = 'src/firstRunExperience.test.mjs'; + +const FILES = { + module: path.join(ROOT, 'src', 'firstRunExperience.js'), + html: path.join(ROOT, 'index.html'), + css: path.join(ROOT, 'style.css'), + vite: path.join(ROOT, 'vite.config.js'), + main: path.join(ROOT, 'src', 'main.js'), + ui: path.join(ROOT, 'src', 'ui.js'), + docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), +}; + +/** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */ +const MUTATIONS = [ + // ── Show policy (product decision: session-scoped dismiss vs durable checkbox) ── + { + defect: 'dismissing writes the DURABLE key, so the launcher never returns', + file: 'module', + from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');", + to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');", + }, + { + defect: 'the checkbox is ignored, so nothing can ever suppress the launcher', + file: 'module', + from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')", + to: ' ? false', + }, + { + defect: 'unticking the box cannot take the suppression back', + file: 'module', + from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);", + to: ' : true;', + }, + { + defect: 'the session flag is never read, so it re-nags on every reload', + file: 'module', + from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", + to: '// session check removed', + }, + { + defect: 'the durable flag is never read, so the checkbox does nothing', + file: 'module', + from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", + to: '// durable check removed', + }, + { + defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it', + file: 'module', + from: "if (params.get('welcome') === '1') return true;", + to: '// replay hatch removed', + }, + { + defect: '?welcome=0 stops suppressing', + file: 'module', + from: "if (params.get('welcome') === '0') return false;", + to: '// suppression param removed', + }, + { + defect: 'a share link gets the launcher over the view its author chose', + file: 'module', + from: 'if (hasShareState) return false;', + to: '// share bypass removed', + }, + { + defect: 'storage is read from a DEFAULT PARAMETER, outside every guard', + file: 'module', + from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {', + to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {', + }, + { + defect: 'shouldShowFirstRun resolves storage in its parameter list again', + file: 'module', + from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,', + to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,', + }, + { + defect: 'the guarded resolver stops catching, so a hostile getter escapes', + file: 'module', + from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", + to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;", + }, + { + defect: 'a blocked getItem throws instead of reading as "nothing stored"', + file: 'module', + from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }', + to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }', + }, + + // ── ESC arbitration ─────────────────────────────────────────────────────── + { + defect: 'the key handler trusts the CLASS, so an invisible card eats ESC', + file: 'module', + from: 'if (closing || !isTopmost()) return;', + to: 'if (closing) return;', + }, + { + defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC', + file: 'module', + from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();', + to: 'if (!revealed) reveal();', + }, + { + defect: 'nothing watches for another surface taking the screen', + file: 'module', + from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", + to: '/* observer never attached */', + }, + { + defect: 'cockpit drops off the exclusive list and can stack with the launcher', + file: 'module', + from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',", + to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([', + }, + { + defect: 'a scene hides the launcher in CSS but the JS never learns of it', + file: 'css', + from: 'body.scene-playback-mode #first-run-launcher {', + to: 'body.scene-playback-mode-typo #first-run-launcher {', + }, + { + defect: 'yielding steals focus back from the surface that just took over', + file: 'module', + from: 'dismiss({ restoreFocus: false });', + to: 'dismiss();', + }, + { + defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again', + file: 'module', + from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', + to: ' && root.getClientRects().length > 0;', + }, + { + defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working', + file: 'module', + from: ' return Boolean(hit) && !root.contains(hit);', + to: ' return !root.contains(hit);', + }, + { + defect: 'the launcher stops honouring a key another surface already claimed', + file: 'module', + from: ' if (event.defaultPrevented) return;', + to: ' /* belt removed */', + }, + { + // Anchored past the call so it cannot land on the cockpit disclosure's own + // stopImmediatePropagation() a few lines below and prove nothing about this. + defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', + file: 'ui', + from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });', + to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });', + }, + { + defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', + file: 'module', + from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears', + to: ' * (note removed)', + }, + { + defect: 'a bounded reveal timer punches the card through a recording in progress', + file: 'module', + from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;', + to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);', + }, + { + defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug', + file: 'docs', + from: 'a surface class that never clears means no launcher for that page', + to: 'the launcher always turns up eventually for that page', + }, + + // ── The checkbox may not promise what storage refused ───────────────────── + { + defect: 'a refused write leaves the box ticked, promising a suppression nobody stored', + file: 'module', + from: ' if (setFirstRunSuppressed(wanted, storage)) return;', + to: ' setFirstRunSuppressed(wanted, storage);\n return;', + }, + { + defect: 'a missing storage area reports the write as saved', + file: 'module', + from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;", + to: ' store?.setItem?.(key, value);\n return true;', + }, + { + defect: 'the scroll fade promises more list on a card where everything fits', + file: 'module', + from: 'choiceList.dataset.scrollable = String(overflows);', + to: "choiceList.dataset.scrollable = 'true'; void overflows;", + }, + + { + defect: 'the DISPLAY rail opens on first run again, stealing the impression', + file: 'ui', + from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;", + to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;", + }, + { + defect: 'the first-run rail default overrides a stored user choice', + file: 'ui', + from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;", + to: "if (panelId === 'pp-toggles') collapsed = true;", + }, + + // ── Mission definitions ──────────────────────────────────────────────────── + { + defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', + file: 'module', + from: " environmental: Object.freeze({", + to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({", + }, + { + defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key', + file: 'module', + from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),", + to: "layerIds: Object.freeze(['local-firms']),", + }, + { + defect: 'FIRMS is dropped again from the tile whose subcopy promises it', + file: 'module', + from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),", + to: "layerIds: Object.freeze(['earthquakes']),", + }, + { + defect: 'the tile stops promising the fires it actually turns on', + file: 'html', + from: 'Live earthquakes and active fires, from USGS and NASA', + to: 'Live earthquakes worldwide, straight from USGS', + }, + { + defect: "the final first-run line is quietly rewritten", + file: 'html', + from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', + to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.", + }, + { + defect: 'the ledgered global-chip defect loses its pointer to the real fix', + file: 'module', + from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state', + to: ' // (note removed)', + }, + { + defect: 'the globe missions never pull the camera out', + file: 'module', + from: ' .then(() => flyToGlobe())', + to: ' .then(() => null)', + }, + { + defect: 'a refused layer is reported as a successful mission', + file: 'module', + from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };', + to: 'return { ok: true, choice, failedLayerIds };', + }, + { + defect: 'a cancelled globe flight fails the whole mission', + file: 'module', + from: ' .catch(() => null);', + to: ' .catch(() => { throw new Error("flight failed"); });', + }, + { + defect: 'Explore manually quietly runs a mission instead of nothing', + file: 'module', + from: "if (mission.kind === 'none') return { ok: true, choice };", + to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };", + }, + { + defect: 'an unknown choice is treated as a success', + file: 'module', + from: 'if (!mission) return { ok: false, choice };', + to: 'if (!mission) return { ok: true, choice };', + }, + { + defect: 'the fires/quakes tile name stops being switchable from one constant', + file: 'module', + from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;', + to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;', + }, + + // ── Defaults interplay (the ruling this whole integration turns on) ──────── + { + defect: "mission layer enables stop persisting, unlike the clicks they stand for", + file: 'module', + from: "dataManager.setEnabled(layerId, true, { origin: 'user' })", + to: 'dataManager.setEnabled(layerId, true)', + }, + { + defect: 'a mission hand-edits detection and kills the style auto-preset contract', + file: 'module', + from: ' flyToGlobe: () => styleManager.resetToGlobeView(),', + to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },', + }, + { + defect: 'a mission persists a 3D-models choice nobody made', + file: 'module', + from: ' const result = await styleManager.setContextMode(mode);', + to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);', + }, + { + defect: 'the Context panel is left collapsed, hiding the mode that just started', + file: 'module', + from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", + to: '// panel reveal removed', + }, + { + defect: 'the decision table is deleted, so the next editor re-litigates it blind', + file: 'module', + from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST', + to: ' * (notes removed)', + }, + + // ── Accessibility and the transplant defects ────────────────────────────── + { + defect: 'disabling the focused tile drops the keyboard to mid-flight', + file: 'module', + from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", + to: 'for (const button of buttons) button.disabled = next;', + }, + { + defect: 'Tab escapes into an app the visitor has not seen yet', + file: 'module', + from: "if (event.key !== 'Tab') return;", + to: 'return;', + }, + { + defect: 'ESC no longer dismisses the launcher', + file: 'module', + from: "if (event.key === 'Escape') {", + to: 'if (false) {', + }, + { + defect: 'focus is never returned to where the visitor left it', + file: 'module', + from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {", + to: 'if (false) {', + }, + { + defect: 'app hotkeys eat the launcher keys (bubble instead of capture)', + file: 'module', + from: "documentRef.addEventListener('keydown', onKeyDown, true);", + to: "documentRef.addEventListener('keydown', onKeyDown);", + }, + { + defect: 'the card stays clickable through its fade-out (double-launch)', + file: 'css', + from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', + to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', + }, + { + defect: 'the launcher survives Clean-UI and recording mode', + file: 'css', + from: 'body.ui-clean-view #first-run-launcher,', + to: 'body.never-matches-anything #first-run-launcher,', + }, + { + defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal', + file: 'css', + from: '#first-run-launcher[hidden] {\n display: none;\n}', + to: '/* [hidden] rule removed */', + }, + { + defect: 'the card loses its viewport cap and clips on a short landscape phone', + file: 'css', + from: ' max-height: calc(100dvh - 1.5rem);', + to: ' /* cap removed */', + }, + { + defect: 'the mission list stops scrolling, so tiles overflow the capped card', + file: 'css', + from: ' min-height: 0;\n overflow-y: auto;', + to: ' /* scroll removed */', + }, + { + // Anchored to the launcher's OWN selectors: style.css has four other + // reduced-motion blocks, and a bare `@media` anchor would mutate one of + // those instead and prove nothing about this feature. + defect: "the launcher's reduced-motion opt-out is dropped", + file: 'css', + from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }', + to: ' .first-run-nothing { transition: none; }', + }, + + // ── Markup and startup ordering ─────────────────────────────────────────── + { + defect: 'a mission tile is dropped from the menu', + file: 'html', + from: '