diff --git a/.env.example b/.env.example index 1949c48f4..e16e85a27 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,13 @@ AISSTREAM_API_KEY= # 0 disables the silence watch entirely. # AISSTREAM_SILENCE_TIMEOUT_MS=120000 +# Optional: local ADS-B decoder feeds for the Local ADS-B layer. SERVER-SIDE +# ONLY. Comma-separated band=url entries; band is 1090 or 978 (UAT). Each host +# must be loopback, a private (RFC1918) address, localhost or a *.local name; +# the path must end in aircraft.json. Invalid entries are logged and skipped. +# See docs/LOCAL-RECEIVERS.md. +# LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json + # Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000 # Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card # required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: diff --git a/CHANGELOG.md b/CHANGELOG.md index 33174d45b..05f9936a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,251 @@ TomTom key when the public OpenStreetMap mirrors are the side that failed. Failures the layer cannot classify keep the general line. +- Bound the client terrain-height cache at 20 000 entries with least-recently-used eviction, so a long session no longer retains every coordinate it ever resolved. Consumer reads promote their entry and a batch still reports every point it resolved (Pedro Lobato, #594). + +- Release CCTV media streams whose upstream falls silent after answering. The + 15-second media deadline covered only the wait for response headers, so a + camera that replied and then stopped sending held both the proxy connection + and its upstream socket open for as long as the camera host allowed; the + declared `Content-Length` ceiling was the only body bound, and a chunked or + length-less body had none. A 30-second idle deadline now bounds the gap + between upstream chunks and releases a body that has gone silent. It is + rescheduled while the response is still waiting to drain, so a viewer on a + slow link is not mistaken for a dead camera; live feeds are unaffected + (Ethan Stoner, #688). + +- Aircraft track backfill (`/api/opensky-track`, `/api/adsblol/trace`) now + answers 502 when the upstream body exceeds the 5 MB cap, instead of a 200 + whose error body the client read as an empty track. The failure is cached + like other upstream errors, so retries inside the 60 s window do not spend + OpenSky credits (Raushankumar0720, #720, #722). + +- Saving a key from Provider Settings works again on Macs where Nix or + Homebrew coreutils sit ahead of `/bin` on `PATH`. The credential hardener + now spawns Apple's `/bin/chmod -N` by absolute path; GNU `chmod` has no `-N`, + so the ACL strip failed closed and every save was refused (Arthur Bogaart, #694). + +- Render on iPad and iPhone instead of stopping with "An error occurred while + rendering." Cesium's per-vertex model atmosphere binds shader `out` + parameters directly to varyings, which Apple's Metal/ANGLE backend cannot + link, so the program failed and the render loop was torn down. The stage is + now kept out of the pipeline on affected devices by clearing + `scene.fog.renderable`, which leaves `fog.enabled` — and the fog density that + drives 3D Tiles refinement — untouched. Detection is a WebGL2 link probe of + the same pattern, so a future driver fix restores the effect with no code + change, with iOS/iPadOS detection as a backstop. Sky atmosphere and the + ground-atmosphere fragment path route through locals and are unaffected; + affected devices lose distance fog on 3D tiles and on the globe basemaps. + `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and + the `renderable`-not-`enabled` choice (KnottyDyes, #705). + +- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over + the Alps") work again in the dev server: the bundled Natural Earth and + neighborhood packs are fetched as JSON in the browser + (`src/data/bundledJson.js`). When a region is not in the bundled packs, the + geocode and admin-boundary fallback answers `region-timeout` after 3 s + instead of holding the reply. + +- Transit and Directions rows repaint as soon as their data lands again: + `refreshLayerStats()` now lives on the layer lifecycle, not only on the + compatibility facade. `scripts/qa-radio.mjs` uses it instead of a private + panel method. +- On phones the title bar sits 16 px from the top so both Radio broadcast + waves stay on-screen. +- Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context + headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact + player. Add Cyber Sonar voice controls with settings and effect-state readback. + Keep keyboard-focus outlines visible inside Cyber's clipped map and cockpit + expand/collapse buttons, with a matching red hover border. + +- Add the opt-in Cyber HUD layout with coordinated map and cockpit panel + styling. Display exposes Sonar on/off, rings, range, power, opacity and sector. + Native point, billboard and label highlighting uses GPU draw commands; there + is no scene-dimming effect selector. Unsupported shaders retain native contact + rendering, and leaving Cyber restores the standard shell and contact treatment. + +- Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). + Select a box (drag, the current view, or around a pin; up to 1,000 km a + side) and the right-rail panel lists the last 30 days of Sentinel-2 / + Landsat (30 m) and, when switched on, VIIRS daily overview imagery over it, + with thumbnails and scene cloud. Three modes: IMAGE shows one day, VS + BASEMAP swipes it against the map, A / B swipes two days; a SHOW or A / B + chip on each day card pins it, arrow keys preview the focused day while a + slot is empty, and no control moves when anything changes. While imagery is + shown on Google 3D the map switches to Esri and comes back when it is + cleared. Either image exports as a PNG; box, pins, mode and split travel in + share links; the NASA acknowledgement is in the credits and + `DATA_SOURCES.md`. The swipe is now shared with the Nepal scene + (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and + `MapSourceController.subscribe()` reports every settled map switch. + +## Unreleased — local receiver feeds + +- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder + feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978 + serves. Configure them server-side with `LOCAL_RECEIVER_FEEDS` + (`band=url`, comma-separated); there is no feed editing in the browser. + Hosts must be loopback, RFC1918, `localhost` or `*.local`, the scheme http or + https and the path must end in `aircraft.json`; any other entry is logged at + startup, reported `invalid` and never fetched. +- Add `GET /api/local-receivers/aircraft`. It reads every configured feed in + parallel (2 s timeout, redirects refused, 2 MB body cap, about 1 s of shared + cache) and reports each feed `live`, `stale` (its own `now` is over 10 s old), + `unreachable` or `invalid`. Unconfigured, it answers + `{ configured: false }` and fetches nothing. Upstream error text is never + returned. +- The layer merges browser-SDR and feed aircraft by ICAO, keeping the newest + position, polls the route every second only while it is enabled, and remembers + which bands and sources heard each aircraft in the last 60 s. The click card + names them (for example "Heard by your receiver · 978 MHz UAT · decoder + feed"); aircraft heard only on 978 MHz carry a thin ring. The row status + covers both inputs ("2 feeds live · 14 heard", "feed 978 unreachable"), and + the Local RTL-SDR card shows a read-only decoder-feed line. +- While any input is producing aircraft (the browser receiver streaming, or a + feed live), the Local ADS-B row stays ON and lists feeds that are not live as + a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale") instead of + showing DEGRADED. +- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`). +- See `docs/LOCAL-RECEIVERS.md`. + +## Unreleased — local RTL-SDR and Local ADS-B + +- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in + desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek, + volume) or 1090 MHz ADS-B. Local FM and internet-radio playback never play + together: starting one stops the other. +- Add a gain control: AUTO or a manual R820T step, remembered per mode. ADS-B + defaults to 28.0 dB (the earlier 20.7 dB gave about 1 msg/s against about 9 + at 28.0 dB on the same antenna; a stored choice still wins), FM to AUTO; + changes apply without reconnecting. In + ADS-B mode the card shows CRC-valid messages per second, aircraft heard, + aircraft positioned and the IQ level. +- Prefer the ADS-B/1090 MHz channel of a dual-channel receiver, remember an + explicitly chosen device per mode, and add CHANGE DEVICE to reopen the + WebUSB picker. +- Add the Local ADS-B layer (`local-adsb`, off by default, not part of share + links). Aircraft heard by the receiver draw in magenta beside public Flights; + a marker drops when its position is 60 s old and the aircraft is forgotten + after 60 s without a message. Clicking one opens a card; markers are not + camera-followed. Voice `set_layer_visibility` accepts `local-adsb`. +- Normalize local ADS-B into one record shape, with a pure adapter for + dump1090/readsb `aircraft.json` documents. +- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see + `THIRD_PARTY_NOTICES.md`. + +## Unreleased — weather review + +- On 3D Tiles, draw a 4096×2048 detail window around the view on each + observed-weather shell except global infrared, sampled by the shell's own + surface. It follows the view on camera move end, keeps its place while the + view stays near its centre, and hides until its image is ready after a move; + an older frame's detail stays over at most one newer frame. The image proxy + accepts a 2:1 `bbox` inside the product bounds, rounded to 0.25°. + Lightning's whole-extent image is now 4096×2048; each shell caches up to + 128 MiB of decoded images. + +- On 3D Tiles, show observed weather and the wind color field as raised, + translucent shells (5.0–6.6 km, lightning highest) with one full-extent image + per frame instead of draping onto tiles; they show at any camera height. + Globe hosts are unchanged. The weather image proxy serves every product + (radar and regional infrared up to 4096×2048, lightning and global infrared + up to 2048×1024) with a size parameter and a 16 MiB cap. Accept bounded tile + sizes in the proxy with separate immutable cache entries. + +- Cache exact-time weather images and tiles for 24 hours. Retain up to 6 decoded + global mosaics per renderer and warm the next observation during playback; + tile prefetch is bounded to eight requests and cancels when suspended. + +- Move weather times, coverage, legends and cyclone advisory details into keyed + right-rail cards; left rows keep status and configuration. Use one native + observed-history timeline with local preview and coalesced drag commits. +- Add reusable rail card and timeline components. Keep panel collapse, count, + hidden-empty behavior and first-appearance expansion. +- Show wind unit controls beside speed legends and inside inspection readings; + changing units preserves the sampled location and open reading. +- Name satellite imagery Satellite clouds, with Clouds only / Full image modes + and explicit regional coverage. Existing share parameters are unchanged. + +- Share one observed history clock across radar, infrared and lightning. Step + through their union timeline with bounded nearest-at-or-before selection; + hide products without an eligible frame. Latest uses each product's newest + observation; playback waits for all frame loads to settle before advancing. +- Label wind as a forecast that does not follow observed history. Keep history + transient and product readouts synchronized. + +- Keep Google 3D Tiles drawing while draped weather imagery loads and retain the + old observation until the replacement has rendered. +- Decode one bounded global infrared mosaic per frame on both map hosts, then + crop local tiles to avoid request-dependent brightness seams. +- Add Clouds only / Full image controls and share-link state. Filtered mode uses + a soft linear-brightness alpha ramp from 0.40 to 0.70 around the old 0.55 cut. + +- Dock the weather summary in the right rail with standard panel collapse and drag chrome. + +- Filter infrared brightness so cold cloud tops stand out; this is a display + filter, not a cloud mask. +- Retry throttled weather tiles up to three times per tile. + +- Cull regional wind batches per frame, fade curves below 60 km, and stop idle + rendering when no curve is visible. +- Fade the wind color field below ~1,200 km camera height and hide it at 200 km. + On 3D Tiles, update alpha in 0.1 steps only at camera move end or installation; + globe imagery keeps its smooth per-frame fade. + +- Hide cyclone markers, labels, tracks and cones beyond the horizon on every map source. +- Reserve stable weather status space and coalesce panel refreshes per frame. + +- Add keyless NOAA/NHC cyclone advisory positions, coherent forecast tracks and + uncertainty cones, plus NOAA's observed 15-minute lightning density imagery. + Preserve source clocks, basin coverage and explicit pending/stale states. +- Make wind default to trails, preserve earlier share-link appearance, retain + geometry across scalar changes, and show a compact weather summary with a + location marker and selected-field emphasis for forecast inspection. + +- Add keyless NOAA observed rain radar and infrared satellite layers to Weather, + with explicit coverage/freshness, recent observation playback and native Cesium tiles. +- Increase desktop wind density to 7,200 paths and improve temperature contrast + while retaining the 1,200-path narrow-screen budget and unchanged forecast values. + +- Expand Wind into a surface-weather prototype: globe-draped speed shading, + optional same-run 2 m temperature and mean sea-level pressure, GFS/ECMWF model + selection, a numeric legend, wind units, Pause, and a dismissible map-center + reading. Keep wind visible when an optional field is unavailable; respect + reduced motion and stop animation while hidden or disabled. Bake bounded + forecast-following curves once per field and animate their phase on the GPU, + with a canvas fallback and globe view lighting owned only while Wind is enabled. + Display lift does not + change the 10 m forecast level; this adds no cloud volume, radar or forecast-time + playback. Native hardware GPU behavior remains unverified. + +Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). + +Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). +- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency + wildfire incident perimeters as ground-clamped polygons with a + containment-colored fire line, refreshed every 5 minutes from the public + keyless feature service with truncation paging. Clicking a perimeter shows + an incident card (acreage, containment, cause, behavior, personnel, county, + cost, complex membership) and, when the incident has a state- and + recency-verified InciWeb page, a click-through link to it. Recency uses + incident page origin and update times because the publication API was retired. + The layer is reachable from the panel, voice control, share links (token `2`), and the + analyst query engine. WFIGS and InciWeb requests use a capped, cached + same-origin proxy with timeouts and a per-client limit. Unchanged refreshes + retain geometry; incident-link checks abort on disable or selection change, + and the row includes a containment legend. + +- Remove the spurious scrollbars that appeared on both panel stacks at narrow + widths (720px and below) as soon as a panel was expanded. The stacks scroll + vertically there, and each panel's decorative glow, absolutely positioned + with a negative inset, became 18–20px of scrollable overflow on both axes: a + horizontal scrollbar band under the expanded CCTV, Context, Data Layers or + Scenes panel plus a vertical scrollbar that scrolled nothing but glow. The + narrow-screen rules now pin the glow to its panel box; the stacks still + scroll for genuinely tall content such as an expanded DISPLAY panel, and the + Context radio popover is not clipped. `src/ui/panelRails.test.mjs` pins the + rule against every 720px media block. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing @@ -59,17 +304,14 @@ with shared playback/seek interpolation, easing and holds. Navigation and manual input cancel authored motion; older scene files retain existing flights. - - Director validates bounded version-3 scene files before replacing a project, preserves unreadable browser saves, migrates legacy bloom once and preserves zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner. - - Separate Director timing, seek calculations, playback clocks and registered scene-pack presentation rules. Preserve authored content and controls; Stop releases pending hold timers and stale ticks cannot affect replacement playback. - - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport @@ -163,6 +405,13 @@ - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. +## Satellite pass prediction + +- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola. +- Mark passes visible from Earth-shadow and civil-twilight checks. +- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite. +- `next_iss_pass` retains the next geometric pass and adds visibility metadata. `next_satellite_pass` adds bounded loaded-catalog name/NORAD lookup and optional visible-only filtering (Rehaan Delmotra, #451; maintainer adaptation). + ## Voice component boundaries - Separate voice controls, Realtime connection requests and the action runner. @@ -254,8 +503,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] -- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. +- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. +- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. - Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice. @@ -350,6 +600,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added +- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the + existing `FIRMS_MAP_KEY` and 30-minute cache. - Two map-orientation controls sit beside Share in the top-center globe actions. Tilt Map swings between a straight-down map and a 35-degree oblique around the point under the centre of the view, keeping that point and the @@ -384,6 +636,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md Visible animation, detection membership, history storage and proxy requests are bounded. Share links carry Transit as token `j`. +- Add a keyless **Wind** layer from NOAA GFS 10 m wind (#459, thanks @beneduzi). The `/api/wind` proxy + byte-range fetches only the UGRD/VGRD GRIB2 messages from the public AWS bucket, + decodes them with ecCodes (WASM), and serves a compact Float32 U/V grid; the + client renders nullschool-style animated particles in a canvas overlay that + follows the Cesium camera and skips globe-occluded points. Forecast, not + observations. Requires Node ≥24 for the WASM decoder. - Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area highway cameras, with server-registered still URLs and attribution. - CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh). @@ -1046,3 +1304,10 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + +### Live CCTV integration candidate + +- Live HLS video shares one decoder between the camera panel and projection, + with a DelDOT HTTPS source pack. Credit: Daniel Slay (@Danielslay86), PR #489. +- Maintainer adjustments bound sessions and downloads, remove disk/subprocess + remuxing, reject redirects, and clean up playback on switching or disabling. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 7c822558a..b1439230e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -15,6 +15,11 @@ How to read this: | Source | Used for | License / terms | Attribution | | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **NOAA GFS (wind)** (`noaa-gfs-bdp-pds.s3.amazonaws.com`) | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind. Keyless; latest 6-hourly 0.25° cycle, byte-range GRIB2 reads, cached for an hour | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) | +| **ECMWF IFS (wind)** (`data.ecmwf.int/forecasts`, ECMWF Open Data) | The alternative Wind model: 10 m wind, optional 2 m temperature and mean sea-level pressure. Keyless; latest 6-hourly 0.25° run, byte-range GRIB2 reads, cached for an hour | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) plus the [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) | "This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF)", with the CC BY 4.0 link, the modification notice and ECMWF's liability disclaimer — shown in-app | +| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" | +| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" | +| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping | | **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 | @@ -26,6 +31,9 @@ How to read this: | **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) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | +| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" | | **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) | @@ -41,6 +49,7 @@ How to read this: | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) | | **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | +| **DelDOT (video.deldot.gov)** | CCTV camera catalog (`tmc.deldot.gov/json/videocamera.json`, cached for 15 minutes) + live HTTPS HLS video, Delaware. Keyless | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) | "DelDOT — Delaware Department of Transportation" (courtesy) | | **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" | | **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario | | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | @@ -58,6 +67,10 @@ How to read this: | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover | +### Local receiver input (not fetched from a service) + +The optional Local ADS-B layer and the broadcast-FM receiver use radio you receive yourself: a USB RTL-SDR opened in the browser through WebUSB, or the `aircraft.json` of a decoder you run on your own machine or network (`LOCAL_RECEIVER_FEEDS`). Radio samples, audio and the optional receiver location stay in the browser. Decoder feeds are read by your local GEV server and relayed only to your browser, with about a second of in-memory caching. When you select a local aircraft, its ICAO address (and an airline-style callsign) is looked up, and aircraft that qualify for a 3D model are looked up by ICAO address, through the same adsbdb proxy the public flight layers use; your server caches those answers on disk and treats them as fresh for 24 hours. Received radio data is not stored or redistributed. ADS-B is an unauthenticated public broadcast and may be incomplete, delayed, spoofed or decoded in error; it is not suitable for navigation or operational use. + ### 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)). @@ -67,6 +80,7 @@ How to read this: - **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. - **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. - **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed. @@ -148,7 +162,7 @@ The OSM-derived datasets are under the **Open Database License**. ODbL's share-a 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 +Suomi-NPP) and MODIS NRT (Terra + Aqua), 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. @@ -191,6 +205,141 @@ Douglas-Peucker simplification, 6-decimal rounding). --- +## Weather sources + +### ECMWF IFS wind + +Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and byte-range fetches 10u/10v GRIB messages, optionally adding 2t (2 m temperature) or msl (mean sea-level pressure) from the same run and forecast time. It resamples these fields to the approximately 1° display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) also apply. In-app attribution identifies this service as based on ECMWF data and products, links the CC BY 4.0 licence, identifies resampling and animation as modifications, and retains the required ECMWF liability disclaimer. Both model issue time and the selected forecast valid time appear in the WEATHER panel. These are model forecasts, not observations. + +### NOAA GFS wind + +The optional **Wind** layer animates the global 10 m wind field from NOAA's +Global Forecast System (GFS). The `/api/wind` server-side proxy selects the +latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only +the `UGRD`/`VGRD` 10 m GRIB2 messages, optionally adding `TMP` at 2 m or +`PRMSL` at mean sea level from the same run and forecast time. It decodes the +selected messages with ecCodes (WASM), resamples to a compact grid (1° by +default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached +per cycle for an hour, and is a **forecast, not an observation**: the particles +show model flow, not measured wind. The renderer bakes bounded curves through +the sampled field and animates their phase on the GPU, with a canvas fallback. +Any display lift is a visual aid and does not change the forecast's 10 m level. +NOAA GFS data is U.S. public domain; the credit above is a courtesy and does not +imply endorsement. + +For both models, optional temperature is normalized from K to °C and pressure +from Pa to hPa before delivery. A missing, malformed or timed-out companion field +does not discard valid wind: the manifest marks that field unavailable, and the +UI retains wind without inventing scalar values. The proxy caches by model and +requested field, with bounded current/previous grids and shared in-flight loads. +Wind speed shading is computed locally from U/V and needs no companion download. +The legend and map-center reading explain units and forecast validity; the +animation is a visual flow through one forecast, not advancing forecast time. +Optional globe relief uses existing terrain vertex normals for view lighting; +without those normals it shades global globe curvature only, not local terrain +relief. Neither mode represents measured sunlight or a new elevation source. +This Wind prototype adds no cloud-volume or radar data. Mapped.earth's public +bundles were studied for rendering ideas, but +no code or assets were reused and no application licence granting reuse was +found. Its presentation is not a weather-data source for this implementation. + +### Observed weather: NOAA nowCOAST + +The Weather section provides keyless, observed **MRMS radar reflectivity** for the +contiguous United States and **infrared satellite imagery**. Fixed upstream WMS +services: `https://nowcoast.noaa.gov/geoserver/observations/weather_radar/ows` and +`https://nowcoast.noaa.gov/geoserver/observations/satellite/ows`. + +- `conus_base_reflectivity_mosaic`: approximately 1 km, usually 4-minute updates. + dBZ measures radar reflectivity, not rainfall rate, probability or future rain. + Coverage gaps do not mean no precipitation. The numeric legend follows NOAA's + `weather_radar_base_reflectivity` style. +- `goes_longwave_imagery`: GOES-19/18 Band 14, approximately 2 km, 5-minute updates, + regional North America. **Clouds only** softly dims pixels with a linear + brightness ramp from 0.40 to 0.70; **Full image** retains source alpha. This + is a display filter, not a cloud mask or measured cloud volume. +- `global_longwave_imagery_mosaic`: approximately 3 km, hourly, nominal 60°S–60°N + coverage and typically 2–3-hour latency. It is slower global context. + +The UI displays the exact advertised observation time separately from acquisition; +latest means the newest available observation, not zero-delay real time. Up to 13 +recent advertised frames can be replayed as history. No nowcast is synthesized. +Tiles use WMS 1.1.1 EPSG:4326 longitude/latitude bounds; Cesium's geographic 2×1 +root grid is capped at level 6 for radar and regional GOES. Global infrared uses +one fixed 2048×1024 geographic image on both hosts: the source's request-dependent +contrast otherwise creates brightness seams between tiles. The browser decodes +and filters it once, then crops 256 px tiles bounded to the manifest extent at +maximum level 3. This broad context view has a coarser display resolution than +its 3 km source. No browser reprojection is used. Browser imagery owns a displayed +frame and a staging frame, retaining the outgoing layer for one render after +admission. + +On Google 3D Tiles each observed product is instead a raised shell showing one +whole-extent image per frame (up to 4096×2048; global infrared 2048×1024). Every +product except global infrared also draws a 4096×2048 detail window around the +view, requested with a 2:1 `bbox` inside the product bounds, rounded to 0.25°. +The proxy has an 8-request concurrency budget and a 12-second deadline; tiles +(256, 512 or 1024 px) are capped at 1 MiB or 4 bytes per pixel plus 64 KiB, +whichever is larger, and whole-extent and detail images at 16 MiB. Its shared +cache holds up to 128 images and 16 MiB, each exact-time image for at most 24 +hours. +Metadata refreshes every two minutes with explicitly stale last-good fallback. +No key, new dependency, image reprojection job, or full-disk image download is needed. + +Credit: NOAA nowCOAST, NWS/OAR MRMS, NESDIS GOES and global satellite partners. +[NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html). +Community context: [#85](https://github.com/bilawalsidhu/gods-eye-view/issues/85), +[#588 radar](https://github.com/bilawalsidhu/gods-eye-view/pull/588), and +[#457 clouds](https://github.com/bilawalsidhu/gods-eye-view/pull/457). +This implementation is original; those contributions have not been merged here. + + +### Weather: NHC/CPHC cyclone advisories + +- **Sources:** [NHC current tropical cyclone status](https://www.nhc.noaa.gov/CurrentStorms.json) and + [NOAA tropical weather summary GIS](https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer). +- **Rights/credit:** NOAA/NWS National Hurricane Center / Central Pacific Hurricane Center; + [NWS public-data terms](https://www.weather.gov/disclaimer). Fetch official products at runtime; + no bundled advisory archive and no NOAA endorsement implied. +- **Coverage/meaning:** Atlantic and eastern/central North Pacific, not global cyclone coverage. + Status position time and advisory issue time remain separate. Forecast track, points and cone + require matching advisory numbers across all GIS parts. Cone means uncertainty in the + forecast center track, not storm size or a complete hazard boundary. +- **Delivery:** keyless same-origin `/api/cyclones`, five-minute singleflight cache, + fixed upstream endpoints, bounded bodies/geometry/deadline, explicit unavailable/stale states. + Direct status fetching is unsuitable in browsers because NHC does not advertise CORS. + +### Weather: observed lightning density + +- **Source:** [NOAA nowCOAST lightning detection WMS](https://nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows), + fixed `ldn_lightning_strike_density` layer and `lightning_density` style. +- **Rights/credit:** NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360. + [Official product description and public distribution terms](https://ocean.weather.gov/lightning/lightning_pdd.php) + permit distribution of this NOAA Level-5 derived product, not raw Vaisala detections. +- **Meaning:** 15-minute accumulated density on an approximately 8 km grid; + source color scale is strikes per km² per minute ×10³. Not individual GLM optical flashes, + a live ground-strike counter, an all-clear indication, or global coverage. +- **Coverage:** 110°E across the Pacific/Americas to 0°, 25°S–80°N. Display exact advertised + observations; ten-minute metadata refresh, source-matched colors, bounded shared imagery cache. + +### Community weather prior art + +The weather design builds on Gustavo Beneduzi's retained GFS/ECMWF contribution +commits ([#459](https://github.com/bilawalsidhu/gods-eye-view/pull/459), +[#464](https://github.com/bilawalsidhu/gods-eye-view/pull/464)). Related community +proposals informed the observed-weather experience: Sandiv D's on-demand radar +and imagery controls ([#588](https://github.com/bilawalsidhu/gods-eye-view/pull/588)); +Gustavo Beneduzi's GOES and GLM source work +([#457](https://github.com/bilawalsidhu/gods-eye-view/pull/457), +[#458](https://github.com/bilawalsidhu/gods-eye-view/pull/458)); and HadiMuhammed's +NOAA hazard provenance and freshness work +([#414](https://github.com/bilawalsidhu/gods-eye-view/pull/414)). +The fixed nowCOAST imagery and NHC advisory implementations are original maintainer +work; this acknowledgement does not represent merging those four PRs or equate +NOAA density imagery with the raw GLM product. + +--- + ## 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, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, 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/README.md b/README.md index 580bb147c..88690d52f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # 🌐 God's Eye View -[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) +[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521) ### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real. @@ -18,8 +18,6 @@ _No place left behind._ ▶️ **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) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view) -[![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521) - 🏆 **Reached #1 on GitHub Trending, daily and weekly · August 2026** **[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag @@ -70,6 +68,14 @@ Start with the included data sources, then add your own. Each layer is a separat - **🎥 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. +- **🌦️ Weather:** Animate GFS or ECMWF forecast wind, replay observed radar, satellite clouds and lightning on one timeline, and follow NHC/CPHC cyclone tracks. No key needed. +- **📷 Mapped ALPR cameras:** License-plate-reader camera locations tagged in OpenStreetMap, one city at a time. Locations and tags only; no key needed. +- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it. +- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback. +- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered. +- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim. +- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs. +- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)). --- @@ -90,7 +96,7 @@ cameras, radio, and launches are available without keys. For photorealistic 3D, add a **Cesium ion token** for eligible personal, non-commercial use, or a **Google Maps key** for the direct, metered route and -in-app place search. Provider terms and quotas apply. Add keys through the +Google place search. Provider terms and quotas apply. Add keys through the app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. ### Path 1 — One click, no terminal @@ -153,8 +159,8 @@ reopens the same panel. - **What to get first:** the free [Cesium ion](https://cesium.com/ion) token (eligible personal, non-commercial use; current terms and quotas apply) for photorealistic 3D and world terrain; a Google Maps key only for the - billing-enabled, metered route + place search; OpenAI when you want to talk - to the world. Full map, costs included, in [Keys & Costs](#-api-keys). + billing-enabled, metered route + Google place search; OpenAI when you want to + talk to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
Older Pinokio versions and credential storage @@ -188,7 +194,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea ![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, California, or Finland. 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. +4. **Look through a public camera.** Turn on **Cameras** over Austin, London, California, Finland, or Delaware (live video). 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) @@ -203,7 +209,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 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. +**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- @@ -235,7 +241,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic - **🎬 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: +Twenty-nine tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook: **🎥 Direct it** — drone-operator camera verbs: @@ -253,7 +259,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod **🔎 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?"_ +> 🗣️ _"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?"_ · _"How many datacenters are in view?"_ **🎛️ Operate it** — the whole console, hands-free: @@ -271,7 +277,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -282,7 +288,8 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add | 🛰️ **Satellites** | 838-object 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** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📻 **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 | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | @@ -290,6 +297,13 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add | 🔥 **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 | 🟢 | +| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | +| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | +| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | + +**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. + +In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities. **The basemap ladder — what each tier buys you:** @@ -297,7 +311,7 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it | | 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply | -| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route | +| 🔴 A Google Maps key | The same 3D direct from Google, plus Google place search — the billing-enabled, metered route | ![A reconstructed Falcon 9 ascent climbing and curving into its projected orbit](docs/media/08-falcon9-replay.gif) @@ -364,7 +378,8 @@ src/ ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks -├── voice/ # OpenAI Realtime session + 28 voice tools +├── voice/ # OpenAI Realtime session + 29 voice tools +├── layers/ # Layer components — weather, wind, cyclones, transit, ALPR, … ├── data/ # One module per layer + orchestration + context store │ ├── iconOrientation.js # Screen-projected headings + horizon cull │ └── local_data/ # Bundled datasets (per-folder provenance) @@ -384,6 +399,11 @@ each provider enables; none is required to start. See the [setup instructions](#then-power-it-up--in-the-app-not-in-a-file) for storage and configuration details. +No key needed for Wind, Rain radar, Satellite clouds, Lightning density, +Cyclone advisories, Mapped ALPR Cameras, Transit, Directions, Delaware live +video, and coordinate or bundled-place search. A layer row waiting on a key +names it. + ### Choose the capabilities you want Six keys. Four have a free tier, and the two 🔴 ones are metered: @@ -441,15 +461,35 @@ OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth cred
+
+Live video cameras (HLS) + +CCTV sources with `"feedType": "hls"` and a registered HTTP(S) `.m3u8` +URL play through a lazily loaded hls.js decoder shared by the monitor plane +and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that +pack with `CCTV_DELDOT_ENABLED=0`. + +The server allows two concurrent sessions. Each retains at most 12 segments +and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten +second deadline. There are no segment files or ffmpeg processes. Redirects, +off-origin references, encrypted playlists and non-MPEG-TS segments are refused. +Each decoder has its own client lease (at most eight per session), including +native HLS. Closing it releases only that lease; abandoned leases expire after +15 seconds without access. The last release stops upstream work. Failed live video +uses the existing still/Street View/synthetic fallback, which is not live video. +RTMP-only sources are not supported by this integration. + +
+ ### 💸 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. | +| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, bundled datasets. | | **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. | -| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. | +| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for Google place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. | | **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. | Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic diff --git a/SECURITY.md b/SECURITY.md index a21ea99a4..718c43778 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -62,9 +62,11 @@ browser-key fallback for existing single-key setups. The data proxies under `server/providers/` 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. +- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary. - **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. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. +- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). diff --git a/TESTING.md b/TESTING.md index adb247880..19cff9fed 100644 --- a/TESTING.md +++ b/TESTING.md @@ -210,6 +210,18 @@ While recording, call out anything in these areas — this is the feedback I mos happened? Any command it misunderstood? - **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold. +--- + +## 6. Live video CCTV (HTTPS HLS) — soak, no mic + +> Requires the DelDOT pack (loads by default; `CCTV_DELDOT_ENABLED=0` disables it). + +1. Turn on **CCTV**, fly to Dover, DE, and activate **DE 1 @ Main Toll Plaza**. ✅ Within ~30 s the monitor plane and the panel preview both show moving video,. +2. Leave it for **five minutes**. ✅ Continuous playback; at most a short hitch about once a minute (the agency restarts its stream on a timer). ❌ A freeze that does not recover, or the panel picture stopping while the plane keeps moving. +3. Toggle **PROJECTION** off. ✅ The panel keeps playing; the plane and the active camera's cone hide. +4. Switch to **DE 8 @ Saulsbury Rd**, then back. ✅ Each switch resumes within ~10 s with no stale frame from the previous camera. +5. Optional: DevTools → Network, filter `media/`. ✅ Playlist responses carry `X-CCTV-Source: hls-pull`; segments return `video/mp2t`. + ## If something looks off - **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..071299fba --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,21 @@ +# Third-party software notices + +The project depends on third-party npm packages whose own licenses apply. This +file records the packages added for the browser-local SDR feature; the complete +resolved dependency inventory remains in `package-lock.json`. + +## Web RTL-SDR + +- Package: `@jtarrio/webrtlsdr` 3.0.6 +- Author: Jacobo Tarrio Barreiro; portions copyright Google Inc. +- Source: +- License: Apache License 2.0 +- License text: + +## Signals + +- Package: `@jtarrio/signals` 0.10.1 +- Author: Jacobo Tarrio Barreiro +- Source: +- License: Apache License 2.0 +- License text: diff --git a/build/vite.js b/build/vite.js index 82a1c3d68..cdff4a27c 100644 --- a/build/vite.js +++ b/build/vite.js @@ -9,10 +9,25 @@ export function createBrowserViteConfig({ cesiumToken, host = 'localhost', port = 4173, + command, } = {}) { return { plugins: [cesium(), applicationHtmlPlugin(), ...plugins], ...(publicDir === undefined ? {} : { publicDir }), + // A production build must not clean the dependency cache a running dev + // server is still serving optimized module URLs from. + ...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), + optimizeDeps: { + // First reached through the SDR worker or a dynamic import. Pre-bundle + // them at startup so first use cannot invalidate already-transformed + // URLs with Vite's "Outdated Optimize Dep" 504 response. + include: [ + '@jtarrio/signals/demod/demodulator.js', + '@jtarrio/signals/demod/modes.js', + '@jtarrio/webrtlsdr/rtlsdr.js', + 'egm96-universal', + ], + }, server: { host: host || 'localhost', port: parseInt(port, 10) || 4173, diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index cf901c8a6..deafe9168 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -105,14 +105,17 @@ checks when changing this wiring. `src/search` owns forward/reverse geocoding, place search and route provider interfaces. `createStandaloneApplication({ geospatial })` accepts `endpoints` -and `providers`; the defaults preserve Google/Photon search, Google place context -and the local OSRM route proxy. Location, annotations, HUD labels and voice use -the composed service. Provider-specific credentials belong in the selected -transport; server secrets never belong in browser configuration. +and `providers`. Default forward search tries decimal coordinates and bundled +place names (`presets`) first, without a network request, then Google when a key +is configured, Photon, and the local `/api/geocode` Nominatim route. The +defaults also keep Google place context and the local OSRM route proxy. +Location, annotations, HUD labels and voice use the composed service. +Provider-specific credentials belong in the selected transport; server secrets +never belong in browser configuration. -For a compatible protocol, configure `geocode`, `photon`, `reverse`, `textSearch`, -`nearby` or `route` endpoints. These are developer-selected configuration, not -URLs accepted from page queries or model arguments. A different protocol supplies +For a compatible protocol, configure `geocode`, `photon`, `nominatim`, `reverse`, +`textSearch`, `nearby` or `route` endpoints. These are developer-selected +configuration, not URLs accepted from page queries or model arguments. A different protocol supplies an adapter function instead. The exported `createDefaultPlaceSearch` constructor supports the same options for direct composition: @@ -181,10 +184,14 @@ separate constructors. Pass these to `createApplication`; use its `signal` and `defer` for ownership. The compatibility catalog is still page-scoped: construct one application per page. Controls, actions and renderers use the same layer instances. Configure sources with `application/sources` before registration or -state restoration, and release them after the consumers stop. +state restoration, and release them after the consumers stop. The catalog +constructor (`application/layers`) also requires `wind`, `weather` and +`cyclones` sources with `getSnapshot` for the Weather layers, and owns the +observed-history clock those layers share for the catalog's lifetime. `application/services` accepts boundary, terrain, regional-context, weather and -summary services. `application/requests` supplies the existing HTTP protocols +summary services; its weather service supplies cockpit local conditions, not the +Weather layers. `application/requests` supplies the existing HTTP protocols with configurable endpoints and a scoped transport. Changing an endpoint works only for a compatible protocol; another protocol supplies a service adapter. Cancellation discards late response bodies, and replacing a source invalidates diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 35409e1d4..1aeddd565 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -277,6 +277,90 @@ share restoration and movement of controls between containers remain caller-owne Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain compatible through re-exports. +### Adding a right-rail readout panel + +Add the shell in `src/ui/templates/context.html` inside `#right-context-rail`: +a `.panel-collapsible` with a stable `id` and matching `data-panel-id`, the +existing `.panel-header`, collapse button and a dedicated body. Register it +with the existing panel chrome/layout owners; do not allocate rail space in +the body renderer. Set `hidden` while empty so the allocator ignores it, and +expand through the existing collapse button only on first appearance per page +session, and only when the chrome marked the restored state as a default +(`data-collapsed-preference`), never over a stored or shared choice. Preserve +the user's later collapse choice, including body remounts. Mark the body +`data-rail-scroller` when it scrolls: the rail's measuring pass lifts its +`max-height` and puts the scroll offset back afterwards. Panel bodies only +scroll on an open-card change, with one write, never on a refresh. + +Compose `createRailCards({ container, document, onParams, onOpen })` and +`createRailTimeline({ container, document, onCommit, onPreview, onStep, +onLatest, onPlay })`. Cards reconcile `{ id, title, badge, open, compact, +compactStatus, blocks }` in place. Their full-width native button headers expose +`aria-expanded` and `aria-controls`; articles expose `data-open`. The feature +adapter owns accordion state. Generic cards do not select data or enable layers. + +`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors, +rendered by `railCardBlocks.js`. There are no per-section disclosures: + +- `summary`: `text`, a single summary line. +- `list`: `list: { ariaLabel, items }`, reconciled by `syncRowList` in `rowList.js`. +- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status. +- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px + label column and a wrapping `syncChipGroup` from `chipGroup.js`. +- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a + ramp/scale (including a physical freezing anchor) or separate keyed swatches. +- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?, +hint? }]` in a footer, using buttons or safe new-tab links. +- `result`: `label`, keyed `lines`, and optional `clear: { params | onClick }`; + its accessible × button sits beside the title. + +Every block and control has a stable id. Lists, settings, actions and results +can appear in any requested order, including a scene panel's rectangle action, +results list, then selected-scene details. Chip/list/action `params` dispatch +through `onParams(cardId, params)`; the feature adapter supplies +`setLayerParams(id, params, { origin: 'user' })`. Current descriptors and disabled +state are read at dispatch. Identical updates do not move nodes or write DOM; +focused controls survive feed refreshes. + +Weather descriptors retain top-level `chips`, `list` and `legend` for non-DOM +consumers. They expose `summary.settings`, `summary.actions` and optional +`summary.result`; wind also retains its raw captured `summary.reading`. +`readout: true` layer rows render only the toggle and source/meta line; their +subscriptions still refresh the cards. Cards own configuration and readings. + +Weather orders cyclones, wind, then a bordered Observed history group containing +the timeline and active radar, satellite and lightning cards. The group heading +and scope name its active products. The timeline stays visible whenever any +observed product is enabled; fewer than two ticks disables transport. Pass +`heading: false` to the timeline when the containing group owns the heading. +Exactly one active card is open. Header clicks and newly enabled ids choose it +(the last new entry wins a batch); refreshes and clock ticks retain it. First +appearance uses cyclones with storms, otherwise the first card. Disabling the +open layer falls back to the first remaining card. A WeakMap per document keeps +explicit choices through body remounts without adding share state. Card opening, +storm selection and layer enabling remain independent. + +Optional card/badge/slider class names provide feature styling. Card titles use +`.data-name` typography; meta/time/badges use `.data-toggle-meta` typography; +setting/result labels use `.panel-title`; chips retain `.data-toggle-chip`. +Reserve one status line, even without a legend. Use app tokens. Wind's footer +reads the map center into a result after the action. The captured location stays +fixed until another read; model changes resample that location, units reformat +it, and × clears both reading and map marker. + +The timeline shows endpoint times and receives ticks, index, mode, playing, +readout and disabled state. Its preview callback receives the tick and index and +may return readout text; commits receive the same values after a 150 ms coalesced +drag or final change. Feature adapters own data mapping and service subscriptions; +generic modules own DOM/listeners/timers and release them in `destroy()`. + +Keep persistence with panel chrome: collapse uses +`godsEyeView.v6.panelCollapsed.`, position uses +`godsEyeView.v8.panelPos.`, and share state uses the registered panel id. +Do not rename existing +ids or reset persistence versions when adding a body. The WEATHER adapter is +`src/ui/weatherPanel.js`; its observed-history selection remains transient. + ## Visual input `gods-eye-view/ui/input` exports `bindApplicationShortcuts` and diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e63817fd8..237e0dc1a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,229 @@ # God's Eye View Current State +## Cyber HUD — September 23, 2026 + +Display > HUD > Layout includes Cyber, also available through the HUD voice +action and shared visual state. An explicit first transition into Cyber selects +FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain +authoritative. The skin uses shared red/slate panel treatments in map and cockpit, +with compact 200px collapsed controls and wider expanded panels. Other HUD +layouts retain their existing presentation. +Chamfered panel expand/collapse controls use an inset keyboard-focus outline and +red hover border in both map and cockpit, so clipping does not hide the highlight. + +Cyber's map-side right rail opens one panel at a time while keeping collapsed +launchers available. Display, CCTV and Context scroll their contents inside fixed +headers and decorative frames. The narrow-screen rail remains scrollable to reach +each panel. Radio retains the shared nested Context player and compact +header disclosure, without relocating playback controls on theme changes. + +Sonar has one contact-highlighting method. Native Cesium points, billboards and +labels are treated in GPU draw commands without replacing their positions, +identities or pick commands. Model and canvas/DOM overlays retain their own +rendering paths. Sonar OFF stops the sweep while retaining Cyber's neutral +contact treatment. Reduced motion disables the animated sweep; cockpit and +hidden HUD states do not apply the map treatment. If the native shader is not +supported, native contacts remain available and Display reports the limitation. +No CSS scene-dimming fallback or effect selector is exposed. + +The Sonar controls adjust rings, range, power, opacity and sector. Sweep opacity +affects detection painting rather than label cohort admission. Theme exit and +renderer teardown release the derived GPU resources and restore owned model +colors without replacing newer layer-owned color changes. + +The `set_cyber_sonar` voice action adjusts the same controls without switching +themes or enabling omitted settings. State readback distinguishes configured +settings from active map/contact effects. Sonar settings are session-only and +return to their defaults on reload. Sliders accept integer values matching the +action's ranges. + +Wind appears in the Weather group before Utilities. The surface-weather prototype +uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid. +It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning. +Weather rows contain only the toggle and source/meta line. Configuration lives +in each WEATHER card's Settings section: model, No color field, Speed, Pressure, +Temperature, wind units, Pause/Resume and **Inspect center** for wind; coverage, +image treatment and opacity controls for observed products. Cyclone cards carry +a Storms section whose entries select and fly to the advisory, plus the official +advisory link. Settings and Storms default closed; disclosure choices last for +the page session. + +Inspect center adds an open Reading section to the wind card with captured +coordinates, interpolated wind speed and meteorological direction, selected +scalar, model, valid time and freshness. Clear reading removes the sample and +map marker. Model/field changes or refresh also clear them. Unit chips reformat +the captured sample without resampling or moving its marker. There is no separate +floating reading surface. The card shows forecast issue and valid times; the +observed-history timeline labels the oldest and newest available ticks. Animation +moves through a fixed forecast; it does not advance forecast time. + +Temperature is air temperature at 2 m in °C; pressure is mean sea-level pressure +in hPa. Optional companion fields come from the same model run/forecast as the +wind. A missing or invalid companion leaves usable wind visible and identifies +the selected field as unavailable. The color texture drapes the globe basemap; on +photorealistic 3D Tiles the same 360×181 raster is a raised shell 5 km above the +ellipsoid. With GPU rendering, the color field fades out below ~1,200 km camera +height and is hidden at or below 200 km. Globe imagery keeps the smooth +per-frame fade. On 3D Tiles, the shell alpha is quantized to 0.1 steps and +updated only on camera move end, installation or rehome. +GPU wind curves follow the sampled forecast field. Their 12 km display lift is a +rendering aid; the source remains 10 m wind, +not a forecast at the displayed height or a street-level observation. + +The renderer owns field installation, scalar imagery and the animation lifecycle. +It bakes at most 7,200 curves (1,200 below 700 px) with at most 33 geographic points each on the CPU +when installing a wind field, then advances a GPU phase along those curves +without CPU projection of every point on every frame. Regional 30° batches are +culled against the horizon and view frustum each frame, including when the globe +is hidden; curves fade below 60 km and disappear at 15 km. Animation stops when +no batch is visible and resumes on camera events. A canvas renderer remains +available as a fallback. Pause and reduced-motion mode show a static flow view +without an idle animation loop; hidden tabs suspend animation, and +disable/destruction releases owned rendering +resources and subscriptions. The globe relief helper is enabled with Wind and +released when Wind is disabled. It uses terrain vertex normals +for view-directed shading, falling back to global globe-curvature shading when +normals are unavailable; the fallback does not show local hillshade. Neither mode +adds elevation data or represents sunlight. Relief declines to replace an +existing globe material, restores the prior empty material only while still +owning it, and leaves later owners intact. +Source acquisition retains deadlines, body budgets, disconnect cancellation, +per-model/field singleflight caching and one-minute failure backoff. Application +catalog construction owns each instance. + +Wind animates one forecast without advancing forecast time. Separate Weather +observation layers provide radar and satellite history; none claims measured cloud volume. +Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each +observed product is instead a raised, translucent shell: one rectangle over the +product bounds at a fixed height (global infrared 5.5 km, regional infrared +5.8 km, radar 6.2 km, lightning 6.6 km) showing one full-extent image per frame. +Draping onto 3D Tiles was limited by Cesium's per-primitive texture budget and +rebuilt every tile's coverage on each change; shells avoid both and show at any +camera height. Shells draw first in the opaque pass, in height order, +alpha-blended without depth writes: lightning draws over the other products, +other map content draws over the shells, and shells are not pickable. A +map-source change tears down one renderer and restages the shown observation on +the other. A map without an imagery host pauses history while metadata refresh +continues, then resumes when a host returns. +Globe tiled products use 256 px tiles to maximum level 6. The tile proxy accepts +size=256 (default), 512 or 1024 and keys cached bytes by size, retaining the +24-hour immutable response and eight upstream slots. The image proxy serves every +product as one whole-extent PNG at its advertised bounds, up to 4096×2048 for +radar, regional infrared and lightning and 2048×1024 for global infrared +(`size=W×H`, default the maximum), capped at 16 MiB and cached by product, time +and size. NOAA returned each of these sizes from a single request in a live +check; the WMS capabilities advertise no size maximum. Devices whose texture +limit is below the product size request halved images. +On 3D Tiles, each shell except global infrared also draws a detail window: the +shell's own surface samples a second, 4096×2048 image of the area around the +view inside that window, so the two images share one mesh and never blend. On +camera move end and host switch the window is centred on the camera's ground +footprint, max(2 × its longitude span, 6°) wide and half as tall, on a 0.5° grid +inside the product bounds. It is used only while narrower than half the +product's longitude extent and while the view overlaps the product; it moves +only when the view centre leaves its inner half or the span changes by more +than 50 %. A window applies once its image has drawn; until then the +full-extent image covers it. A new frame swaps the full-extent image first and +keeps the previous detail image until the new one decodes, over at most one +newer frame; a moved window hides until its image arrives. Global +infrared contrast depends on the requested extent, so it keeps one image. The +image proxy accepts `bbox=west,south,east,north` for every product: inside the +product bounds, 2:1 within 1 %, rounded to 0.25°, up to 4096×2048 (the +default), cached by product, time, size and bbox. Diagnostics report +`shell.detail` (`bbox`, `size`, `ready`, `enabled`). +Clouds only applies a soft brightness ramp to decoded pixels once, using +Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40 +to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are +preserved in Full image mode; both modes use the chosen layer opacity. This +is a display filter, not a cloud mask. Satellite share links retain the display +mode; observation history remains transient and links open latest. +Global infrared fetches one capped 4 MiB, 2048×1024 mosaic per frame and decodes +and processes it before staging. Globe hosts crop that canvas into 256 px tiles +to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent, +avoiding request-dependent contrast seams. +On globe hosts, regional infrared retains network tiles and processes each +decoded tile once; shells process each whole frame once. +Exact-time tile and image responses are immutable for 24 hours; manifests and +errors remain uncached. Each globe renderer retains up to 6 processed global +mosaics in a least-recently-used cache keyed by observation time and infrared +mode (up to 48 MiB of canvas pixels). Each shell keeps decoded full-extent and +detail images in one least-recently-used cache bounded at 128 MiB, keyed by +time, mode and window: the shown frame and the warmed next frame, each +full-extent and detail; nothing older survives them. Disable clears the caches. +Cache hits skip +fetch/decode and report `mosaic.cached: true` with zero decode time. +During playback, a successful frame warms the next advertised observation, +wrapping at the end. Global imagery warms a decoded frame and shells warm the +next frame and its detail window; globe tiled +products fetch at most eight level-0/1 tiles intersecting the view and product +bounds. Prefetch is best effort, has a deadline, and cancels on frame +replacement, pause, suspension or clear. Diagnostics expose the renderer +(`host`), mosaic count and active prefetch state. +Browser cache reuse and scrub-back latency still require browser verification. +On globe hosts a replacement becomes visible before the previous layer retires +on the next rendered frame; a shell keeps its previous texture until the new +image is uploaded. Failed acquisition or staging retains the previous +observation. Imagery ordering skips already ordered layers. Diagnostics expose +the infrared mode and mosaic fetch/decode state. +Throttled weather requests (429/503) get at most three retries per tile within +a frame, independent of other tiles; successful requests reset only their own +counter, and closing a frame clears its retry state. +Mapped.earth's public bundles informed the rendering study; no code or assets +were reused, and the study found no application licence granting reuse. Native +hardware GPU behavior remains unverified; software-rendered checks do not +establish native GPU performance or compatibility. + +The right-rail WEATHER panel owns active-product readouts, coverage badges, +legends and the single observed-history timeline. The timeline appears with an +active observed layer and at least two union times; wind alone does not show it. +Dragging previews UTC and age locally and coalesces clock commits at 150 ms; +release commits immediately. Latest selects the newest frame per product, and +history labels the actual displayed frame as synced or nearest. Missing-frame +messages use each product's eligibility gap (30 minutes, or three hours for the +global mosaic). Wind shows forecast valid and issue times and does not follow +history. Satellite clouds uses Clouds only / Full image configuration labels. + +Left weather rows retain only the toggle and one status line with a source +tooltip. Configuration and the cyclone Storms list live in the cards, as do the +cyclone advisory, position time, wind, pressure, geometry status and the +official advisory link. The panel hides when empty, auto-expands once per page +session and then preserves the user's collapsed state. Status lines reserve their space and refreshes are coalesced +per frame. Generic keyed rail cards and the native rail timeline preserve DOM +nodes and avoid identical writes; the weather panel owns descriptor mapping. +Inspection emphasizes the chosen scalar and marks the exact sampled location; +the passive marker follows that snapshot, respects globe occlusion and disappears +on dismissal, field/model changes, disable or teardown. Clean view and +recording mode hide weather presentation with the other controls. Scalar changes +reuse native wind geometry when the model, issue/valid time, grid and U/V values +are identical; new or revised wind still rebuilds. Source and renderer clocks +remain independent of the shared Cesium clock. + +Weather also offers default-off lightning density and cyclone advisories. +Lightning uses NOAA/NWS nowCOAST's public derived 15-minute density product from +Vaisala NLDN/GLD360, with ten-minute metadata refresh and exact source times. +Coverage is the Americas/Pacific (110°E across the dateline to 0°, 25°S–80°N), +not worldwide detections; units are strikes/km²/min ×10³. It is neither raw GLM +flashes nor a live strike counter. Weather imagery orders scalar context below +satellite, radar and lightning consistently across enable and history order. + +Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded +same-origin provider. The status endpoint lacks browser CORS; each installation +fetches keyless public sources at runtime, with no centralized ingestion service. +The five-minute snapshot covers Atlantic and eastern/central North Pacific only. +Current positions, advisory issue time and position time stay distinct. Tracks, +forecast lead-hour points and cones render only when all source parts match the +status advisory; a newer status shows its position with geometry pending rather +than relabeling older geometry. The cone represents forecast center uncertainty, +not storm size or the full hazard area. Successful empty and unavailable states +are distinct. One owned native Cesium data source preserves polygon holes and +geographic seams and releases on disable; forecast animation is not implied. +Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. +Consult the linked official advisory for safety decisions. + +Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. + +Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records (default 2,000 per new layer, core satellite rows before dense extras); omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available. + AIS encodes speed over ground in 0.1-knot units and course over ground in 0.1-degree units, reserving the top code of each field for "not available", so those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, @@ -125,7 +349,7 @@ Source factories have dedicated `layers//source` exports. ALPR and earth Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/result, interruption and completion events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner. -Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds the same 28 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. +Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 29 tools, retaining the existing 28 names and argument contracts apart from the three additive analyst-layer enum values using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged. @@ -154,7 +378,6 @@ owns disclosure, docking and Cockpit panel snapshots. Each takes explicit operations and reads instead of the complete shell. Existing controls, share formats and one-application-per-page behavior are preserved. - Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel, render-governor or detection imports. Application composition mounts a separate layer presentation owner that reacts to status, accepted visibility/parameter @@ -163,7 +386,6 @@ rendered detection set. The legacy manager retains panel helper compatibility; registration sealing, restoration order and failed-transition rollback remain unchanged. - Application scene setup owns request services, terrain/floor caches and annotation lookup state. Controls, layers and voice share those owners, and cancellation clears caches and removes map-stack listeners. Standalone setup supplies the @@ -172,7 +394,6 @@ framing and annotation boundaries receive their service instances explicitly. Compatibility entrypoints retain defaults for direct callers; normal startup does not change global service slots. Analyst follow-up memory belongs to its voice runner. - Application startup constructs fresh layer instances from explicit source objects. Standalone composition selects the existing providers. Controls, launch orbit lookups, Contacts voice answers and ISS pass queries use the registered catalog. @@ -180,7 +401,6 @@ Aircraft classification is catalog-owned and cancelled with application teardown scene engines still support one application per page. Compatibility data entrypoints remain for direct callers, but normal startup does not configure global sources. - Application data setup receives an ordered layer catalog and serialization metadata. Controls use the same instances. The standalone composition selects the existing default catalog, and restoration starts only after all registrations are sealed. @@ -199,7 +419,6 @@ transports and cancellation through response parsing. Stop and application teardown abort pending connections; reconnect requests a new client secret. Microphone, radio handoff, tool schemas and default model behavior are preserved. - Geospatial lookups are composed through `src/search`: forward/reverse geocoding, text/nearby search and routing use configurable providers/endpoints. Annotation, HUD and voice consumers share the configured service. Existing Google/Photon @@ -207,7 +426,6 @@ selection, route/direct-line honesty and tool schemas are preserved. Node Google, OSRM and Nominatim adapters accept trusted construction-time configuration; request parameters cannot choose arbitrary upstream URLs. See APPLICATION.md. - The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations, not camera footage or plate records. City-scale queries use the existing Overpass provider, with capped results, retry, cached-data and incomplete-coverage notices. @@ -311,7 +529,6 @@ Selection changes, disable and destruction cancel pending trail requests; late responses cannot refill a cleared or replacement trail. Heading and course, sea-surface placement, click ownership and card selection policy are unchanged. - ## Military-flight components and aircraft mechanics `gods-eye-view/layers/military` exports `createMilitaryFlightLayer`. It uses the @@ -368,7 +585,6 @@ messages; arbitrary HTTP response bodies are not surfaced as diagnostics. Sources receive cancellation signals and check them after body parsing. The layer's existing lifecycle and selection guards continue to reject late work. - ## State and action outcomes Share preferences, place lookups and Scene playback expose immutable snapshots @@ -417,7 +633,6 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti and queued panel work stop before asynchronous layer restoration; final camera and portal cleanup follows that restoration. - ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -427,7 +642,6 @@ presentation work stop during disposal; layer restoration retains its existing compensation and latest-intent rules. Clear Selected Layers shares this owner, so an older restore cannot replay over a newer Clear action. - ## Camera panel ownership CCTV controls receive the existing camera port and explicit application actions. @@ -438,7 +652,6 @@ calibration edit. Failed refreshes preserve settled pixels for the same camera, while a newly selected camera never shows the prior camera's image. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. - ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag @@ -480,7 +693,6 @@ and result presentation. Existing search providers and camera handoff policy are preserved. Replacing or closing a POI row cancels its pending expansion frame; destruction releases listeners, pending searches and the orbit indicator. - ## Layer panel ownership Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners @@ -489,6 +701,24 @@ snapshots, lifecycle actions and row descriptors. Remount and teardown remove listeners and row subscriptions; obsolete completions do not repaint old rows. The clear control presents busy state while its existing action owns the transaction. +## Model atmosphere on Apple Metal + +Cesium's per-vertex model atmosphere is kept out of the pipeline on devices +whose driver cannot link it. `AtmosphereStageVS` binds shader `out` parameters +directly to varyings, which ANGLE's Metal backend rejects at link time, tearing +down the render loop on iPadOS and iOS. `ModelSceneGraph.configurePipeline` +attaches that stage only when `fog.enabled && fog.renderable`, so the viewer +clears `scene.fog.renderable` and leaves `fog.enabled` — and the fog density +that drives 3D Tiles screen-space-error scaling — intact. Sky atmosphere and the +ground-atmosphere fragment path route through locals and are unaffected. +Affected devices lose distance fog on 3D tiles and on globe basemaps (Esri, +Bing, keyless), since the globe fog shader uses the same `renderable` flag. +The probe releases its throwaway WebGL2 context immediately. + +Detection is a WebGL2 link probe of the same out-parameter/varying pattern, so a +future driver fix restores the effect with no code change; iOS/iPadOS platform +detection is the backstop for when no probe context can be created. Applied +during viewer construction, before any tile builds a draw command. ## Map Source control ownership @@ -541,7 +771,6 @@ panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. - ## Surface keyboard lifecycle `ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return @@ -553,7 +782,6 @@ preferences. Provider Settings retains its existing visibility and save policy. Initial focus, transitions and DOM content remain with each screen. This component does not add modal semantics or make the map inert. - ## Panel disclosure lifecycle Panel collapse buttons, nested Escape handling and dock hover/focus timing now @@ -563,7 +791,6 @@ Location draft cleanup and Map Source selection remain with their existing callers. Listener and timer cleanup is synchronous when controls are replaced or disposed, preventing old hover or focus work from changing a later view. - ## Military feed cooldown and loading guidance The adsb.lol military proxy reuses its last response during upstream 429/5xx @@ -577,7 +804,6 @@ Mapped-installation zoom guidance appears in the layer row without counting as a global loading failure. Guidance statuses do not suppress independent refresh errors. - ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, @@ -587,21 +813,20 @@ their `configured: false` response. CCTV media waits at most 15 seconds for upstream response headers and returns 504 on timeout. Its timer stops when headers arrive, so live bodies can continue -streaming; body idle deadlines are separate from this header deadline. Error -responses are cancelled. Buffered snapshots have a 16 MiB streaming cap; an -oversized image remains an upstream miss and uses the normal fallback chain. -The existing declared media size ceiling remains 64 MiB. - +streaming; a separate 30-second idle deadline then bounds the gap between +upstream chunks and releases a body that has gone silent. A response that is +still waiting to drain reschedules that deadline, so a slow client does not read +as a stalled upstream. Error responses are cancelled. Buffered snapshots have a +16 MiB streaming cap; an oversized image remains an upstream miss and uses the +normal fallback chain. The existing declared media size ceiling remains 64 MiB. ## GBFS upstream bounds The station reader addresses the proxy as `/api/gbfs/`; the -feed address is read from the path, and a query-string form is refused with a -400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while +feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. - ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream @@ -634,7 +859,6 @@ preview returns JSON 404 for those endpoints. Server credentials come from the local environment; browser keys are captured at build time. Rebuild after changing a browser key. Preview is for local build verification, not a production server. - ## CCTV and radio provider modules CCTV catalog acquisition, source normalization and frame/media delivery now live @@ -655,6 +879,22 @@ under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic til math and GBFS source rules are available under `gods-eye-view/sources/`. The browser layers and their rendering remain in their existing modules. +## Terrain height cache bound + +The client terrain-height resolver's in-memory cache is bounded at 20 000 +entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through +`createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated +visits to one place share a key but does not bound how many distinct places a +session visits, and the cache previously had no eviction, so a long session +retained every coordinate it ever resolved. Eviction is least-recently-used: +a consumer read promotes its entry, so the cell being watched is not dropped for +having been resolved early. The bound sits far above a city-scale session, so +normal use never evicts and issues no additional proxy requests. A batch's +results are assembled from what that call resolved, so eviction during a large +batch cannot report a just-resolved point as unresolved. Re:Earth and +geoid-fallback entry semantics, the fallback cooldown and the abort-time flush +are unchanged. + ## Landmark annotation identity When a landmark geocode contains only address components, annotations retain @@ -728,7 +968,6 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. - ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's @@ -848,7 +1087,7 @@ Non-object or array-valued properties reject the response instead of being treat Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. -Updated: September 15, 2026 +Updated: September 23, 2026 ## Aircraft and vessel server modules @@ -866,7 +1105,6 @@ The existing aircraft normalizer is separately available through the portable `gods-eye-view/sources/adsb-lol` export. Provider URLs, local credentials, cache policy, fallback behavior, response shapes and rendering remain unchanged. - ## Control names for assistive technology Scope, Bloom, Sharpen and location search have explicit accessible names. @@ -961,7 +1199,7 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > 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 +> 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 @@ -985,7 +1223,7 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > 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 +> lightbox is 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 @@ -996,7 +1234,7 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > `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. +> 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 @@ -1052,6 +1290,7 @@ 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 @@ -1074,7 +1313,6 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 @@ -1279,7 +1517,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 +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× @@ -1337,17 +1575,17 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 +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 +> _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 +> _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 @@ -1467,7 +1705,7 @@ This is the current runtime/source-of-truth snapshot for the project. > - **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 +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 @@ -1797,7 +2035,7 @@ This is the current runtime/source-of-truth snapshot for the project. > - **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 +> it, and narrow screens retain Cesium's 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 @@ -2012,8 +2250,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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. +> 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 @@ -2107,7 +2344,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 +> 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 @@ -2225,7 +2462,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 +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 @@ -2407,7 +2644,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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 +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 @@ -2473,7 +2710,7 @@ This is the current runtime/source-of-truth snapshot for the project. > 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. +> The live runtime now declares 29 voice tools; the 17→20 count above is retained only as milestone history. ## Canonical Docs Order @@ -2510,25 +2747,29 @@ nearby place—always use station selection. Unqualified “turn on/start the ra 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 + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | -| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| 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) | +| 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 + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| 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 + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | + +Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the @@ -2584,7 +2825,7 @@ tool holds the pointer. FLY runs through the UI shell's immediate-navigation facade — the same camera authority validated voice destinations use — and is handed the shared ground-floor read and corridor warm, so the dolly does not fly a mountain corridor at sea level. A reroute (SWAP, or a profile change), -CLEAR, disable and destroy stop the flight *this layer started* and only that +CLEAR, disable and destroy stop the flight _this layer started_ and only that one: `flyRoute` returns a motion id and `interruptCameraMotionIfActive` refuses to stop a motion someone else began. @@ -3161,7 +3402,7 @@ silently demoting every later lookup for the session. 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 + 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` @@ -3173,7 +3414,7 @@ silently demoting every later lookup for the session. poses, and the layer is stills-first. sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`, kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list - covering the whole country, where one *preset* (a station's fixed view) is one camera — 806 + covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806 GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin across packs, so a lower global cap thins every region instead of starving the last pack. @@ -3226,7 +3467,7 @@ silently demoting every later lookup for the session. `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. +- **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. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness. - **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. @@ -3362,7 +3603,8 @@ silently demoting every later lookup for the session. - **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`. +- **Twenty-nine tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.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`, `next_iss_pass`, and `next_satellite_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 @@ -3392,15 +3634,15 @@ silently demoting every later lookup for the session. - **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*. + - **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. + - **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: +- **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. @@ -3491,6 +3733,18 @@ are omitted rather than framing the wrong part of the globe. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### Recent Imagery (September 2026) + +- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. +- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. +- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. +- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied. +- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes. +- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. +- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`. +- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). +- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check. + ### 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. @@ -3600,6 +3854,7 @@ are omitted rather than framing the wrong part of the globe. - The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored. - The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded). - Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally. +- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would also clip the Context radio popover and would not remove the vertical scrollbar. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet. - `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay. - Collapsed mini-status indicators show active style and active location/landmark. - Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it. @@ -3674,7 +3929,7 @@ are omitted rather than framing the wrong part of the globe. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. - **Manual-first cockpit briefing:** the right-side Live Signals / Regional News / Local Info carousel does not advance automatically on page load. Previous, Next, and direct page controls remain available; the visible `CYCLE OFF` / `CYCLE ON` toggle explicitly starts or stops the nine-second page cycle, which still pauses on hover/focus and while collapsed, hidden, or outside cockpit mode. Live signal data continues refreshing in either state. - **Photoreal horizon blend:** Cesium's sky atmosphere remains enabled behind the hidden base globe, but its light intensity, saturation, and brightness are reduced from the library defaults so the distant Google Photorealistic 3D Tiles boundary blends into the sky instead of producing a bright cyan horizon seam. - - **Cockpit direction and speed tapes:** plausible destination metadata now drives one translucent, isometric visor chevron labeled directly below with the estimated geographic bearing; the prior full geodesic dashed path is not rendered. A mirrored live ground-speed tape follows the inside-left keyhole rim using the same responsive curve, end fades, fixed pointer, and fractional tick motion as the altitude tape on the right. Speed values scroll upward as they increase while altitude values scroll downward. Its tick endpoints and current-speed pointer share the rail's inset-circle origin, so the markings stay attached to the visible curve rather than drifting inward with the text-label gutter. + - **Cockpit direction and speed tapes:** plausible destination metadata now drives one translucent, isometric visor chevron labeled directly below with the estimated geographic bearing; the prior full geodesic dashed path is not rendered. A mirrored live ground-speed tape follows the inside-left keyhole rim using the same responsive curve, end fades, fixed pointer, and fractional tick motion as the altitude tape on the right. Speed values scroll upward as they increase while altitude values scroll downward. Its tick endpoints and current-speed pointer share the rail's inset-circle origin, so the markings stay attached to the visible curve rather than drifting inward with the text-label gutter. - Voice control UI (`#gev-voice-control`) shows status states OFF / CONNECTING / LISTENING / EXECUTING / ERROR. ### Current Global Post Defaults @@ -3917,7 +4172,6 @@ frames. Typing fields, modified keys and key repeats do not toggle it. The readout starts hidden each session and releases its timer and frame listener when hidden or when the application is disposed. - ## Radio components The radio entry composes one layer from directory ingestion, station queries, @@ -3928,6 +4182,134 @@ Existing catalog validation, category filters, tuning and voice playback behavio remain unchanged. Audio connects directly to the broadcaster after an explicit play action; the source does not relay or record streams. +## Local RTL-SDR and Local ADS-B + +The Radio panel holds a Local RTL-SDR card below internet radio. One browser +WebUSB session (`src/sdr/controller.js`) drives a USB RTL-SDR in desktop Chrome +or Edge on localhost or HTTPS; nothing opens until CONNECT. Demodulation and +Mode S decoding run in a worker; FM audio plays through an audio worklet. +Local FM and internet radio are one listening surface: starting local FM stops +internet-radio playback, and starting internet radio stops local FM. ADS-B +reception does not interrupt internet radio. + +- **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz. + With a receiver open, selecting ADS-B enables the Local ADS-B layer; + selecting FM disables it. + Enabling the layer switches the receiver to ADS-B; disabling it leaves the + receiver mode unchanged. +- **Gain.** AUTO (tuner AGC) or a manual R820T step from 0.0 to 49.6 dB, + stored per mode in `gev:sdr:gain:v1`. Defaults: ADS-B 28.0 dB, FM AUTO. + Changes apply to the open receiver without reconnecting. +- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, + aircraft with a fresh position and the IQ level. +- **Devices.** An already-authorized receiver opens without the WebUSB + picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the + 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. + A device chosen in the picker is remembered per mode by vendor, product, + product name and serial in `gev:sdr:device:v1`. CHANGE DEVICE reopens the + picker. LOCATE uses browser location for receiver-relative CPR decoding. + +The Local ADS-B layer (`local-adsb`, `src/layers/localAdsb/`) is off by default +and registered as local-only: it never enters share links or stored layer +state. Its aircraft get the public Flights treatment in magenta, alongside +the public Flights layer. Public Flights renders about one poll interval +(~30 s) behind for smooth interpolation while local aircraft are shown as +heard, so a local marker usually leads its public twin; both markers are drawn +on purpose: + +- **Class.** Each aircraft is classified with `aircraftClass.js` from its + ADS-B emitter category (dump1090 strings: `A1` light, `A3` large, `A7` + rotorcraft; set A/B/C/D = type code 4/3/2/1) and, once known, its adsbdb + ICAO type. Silhouette and per-class scale come from `aircraftIcons.js` + (×0.8 on the ground), tinted magenta. +- **3D.** The DISPLAY rail's 3D toggle and its proximity/all mode apply: the + layer reads the Flights layer's `models3d` params and uses the same ceiling, + caps, add/keep radii, visible-first eligibility + (`src/data/modelEligibility.js`) and per-class GLB + (`src/layers/flights/modelSpec.js`), tinted magenta through the same MIX + colour blend Flights uses for military amber. Heights are barometric + + geoid N, as Flights renders a contact without a geometric altitude; a + grounded model rides the one-shot ground snap and belly offset. +- **Motion.** No display delay. The marker is extrapolated forward from the + newest fix with `motionModel.js` (arc extrapolation, path-derived course + blended over the reported track, rate-limited slew); a new fix is absorbed + by a correction that decays over 900 ms. Coasting continues while messages + arrive and stops 10 s after the last one (60 s at most). +- **Sanity filter.** For airborne positions the browser decoder prefers a + fresh even/odd pair, then a frame decoded relative to the aircraft's own + position (under 10 minutes old), then the receiver location. Surface + positions are stricter: a single surface frame decodes only against the + aircraft's own recent fix, and only when that fix is provably within 45 NM + (it could not have gone farther since at its speed limit); the receiver + location never decodes a lone surface frame and only picks the quadrant of + a surface pair. Without an existing suitable fix for that aircraft + (airborne or surface, provably within 45 NM), surface acquisition + requires an even/odd pair. Every fix + must be reachable from the last accepted one at 1.5 × reported ground + speed + 50 kt (1,000 kt without a speed, 350 kt for A1/A7/B1/B4) over the + elapsed time + 1 s, plus 500 m. + Refused fixes are counted (`positionsRejected` in the SDR state; the layer + applies the same check to merged feed records and reports the total as + `rejectedPositions` in its stats). Three refusals in a row re-anchor. +- **Trail.** A selected aircraft draws a magenta trail of the positions the + receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft), + with the tracked-flight trail look and a live head segment. No network + backfill. +- **Enrichment.** Only the selected aircraft (type + route) and aircraft + about to render as models (type) are looked up, through the Flights + source's cached adsbdb proxy with the same once-per-session, four-in-flight, + 200 ms drip rules. + +A marker drops once its newest position is 60 s old; an aircraft is forgotten +after 60 s without a message. Clicking a marker opens a readout card (ICAO, +callsign, class and category, adsbdb operator/type and a plausible route when +known, altitude, ground speed, track, vertical rate, position age, message +count, "Heard by your receiver"); markers are not camera-followed. Positioned +aircraft join detection boxes, labelled only with a decoded callsign. + +Receivers publish one record shape from `src/sources/adsbRecords.js` +(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, +`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`, +`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is +`webusb` (browser SDR) or `feed` (decoder feed). +`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ +skyaware978 `aircraft.json` document into the same records. Voice +`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", +"my antenna"). + +**Decoder feeds.** The layer's second input is the server route +`GET /api/local-receivers/aircraft` +(`server/providers/local-receivers.js`), configured only by the +`LOCAL_RECEIVER_FEEDS` environment value (`band=url`, comma-separated, e.g. +`1090=http://localhost:8080/data/aircraft.json`). Every host must pass +`parseTapAddress` (loopback, RFC1918, `localhost`, `*.local`), the scheme must +be http(s) and the path must end in `aircraft.json`; an invalid entry is logged +at startup, reported `invalid` and never fetched. The route reads all feeds in +parallel (2 s timeout, redirects refused, 2 MB cap, single-flight with a 1 s +cache) and returns `{ configured, generatedAt, feeds: [{ band, label, status, +aircraft, ageMs }], records }`, where a feed is `live`, `stale` (its own `now` +is over 10 s old), `unreachable` or `invalid`. Labels name the band only; +addresses and upstream error text stay on the server. Unconfigured, it returns +`{ configured: false, feeds: [], records: [] }` without fetching. + +The browser session (`src/layers/localAdsb/feeds.js`) polls the route every +second only while the layer is enabled and only while the route reports +`configured`; the Local RTL-SDR card makes one probe request at startup to show +its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are +rebased to the browser clock and kept up to 60 s after their last message. +`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position +(tie: newest message) and the bands/sources heard in the last 60 s; the card's +receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT · +browser SDR + decoder feed"). Aircraft heard only on 978 MHz UAT draw with a +thin light-magenta ring. With feeds configured the row status reads "2 feeds +live · 14 heard" when every feed is live. While any input is producing (the +browser receiver streaming, or a feed live) the row stays nominal and names the +other feeds as a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale"), +since a decoder stopped so the browser can take the dongle is not a fault. With +nothing producing, every readable feed stale reads STALE and anything else is +an error. Without feeds it keeps the WebUSB statuses. See +`docs/LOCAL-RECEIVERS.md`. + ## Bundled geography and submarine cable components Submarine cables use separate source, geometry, rendering, interaction and @@ -3960,3 +4342,55 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending work and releases owned resources, including late factory results. Supplied 3D tilesets remain owned by the caller; tilesets created through the controller's factory are added to its viewer and removed on destruction. + +## Live CCTV integration candidate (#489) + +Daniel Slay's shared-decoder panel/projection feature is adapted to bounded +HTTP(S) HLS. DelDOT uses official HTTPS HLS URLs; RTMP/ffmpeg remux is excluded +because subprocess transport does not inherit the Node redirect policy. Server +sessions reserve synchronously, cap memory and response bytes, reject redirects, +and abort on release/shutdown. No disk store or background sweep exists while +inactive. The panel paints the same video at at most 640px/15 fps; collapse stops +its paint loop. Camera deactivation destroys the decoder, and fatal playback +errors revert to the labeled frame fallback. Camera changes clear the old panel +frame. hls.js is imported only when an HLS camera starts. Two live sessions may +coexist with at most eight independent consumer leases each; no-access lease +expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail +closed. Performance and live agency reliability require recorded validation. + +## Wind forecast models + +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. + +### Local Weather review candidate + +Weather combines the GFS/ECMWF surface forecast with three observation layers: +NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared), +and 15-minute lightning density (Americas and Pacific). One transient observed +clock drives all enabled observation rows. Earlier/Later use the sorted union of +non-suspended products' advertised times; each product selects its nearest frame +at or before the requested UTC time, never a future frame or an interpolation. +The maximum gap is 30 minutes for radar, regional infrared and lightning, and +3 hours for hourly global infrared. A missing eligible frame hides that product +and reports the gap; rows show actual displayed UTC time with synced/nearest labels. +Manifest rollover preserves the requested history time, even when frames expire. + +Latest follows each product's own newest frame, so its timestamps can differ. +Playback advances two seconds after all products settle, wraps, and stops with +fewer than two union times or when all products are suspended. Hidden documents, +reduced motion and unavailable imagery hosts suspend products. Re-enabling or +restoring a host joins the retained history target. The wind row explicitly says +“Forecast · does not follow history” while the observed clock is in history mode. +The catalog exposes `weatherClock` and destroys it with its lifetime signal. + +Source, opacity, coverage navigation and transport controls live in the WEATHER +panel's cards and timeline, not in the rows. The viewer clock is untouched. +Observation layers are off by default; share/persist includes appearance and product, while history +is transient and shared links open latest. Rendering retains the previous frame +when an incoming frame fails; no eligible historical frame instead hides imagery. + +Desktop wind now allows 7,200 baked native GPU paths (narrow viewports remain at +1,200). This doubles the maximum desktop geometry budget, not forecast resolution. +Temperature uses stronger fixed −40..50°C colors; the underlying 1° forecast and +numeric inspection values are unchanged. No volumetric cloud height or local rain +arrival prediction is claimed. NOAA source limits are documented in DATA_SOURCES. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index d29e356f9..76567e605 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -36,7 +36,8 @@ const layers = createInfrastructureLayers({ | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, -`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`. +`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and +`getAnalystRecords()`. Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay host permits one live instance per layer ID; destroy an old instance before replacing it. Separate applications may use identical IDs with separate hosts. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 92c92c1f9..e41623cc6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -1,6 +1,6 @@ # KNOWN ISSUES -Updated: July 8, 2026 +Updated: September 22, 2026 This file tracks active runtime issues only. @@ -78,6 +78,56 @@ Status: Open (owner-accepted 2026-07-08, documented) --- +### Weather layers: coverage and meaning +Status: Open (source limits, by design) + +- **Rain radar covers the contiguous United States only.** It shows MRMS + reflectivity in dBZ, not rainfall rate or a forecast; a gap in coverage does not + mean no precipitation. +- **Lightning density is a ground-network grid, not GLM flashes.** It is NOAA's + 15-minute density product derived from Vaisala NLDN/GLD360 on an approximately + 8 km grid, for the Americas and Pacific only (110°E across the dateline to 0°, + 25°S–80°N). It is not a live strike counter or an all-clear. +- **Cyclone advisories cover NOAA's basins only:** the Atlantic and the + eastern/central North Pacific (NHC and CPHC). Storms elsewhere do not appear. + The cone is forecast-center uncertainty, not storm size. +- **Satellite clouds:** global infrared is hourly with typically 2–3 hours of + source latency. Clouds only is a brightness filter, not a cloud mask. +- **Wind is a forecast.** The animation flows through one forecast on an + approximately 1° grid and does not advance forecast time or follow the + observed-history timeline. +- **History is not shared.** Share links carry the weather layers and their + settings but open at Latest. +- **3D Tiles detail window during history playback:** when a frame's detail image + is slow to arrive, the window may fall back to the coarser full-extent image + until it does. +- Native hardware GPU behavior of the wind and weather renderers is not yet + verified. + +--- + +### Satellite passes and analyst answers +Status: Open (by design) + +- Pass predictions search the next 24 hours for satellites in the loaded catalog. + Visibility is a geometric estimate (satellite sunlit, observer's Sun at or + below −6°); it ignores weather, satellite brightness and orbital-element age. +- Analyst counts and ranks cover a bounded set of loaded records (by default + 2,000 per layer for satellites, datacenters and dams), so omitted records can + change a nearest or count answer. Satellite distance is ground distance. + +--- + +### Live camera video (HLS) +Status: Open (limits) + +- At most two live-video sessions run at once. RTMP-only sources and encrypted, + fMP4 or byte-range playlists are not supported. +- When live video fails, the camera falls back to its labeled still, Street View + or placeholder frame, which is not live video. + +--- + ## Closed / Intentional (for clarity) ### Proxy SSRF and error-surface hardening gaps @@ -118,5 +168,14 @@ Context: converted to **live NASA FIRMS data** on 2026-07-16: the `/api/firms` proxy merges three VIIRS NRT sources (trailing 24 h, 30 min cache, serve-stale-on-failure) and the bundled snapshot was deleted. Requires a free server-side `FIRMS_MAP_KEY`; without it - the layer shows a KEY REQUIRED state. -- Weather radar is still held out of OSS v1 after QA found the previous overlay did not provide reliable visible value. + the layer row reads "Needs FIRMS_MAP_KEY — add it in Provider Settings". + +--- + +### Weather radar held out of the open-source release +Status: Closed — observed rain radar shipped (September 2026) + +Context: +- Rain radar, Satellite clouds, Lightning density, Wind and Cyclone advisories + are in the Weather group of Data Layers. Their current limits are listed under + "Weather layers: coverage and meaning" above. diff --git a/docs/LOCAL-RECEIVERS.md b/docs/LOCAL-RECEIVERS.md new file mode 100644 index 000000000..ee75d9a70 --- /dev/null +++ b/docs/LOCAL-RECEIVERS.md @@ -0,0 +1,82 @@ +# Local receivers + +The Local ADS-B layer draws aircraft heard by your own receivers. It has two +inputs, and it can use both at once. + +| Input | What it is | Bands | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| Browser SDR | A USB RTL-SDR opened by the browser through WebUSB (Radio panel, Local RTL-SDR card). Decoding runs in the page. | 1090 MHz | +| Decoder feed | The `aircraft.json` served by a decoder you run: dump1090-fa, readsb or tar1090 (1090 MHz), or dump978-fa + skyaware978 (978 MHz). | 1090 MHz, 978 MHz UAT | + +Aircraft from both inputs are merged by ICAO address. The newest position +wins, and the click card names every band and source that heard the aircraft +in the last 60 s. Aircraft heard only on 978 MHz UAT draw with a thin ring. + +Local aircraft usually lead their public twins. The public Flights layer +renders positions about one poll interval (~30 s) behind so it can interpolate +smoothly between fixes, while local receiver aircraft are shown as heard. An +aircraft in both layers therefore draws twice, the magenta local marker ahead +of the public one; both markers are drawn on purpose. + +## Which to use + +- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR. + Nothing else to install. +- **A dual-band board, a Raspberry Pi feeder, or a dongle already used by a + decoder:** use a decoder feed. A USB dongle can be opened by only one program + at a time, so a dongle that dump1090 or dump978 owns cannot also be opened by + the browser. + +## Configuring feeds + +Feeds are set in the server environment only (`.env`), never in the browser: + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json +``` + +Each entry is `band=url`, where `band` is `1090` or `978`. The URL must: + +- use `http` or `https`; +- point at loopback, a private (RFC1918) address, `localhost` or a `*.local` + name. Public addresses, link-local addresses (including `169.254.169.254`), + IPv6 and other host names are refused; +- have a path ending in `aircraft.json`, with no credentials, query or + fragment. + +An entry that breaks a rule is logged when the server starts, shown as +`invalid` and never fetched. Restart the server after changing the value. + +The server reads every feed about once a second while the Local ADS-B layer is +on. A feed is `live`, `stale` (its own `now` is more than 10 s old, usually a +stopped decoder), `unreachable` or `invalid`. The Layers panel row and the +Local RTL-SDR card show these states. + +## Example: dump1090-fa and dump978-fa on macOS or Linux + +Write each decoder's JSON to a directory, then serve the directory on +localhost. Each command keeps running, so start each in its own terminal: + +```sh +# 1090 MHz +dump1090-fa --device-index 0 --write-json /tmp/adsb1090 +python3 -m http.server 8080 --bind 127.0.0.1 --directory /tmp/adsb1090 + +# 978 MHz UAT: dump978-fa decodes, skyaware978 writes aircraft.json +dump978-fa --sdr driver=rtlsdr,rtl=1 --raw-port 30978 +skyaware978 --connect localhost:30978 --json-dir /tmp/adsb978 +python3 -m http.server 8978 --bind 127.0.0.1 --directory /tmp/adsb978 +``` + +```sh +LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/aircraft.json,978=http://localhost:8978/aircraft.json +``` + +A board with two identical-serial channels, such as the Nooelec FlyCatcher, +cannot be told apart by serial number. Select each channel by index: +`--device-index ` for dump1090-fa and `--sdr driver=rtlsdr,rtl=` for +dump978-fa. `rtl_test` lists the indexes. + +A tar1090 or readsb install on a Raspberry Pi already serves +`http:///tar1090/data/aircraft.json`. Use the Pi's private address +or its `.local` name. diff --git a/package-lock.json b/package-lock.json index c24590e4a..17bb0691e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,13 @@ "version": "0.1.1", "license": "MIT", "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", + "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "hls.js": "^1.7.3", "mgrs": "^2.1.0", "pbf": "^5.1.2", "satellite.js": "^6.0.2" @@ -1059,6 +1063,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jtarrio/signals": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz", + "integrity": "sha512-X6tJOugEATYH9KJy6j2feEBVCUmRv1U9SSbg9ITQZCx/Qruw2fE2zsS1HKH3yOsxrZBE+eQTGM+d0roX4xuDdw==", + "license": "Apache-2.0" + }, + "node_modules/@jtarrio/webrtlsdr": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jtarrio/webrtlsdr/-/webrtlsdr-3.0.6.tgz", + "integrity": "sha512-+P8dsbXQ/6Mwru/vV613yHH68nHFyUJ9zPK68hE/Hh2F3jwyirUAjjBtIAfN/MlJbFioMJ3NS6dMGrRj9deFvw==", + "license": "Apache-2.0", + "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@types/w3c-web-usb": "^1.0.10" + } + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1076,6 +1096,24 @@ "pbf": "^5.0.0" } }, + "node_modules/@meri-imperiumi/eccodes-wasm": { + "version": "2.48.2", + "resolved": "https://registry.npmjs.org/@meri-imperiumi/eccodes-wasm/-/eccodes-wasm-2.48.2.tgz", + "integrity": "sha512-Z7rD3xInomPG7FS97ORD1UGKAdgeeRP9ofa9k4m8d+k2sDfDQeAjTzvs9UOndyaZovaSbEQeIoNX8gTmQNhiSA==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "os": [ + "darwin", + "linux", + "win32" + ], + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@puppeteer/browsers": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", @@ -1518,6 +1556,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "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", @@ -1944,6 +1988,12 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "license": "MIT" }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 809841ba2..3e80dc55e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "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", @@ -47,9 +47,13 @@ "qa:transit": "node scripts/qa-transit.mjs" }, "dependencies": { + "@jtarrio/signals": "^0.10.0", + "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", + "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "hls.js": "^1.7.3", "mgrs": "^2.1.0", "pbf": "^5.1.2", "satellite.js": "^6.0.2" @@ -150,6 +154,8 @@ "./aircraft": "./src/layers/aircraft/index.js", "./layers/vessels": "./src/layers/vessels/index.js", "./layers/earthquakes": "./src/layers/earthquakes/index.js", + "./layers/recent-imagery": "./src/layers/recentImagery/index.js", + "./layers/perimeters": "./src/layers/perimeters/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -221,6 +227,7 @@ "./layers/bikeshare/source": "./src/layers/bikeshare/source.js", "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", + "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 3655cd998..28627e4ac 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) { const seen = new Set(); await build({ root, + // Keep this gate out of a running dev server's dependency cache + // (node_modules/.vite): a build there re-optimizes dependencies and the + // dev server's workers then fail with 504 "Outdated Optimize Dep". + cacheDir: path.join(root, 'node_modules/.vite-boundaries'), configFile: false, envFile: false, publicDir: false, @@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) { assetsInlineLimit: 0, rollupOptions: { input, - external: group.external, + // A declared dependency also owns its package subpath exports + // (for example `@jtarrio/webrtlsdr/rtlsdr.js`). + external: (id) => + group.external.some( + (name) => id === name || id.startsWith(`${name}/`), + ), // Unused imports must still obey ownership; tree shaking is not a boundary. treeshake: false, preserveEntrySignatures: 'strict', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2ed4f1081..eee546dd5 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -31,6 +31,7 @@ "src/app/mapLifecycle.test.mjs", "src/app/startupCamera.test.mjs", "src/app/viewer.js", + "src/app/atmosphereCompat.test.mjs", "src/standalone/application.js", "src/standalone/controls.js", "src/standalone/data.js", @@ -99,6 +100,8 @@ "src/tooling/previewServing.test.mjs", "server/providers/common/request.js", "server/providers/common/source-root.js", + "src/data/tapAddress.test.mjs", + "src/data/tapAddress.js", "server/providers/local.js", "server/providers/military-installations.js", "server/providers/military-installations/cache.js", @@ -142,6 +145,8 @@ "src/keylessGeocoder.js", "src/standalone/placeSearch.js", "src/ui/panelDisclosure.js", + "src/ui/imagerySplit.js", + "src/ui/imagerySplit.test.mjs", "src/ui/surfaceKeyboard.js", "src/ui/surfaceKeyboard.test.mjs", "src/ui/panelRails.js", @@ -334,6 +339,16 @@ "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/ownership.test.mjs", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/records.test.mjs", + "src/layers/perimeters/source.test.mjs", + "src/layers/perimeters/cards.test.mjs", + "src/layers/perimeters/inciweb.test.mjs", + "src/layers/perimeters/ownership.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", @@ -383,6 +398,10 @@ "src/layers/launches/model.js", "src/layers/launches/selection.js", "src/layers/launches/camera.js", + "src/data/localGeojson.analyst.test.mjs", + "src/data/satellites.analyst.test.mjs", + "src/data/layerSnapshot.test.mjs", + "src/data/satellitePass.test.mjs", "src/data/satellites.js", "src/data/rocketLaunches.js", "src/layers/satellites/source.test.mjs", @@ -544,11 +563,13 @@ "src/data/telegeographySubmarineCables.test.mjs", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/layers/submarineCables/bundledSource.test.mjs", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", "src/maps/controller.test.mjs", + "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", @@ -596,6 +617,7 @@ "src/app/layers/flights.js", "src/app/layers/radio.js", "src/app/layers/earthquakes.js", + "src/app/layers/perimeters.js", "src/app/layers/cctv.js", "src/app/layers/firms.js", "src/app/layers/satellites.js", @@ -611,6 +633,7 @@ "src/data/surfaceServices.js", "src/annotations/resolver.js", "src/services/terrainHeights.js", + "src/services/terrainHeights.test.mjs", "src/services/groundFloor.js", "src/services/meshFloorSampler.js", "src/services/groundSnap.js", @@ -730,6 +753,27 @@ "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", "src/sources/overpassFeatures.test.mjs", + "src/data/cctvHlsStream.test.mjs", + "src/layers/cctv/videoPlayback.test.mjs", + "src/ui/cctvVideo.test.mjs", + "src/ui/styles/weather.css", + "src/layers/weather/index.js", + "src/layers/weather/clock.js", + "src/layers/weather/clock.test.mjs", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/shellRendering.test.mjs", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredAlpha.test.mjs", + "src/layers/weather/infraredImage.js", + "src/layers/weather/infraredImage.test.mjs", + "src/layers/weather/source.js", + "src/layers/weather/weather.test.mjs", + "src/layers/weather/rasterTiles.test.mjs", + "src/layers/wind/rendering.test.mjs", + "src/layers/weather/source.test.mjs", + "src/data/weatherProxy.test.mjs", + "server/providers/weather.js", "src/director/clock.test.mjs", "src/director/timeline.test.mjs", "src/scenes/packs/registry.test.mjs", @@ -745,5 +789,55 @@ "scripts/qa-director-interactions.mjs", "src/director/sharing/sharing.test.mjs", "scripts/qa-director-sharing.mjs", - "src/data/aisStreamSentinels.test.mjs" + "src/ui/weatherPanel.js", + "src/ui/weatherPanel.test.mjs", + "src/ui/railCards.js", + "src/ui/railCards.test.mjs", + "src/ui/railTimeline.js", + "src/ui/railTimeline.test.mjs", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/imageryOrder.test.mjs", + "server/providers/cyclones.js", + "server/providers/firePerimeters.js", + "src/data/cyclonesProxy.test.mjs", + "src/data/firePerimetersProxy.test.mjs", + "src/layers/cyclones/index.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/source.js", + "src/layers/cyclones/rendering.test.mjs", + "src/layers/cyclones/labels.test.mjs", + "src/layers/cyclones/index.test.mjs", + "src/layers/cyclones/source.test.mjs", + "src/data/aisStreamSentinels.test.mjs", + "scripts/qa-weather-perf.mjs", + "scripts/qa-weather-journey.mjs", + "scripts/qa-weather-swap.mjs", + "scripts/qa-weather-teardown.mjs", + "src/layers/weather/imageryHost.test.mjs", + "src/sdr/adsbDecoder.test.mjs", + "src/sdr/controller.test.mjs", + "src/sdr/gain.test.mjs", + "src/sdr/spectrum.test.mjs", + "src/sdr/usbDevices.test.mjs", + "src/sources/adsbRecords.test.mjs", + "src/layers/localAdsb/index.test.mjs", + "src/ui/localSdrControls.test.mjs", + "src/ui/localSdrMarkup.test.mjs", + "src/ui/localSdrPresentation.test.mjs", + "src/data/localReceiversProxy.test.mjs", + "src/layers/localAdsb/feeds.test.mjs", + "src/ui/railScrollers.test.mjs", + "src/layers/localAdsb/motion.test.mjs", + "src/data/modelEligibility.test.mjs", + "src/layers/recentImagery/model.test.mjs", + "src/layers/recentImagery/catalog.test.mjs", + "src/maps/imageryHost.test.mjs", + "src/layers/recentImagery/rendering.test.mjs", + "src/layers/recentImagery/thumbnails.test.mjs", + "src/layers/recentImagery/index.test.mjs", + "src/ui/imageryBoxTool.test.mjs", + "src/ui/recentImagery.test.mjs", + "src/layers/recentImagery/testDoubles.mjs", + "scripts/qa-recent-imagery.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..8e9c9a61c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -20,7 +20,7 @@ }, "viewer": { "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], + "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], "external": ["cesium"] }, "vite-build": { @@ -161,6 +161,7 @@ "server/providers/cctv/normalize.js", "server/providers/cctv/range.js", "server/providers/cctv/sources.js", + "server/providers/cctv/stream.js", "server/providers/common/http.js", "src/data/directionText.js", "server/providers/common/geo.js", @@ -256,6 +257,7 @@ "runtime": "node", "exports": ["./server/providers/openai"], "modules": [ + "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", "server/providers/common/source-root.js", @@ -268,6 +270,10 @@ "server/providers/openai/realtime.js", "server/providers/openai/tools.js", "src/hudSummaryResponse.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", "src/voice/voiceCost.js", "src/voice/actionSchemas.js", "server/providers/openai/toolDescriptions.js", @@ -322,6 +328,7 @@ "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", + "src/ui/displayPanelScroll.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", "src/ui/panelMeasurement.js", @@ -375,11 +382,18 @@ "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", + "src/ui/weatherPanel.js", + "src/ui/railCards.js", + "src/ui/railCardBlocks.js", + "src/ui/chipGroup.js", + "src/ui/rowList.js", + "src/ui/railTimeline.js", "src/ui/clearLayersControl.js", "src/loadingFeedback.js", "src/data/installationFeedback.js", "src/keySetupCore.mjs", - "src/data/feedState.js" + "src/data/feedState.js", + "src/data/layerSnapshot.js" ], "external": [] }, @@ -413,6 +427,7 @@ "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", + "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js" ], "external": [] @@ -449,6 +464,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -474,6 +490,10 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/cyberSonar.js", + "src/ui/radioPanelPlacement.js", "src/app/stateChannel.js", "src/bloom.js", "src/cctvFocusPolicy.js", @@ -504,6 +524,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", @@ -513,6 +534,7 @@ "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", + "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", @@ -521,6 +543,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -540,6 +563,12 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/weatherPanel.js", + "src/ui/railCards.js", + "src/ui/railCardBlocks.js", + "src/ui/chipGroup.js", + "src/ui/rowList.js", + "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", "src/ui/location.js", @@ -577,10 +606,15 @@ "src/ui/visualSettings.js", "src/worldFocus.js", "src/data/feedState.js", + "src/data/layerSnapshot.js", "src/ui/aircraftDisplay.js", + "src/layers/weather/imageryHost.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", - "src/ui/shellFacade.js" + "src/ui/shellFacade.js", + "src/ui/localSdrControls.js", + "src/layers/localAdsb/feedNames.js", + "src/ui/localSdrPresentation.js" ], "external": ["cesium"] }, @@ -608,7 +642,9 @@ "src/ui/styles/command-dock-sliding.css", "src/ui/styles/voice-cost.css", "src/ui/styles/first-run.css", - "src/ui/styles/provider-settings.css" + "src/ui/styles/provider-settings.css", + "src/ui/styles/weather.css", + "src/ui/styles/recent-imagery.css" ], "external": [] }, @@ -700,6 +736,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -731,13 +768,16 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/layers/flights/modelSpec.js", + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -766,7 +806,8 @@ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/data/modelEligibility.js" ], "external": ["cesium"] }, @@ -828,6 +869,18 @@ ], "external": ["cesium"] }, + "recent-imagery": { + "exports": ["./layers/recent-imagery"], + "modules": [ + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", + "src/maps/imageryHost.js" + ], + "external": ["cesium"] + }, "fire-layer": { "exports": ["./layers/firms"], "modules": [ @@ -856,6 +909,7 @@ "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -864,6 +918,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", @@ -877,6 +932,7 @@ "launches-layer": { "exports": ["./layers/launches"], "modules": [ + "src/cyberSonar.js", "src/layers/launches/camera.js", "src/layers/launches/controls.js", "src/layers/launches/index.js", @@ -923,6 +979,7 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/cyberSonar.js", "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", @@ -1025,6 +1082,7 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", + "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", @@ -1035,7 +1093,7 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": ["cesium"] + "external": ["hls.js", "cesium"] }, "alpr-cameras": { "exports": ["./layers/alpr"], @@ -1116,6 +1174,7 @@ "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", "src/data/local_data/natural_earth/regions.json", @@ -1136,13 +1195,15 @@ "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", + "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", "src/maps/registry.js", "src/maps/terrain.js", - "src/keySetupCore.mjs" + "src/keySetupCore.mjs", + "src/maps/imageryHost.js" ], "external": ["cesium"] }, @@ -1200,8 +1261,15 @@ "./standalone/catalog" ], "modules": [ + "src/cyberSonarScene.js", + "src/cyberSonar.js", + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/ui/radioPanelPlacement.js", + "src/cyberSonarGpu.js", "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiEvent.js", + "src/ui/imagerySplit.js", "src/data/bhoteKoshiEmbeddedMedia.js", "src/data/bhoteKoshiLocator.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -1245,6 +1313,7 @@ "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", + "src/app/atmosphereCompat.js", "src/bloom.js", "src/camera.js", "src/cameraVerbs.js", @@ -1279,6 +1348,19 @@ "src/data/contactMatch.js", "src/data/contextStore.js", "src/data/dataCredits.js", + "src/data/wind.js", + "src/layers/wind/index.js", + "src/layers/wind/model.js", + "src/layers/wind/fields.js", + "src/layers/wind/streamlines.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/relief.js", + "src/layers/wind/inspection.js", + "src/layers/wind/presentation.js", + "src/layers/wind/rendering.js", + "src/layers/weather/rasterTiles.js", + "src/layers/wind/source.js", + "src/sources/windBody.js", "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", @@ -1328,10 +1410,12 @@ "src/data/regionalBrief.js", "src/data/regionalModel.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/rocketLaunches.js", "src/data/routePlausible.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/data/satellites.js", "src/data/scenePick.js", "src/data/spriteOrder.js", @@ -1414,6 +1498,7 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", + "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", @@ -1519,6 +1604,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", @@ -1574,6 +1660,7 @@ "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", + "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", @@ -1620,6 +1707,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", "src/ui/cameraOrientationControls.js", @@ -1629,6 +1717,7 @@ "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", + "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", @@ -1637,6 +1726,7 @@ "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", + "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -1657,6 +1747,12 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/weatherPanel.js", + "src/ui/railCards.js", + "src/ui/railCardBlocks.js", + "src/ui/chipGroup.js", + "src/ui/rowList.js", + "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", "src/ui/location.js", @@ -1705,6 +1801,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -1727,8 +1824,15 @@ "src/voice/realtimeEvents.js", "src/app/localGeojsonServices.js", "src/data/feedState.js", + "src/data/layerSnapshot.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/app/layers/perimeters.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -1757,6 +1861,7 @@ "src/data/contactTrailRenderer.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", + "src/data/modelEligibility.js", "src/layers/transit/trails.js", "src/layers/transit/movement.js", "src/layers/transit/qaMetrics.js", @@ -1782,6 +1887,13 @@ "src/voice/realtimeRadio.js", "src/voice/realtimeTurns.js", "src/voice/realtimeConnection.js", + "src/layers/weather/index.js", + "src/layers/weather/clock.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/source.js", "src/director/clock.js", "src/director/timeline.js", "src/scenes/shotPresentation.js", @@ -1809,15 +1921,49 @@ "src/director/sharing/lifetime.js", "src/director/authoring.js", "src/scenes/sharing.js", - "src/ui/sceneSharing.js" + "src/ui/sceneSharing.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/imageryHost.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", + "src/app/layers/recentImagery.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", + "src/maps/imageryHost.js", + "src/ui/recentImagery.js", + "src/ui/imageryBoxTool.js" ], "external": [ + "hls.js", "@mapbox/vector-tile", "cesium", "egm96-universal", "mgrs", "pbf", - "satellite.js" + "satellite.js", + "@jtarrio/webrtlsdr" ] }, "application-catalog": { @@ -1828,7 +1974,10 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", + "src/ui/imagerySplit.js", + "src/maps/imageryComparison.js", "src/data/bhoteKoshiEmbeddedMedia.js", "src/data/bhoteKoshiLocator.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -1838,6 +1987,16 @@ "src/annotations/resolver.js", "src/app/catalog.js", "src/app/constructCatalog.js", + "src/layers/wind/index.js", + "src/layers/wind/model.js", + "src/layers/wind/fields.js", + "src/layers/wind/streamlines.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/relief.js", + "src/layers/wind/inspection.js", + "src/layers/wind/presentation.js", + "src/layers/wind/rendering.js", + "src/layers/weather/rasterTiles.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", @@ -1885,6 +2044,7 @@ "src/data/infrastructure.js", "src/data/installationFeedback.js", "src/data/issPass.js", + "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/layerState.js", "src/data/localGeojson.js", @@ -1902,6 +2062,7 @@ "src/data/pickRegistry.js", "src/data/radioCountry.js", "src/data/renderAltitude.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/routePlausible.js", "src/data/satelliteClass.js", @@ -1976,6 +2137,7 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", + "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", @@ -2081,6 +2243,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", @@ -2144,6 +2307,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", + "src/layers/flights/modelSpec.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", @@ -2167,6 +2331,12 @@ "src/app/localGeojsonServices.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/app/layers/perimeters.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2203,15 +2373,56 @@ "src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeatures.js", "src/sources/featureSource.js", - "src/sources/overpassRoads.js" + "src/sources/overpassRoads.js", + "src/layers/weather/index.js", + "src/layers/weather/clock.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/source.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/imageryHost.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/source.js", + "src/app/layers/localAdsb.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/status.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/enrichment.js", + "src/sources/adsbRecords.js", + "src/sdr/controller.js", + "src/sdr/spectrum.js", + "src/sdr/gain.js", + "src/sdr/usbDevices.js", + "src/data/detection.js", + "src/data/detectionRenderDemand.js", + "src/data/detectionPresentation.js", + "src/data/modelEligibility.js", + "src/app/layers/recentImagery.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", + "src/maps/imageryHost.js" ], "external": [ + "hls.js", "cesium", "egm96-universal", "satellite.js", "@mapbox/vector-tile", "pbf", - "mgrs" + "mgrs", + "@jtarrio/webrtlsdr" ] }, "application-operations": { @@ -2229,6 +2440,7 @@ "src/data/local_data/neighborhoods/san-francisco.json", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", + "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/scenePick.js", "src/keylessGeocoder.js", @@ -2296,6 +2508,10 @@ "ui-visual-settings": { "exports": ["./ui/visual-settings"], "modules": [ + "src/cyberSonar.js", + "src/hudLayouts.js", + "src/hudLayoutPolicy.js", + "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", @@ -2320,6 +2536,8 @@ "ui-panel-chrome": { "exports": ["./ui/panel-chrome"], "modules": [ + "src/ui/displayPanelScroll.js", + "src/ui/radioPanelPlacement.js", "src/panelStackLayout.js", "src/ui/leftPanelRail.js", "src/ui/panelChrome.js", @@ -2410,7 +2628,7 @@ }, "voice-action-schemas": { "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js"], + "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], "external": [] }, "voice-session": { @@ -2499,6 +2717,9 @@ "src/sources/reference.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", "src/layers/submarineCables/bundledSource.js" ], "external": [] @@ -2569,6 +2790,7 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/cyberSonar.js", "src/app/sourceSlot.js", "src/celestialRing.js", "src/data/cameraSensitivity.js", @@ -2694,5 +2916,26 @@ "src/sources/featureSource.js" ], "external": [] + }, + "perimeters": { + "exports": ["./layers/perimeters"], + "modules": [ + "src/layers/perimeters/index.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js" + ], + "external": ["cesium"] + }, + "perimeters-source": { + "exports": ["./layers/perimeters/source"], + "modules": [ + "src/layers/perimeters/records.js", + "src/sources/httpBody.js", + "src/layers/perimeters/source.js" + ], + "external": [] } } diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 9213dcaa5..607889bbe 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1943,6 +1943,98 @@ try { }), ); + // Cyber relocates its collapsed briefing header into the expanded utility + // lane. Exercise actual height changes without reload and measure both cards. + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setHudVariant('cyber'); + manager.hud.setMode('on'); + manager._setCockpitDisclosure('display', false); + manager._setCockpitDisclosure('radio', true); + }); + const cyberRadioOrder = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.cockpitView.syncSignalLayout(); + const display = document + .getElementById('cockpit-display-toggle-btn') + .closest('.cockpit-utility-control'); + const radio = document + .getElementById('cockpit-radio-toggle-btn') + .closest('.cockpit-utility-control'); + const displayRect = display.getBoundingClientRect(); + const radioRect = radio.getBoundingClientRect(); + return { + display: displayRect.toJSON(), + radio: radioRect.toJSON(), + displayExpanded: display.classList.contains('is-expanded'), + radioExpanded: radio.classList.contains('is-expanded'), + gap: radioRect.top - displayRect.bottom, + }; + }); + check( + 'Cyber expanding Radio preserves the authored Display-then-Radio order', + !cyberRadioOrder.displayExpanded + && cyberRadioOrder.radioExpanded + && cyberRadioOrder.display.height > 0 + && cyberRadioOrder.radio.height > 0 + && cyberRadioOrder.gap >= 7, + JSON.stringify(cyberRadioOrder), + ); + await page.screenshot({ path: path.join(shotsDir, 'cyber-radio-order.png') }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setCockpitDisclosure('radio', false); + manager._setCockpitDisclosure('display', true); + }); + const cyberStyleStates = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const states = []; + for (const style of ['normal', 'surveillance', 'thermal', 'retro']) { + document.querySelector(`.style-btn[data-style="${style}"]`).click(); + states.push({ + requestedStyle: style, + activeStyle: manager.activeStyle, + hud: manager.hud.getVariant(), + theme: document.documentElement.dataset.uiTheme, + selector: document.getElementById('hud-layout-select').value, + savedHud: manager.getVisualState().hud.variant, + }); + } + return states; + }); + check('Cockpit visual-style controls retain Cyber HUD, selector, shell and saved layout', + cyberStyleStates.every(state => state.activeStyle === state.requestedStyle + && state.hud === 'cyber' && state.theme === 'cyber' + && state.selector === 'cyber' && state.savedHud === 'cyber'), + JSON.stringify(cyberStyleStates)); + for (const height of [987, 720, 640, 900]) { + await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 }); + await new Promise((resolve) => setTimeout(resolve, 400)); + const geometry = await page.evaluate(() => { + const signal = document.getElementById('cockpit-signal-stream'); + const utility = document.getElementById('cockpit-utility-controls'); + const expanded = utility.querySelector('.cockpit-utility-control.is-expanded'); + const s = signal.getBoundingClientRect(); + const u = expanded.getBoundingClientRect(); + const toggle = document.getElementById('cockpit-signal-toggle'); + const t = toggle.getBoundingClientRect(); + const hit = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2); + return { height: innerHeight, theme: document.documentElement.dataset.uiTheme, + collapsed: signal.dataset.collapsed, signal: s.toJSON(), utility: u.toJSON(), + reachable: Boolean(hit && toggle.contains(hit)), + contained: u.bottom <= s.top - 7 && s.bottom < innerHeight, + }; + }); + check(`Cyber collapsed Live Signals remains visible and reachable after resize to ${height}px`, + geometry.theme === 'cyber' && geometry.collapsed === 'true' + && geometry.signal.width > 0 && geometry.signal.height > 0 + && geometry.reachable && geometry.contained, JSON.stringify(geometry)); + if (height === 640 || height === 900) { + await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) }); + } + } + await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator')); + const signalTransition = await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; const cockpit = manager.cockpitView; diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index 453009929..1282c6b50 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -23,13 +23,17 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => window.__godsEyeView?.dataManager?._layerPanel, - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 60000, + }); const results = await page.evaluate(async () => { const manager = window.__godsEyeView.dataManager; - const container = manager._toggleContainer; + const entry = document.querySelector( + 'script[type="module"][src*="/src/main.js"]', + ); + const { application } = await import(entry.src); + const { presentation } = application.getComponents().data; + const container = presentation.panel._toggleContainer; const id = 'qa-panel-lifecycle'; let listener = null; let enabled = 0; @@ -54,7 +58,7 @@ try { }); const result = []; try { - manager.buildTogglePanel(container); + presentation.mount(container); const row = () => container.querySelector(`[data-layer-id="${id}"]`); const first = row().querySelector('.data-toggle-btn'); result.push([ @@ -66,7 +70,7 @@ try { 'row subscription is installed', typeof listener === 'function', ]); - manager.buildTogglePanel(container); + presentation.mount(container); first.click(); await Promise.resolve(); result.push([ @@ -97,7 +101,7 @@ try { ]); await manager.setEnabled(id, true); const final = row().querySelector('.data-toggle-btn'); - manager._layerPanel.destroy(); + presentation._panel.destroy(); final.click(); await Promise.resolve(); result.push([ @@ -109,10 +113,10 @@ try { manager.isEnabled(id), ]); } finally { - manager._layerPanel?.destroy(); - manager._layerPanel = null; + presentation._panel?.destroy(); + presentation._panel = null; await window.__gevQaUnregisterLayer(manager, id); - manager.buildTogglePanel(container); + presentation.mount(container); } result.push([ 'ordinary layer rows return after reassembly', diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index c5629d2f4..c30a17ee0 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -374,7 +374,7 @@ async function main() { enabled: true, uncertain: true, }); - gev.dataManager._refreshTogglePanel(); + gev.dataManager.refreshLayerStats(); const staticResult = module.setTuningStatic(true); const filterBefore = module.getUIState().filter; const filterControl = document.getElementById('radio-filter'); @@ -447,7 +447,7 @@ async function main() { enabled: true, uncertain: false, }); - gev.dataManager._refreshTogglePanel(); + gev.dataManager.refreshLayerStats(); module.cancelTuning(); camera.flyTo = originalFlyTo; return result; @@ -4048,7 +4048,13 @@ async function main() { const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); const expectedEntryId = `selected:${selected.id}`; let painted = null; - for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) { + // Radio's horizon pass reveals the selected entity on its own timer + // after the camera jump, so a stationary view waits for both to settle. + for ( + let attempt = 0; + attempt < 20 && !(painted?.rect && selectedEntity?.show === true); + attempt += 1 + ) { painted = await new Promise((resolve) => { let removePostRender = null; const timeout = setTimeout(() => { diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs new file mode 100644 index 000000000..1460ed3b5 --- /dev/null +++ b/scripts/qa-recent-imagery.mjs @@ -0,0 +1,1480 @@ +#!/usr/bin/env node +/** + * Rendered acceptance for DATA ▸ Recent Imagery against the real NASA + * endpoints (CMR catalog, GIBS tiles, Worldview snapshots): the rail panel, + * USE VIEW and the live preview, the three modes (IMAGE, VS BASEMAP, A / B) + * with their pins, the divider and the Esri surface on Google 3D, Escape + * order, the oversized-view refusal and ZOOM IN, CLEAR, the share link, + * map-switch rebind (a manual switch to Google 3D keeps the imagery on + * Esri), CCTV stability at 900 and 700 px, scroll preservation, a + * layout-shift measurement across every transition, and disable. + * + * Run: start the app (`./scripts/dev-fresh.sh`) and + * node scripts/qa-recent-imagery.mjs + * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default + * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without + * network access to NASA the run skips with exit code 0. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; +const HEADFUL = process.env.QA_HEADFUL === '1'; +const STEP_TIMEOUT = 20_000; +const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; +const PANEL = '#recent-imagery-panel'; + +async function networkAvailable() { + try { + const response = await fetch( + 'https://cmr.earthdata.nasa.gov/search/collections.json?page_size=1', + { signal: AbortSignal.timeout(8_000) }, + ); + return response.ok; + } catch { + return false; + } +} + +if (!(await networkAvailable())) { + console.log( + 'SKIP: cmr.earthdata.nasa.gov is unreachable — the live Recent Imagery gate needs network access.', + ); + process.exit(0); +} + +const browser = await puppeteer.launch({ + headless: !HEADFUL, + ...(HEADFUL + ? { + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + } + : {}), + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ...(HEADFUL ? ['--window-size=1280,860'] : []), + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +let failures = 0; +let checks = 0; +function check(name, passed, detail = '') { + checks += 1; + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures++; +} +fs.mkdirSync(SHOT_DIR, { recursive: true }); +const shots = []; +const shot = async (name) => { + const file = path.join(SHOT_DIR, `${name}.jpg`); + await page.screenshot({ path: file, type: 'jpeg', quality: 82 }); + shots.push(file); +}; +const panelShot = async (name) => { + const panel = await page.$(PANEL); + if (!panel) return; + const file = path.join(SHOT_DIR, `${name}.png`); + await panel.screenshot({ path: file }); + shots.push(file); +}; +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Poll a page predicate; false on timeout instead of throwing. */ +async function until(fn, arg, timeout = STEP_TIMEOUT) { + try { + await page.waitForFunction(fn, { timeout, polling: 250 }, arg); + return true; + } catch { + return false; + } +} + +/** + * Page-side helpers every predicate can use: the layer handle, GIBS layers + * per host with their split directions, and whether a header is on screen. + */ +function installPageHelpers() { + window.__riQa = { + layer: () => window.__gevRecentImagery.layer, + snap: () => window.__gevRecentImagery.layer.getSnapshot(), + owned: () => window.__gevRecentImagery.layer.diagnostics().ownedCount, + gibs(collection) { + const out = []; + for (let i = 0; i < (collection?.length || 0); i += 1) { + const layer = collection.get(i); + if ( + String(layer?.imageryProvider?.url || '').includes( + 'earthdata.nasa.gov', + ) + ) + out.push(layer.splitDirection); + } + return out; + }, + hosts() { + const gev = window.__godsEyeView; + const globe = this.gibs(gev.viewer.imageryLayers); + const tileset = this.gibs(gev.tileset?.imageryLayers); + return { + globe: globe.length, + tileset: tileset.length, + splits: [...globe, ...tileset], + globeShown: gev.viewer.scene.globe.show, + }; + }, + splitShown() { + const line = document.getElementById('recent-imagery-split-line'); + return Boolean(line && !line.hidden); + }, + headerOnScreen(id) { + const element = document.getElementById(id); + const header = + element?.querySelector('.panel-header, .pp-header-row') || element; + const rect = header?.getBoundingClientRect(); + return rect + ? rect.height > 0 && rect.top >= 0 && rect.bottom <= window.innerHeight + : false; + }, + activeStack: () => + window.__godsEyeView.mapStackController?.getActiveId?.() ?? null, + }; +} + +const state = () => + page.evaluate(() => { + const qa = window.__riQa; + const snapshot = qa.snap(); + const gev = window.__godsEyeView; + const split = document.getElementById('recent-imagery-split-line'); + const text = (id) => document.getElementById(id)?.textContent || ''; + const hosts = qa.hosts(); + return { + box: snapshot.box, + boxError: snapshot.boxError, + mode: snapshot.mode, + candidates: snapshot.candidates.map((c) => ({ + key: c.key, + drapable: c.drapable, + })), + focus: snapshot.focus?.key || null, + pinA: snapshot.pins.a.key, + pinB: snapshot.pins.b.key, + preview: snapshot.preview.key, + shown: snapshot.shown, + notice: snapshot.notice, + comparison: snapshot.comparison, + borrowedEsri: snapshot.borrowedEsri, + error: snapshot.error, + diagnostics: qa.layer().diagnostics(), + cards: document.querySelectorAll('#recent-imagery-panel .ri-card').length, + panelHidden: document.getElementById('recent-imagery-panel')?.hidden, + noticeText: text('ri-notice-text'), + hintText: text('ri-hint'), + rowA: text('ri-slot-a'), + rowB: text('ri-slot-b'), + splitVisible: Boolean( + split && !split.hidden && split.getBoundingClientRect().height > 0, + ), + splitLabels: split + ? [ + split.querySelector('span.before')?.textContent, + split.querySelector('span.after')?.textContent, + ] + : null, + splitLeft: split ? split.getBoundingClientRect().left : null, + splitPosition: gev.viewer.scene.splitPosition, + canvasWidth: gev.viewer.scene.canvas.clientWidth, + activeStack: qa.activeStack(), + gibsOnGlobe: hosts.globe, + gibsOnTileset: hosts.tileset, + gibsSplits: hosts.splits, + }; + }); +const hostsDetail = (s) => + `owned=${s.diagnostics.ownedCount} host=${s.diagnostics.host} globe=${s.gibsOnGlobe} tileset=${s.gibsOnTileset} splits=${s.gibsSplits}`; + +/** Put the camera straight down over a point at a height, no flight. */ +const setView = (view) => + page.evaluate((target) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (target.lon * Math.PI) / 180, + latitude: (target.lat * Math.PI) / 180, + height: target.height, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }, view); + +/** Ground width of the camera's view rectangle, in km (0 without one). */ +const viewWidthKm = () => + page.evaluate(() => { + const rect = window.__godsEyeView.viewer.camera.computeViewRectangle?.(); + if (!rect) return 0; + const lat = (rect.south + rect.north) / 2; + return Math.abs(rect.east - rect.west) * 6371 * Math.cos(lat); + }); + +const click = (selector) => + page.evaluate((target) => { + const button = document.querySelector(target); + if (!button || button.disabled || button.hidden) return false; + button.click(); + return true; + }, selector); +const clickAction = (id) => click(`${PANEL} [data-action-id="${id}"]`); +const clickMode = (mode) => click(`${PANEL} .ri-mode-btn[data-mode="${mode}"]`); +const focusStrip = () => + page.evaluate(() => + document.getElementById('ri-strip')?.focus({ preventScroll: true }), + ); +const pressOnStrip = async (key) => { + await focusStrip(); + await page.keyboard.press(key); +}; + +/** Average colour of the canvas centre — the inside of a centred 10 km box. */ +async function probe() { + const base64 = await page.screenshot({ + clip: { x: 540, y: 330, width: 200, height: 200 }, + type: 'png', + encoding: 'base64', + }); + return page.evaluate(async (data) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${data}`)).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; + const sum = [0, 0, 0]; + for (let i = 0; i < pixels.length; i += 4) + for (let c = 0; c < 3; c += 1) sum[c] += pixels[i + c]; + return sum.map((value) => Math.round(value / (pixels.length / 4))); + }, base64); +} + +const tilesSettled = () => + until(() => { + const gev = window.__godsEyeView; + if (gev.viewer.scene.globe.show) return gev.viewer.scene.globe.tilesLoaded; + return !gev.tileset || gev.tileset.tilesLoaded; + }); +const backOnPhotoreal = () => + until(() => window.__riQa.activeStack() === 'photoreal'); +const onEsri = () => + until(() => window.__riQa.activeStack() === 'esri-imagery'); +const ownedIs = (count) => + until((n) => window.__riQa.owned() === n, count, 10_000); + +// ── layout-shift measurement ──────────────────────────────────────────────── +const CONTROLS = { + 'select-box': `${PANEL} [data-action-id="select-box"]`, + 'use-view': `${PANEL} [data-action-id="use-view"]`, + clear: `${PANEL} [data-action-id="clear"]`, + notice: '#ri-notice', + 'zoom-in': '#ri-zoom-in', + strip: '#ri-strip', + hint: '#ri-hint', + 'mode-image': `${PANEL} .ri-mode-btn[data-mode="image"]`, + 'mode-basemap': `${PANEL} .ri-mode-btn[data-mode="basemap"]`, + 'mode-ab': `${PANEL} .ri-mode-btn[data-mode="ab"]`, + 'row-a': '#ri-slot-a', + 'row-b': '#ri-slot-b', + 'unpin-a': '#ri-unpin-a', + 'unpin-b': '#ri-unpin-b', + opacity: '#ri-opacity', + swap: '#ri-swap', + 'export-a': `${PANEL} [data-action-id="export-a"]`, + 'export-b': `${PANEL} [data-action-id="export-b"]`, + details: '#ri-details .rail-card-header', +}; +const measure = () => + page.evaluate((controls) => { + const body = document.getElementById('recent-imagery-panel-body'); + const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; + for (const [name, selector] of Object.entries(controls)) { + const rect = document.querySelector(selector)?.getBoundingClientRect(); + out.rects[name] = rect + ? [rect.left, rect.top, rect.width, rect.height].map( + (v) => Math.round(v * 10) / 10, + ) + : null; + } + return out; + }, CONTROLS); +let layoutBaseline = null; +/** Every control's rect matches the baseline within 1 px. */ +async function checkLayout(label) { + await wait(150); + const now = await measure(); + if (!layoutBaseline) { + layoutBaseline = now; + check( + `layout baseline recorded (${label})`, + Object.values(now.rects).every(Boolean), + JSON.stringify(now.rects), + ); + return; + } + const moved = Object.entries(now.rects).filter(([name, rect]) => { + const base = layoutBaseline.rects[name]; + return ( + !rect || !base || rect.some((value, i) => Math.abs(value - base[i]) > 1) + ); + }); + check( + `no control moves: ${label}`, + moved.length === 0, + moved + .map( + ([name, rect]) => + `${name} ${JSON.stringify(layoutBaseline.rects[name])}→${JSON.stringify(rect)}`, + ) + .join(' '), + ); +} + +try { + await page.setViewport({ width: 1280, height: 860 }); + await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + const booted = await until( + () => + window.__gevRecentImagery && + window.__godsEyeView && + document.getElementById('loading-screen')?.classList.contains('hidden'), + null, + 90_000, + ); + check('the app boots with the Recent Imagery handle', booted); + if (!booted) throw new Error('boot timeout'); + await page.evaluate(installPageHelpers); + + await setView(AUSTIN); + await tilesSettled(); + const initialStack = await page.evaluate(() => window.__riQa.activeStack()); + const photoreal = initialStack === 'photoreal'; + console.log(`initial map stack: ${initialStack}`); + const onPhotorealOnly = (name, passed, detail) => + photoreal + ? check(name, passed, detail) + : console.log(`[SKIP] ${name} — the boot stack is ${initialStack}`); + + // ── enable: the panel in the rail, per the rail readout pattern ───────── + const layerToggle = '[data-layer-id="recent-imagery"] .data-toggle-btn'; + check( + 'the data panel carries a Recent Imagery toggle', + await click(layerToggle), + ); + check( + 'the layer enables from its toggle', + await until(() => + window.__godsEyeView.dataManager.isEnabled('recent-imagery'), + ), + ); + check( + 'the panel appears on the right rail once enabled', + await until( + () => document.getElementById('recent-imagery-panel')?.hidden === false, + ), + ); + const railPlacement = await page.evaluate(() => { + const rail = document.getElementById('right-context-rail'); + const panel = document.getElementById('recent-imagery-panel'); + const body = document.getElementById('recent-imagery-panel-body'); + const order = [...rail.children].map((child) => child.id); + const at = (id) => order.indexOf(id); + return { + inRail: panel?.parentElement === rail, + beforeContext: at('recent-imagery-panel') < at('global-context-panel'), + afterWeather: at('weather-panel') < at('recent-imagery-panel'), + collapsed: panel?.classList.contains('collapsed'), + scroller: body?.hasAttribute('data-rail-scroller'), + readoutInBody: body?.firstElementChild?.classList.contains( + 'recent-imagery-readout', + ), + body: body?.offsetHeight, + title: panel?.querySelector('.panel-title')?.textContent, + order, + }; + }); + check( + 'the panel sits in the rail after WEATHER and before CONTEXT, expanded on first appearance', + railPlacement.inRail && + railPlacement.beforeContext && + railPlacement.afterWeather && + railPlacement.collapsed === false && + railPlacement.body > 0, + JSON.stringify(railPlacement), + ); + check( + 'the body is a rail scroller holding the readout, under a RECENT IMAGERY header', + railPlacement.scroller && + railPlacement.readoutInBody && + railPlacement.title === 'RECENT IMAGERY', + JSON.stringify(railPlacement), + ); + const listOf = (selector, key) => + page.evaluate( + (sel, field) => + [...document.querySelectorAll(sel)] + .map((node) => node.dataset[field] || node.id) + .join(','), + selector, + key, + ); + check( + 'the data-panel row carries only the two source chips', + (await listOf( + '[data-layer-id="recent-imagery"] .data-toggle-chip', + 'chipId', + )) === 'hls,viirs', + ); + const actionIds = await listOf( + `${PANEL} .ri-actions-row [data-action-id]`, + 'actionId', + ); + check( + 'the actions row holds SELECT BOX, USE VIEW and CLEAR', + actionIds === 'select-box,use-view,clear', + actionIds, + ); + const blocks = await listOf(`${PANEL} .recent-imagery-readout > *`, 'none'); + check( + 'the body is the fixed stack: actions, notice, strip, hint, selection, controls, details', + blocks === + 'ri-actions,ri-notice,ri-strip,ri-hint,ri-selection,ri-controls,ri-details', + blocks, + ); + let current = await state(); + check( + 'before a box: CLEAR is disabled, the hint says what to do, IMAGE is the mode', + (await page.evaluate( + () => + document.querySelector('#recent-imagery-panel [data-action-id="clear"]') + ?.disabled, + )) === true && + current.hintText === 'Select a box or use the view' && + current.mode === 'image', + `${current.hintText} ${current.mode}`, + ); + await checkLayout('empty'); + await panelShot('01-enabled-panel'); + + // ── USE VIEW at 12 km: days and the live preview ──────────────────────── + const beforeColour = await probe(); + const viewKm = await viewWidthKm(); + check('USE VIEW is clickable', await clickAction('use-view')); + const searched = await until(() => { + const snapshot = window.__riQa.snap(); + return !snapshot.searching && snapshot.candidates.length > 0; + }); + current = await state(); + check( + `USE VIEW at 12 km (${viewKm.toFixed(1)} km wide) sets a box and the catalog returns days within 20 s`, + searched && Boolean(current.box), + `${current.candidates.length} candidates, error=${current.error} boxError=${current.boxError}`, + ); + check( + 'the strip renders one card per day', + current.cards === current.candidates.length && current.cards > 0, + `${current.cards} cards`, + ); + const countText = await page.evaluate( + () => document.getElementById('recent-imagery-panel-count')?.textContent, + ); + check( + 'the header count reads the day count', + /^\d+ DAYS?$/.test(countText), + countText, + ); + const startHere = await page.evaluate(() => + [...document.querySelectorAll('#recent-imagery-panel .ri-card-start')] + .filter((node) => !node.hidden) + .map((node) => node.closest('.ri-card').dataset.key), + ); + check( + 'exactly one card wears START HERE', + startHere.length === 1, + startHere.join(','), + ); + check( + 'the START HERE day previews on its own', + await until(() => { + const snap = window.__riQa.snap(); + return ( + Boolean(snap.preview.key) && + snap.preview.slot === 'a' && + window.__riQa.owned() === 1 + ); + }), + ); + current = await state(); + check( + 'row A reads the previewed day, row B reads the basemap', + /^IMAGE.+ · preview×$/.test(current.rowA) && current.rowB === 'VSBasemap×', + `${current.rowA} | ${current.rowB}`, + ); + onPhotorealOnly( + 'on Google 3D a shown image borrows the Esri surface', + await onEsri(), + ); + const esriNote = await until( + () => + document.getElementById('ri-notice-text')?.textContent === + 'Imagery on Esri · Google 3D returns when cleared', + undefined, + 5_000, + ); + current = await state(); + onPhotorealOnly( + 'the notice says the imagery is on Esri until cleared', + esriNote, + current.noticeText, + ); + check( + 'one owned imagery layer on the globe, no divider', + current.diagnostics.ownedCount === 1 && + current.gibsOnGlobe + current.gibsOnTileset === 1 && + !current.splitVisible, + hostsDetail(current), + ); + check( + 'at least one thumbnail settles (present/empty/error)', + await until(() => + window.__riQa + .snap() + .candidates.some((c) => c.thumbnail.status !== 'unknown'), + ), + ); + await tilesSettled(); + let changedColour = null; + for (let deadline = Date.now() + STEP_TIMEOUT; Date.now() < deadline;) { + const colour = await probe(); + if (Math.hypot(...colour.map((c, i) => c - beforeColour[i])) > 6) { + changedColour = colour; + break; + } + await page.evaluate(() => + window.__godsEyeView.viewer.scene.requestRender(), + ); + await wait(1000); + } + check( + 'the rendered frame inside the box changes once the preview drapes', + Boolean(changedColour), + `before=${beforeColour} after=${changedColour || (await probe())}`, + ); + await checkLayout('preview'); + await shot('02-preview'); + await panelShot('02-preview-panel'); + + // ── arrow keys: the preview follows the focus ─────────────────────────── + await until(() => { + const snap = window.__riQa.snap(); + return snap.candidates.some( + (c) => c.key !== snap.preview.key && c.drapable, + ); + }); + current = await state(); + const targetIndex = current.candidates.findIndex( + (c) => c.key !== current.preview && c.drapable, + ); + check( + 'a present day other than the preview is reachable', + targetIndex >= 0, + String(targetIndex), + ); + const targetKey = current.candidates[targetIndex]?.key; + await focusStrip(); + await page.keyboard.press('Home'); + for (let i = 0; i < targetIndex; i += 1) + await page.keyboard.press('ArrowRight'); + const pressedAt = Date.now(); + check( + 'arrow keys move focus at once', + await until( + (key) => window.__riQa.snap().focus?.key === key, + targetKey, + 2_000, + ), + targetKey, + ); + const followed = await until( + (key) => window.__riQa.snap().preview.key === key, + targetKey, + 1_500, + ); + const elapsed = Date.now() - pressedAt; + check( + 'the focused present day previews within 1 s', + followed && elapsed <= 1_250, + `preview after ${elapsed} ms`, + ); + current = await state(); + check( + 'still a single owned layer while scrubbing', + current.diagnostics.ownedCount === 1, + hostsDetail(current), + ); + const flag = await page.evaluate( + (key) => + document.querySelector( + `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-card-flag`, + )?.textContent, + targetKey, + ); + check('the previewed card wears PREVIEW', flag === 'PREVIEW', flag); + + // ── IMAGE: S pins the focused day; focus no longer moves the map ──────── + await pressOnStrip('s'); + current = await state(); + check( + 'S pins the focused day and its SHOW chip lights', + current.pinA === targetKey && + (await page.evaluate( + (key) => + document + .querySelector( + `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-chip[data-slot="a"]`, + ) + ?.getAttribute('aria-pressed'), + targetKey, + )) === 'true', + current.pinA, + ); + check( + 'row A reads the pinned day without "preview"', + /^IMAGE.+×$/.test(current.rowA) && !/preview/.test(current.rowA), + current.rowA, + ); + await checkLayout('pinned'); + await page.keyboard.press(targetIndex > 0 ? 'ArrowLeft' : 'ArrowRight'); + await wait(700); + current = await state(); + check( + 'with the image pinned, focus moves without changing the map', + current.focus !== targetKey && current.shown.a === targetKey, + `focus=${current.focus} shown=${current.shown.a}`, + ); + await page.evaluate(() => { + const slider = document.getElementById('ri-opacity'); + slider.value = '60'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + }); + current = await state(); + check( + 'OPACITY sets the draped alpha', + Math.abs((await page.evaluate(() => window.__riQa.snap().alpha)) - 0.6) < + 1e-9, + ); + await checkLayout('opacity'); + + // ── VS BASEMAP: the pinned day swipes against the basemap ─────────────── + check('VS BASEMAP is one click', await clickMode('basemap')); + const basemapSwipe = await until( + () => + window.__riQa.snap().shown.swipe === 'basemap' && + window.__riQa.splitShown(), + ); + current = await state(); + check( + 'VS BASEMAP shows the divider, labelled IMAGE / BASEMAP', + basemapSwipe && + JSON.stringify(current.splitLabels) === '["IMAGE","BASEMAP"]', + JSON.stringify(current.splitLabels), + ); + check( + 'the image is on the left and the basemap on the right: one layer, split LEFT', + current.diagnostics.ownedCount === 1 && + JSON.stringify(current.gibsSplits) === '[-1]', + hostsDetail(current), + ); + check( + 'the divider starts centred', + Math.abs(current.splitPosition - 0.5) < 0.01 && + Math.abs(current.splitLeft - current.canvasWidth / 2) <= 2, + `splitPosition=${current.splitPosition} left=${current.splitLeft} canvas=${current.canvasWidth}`, + ); + check( + 'row B reads the basemap, lit', + current.rowB === 'VSBasemap×' && + (await page.evaluate(() => + document.getElementById('ri-slot-b').classList.contains('lit'), + )), + current.rowB, + ); + onPhotorealOnly( + 'the swipe runs on Esri', + current.activeStack === 'esri-imagery', + current.activeStack, + ); + check( + 'the hint says SWAP trades the sides (VS BASEMAP)', + current.hintText === 'Drag the divider · SWAP trades sides', + current.hintText, + ); + await checkLayout('vs basemap'); + await tilesSettled(); + await shot('03-vs-basemap'); + await panelShot('03-vs-basemap-panel'); + + // ── A / B: pin A, preview then pin B, swipe, SWAP, unpin ──────────────── + check('A / B is one click', await clickMode('ab')); + current = await state(); + check( + 'A / B keeps the pin as A and waits for B', + current.mode === 'ab' && + current.pinA === targetKey && + !current.splitVisible && + /^A.+×$/.test(current.rowA) && + current.rowB === 'BNot set×', + `${current.rowA} | ${current.rowB}`, + ); + const chipLabels = await page.evaluate( + (key) => + [ + ...document.querySelectorAll( + `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-chip`, + ), + ].map((chip) => chip.textContent), + targetKey, + ); + check( + 'the cards now carry A and B chips', + JSON.stringify(chipLabels) === '["A","B"]', + JSON.stringify(chipLabels), + ); + await checkLayout('A / B, A pinned'); + const secondIndex = (await state()).candidates.findIndex( + (c) => c.key !== targetKey && c.drapable, + ); + const secondKey = current.candidates[secondIndex]?.key; + await focusStrip(); + await page.keyboard.press('Home'); + for (let i = 0; i < secondIndex; i += 1) + await page.keyboard.press('ArrowRight'); + check( + 'arrows preview the other day as B against A', + await until( + (key) => { + const snap = window.__riQa.snap(); + return snap.preview.key === key && snap.preview.slot === 'b'; + }, + secondKey, + 3_000, + ), + secondKey, + ); + await page.keyboard.press('b'); + const abSwipe = await until( + (key) => + window.__riQa.snap().pins.b.key === key && + window.__riQa.snap().shown.swipe === 'ab' && + window.__riQa.splitShown(), + secondKey, + ); + current = await state(); + check( + 'B pins the second day and the divider reads A / B', + abSwipe && JSON.stringify(current.splitLabels) === '["A","B"]', + JSON.stringify(current.splitLabels), + ); + check( + 'two owned layers: A left, B right', + current.diagnostics.ownedCount === 2 && + JSON.stringify([...current.gibsSplits].sort()) === '[-1,1]', + hostsDetail(current), + ); + check( + 'the divider recentres for the new comparison', + Math.abs(current.splitPosition - 0.5) < 0.01, + String(current.splitPosition), + ); + check( + 'row B reads the pinned day', + /^B.+×$/.test(current.rowB) && !/Not set/.test(current.rowB), + current.rowB, + ); + onPhotorealOnly( + 'the A / B swipe runs on Esri', + current.activeStack === 'esri-imagery', + current.activeStack, + ); + check( + 'the hint says SWAP trades the sides (A / B)', + current.hintText === 'Drag the divider · SWAP trades sides', + current.hintText, + ); + await checkLayout('A / B, both pinned'); + await tilesSettled(); + await shot('04-ab'); + await panelShot('04-ab-panel'); + // SWAP trades the sides (Space is push-to-talk, so no key does this). + const splitBeforeSwap = current.splitPosition; + check('SWAP is one click while the swipe is live', await click('#ri-swap')); + const swapped = await until( + () => + window.__riQa.snap().swapped === true && + document.querySelector('#recent-imagery-split-line span.before') + ?.textContent === 'B', + ); + current = await state(); + check( + 'SWAP trades the divider labels to B / A and keeps the divider where it was', + swapped && + JSON.stringify(current.splitLabels) === '["B","A"]' && + JSON.stringify([...current.gibsSplits].sort()) === '[-1,1]' && + Math.abs(current.splitPosition - splitBeforeSwap) < 0.01, + `${JSON.stringify(current.splitLabels)} ${hostsDetail(current)} split=${current.splitPosition}`, + ); + await checkLayout('A / B, swapped'); + check('SWAP again is one click', await click('#ri-swap')); + const restored = await until( + () => + window.__riQa.snap().swapped === false && + document.querySelector('#recent-imagery-split-line span.before') + ?.textContent === 'A', + ); + current = await state(); + check( + 'SWAP again restores A / B', + restored && JSON.stringify(current.splitLabels) === '["A","B"]', + JSON.stringify(current.splitLabels), + ); + check('B unpins from its ×', await click('#ri-unpin-b')); + const singleA = await until( + () => + window.__riQa.owned() === 1 && + !document.getElementById('recent-imagery-split-line'), + ); + current = await state(); + check( + 'unpinning B drops its layer at once: A alone, no divider', + singleA && current.shown.a === targetKey && !current.shown.b, + hostsDetail(current), + ); + check( + 'without a swipe SWAP keeps its slot, disabled and invisible', + await page.evaluate(() => { + const swap = document.getElementById('ri-swap'); + return Boolean( + swap?.disabled && + swap.classList.contains('is-reserved') && + getComputedStyle(swap).visibility === 'hidden', + ); + }), + ); + onPhotorealOnly( + 'A alone stays on Esri', + current.activeStack === 'esri-imagery', + current.activeStack, + ); + await checkLayout('B unpinned'); + check('A unpins from its ×', await click('#ri-unpin-a')); + check('with nothing shown, no layer remains', await ownedIs(0)); + onPhotorealOnly( + 'with nothing shown, Google 3D comes back', + await backOnPhotoreal(), + ); + await checkLayout('A unpinned'); + + // ── the share link carries the pins and the mode ──────────────────────── + await clickMode('basemap'); + await pressOnStrip('a'); + const shared = await until( + () => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const layers = (params.get('l') || '').split('.'); + const options = params.get('lo') || ''; + return ( + layers.includes('1') && + /(^|_)1\.a\.[SLV]\d{8}/.test(options) && + /(^|_)1\.m\.1(_|$)/.test(options) && + /(^|_)1\.w\.-?\d+/.test(options) + ); + }, + undefined, + 8_000, + ); + check( + 'the share link carries the layer, the box, the pin and the mode', + shared, + await page.evaluate(() => window.location.hash.slice(0, 400)), + ); + + // ── Escape order: preview, then the box tool ──────────────────────────── + await click('#ri-unpin-a'); + await clickMode('image'); + await ownedIs(0); + await focusStrip(); + await page.keyboard.press('ArrowRight'); + check( + 'a focus move previews again', + await until( + () => Boolean(window.__riQa.snap().preview.key), + undefined, + 5_000, + ), + ); + check( + 'SELECT BOX arms the box tool', + (await clickAction('select-box')) && + (await until(() => window.__gevRecentImagery.tool.isActive())), + ); + await page.keyboard.press('Escape'); + const escapedPreview = await until( + () => !window.__riQa.snap().preview.key, + undefined, + 3_000, + ); + const stillArmed = await page.evaluate(() => + window.__gevRecentImagery.tool.isActive(), + ); + check( + 'Escape clears the preview first and leaves SELECT BOX armed', + escapedPreview && stillArmed, + `preview cleared=${escapedPreview} toolActive=${stillArmed}`, + ); + await page.keyboard.press('Escape'); + check( + 'the next Escape cancels the box tool', + await until(() => !window.__gevRecentImagery.tool.isActive()), + ); + onPhotorealOnly( + 'with the preview cleared Google 3D comes back', + await backOnPhotoreal(), + ); + + // ── oversized USE VIEW → refusal in the notice line ───────────────────── + let refusalWidth = 0; + for (const height of [400_000, 900_000, 1_800_000]) { + await setView({ ...AUSTIN, height }); + await wait(300); + refusalWidth = await viewWidthKm(); + if (refusalWidth > 1_100) break; + } + const boxBefore = JSON.stringify((await state()).box); + await clickAction('use-view'); + const refused = await until( + () => + /^View is/.test( + document.getElementById('ri-notice-text')?.textContent || '', + ), + undefined, + 5_000, + ); + current = await state(); + check( + `an oversized USE VIEW (${Math.round(refusalWidth)} km) is refused in the notice line`, + refused && + /^View is [\d,]+ km wide · limit 1,000 km$/.test(current.noticeText), + current.noticeText, + ); + check( + 'the hint says to zoom in or draw a smaller box', + current.hintText === 'Zoom in or draw a smaller box', + current.hintText, + ); + check('the refusal keeps the box', JSON.stringify(current.box) === boxBefore); + const refusalLine = await page.evaluate(() => { + const text = document.getElementById('ri-notice-text'); + const chip = document.getElementById('ri-zoom-in'); + const line = document.getElementById('ri-notice').getBoundingClientRect(); + const rect = chip.getBoundingClientRect(); + return { + fits: text.scrollWidth <= text.clientWidth, + scrollWidth: text.scrollWidth, + clientWidth: text.clientWidth, + chipVisible: + getComputedStyle(chip).visibility === 'visible' && + !chip.disabled && + rect.width > 0, + chipAtEnd: Math.abs(rect.right - line.right) <= 1, + lineHeight: line.height, + }; + }); + check( + 'the refusal fits the notice line without an ellipsis', + refusalLine.fits, + JSON.stringify(refusalLine), + ); + check( + 'ZOOM IN shows at the right end of the notice line, which keeps its height', + refusalLine.chipVisible && + refusalLine.chipAtEnd && + Math.abs(refusalLine.lineHeight - 14) <= 0.5, + JSON.stringify(refusalLine), + ); + await checkLayout('refused'); + await panelShot('05-refused-panel'); + // ZOOM IN: straight down to the height whose view is 400 km wide. + const fitHeight = await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + const canvas = viewer.scene.canvas; + const halfWidth = + Math.tan(viewer.camera.frustum.fovy / 2) * + (canvas.clientWidth / canvas.clientHeight); + return Math.min(400, Math.max(5, 400 / (2 * halfWidth))) * 1000; + }); + check('ZOOM IN is clickable', await click('#ri-zoom-in')); + const zoomed = await until( + (target) => + Math.abs( + window.__godsEyeView.viewer.camera.positionCartographic.height - target, + ) <= + target * 0.01, + fitHeight, + 2_000, + ); + const zoomedHeight = await page.evaluate( + () => window.__godsEyeView.viewer.camera.positionCartographic.height, + ); + check( + `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + zoomed, + `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, + ); + await tilesSettled(); + const fittedWidth = await viewWidthKm(); + await clickAction('use-view'); + const fitted = await until( + () => { + const snap = window.__riQa.snap(); + return Boolean(snap.box) && !snap.boxError && !snap.zoomToFit; + }, + undefined, + 5_000, + ); + current = await state(); + check( + `the next USE VIEW (${Math.round(fittedWidth)} km) succeeds and ZOOM IN is reserved again`, + fitted && + (await page.evaluate(() => + document.getElementById('ri-zoom-in').classList.contains('is-reserved'), + )), + `boxError=${current.boxError} notice=${current.noticeText}`, + ); + await checkLayout('fitted'); + + // ── CLEAR ──────────────────────────────────────────────────────────────── + await setView(AUSTIN); + await pressOnStrip('ArrowRight'); + await until(() => window.__riQa.owned() === 1, undefined, 5_000); + await clickAction('clear'); + const clearedAll = await until(() => { + const snap = window.__riQa.snap(); + return ( + !snap.box && + !snap.pins.a.key && + !snap.pins.b.key && + !snap.preview.key && + window.__riQa.owned() === 0 + ); + }); + current = await state(); + check( + 'CLEAR empties the box, the pins and the preview', + clearedAll && current.noticeText === 'Box and images cleared', + `box=${JSON.stringify(current.box)} owned=${current.diagnostics.ownedCount} notice=${current.noticeText}`, + ); + onPhotorealOnly('CLEAR hands Google 3D back', await backOnPhotoreal()); + await wait(1_500); + onPhotorealOnly( + 'after CLEAR the layer does not take Esri again', + (await page.evaluate(() => window.__riQa.activeStack())) === 'photoreal', + ); + await checkLayout('cleared'); + + // ── map-switch rebind ─────────────────────────────────────────────────── + await tilesSettled(); + await page.evaluate( + (view) => window.__riQa.layer().boxFromPinAt(view.lon, view.lat), + AUSTIN, + ); + const redraped = await until(() => { + const snap = window.__riQa.snap(); + return ( + !snap.searching && + Boolean(snap.preview.key) && + window.__riQa.owned() === 1 + ); + }); + check('a 10 km box previews the START HERE day again', redraped); + const stacks = await page.evaluate(() => + window.__godsEyeView.mapStackController + .getStacks() + .filter((s) => s.available) + .map((s) => s.id), + ); + const setStack = (id) => + page.evaluate( + (stack) => window.__godsEyeView.mapStackController.setStack(stack), + id, + ); + /** Wait for the one GIBS drape to sit on the given host only. */ + const drapeOn = (host) => + until( + (where) => { + const hosts = window.__riQa.hosts(); + return where === 'globe' + ? hosts.globeShown && hosts.globe === 1 && hosts.tileset === 0 + : !hosts.globeShown && hosts.globe === 0 && hosts.tileset === 1; + }, + host, + 10_000, + ); + if (photoreal && stacks.includes('esri-imagery')) { + check('the preview sits on the Esri globe', await drapeOn('globe')); + // While a day is shown the imagery keeps Esri: a manual switch to + // Google 3D is answered at once with a new lease. + await setStack('photoreal'); + const kept = (await onEsri()) && (await drapeOn('globe')); + current = await state(); + check( + 'switching to Google 3D by hand while a day is shown keeps the imagery on the Esri globe', + kept && current.diagnostics.host === 'globe', + `${hostsDetail(current)} stack=${current.activeStack}`, + ); + check( + 'the notice says the imagery stays on Esri until CLEAR, and fits', + current.noticeText === 'Imagery stays on Esri · CLEAR to use Google 3D' && + (await page.evaluate(() => { + const text = document.getElementById('ri-notice-text'); + return text.scrollWidth <= text.clientWidth; + })), + current.noticeText, + ); + await wait(1_500); + check( + 'Esri holds after the re-lease: no switch loop', + (await page.evaluate(() => window.__riQa.activeStack())) === + 'esri-imagery', + ); + // The divider survives a manual switch too. + await clickMode('basemap'); + await until(() => window.__riQa.splitShown(), undefined, 10_000); + await setStack('photoreal'); + const dividerKept = + (await onEsri()) && + (await until( + () => + window.__riQa.splitShown() && window.__riQa.snap().comparison.active, + undefined, + 10_000, + )); + current = await state(); + check( + 'a manual switch to Google 3D during VS BASEMAP keeps the divider', + dividerKept && + current.shown.swipe === 'basemap' && + !current.comparison.suspended, + `${hostsDetail(current)} stack=${current.activeStack} comparison=${JSON.stringify(current.comparison)}`, + ); + await tilesSettled(); + await shot('06-google-3d-kept-on-esri'); + await clickMode('image'); + await until(() => !window.__riQa.splitShown(), undefined, 5_000); + } else { + const other = stacks.find( + (id) => id !== initialStack && id !== 'photoreal', + ); + if (other) { + await setStack(other); + check( + `switching to ${other} keeps the drape on the globe`, + await drapeOn('globe'), + ); + await setStack(initialStack); + } else { + console.log('[SKIP] map-switch rebind — no second map stack available'); + } + } + + // ── collapse / expand, CCTV stability, DISPLAY + CONTEXT reachable ────── + /** Click a panel's collapse button; resolves to whether it is expanded. */ + const togglePanel = (id) => + page.evaluate((panelId) => { + const button = document.querySelector( + `.panel-collapse-btn[data-collapse-target="${panelId}"]`, + ); + if (!button) return null; + button.click(); + return !document.getElementById(panelId)?.classList.contains('collapsed'); + }, id); + const imageryPanel = 'recent-imagery-panel'; + const collapsedNow = (await togglePanel(imageryPanel)) === false; + await wait(400); + const headerOnly = await page.evaluate(() => ({ + panel: document.getElementById('recent-imagery-panel')?.offsetHeight, + body: document.getElementById('recent-imagery-panel-body')?.offsetHeight, + })); + check( + 'collapsing the panel leaves a header bar only', + collapsedNow && + headerOnly.body === 0 && + headerOnly.panel > 0 && + headerOnly.panel <= 80, + JSON.stringify(headerOnly), + ); + const expandedAgain = await togglePanel(imageryPanel); + await wait(400); + const bodyBack = await page.evaluate( + () => document.getElementById('recent-imagery-panel-body')?.offsetHeight, + ); + check( + 'expanding the panel brings the body back', + expandedAgain === true && bodyBack > 0, + `body=${bodyBack}`, + ); + + /** Sample both panel heights for 2 s; settled = the tail never changes. */ + const settledHeights = async () => { + const samples = []; + for (let i = 0; i < 20; i += 1) { + samples.push( + await page.evaluate(() => ({ + imagery: document.getElementById('recent-imagery-panel') + ?.offsetHeight, + cctv: document.getElementById('cctv-panel')?.offsetHeight, + })), + ); + await wait(100); + } + const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + return { + settled: tail('imagery').size === 1 && tail('cctv').size === 1, + detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, + }; + }; + const railFit = () => + page.evaluate(() => { + const px = (element) => + parseFloat( + element?.style.getPropertyValue('--right-panel-allocated-height'), + ); + const cctv = document.getElementById('cctv-panel'); + const imagery = document.getElementById('recent-imagery-panel'); + const rail = document.getElementById('right-context-rail'); + // Reachable: its header is on screen, or the rail's exclusive policy + // (an expanded panel under the tactical HUD) hid it while collapsed. + const reach = (id) => { + const element = document.getElementById(id); + return ( + window.__riQa.headerOnScreen(id) || + (rail?.classList.contains('layout-exclusive') && + element?.classList.contains('collapsed') && + element.getBoundingClientRect().height === 0) + ); + }; + return { + cctvRendered: cctv?.offsetHeight ?? null, + cctvAllocated: px(cctv), + imageryRendered: imagery?.offsetHeight ?? null, + imageryAllocated: px(imagery), + imageryCollapsed: Boolean(imagery?.classList.contains('collapsed')), + imageryDisplay: imagery ? getComputedStyle(imagery).display : null, + focus: rail?.classList.contains('layout-focus'), + exclusive: rail?.classList.contains('layout-exclusive'), + display: reach('pp-toggles'), + context: reach('global-context-panel'), + }; + }); + for (const height of [900, 700]) { + const at = `(${height} px viewport)`; + await page.setViewport({ width: 1280, height }); + await wait(600); + check( + `the CCTV panel expands from its launcher ${at}`, + (await togglePanel('cctv-panel')) === true, + ); + const expanded = await settledHeights(); + check( + `the imagery and CCTV panel heights settle with CCTV expanded ${at}`, + expanded.settled, + expanded.detail, + ); + const fit = await railFit(); + check( + `CCTV renders at its allocated height beside the imagery panel ${at}`, + !fit.focus || + (Number.isFinite(fit.cctvAllocated) && + Math.abs(fit.cctvRendered - fit.cctvAllocated) <= 2), + JSON.stringify(fit), + ); + // A collapsed panel has no allocation, so it is judged on its own terms: + // the exclusive rail (one expanded panel) hides it outright, otherwise it + // is a header bar. An expanded panel must have been allocated a height. + const imageryFits = fit.imageryCollapsed + ? fit.exclusive + ? fit.imageryRendered === 0 && fit.imageryDisplay === 'none' + : fit.imageryRendered > 0 && fit.imageryRendered <= 80 + : !fit.focus || + (Number.isFinite(fit.imageryAllocated) && + fit.imageryRendered <= fit.imageryAllocated + 2); + check( + `the imagery panel stays within its allocation, or takes no room when the exclusive rail hides it collapsed ${at}`, + imageryFits, + JSON.stringify(fit), + ); + check( + `DISPLAY and CONTEXT stay reachable ${at}`, + fit.display && fit.context, + JSON.stringify(fit), + ); + await shot(`07-cctv-expanded-${height}`); + check( + `the CCTV panel collapses again ${at}`, + (await togglePanel('cctv-panel')) === false, + ); + const collapsed = await settledHeights(); + check( + `the panel heights settle with CCTV collapsed ${at}`, + collapsed.settled, + collapsed.detail, + ); + const imageryExpanded = await page.evaluate( + () => + !document + .getElementById('recent-imagery-panel') + ?.classList.contains('collapsed'), + ); + if (imageryExpanded) await togglePanel(imageryPanel); + await wait(400); + const onScreen = await page.evaluate(() => + Object.fromEntries( + [ + 'pp-toggles', + 'global-context-panel', + 'recent-imagery-panel', + 'cctv-panel', + ].map((id) => [id, window.__riQa.headerOnScreen(id)]), + ), + ); + check( + `DISPLAY, CONTEXT, CCTV and the imagery header are all on screen once everything is collapsed ${at}`, + Object.values(onScreen).every(Boolean), + JSON.stringify(onScreen), + ); + await togglePanel(imageryPanel); + await wait(400); + } + + // ── scroll preservation (700 px viewport, body scrollable) ─────────────── + await page.evaluate(() => { + const header = document.querySelector('#ri-details .rail-card-header'); + if (header?.getAttribute('aria-expanded') !== 'true') header?.click(); + }); + await wait(300); + const bodyScroll = () => + page.evaluate(() => { + const body = document.getElementById('recent-imagery-panel-body'); + return { + top: body.scrollTop, + max: body.scrollHeight - body.clientHeight, + }; + }); + const start = await page.evaluate(() => { + const body = document.getElementById('recent-imagery-panel-body'); + const max = body.scrollHeight - body.clientHeight; + if (max > 0) body.scrollTop = Math.round(max / 2); + return { max, top: body.scrollTop }; + }); + check( + 'with DETAILS open at 700 px the body scrolls', + start.max > 0, + JSON.stringify(start), + ); + if (start.max > 0) { + await page.evaluate(() => { + const layer = window.__riQa.layer(); + layer.setAlpha(0.9); + layer.setToolActive(true); + layer.setToolActive(false); + }); + await wait(300); + const afterNotify = await bodyScroll(); + check( + 'the body scroll position survives alpha and state notifications', + Math.abs(afterNotify.top - start.top) <= 1, + `before=${start.top} after=${afterNotify.top}`, + ); + await focusStrip(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await wait(600); + const afterArrows = await bodyScroll(); + check( + 'the body scroll position survives arrow navigation', + Math.abs(afterArrows.top - start.top) <= 1, + `before=${start.top} after=${JSON.stringify(afterArrows)}`, + ); + await shot('08-scrolled'); + } + await page.setViewport({ width: 1280, height: 860 }); + + // ── disable ─────────────────────────────────────────────────────────────── + await click(layerToggle); + check( + 'the layer disables from its toggle', + await until( + () => !window.__godsEyeView.dataManager.isEnabled('recent-imagery'), + ), + ); + const cleared = await until(() => { + const hosts = window.__riQa.hosts(); + return ( + window.__riQa.owned() === 0 && + hosts.globe + hosts.tileset === 0 && + !document.getElementById('recent-imagery-split-line') + ); + }); + current = await state(); + check( + 'disabling leaves zero owned layers and no split line', + cleared, + `${hostsDetail(current)} split=${current.splitVisible}`, + ); + check('the panel hides when the layer is off', current.panelHidden === true); + onPhotorealOnly('disabling hands Google 3D back', await backOnPhotoreal()); + await wait(1_500); + onPhotorealOnly( + 'disabled, the layer never takes Esri again', + (await page.evaluate(() => window.__riQa.activeStack())) === 'photoreal', + ); + await shot('09-disabled'); + + check( + 'no page errors during the run', + errors.length === 0, + errors.join(' | '), + ); +} catch (error) { + check( + 'run completed', + false, + error?.stack || error?.message || String(error), + ); +} finally { + await browser.close(); +} +console.log(`shots: ${shots.join(', ')}`); +console.log(`RESULT: ${checks} checks, ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 4c5c9dedc..de5f44fec 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -625,12 +625,27 @@ async function runBehaviorLayer() { // (4b) analyst engine end-to-end: count flights over Texas (region ring // via NE-pack/admin machinery), then a follow-up over the same set. + // A cold boundary lookup can exceed the resolver's budget; it answers + // region-timeout and keeps loading, so asking again must succeed. + let texasTimeouts = 0; r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + while (r?.code === 'region-timeout' && texasTimeouts < 8) { + texasTimeouts += 1; + await settle(5000); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 }); + } report(r?.ok === true && Number.isFinite(r?.count) && r.count > 0 && String(r?.coverage?.scope || '').includes('Texas'), - 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} err=${r?.error || ''}`); + 'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`); r = await run('analyst_query', { followUp: true, filters: [{ field: 'onGround', op: 'eq', value: false }], sortBy: 'altitudeM', limit: 3 }); report(r?.ok === true && r?.coverage?.followUp === true, 'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`); + // A marine region resolves from the bundled Natural Earth pack in the page, + // without the slower geocode fallback. + const gulfStarted = Date.now(); + r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Gulf of Mexico' }, limit: 5 }); + const gulfMs = Date.now() - gulfStarted; + report(r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && gulfMs < 3000, + 'behavior: analyst resolves the Gulf of Mexico from the bundled pack', `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`); // (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band) r = await run('zoom_to_globe', {}); diff --git a/scripts/qa-weather-journey.mjs b/scripts/qa-weather-journey.mjs new file mode 100644 index 000000000..75cd2c269 --- /dev/null +++ b/scripts/qa-weather-journey.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * qa-weather-journey — drive the Weather layers the way a person would and + * keep the evidence: numbered screenshots plus per-step layer diagnostics. + * + * Starts at the Earth view, opens Data Layers, switches Wind on from its real + * row button, cycles the wind fields/models, switches radar, satellite clouds, + * lightning and cyclones on, follows "View storm" and "View US radar", zooms + * into a regional view, a city view and a street view, replays history, and + * ends with real wheel/drag input. Every step records the enabled layers' + * diagnostics (imagery tiles loaded, shown observation time, wind geometry) + * and a 2 s `scene.postRender` count so a stalled renderer cannot pass as a + * healthy screenshot. + * + * Usage: + * node scripts/qa-weather-journey.mjs [--url http://localhost:4173] + * [--label google] [--headless] [--out qa-shots/weather-journey] + * + * Requires a running dev server. Screenshots and steps.json land in + * /