feat(weather): add globe wind, observed weather and cyclone context (#617)

Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view.

Co-authored-by: Gustavo Beneduzi <[email protected]>
Co-authored-by: Daniel Slay <[email protected]>
Co-authored-by: Rehaan Delmotra <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-22 17:45:39 -05:00
1 parent f01b6a5d84
commit 0251c74109
180 files changed
+30645 -434

No files matched your search

+110 -4
View File
@@ -1,5 +1,93 @@
# Changelog
## 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).
- 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
@@ -63,17 +151,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
@@ -167,6 +252,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.
@@ -258,8 +350,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.
@@ -388,6 +481,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).
@@ -1050,3 +1149,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.
+127
View File
@@ -15,6 +15,7 @@ How to read this:
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **NOAA GFS (wind)** | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
| **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 |
@@ -41,6 +42,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 + live HTTPS HLS video, Delaware | 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" |
@@ -67,6 +69,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/<id>.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.
@@ -153,6 +156,42 @@ 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.
### 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 layer row. 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.
### Natural Earth physical regions (`natural_earth/`)
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
@@ -193,4 +232,92 @@ Douglas-Peucker simplification, 6-decimal rounding).
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, 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.
### 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. Filtered infrared softly dims pixels with a linear
brightness ramp from 0.40 to 0.70; Full infrared 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; the proxy has an 8-request
concurrency budget, 12-second deadline, 1 MiB tile / 4 MiB global PNG caps and a shared 16 MiB/128-image cache.
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.
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.
+22 -2
View File
@@ -188,7 +188,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 **CCTV** 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)
@@ -282,7 +282,7 @@ 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 | 🟢 |
| 📻 **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 | 🟢 |
@@ -441,6 +441,26 @@ OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth cred
</details>
<details>
<summary>Live video cameras (HLS)</summary>
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.
</details>
### 💸 What it actually costs
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
+1
View File
@@ -62,6 +62,7 @@ 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/<id>` 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.
+12
View File
@@ -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.
+84
View File
@@ -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.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, 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
+284 -67
View File
@@ -1,5 +1,193 @@
# God's Eye View Current State
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 the toggle, one status line with a source tooltip,
configuration chips and the cyclone selection/focus list. Cyclone advisory,
position time, wind, pressure, geometry status and the official advisory link
live in the card. Controls opens the corresponding left row. 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 +313,7 @@ Source factories have dedicated `layers/<family>/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 +342,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 +350,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 +358,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 +365,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 +383,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 +390,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 +493,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 +549,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 +597,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 +606,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 +616,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 +657,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,7 +665,6 @@ 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.
## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection
@@ -541,7 +716,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 +727,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 +736,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 +749,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,
@@ -592,16 +763,13 @@ 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/<encoded feed URL>`; 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 +802,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
@@ -728,7 +895,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
@@ -866,7 +1032,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 +1126,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
@@ -996,7 +1161,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 +1217,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 +1240,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 +1444,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 +1502,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 +1632,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
@@ -2012,8 +2177,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 +2271,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 +2389,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 +2571,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
@@ -2510,25 +2674,26 @@ 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, 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) |
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 +2749,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 +3326,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 +3338,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 +3391,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,6 +3527,7 @@ 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`.
> **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
@@ -3391,15 +3557,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 \<place\>" 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.
@@ -3674,7 +3840,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 +4083,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,
@@ -3960,3 +4125,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.
Existing row controls retain source, opacity, coverage navigation and transport;
no controls move in this pass. 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.
+2 -1
View File
@@ -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.
+26
View File
@@ -10,8 +10,10 @@
"license": "MIT",
"dependencies": {
"@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"
@@ -1076,6 +1078,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",
@@ -1944,6 +1964,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",
+3 -1
View File
@@ -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",
@@ -48,8 +48,10 @@
},
"dependencies": {
"@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"
+49 -1
View File
@@ -383,6 +383,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",
@@ -730,6 +734,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 +770,28 @@
"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",
"src/data/cyclonesProxy.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"
]
+96 -5
View File
@@ -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",
@@ -268,6 +269,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",
@@ -375,11 +380,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 +425,7 @@
"src/ui/cctvCalibration.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js"
],
"external": []
@@ -513,6 +526,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",
@@ -540,6 +554,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,7 +597,9 @@
"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"
@@ -608,7 +630,8 @@
"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"
],
"external": []
},
@@ -856,6 +879,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 +888,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",
@@ -1025,6 +1050,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 +1061,7 @@
"src/data/inputOwnership.js",
"src/layers/cctv/sourcePolicy.js"
],
"external": ["cesium"]
"external": ["hls.js", "cesium"]
},
"alpr-cameras": {
"exports": ["./layers/alpr"],
@@ -1279,6 +1305,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",
@@ -1332,6 +1371,7 @@
"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 +1454,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 +1560,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",
@@ -1629,6 +1671,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",
@@ -1657,6 +1700,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",
@@ -1727,6 +1776,7 @@
"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/layers/cctv/sourcePolicy.js",
@@ -1782,6 +1832,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,9 +1866,16 @@
"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"
],
"external": [
"hls.js",
"@mapbox/vector-tile",
"cesium",
"egm96-universal",
@@ -1838,6 +1902,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 +1959,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",
@@ -1976,6 +2051,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 +2157,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",
@@ -2203,9 +2280,23 @@
"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"
],
"external": [
"hls.js",
"cesium",
"egm96-universal",
"satellite.js",
+15 -11
View File
@@ -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',
+423
View File
@@ -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
* <out>/<label>/.
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
? argv[i + 1]
: fallback;
};
const flag = (name) => argv.includes(name);
const rawUrl = arg('--url', 'http://localhost:4173');
const url = rawUrl.includes('welcome=')
? rawUrl
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
const label = arg('--label', 'journey');
const outDir = path.resolve(arg('--out', 'qa-shots/weather-journey'), label);
mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
const browser = await puppeteer.launch({
headless: flag('--headless') ? 'new' : false,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000,
defaultViewport: null,
args: [
'--no-sandbox',
'--window-size=1400,1000',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
const steps = [];
let index = 0;
try {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
// Non-2xx responses, counted per path so a burst of throttled tiles reads
// as one line instead of one console error per tile.
const failures = new Map();
page.on('response', (r) => {
const status = r.status();
if (status < 400) return;
const key = `${status} ${new URL(r.url()).pathname}`;
failures.set(key, (failures.get(key) || 0) + 1);
});
const reportFailures = () => {
if (!failures.size) return;
console.log(
' [http failures] ' +
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
);
failures.clear();
};
page.on('console', (m) => {
if (m.type() === 'error')
console.log(' [console.error]', m.text().slice(0, 200));
});
// The Data Layers panel remembers its collapsed state; start it open.
await page.evaluateOnNewDocument(() => {
try {
localStorage.setItem('godsEyeView.v6.panelCollapsed.data-panel', '0');
} catch {
/* storage may be unavailable */
}
});
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
timeout: 120_000,
});
await sleep(12_000);
const diagnostics = () =>
page.evaluate(
({ WEATHER }) => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
const out = {
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
globeShown: gev.viewer.scene.globe.show,
heightM: Math.round(gev.viewer.camera.positionCartographic.height),
summaryHeight: Math.round(
document.querySelector('.weather-summary')?.getBoundingClientRect()
.height ?? 0,
),
};
for (const id of ['wind', ...WEATHER, 'weather-cyclones']) {
const entry = dm.layers.get(id);
if (!entry?.enabled) continue;
const d = entry.module?.getDiagnostics?.() || {};
const s = entry.module?.getStats?.() || {};
out[id] =
id === 'wind'
? {
mode: d.renderMode,
ready: d.gpu?.ready,
paths: d.gpu?.pathCount,
visibleCells: d.gpu?.visibleCells,
heightFade: d.gpu?.heightFade,
overlay: d.overlay,
imagery: d.imageryActive,
error: s.error,
}
: id === 'weather-cyclones'
? { count: s.count, error: s.error }
: {
time: d.time,
playing: d.playing,
loading: d.loading,
loadedTiles: d.loadedTiles,
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
error: d.error,
};
}
return out;
},
{ WEATHER },
);
const renders = (ms = 2000) =>
page.evaluate(
(ms) =>
new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let n = 0;
const off = scene.postRender.addEventListener(() => {
n += 1;
});
setTimeout(() => {
off();
resolve(n);
}, ms);
}),
ms,
);
const shot = async (name, note = '') => {
index += 1;
const file = `${String(index).padStart(2, '0')}-${name}.png`;
const d = await diagnostics();
const r = await renders(2000);
await page.screenshot({ path: path.join(outDir, file) });
reportFailures();
steps.push({ index, name, note, file, renders2s: r, ...d });
console.log(` ${file} renders/2s=${r} ${note} ${JSON.stringify(d)}`);
};
const clickRow = async (id) => {
const ok = await page.evaluate((id) => {
const btn = document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
);
if (!btn) return false;
btn.scrollIntoView({ block: 'center' });
btn.click();
return true;
}, id);
if (!ok) console.log(` !! no row button for ${id}`);
};
const clickChip = async (id, chip) => {
const ok = await page.evaluate(
({ id, chip }) => {
const btn = document.querySelector(
`[data-layer-id="${id}"] [data-chip-id="${chip}"]`,
);
if (!btn || btn.disabled) return false;
btn.scrollIntoView({ block: 'center' });
btn.click();
return true;
},
{ id, chip },
);
if (!ok) console.log(` !! chip ${id}/${chip} missing or disabled`);
return ok;
};
const waitReady = (ids, timeout = 90_000) =>
page
.waitForFunction(
(ids) => {
const dm = window.__godsEyeView.dataManager;
for (const id of ids) {
const entry = dm.layers.get(id);
if (!entry?.enabled) return false;
const d = entry.module?.getDiagnostics?.() || {};
if (id === 'wind') {
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
return false;
} else if (id !== 'weather-cyclones') {
if (d.loading || (!d.time && !d.error)) return false;
}
}
return true;
},
{ timeout, polling: 250 },
ids,
)
.catch(() =>
console.log(` !! ${ids.join(',')} not ready within ${timeout} ms`),
);
const fly = async (
lon,
lat,
height,
pitch = -90,
heading = 0,
duration = 2,
) => {
await page.evaluate(
({ lon, lat, height, pitch, heading, duration }) => {
const v = window.__godsEyeView.viewer;
const d2r = Math.PI / 180;
v.camera.cancelFlight();
v.camera.flyTo({
destination: v.scene.ellipsoid.cartographicToCartesian({
longitude: lon * d2r,
latitude: lat * d2r,
height,
}),
orientation: { heading: heading * d2r, pitch: pitch * d2r, roll: 0 },
duration,
});
},
{ lon, lat, height, pitch, heading, duration },
);
await sleep(duration * 1000 + 3000);
};
console.log(`\nqa-weather-journey · ${label} · ${url}`);
await page.evaluate(async () => {
const gev = window.__godsEyeView;
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
document.querySelector('#data-panel')?.classList.remove('collapsed');
});
// 1. Earth view, clean.
await fly(-97.7431, 30.2672, 17_368_000);
await shot('globe-clean', 'Earth view, no layers');
// 2. Wind from its row, then each field, then ECMWF.
await clickRow('wind');
await waitReady(['wind']);
await sleep(1500);
await shot('globe-wind-trails', 'Wind on (trails, no field)');
await clickChip('wind', 'overlay-speed');
await sleep(2500);
await shot('globe-wind-speed', 'Wind speed field');
await clickChip('wind', 'overlay-temperature');
await sleep(2500);
await waitReady(['wind']);
await shot('globe-wind-temperature', 'Wind + 2 m temperature');
await clickChip('wind', 'overlay-pressure');
await sleep(2500);
await waitReady(['wind']);
await shot('globe-wind-pressure', 'Wind + MSL pressure');
await clickChip('wind', 'model-ifs');
await waitReady(['wind']);
await sleep(2500);
await shot('globe-wind-ecmwf-pressure', 'ECMWF IFS model');
await clickChip('wind', 'model-gfs');
await clickChip('wind', 'overlay-speed');
await waitReady(['wind']);
await sleep(1500);
// 3. Observed products.
for (const id of WEATHER) await clickRow(id);
await clickRow('weather-cyclones');
await waitReady([...WEATHER]);
await sleep(2500);
await shot(
'globe-all-weather',
'Wind speed + radar + regional IR + lightning + cyclones',
);
await clickChip('weather-satellite', 'clouds');
await waitReady(['weather-satellite']);
await sleep(2500);
await shot('globe-global-infrared', 'Satellite: global infrared mosaic');
await clickChip('weather-satellite', 'clouds-regional');
await waitReady(['weather-satellite']);
// 4. Follow a storm if there is one (advisories arrive a few seconds after enable).
await page
.waitForFunction(
() =>
!!document.querySelector(
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
),
{ timeout: 25_000, polling: 500 },
)
.catch(() => {});
const storm = await page.evaluate(() => {
const first = document.querySelector(
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
);
if (!first) return null;
first.click();
return first.textContent.trim();
});
if (storm) {
await sleep(1500);
if (await clickChip('weather-cyclones', 'focus')) {
await sleep(6000);
await shot('storm-view', `View storm: ${storm}`);
await page.evaluate(() =>
window.__godsEyeView.viewer.camera.zoomIn(400_000),
);
await sleep(4000);
await shot('storm-closer', 'Zoomed toward the storm');
}
} else console.log(' (no active cyclone advisories to follow)');
// 5. US radar, then regional → city → street over the same spot.
await clickChip('weather-radar', 'coverage');
await sleep(6000);
await shot('us-radar', 'View US radar');
await fly(-94.6, 37.5, 600_000);
await shot('regional-600km', 'Regional 600 km');
await fly(-94.6, 37.5, 120_000);
await shot('regional-120km', 'Regional 120 km');
await fly(-97.7431, 30.2672, 12_000, -60, 20);
await shot('city-12km', 'Austin 12 km oblique');
await fly(-97.7431, 30.2672, 1_200, -45, 20);
await shot('city-1200m', 'Austin 1.2 km oblique (the perf route pose)');
await fly(-97.7431, 30.2672, 350, -30, 60);
await shot('street-350m', 'Austin 350 m street level');
// 6. History playback at the city pose (panel behavior while frames swap).
await clickChip('weather-radar', 'play');
const heights = [];
for (let i = 0; i < 4; i++) {
await sleep(2200);
heights.push(
await page.evaluate(() =>
Math.round(
document.querySelector('.weather-summary')?.getBoundingClientRect()
.height ?? 0,
),
),
);
}
await shot(
'city-history-playing',
`History playing; summary heights ${heights.join('/')}`,
);
await clickChip('weather-radar', 'play');
await clickChip('weather-radar', 'latest');
// 7. Real input: wheel-zoom out, drag, wheel-zoom in.
await fly(-97.7431, 30.2672, 40_000, -55, 0, 1.5);
const canvas = await page.$(
'canvas.cesium-widget-canvas, .cesium-widget canvas',
);
const box = canvas
? await canvas.boundingBox()
: { x: 700, y: 460, width: 0, height: 0 };
const cx = box.x + (box.width || 0) / 2;
const cy = box.y + (box.height || 0) / 2;
await page.mouse.move(cx, cy);
for (let i = 0; i < 12; i++) {
await page.mouse.wheel({ deltaY: 240 });
await sleep(120);
}
await sleep(3000);
await shot('wheel-out', 'Wheel zoom out ×12');
await page.mouse.move(cx - 200, cy);
await page.mouse.down();
for (let i = 1; i <= 20; i++) {
await page.mouse.move(cx - 200 + i * 20, cy + i * 4, { steps: 2 });
await sleep(30);
}
await page.mouse.up();
await sleep(2500);
await shot('drag-pan', 'Drag pan');
for (let i = 0; i < 10; i++) {
await page.mouse.wheel({ deltaY: -240 });
await sleep(120);
}
await sleep(4000);
await shot('wheel-in', 'Wheel zoom in ×10');
// 8. Back to Earth with everything on.
await fly(-97.7431, 30.2672, 17_368_000, -90, 0, 2.5);
await shot('globe-return', 'Return to Earth view');
writeFileSync(
path.join(outDir, 'steps.json'),
JSON.stringify({ label, url, steps }, null, 2),
);
console.log(`\nWrote ${steps.length} steps to ${outDir}`);
} finally {
await browser.close();
}
+568
View File
@@ -0,0 +1,568 @@
#!/usr/bin/env node
/**
* qa-weather-perf — matched-route throughput and idle-work measurement for the
* Weather layers (Wind, Rain radar, Satellite clouds, Lightning density).
*
* This is a measurement harness, not a pass/fail gate. It reproduces one fixed
* camera route per weather state so two builds (or two basemaps) can be
* compared on the same numbers:
*
* park 10 km over Austin → 3 s flight to 1.2 km (heading 20°, pitch −45°)
* → 6 s close idle → 8 s orbit at 1.8 km range → 3 s flight back to the
* 17,368 km globe view → 6 s globe idle.
*
* Route windows hold render demand constant (`scene.requestRender()` on every
* animation frame) so the idle-render governor cannot masquerade as low FPS.
* FPS counts `scene.postRender` fires, and every window asserts the canvas
* size did not change, because a stopped renderer with a free-running rAF
* reads as a clean 60 fps otherwise.
*
* The separate idle test restores the normal governor, parks the camera at
* the 1.2 km pose with Wind off, and counts scene redraws, DOM mutations
* inside `.weather-summary`, and `.weather-summary` height changes over
* 8-second windows for: weather off, history paused, history playing.
*
* Usage:
* node scripts/qa-weather-perf.mjs [--url http://localhost:4173]
* [--label google-baseline] [--states off,wind,...] [--repeat 1]
* [--headless] [--no-route] [--no-idle] [--out qa-shots/weather-perf]
*
* States: off · history-paused · history-playing · wind · wind+history-paused
* · wind+history-playing. "history" is radar + regional clouds + lightning
* together; "wind" is animated GFS wind with the Speed field selected.
* Requires a running dev server. Screenshots and results.json land in
* <out>/<label>/.
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
? argv[i + 1]
: fallback;
};
const flag = (name) => argv.includes(name);
const rawUrl = arg('--url', 'http://localhost:4173');
// The first-launch chooser would otherwise sit over the globe; `?welcome=0`
// is the app's own suppression flag (src/firstRunExperience.js).
const url = rawUrl.includes('welcome=')
? rawUrl
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
const label = arg(
'--label',
new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'),
);
const ALL_STATES = [
'off',
'history-paused',
'history-playing',
'wind',
'wind+history-paused',
'wind+history-playing',
];
const states = arg('--states', ALL_STATES.join(','))
.split(',')
.map((s) => s.trim())
.filter(Boolean);
for (const s of states)
if (!ALL_STATES.includes(s)) {
console.error(`unknown state "${s}"; choose from ${ALL_STATES.join(', ')}`);
process.exit(2);
}
const repeat = Math.max(1, Number(arg('--repeat', '1')) || 1);
// `--scale 2` renders at 2× resolutionScale (4× the pixels) so a GPU that idles
// at 60 fps natively still exposes per-frame cost differences.
const scale = Number(arg('--scale', '1')) || 1;
const outDir = path.resolve(arg('--out', 'qa-shots/weather-perf'), label);
mkdirSync(outDir, { recursive: true });
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
const AUSTIN = { lon: -97.7431, lat: 30.2672 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const chromePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
);
const browser = await puppeteer.launch({
headless: flag('--headless') ? 'new' : false,
executablePath: chromePath,
protocolTimeout: 300_000,
defaultViewport: null,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--window-size=1200,960',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
const results = [];
let reportFailures = null;
const record = (row) => {
results.push(row);
reportFailures?.();
const {
phase,
state,
run,
window: win,
fps,
p95,
longTasks,
mutations,
heightChanges,
canvasStable,
} = row;
console.log(
` ${phase.padEnd(5)} ${state.padEnd(22)} r${run} ${win.padEnd(12)} ` +
`fps=${String(fps).padStart(5)} p95=${String(p95).padStart(6)}ms ` +
`long=${String(longTasks).padStart(2)} dom=${String(mutations).padStart(4)} ` +
`h∆=${String(heightChanges).padStart(2)}${canvasStable ? '' : ' CANVAS CHANGED'}`,
);
};
try {
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 886, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
// Non-2xx responses, counted per path so a burst of throttled tiles reads
// as one line instead of one console error per tile.
const failures = new Map();
page.on('response', (r) => {
const status = r.status();
if (status < 400) return;
const key = `${status} ${new URL(r.url()).pathname}`;
failures.set(key, (failures.get(key) || 0) + 1);
});
reportFailures = () => {
if (!failures.size) return;
console.log(
' [http failures] ' +
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
);
failures.clear();
};
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
timeout: 120_000,
});
await sleep(15_000);
if (scale !== 1)
await page.evaluate((scale) => {
window.__godsEyeView.viewer.resolutionScale = scale;
window.__godsEyeView.viewer.scene.requestRender();
}, scale);
const env = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scene = gev.viewer.scene;
return {
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
globeShown: scene.globe.show,
canvas: `${scene.canvas.width}x${scene.canvas.height}`,
dpr: devicePixelRatio,
ua:
navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0] || navigator.userAgent,
msaa: scene.msaaSamples,
resolutionScale: gev.viewer.resolutionScale,
};
});
console.log(`\nqa-weather-perf · ${label}\n url=${url}`);
console.log(` ${JSON.stringify(env)}`);
// Park deterministically, disable every layer, and instrument once.
await page.evaluate(async () => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight();
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled) {
try {
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
} catch {
/* counted through the layer, not here */
}
}
const P = (window.__gevWeatherPerf = {
renders: 0,
longTasks: 0,
longTaskMs: 0,
mutations: 0,
});
v.scene.postRender.addEventListener(() => {
P.renders += 1;
});
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
P.longTasks += 1;
P.longTaskMs += entry.duration;
}
}).observe({ type: 'longtask', buffered: false });
} catch {
/* longtask unsupported → stays 0 */
}
const summary = document.querySelector('.weather-summary');
if (summary)
new MutationObserver((records) => {
P.mutations += records.length;
}).observe(summary, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
});
const pose = (which) =>
page.evaluate(
({ which, AUSTIN }) => {
const C = window.Cesium || null;
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight();
v.camera.lookAtTransform?.(
(C || v.camera.constructor).Matrix4?.IDENTITY ??
v.scene.camera.transform,
);
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
const destination = ell.cartographicToCartesian({
longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r,
height:
which === 'overhead'
? 10_000
: which === 'close'
? 1_200
: 17_368_000,
});
v.camera.setView({
destination,
orientation:
which === 'close'
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
: { heading: 0, pitch: -90 * d2r, roll: 0 },
});
},
{ which, AUSTIN },
);
/** Start a 3 s flight without awaiting it; the caller measures alongside. */
const fly = (which) =>
page.evaluate(
({ which, AUSTIN }) => {
const v = window.__godsEyeView.viewer;
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
v.camera.flyTo({
destination: ell.cartographicToCartesian({
longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r,
height: which === 'close' ? 1_200 : 17_368_000,
}),
orientation:
which === 'close'
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
: { heading: 0, pitch: -90 * d2r, roll: 0 },
duration: 3,
});
},
{ which, AUSTIN },
);
/** Start an 8 s orbit at 1.8 km range around the Austin anchor (not awaited). */
const orbit = () =>
page.evaluate(
({ AUSTIN }) => {
const v = window.__godsEyeView.viewer;
const camera = v.camera;
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
const center = ell.cartographicToCartesian({
longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r,
height: 0,
});
const HPR = camera.constructor?.HeadingPitchRange
? camera.constructor.HeadingPitchRange
: null;
const t0 = performance.now();
let heading = 20 * d2r;
const step = (now) => {
const elapsed = (now - t0) / 1000;
heading = 20 * d2r + (elapsed / 8) * Math.PI * 2;
const hpr = HPR
? new HPR(heading, -45 * d2r, 1800)
: { heading, pitch: -45 * d2r, range: 1800 };
camera.lookAt(center, hpr);
if (elapsed < 8) requestAnimationFrame(step);
else camera.lookAtTransform(camera.transform.constructor.IDENTITY);
};
requestAnimationFrame(step);
},
{ AUSTIN },
);
/** Measure one window; `hold` keeps render demand constant. */
const measure = (ms, hold) =>
page.evaluate(
({ ms, hold }) =>
new Promise((resolve) => {
const gev = window.__godsEyeView;
const scene = gev.viewer.scene;
const P = window.__gevWeatherPerf;
const canvas = scene.canvas;
const w0 = canvas.width;
const h0 = canvas.height;
const r0 = P.renders;
const l0 = P.longTasks;
const lm0 = P.longTaskMs;
const m0 = P.mutations;
const summary = document.querySelector('.weather-summary');
const intervals = [];
const heights = [];
let lastH = -1;
let rafs = 0;
let last = performance.now();
const t0 = last;
const finish = (now) => {
const seconds = (now - t0) / 1000;
intervals.sort((a, b) => a - b);
const q = (p) =>
intervals[
Math.min(intervals.length - 1, Math.floor(p * intervals.length))
] ?? 0;
const hs = heights.map(([, h]) => h);
resolve({
seconds: Number(seconds.toFixed(2)),
renders: P.renders - r0,
fps: Number(((P.renders - r0) / seconds).toFixed(1)),
rafHz: Number((rafs / seconds).toFixed(1)),
p50: Number(q(0.5).toFixed(1)),
p95: Number(q(0.95).toFixed(1)),
max: Number((intervals.at(-1) ?? 0).toFixed(1)),
longTasks: P.longTasks - l0,
longTaskMs: Math.round(P.longTaskMs - lm0),
mutations: P.mutations - m0,
heightChanges: Math.max(0, heights.length - 1),
heightMin: hs.length ? Math.min(...hs) : null,
heightMax: hs.length ? Math.max(...hs) : null,
canvasStable: canvas.width === w0 && canvas.height === h0,
canvas: `${w0}x${h0}`,
});
};
const tick = (now) => {
rafs += 1;
intervals.push(now - last);
last = now;
if (hold) scene.requestRender();
if (summary && !summary.hidden) {
const h =
Math.round(summary.getBoundingClientRect().height * 2) / 2;
if (h !== lastH) {
heights.push([Math.round(now - t0), h]);
lastH = h;
}
}
if (now - t0 < ms) requestAnimationFrame(tick);
else finish(now);
};
requestAnimationFrame(tick);
}),
{ ms, hold },
);
const diagnostics = () =>
page.evaluate(
({ WEATHER }) => {
const dm = window.__godsEyeView.dataManager;
const out = {};
for (const id of ['wind', ...WEATHER]) {
const entry = dm.layers.get(id);
if (!entry?.enabled) continue;
const d = entry.module?.getDiagnostics?.() || {};
out[id] =
id === 'wind'
? {
mode: d.renderMode,
ready: d.gpu?.ready,
paths: d.gpu?.pathCount,
vertices: d.gpu?.vertexCount,
buildMs: d.gpu?.buildMs && Math.round(d.gpu.buildMs),
cells: d.gpu?.cellCount,
visibleCells: d.gpu?.visibleCells,
visibleVertices: d.gpu?.visibleVertexCount,
heightFade: d.gpu?.heightFade,
}
: {
time: d.time,
playing: d.playing,
loading: d.loading,
loadedTiles: d.loadedTiles,
imageryCount: d.imageryCount,
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
error: d.error,
suspended: d.suspended ?? d.globeHidden ?? null,
};
}
return out;
},
{ WEATHER },
);
/** Enable the layers a state names, wait for first imagery/geometry, then set playback. */
const applyState = async (state) => {
const wantWind = state.includes('wind');
const wantHistory = state.includes('history');
const wantPlaying = state.includes('playing');
const started = Date.now();
await page.evaluate(
async ({ wantWind, wantHistory, WEATHER }) => {
const dm = window.__godsEyeView.dataManager;
const set = async (id, on) => {
const entry = dm.layers.get(id);
if (!entry || Boolean(entry.enabled) === on) return;
await dm.setEnabled(id, on, { origin: 'user' });
};
await set('wind', wantWind);
if (wantWind)
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
for (const id of WEATHER) await set(id, wantHistory);
},
{ wantWind, wantHistory, WEATHER },
);
let ready = true;
await page
.waitForFunction(
({ wantWind, wantHistory, WEATHER }) => {
const dm = window.__godsEyeView.dataManager;
if (wantWind) {
const d = dm.layers.get('wind')?.module?.getDiagnostics?.();
if (!d) return false;
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
return false;
}
if (wantHistory)
for (const id of WEATHER) {
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
if (!d || d.loading || (!d.time && !d.error)) return false;
}
return true;
},
{ timeout: 90_000, polling: 250 },
{ wantWind, wantHistory, WEATHER },
)
.catch(() => {
ready = false;
});
const readyMs = Date.now() - started;
await page.evaluate(
({ wantPlaying, WEATHER }) => {
const dm = window.__godsEyeView.dataManager;
for (const id of WEATHER) {
const module = dm.layers.get(id)?.module;
if (!module || !dm.layers.get(id)?.enabled) continue;
const playing = Boolean(module.getDiagnostics?.().playing);
if (playing !== wantPlaying)
dm.setLayerParams(id, { play: true }, { origin: 'user' });
}
},
{ wantPlaying, WEATHER },
);
await sleep(2_500);
return { ready, readyMs, layers: await diagnostics() };
};
const shot = (name) =>
page.screenshot({ path: path.join(outDir, `${name}.png`) });
// ── Matched route ────────────────────────────────────────────────────────
if (!flag('--no-route')) {
console.log('\nRoute windows (continuous-render hold):');
for (let run = 1; run <= repeat; run++) {
const order = run % 2 === 1 ? states : [...states].reverse();
for (const state of order) {
await pose('overhead');
const setup = await applyState(state);
console.log(
` setup ${state}: ready=${setup.ready} in ${setup.readyMs}ms ${JSON.stringify(setup.layers)}`,
);
const base = { phase: 'route', state, run, setup };
await fly('close');
record({ ...base, window: 'zoom-in', ...(await measure(6_000, true)) });
record({
...base,
window: 'close-idle',
...(await measure(6_000, true)),
});
await shot(`${state}-r${run}-close`);
await orbit();
record({ ...base, window: 'orbit', ...(await measure(8_000, true)) });
await fly('globe');
record({
...base,
window: 'zoom-out',
...(await measure(6_000, true)),
});
record({
...base,
window: 'globe-idle',
...(await measure(6_000, true)),
});
await shot(`${state}-r${run}-globe`);
console.log(` after ${state}: ${JSON.stringify(await diagnostics())}`);
}
}
}
// ── Idle test (normal governor) ──────────────────────────────────────────
if (!flag('--no-idle')) {
console.log('\nIdle windows (normal governor, fixed 1.2 km pose):');
for (const state of ['off', 'history-paused', 'history-playing', 'wind']) {
await pose('close');
const setup = await applyState(state);
await sleep(4_000);
for (let run = 1; run <= 2; run++)
record({
phase: 'idle',
state,
run,
window: 'fixed-8s',
setup,
...(await measure(8_000, false)),
});
await shot(`idle-${state}`);
}
await applyState('off');
}
writeFileSync(
path.join(outDir, 'results.json'),
JSON.stringify({ label, url, env, results }, null, 2),
);
const lines = [
`# qa-weather-perf · ${label}`,
'',
`url: ${url} · ${env.basemap} · canvas ${env.canvas} @ DPR ${env.dpr} · ${env.ua}`,
'',
'| phase | state | run | window | fps | p50 ms | p95 ms | max ms | long tasks | long ms | dom mut | height ∆ | height min–max |',
'|---|---|---|---|---|---|---|---|---|---|---|---|---|',
...results.map(
(r) =>
`| ${r.phase} | ${r.state} | ${r.run} | ${r.window} | ${r.fps}${r.canvasStable ? '' : ' ⚠'} | ${r.p50} | ${r.p95} | ${r.max} | ${r.longTasks} | ${r.longTaskMs} | ${r.mutations} | ${r.heightChanges} | ${r.heightMin ?? '—'}–${r.heightMax ?? '—'} |`,
),
];
writeFileSync(path.join(outDir, 'summary.md'), lines.join('\n') + '\n');
console.log(`\nWrote ${path.join(outDir, 'results.json')} and summary.md`);
} finally {
await browser.close();
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Weather history-swap probe: does the scene keep drawing while a draped
* weather frame is replaced? Runs against a live dev server, turns on one
* observed product at the Earth view, steps Earlier and plays history while
* screenshotting, then measures the black fraction inside the globe disc of
* every capture. Writes <out>/<label>/summary.json.
*
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
* [--layer weather-satellite] [--product clouds] [--headless]
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
? argv[i + 1]
: fallback;
};
const flag = (name) => argv.includes(name);
const rawUrl = arg('--url', 'http://localhost:4173');
const url = rawUrl.includes('welcome=')
? rawUrl
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
const label = arg('--label', 'swap');
const layerId = arg('--layer', 'weather-satellite');
const product = arg(
'--product',
layerId === 'weather-satellite' ? 'clouds' : null,
);
const outDir = path.resolve(arg('--out', 'qa-shots/weather-swap'), label);
mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Globe disc in CSS pixels for the 1400×920 viewport at the Earth view.
const DISC = { cx: 700, cy: 460, r: 300 };
const browser = await puppeteer.launch({
headless: flag('--headless') ? 'new' : false,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000,
defaultViewport: null,
args: [
'--no-sandbox',
'--window-size=1400,1000',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
const frames = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
timeout: 120_000,
});
await sleep(12_000);
await page.evaluate(
async ({ layerId, product }) => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
for (const [id, e] of dm.layers)
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
const v = gev.viewer;
const d = Math.PI / 180;
v.camera.setView({
destination: v.scene.ellipsoid.cartographicToCartesian({
longitude: -80 * d,
latitude: 25 * d,
height: 17_368_000,
}),
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
});
await dm.setEnabled(layerId, true, { origin: 'user' });
if (product) dm.setLayerParams(layerId, { product }, { origin: 'user' });
},
{ layerId, product },
);
await page
.waitForFunction(
({ layerId, product }) => {
const d = window.__godsEyeView.dataManager.layers
.get(layerId)
?.module?.getDiagnostics?.();
return d && !d.loading && d.time && (!product || d.product === product);
},
{ timeout: 90_000 },
{ layerId, product },
)
.catch(() => console.log(' !! first frame not ready'));
await sleep(3000);
const diag = () =>
page.evaluate((layerId) => {
const d = window.__godsEyeView.dataManager.layers
.get(layerId)
.module.getDiagnostics();
return {
time: d.time,
loading: d.loading,
pending: d.pendingTiles,
loaded: d.loadedTiles,
imagery: d.imageryCount,
mosaic: d.mosaic ?? null,
};
}, layerId);
const capture = async (name) => {
const file = `${name}.png`;
await page.screenshot({ path: path.join(outDir, file) });
frames.push({ file, ...(await diag()) });
};
await capture('00-latest');
const step = (params) =>
page.evaluate(
({ layerId, params }) =>
window.__godsEyeView.dataManager.setLayerParams(layerId, params, {
origin: 'user',
}),
{ layerId, params },
);
await step({ step: -1 });
for (let i = 1; i <= 15; i++) {
await sleep(400);
await capture(`earlier-${String(i).padStart(2, '0')}`);
}
await step({ play: true });
for (let i = 1; i <= 20; i++) {
await sleep(500);
if (i % 2 === 0) await capture(`play-${String(i).padStart(2, '0')}`);
}
await step({ play: true });
// Black fraction inside the globe disc, measured in a blank page.
const meter = await browser.newPage();
for (const frame of frames) {
const b64 = readFileSync(path.join(outDir, frame.file)).toString('base64');
const black = await meter.evaluate(
async ({ b64, DISC }) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
const s = img.naturalWidth / 1400;
const d = ctx.getImageData(0, 0, c.width, c.height).data;
const rr = (DISC.r * s) ** 2;
let n = 0;
let dark = 0;
for (let y = 0; y < c.height; y += 2)
for (let x = 0; x < c.width; x += 2) {
if ((x - DISC.cx * s) ** 2 + (y - DISC.cy * s) ** 2 > rr) continue;
n++;
const i = (y * c.width + x) * 4;
if (d[i] < 18 && d[i + 1] < 18 && d[i + 2] < 18) dark++;
}
return dark / n;
},
{ b64, DISC },
);
frame.black = Number(black.toFixed(4));
console.log(
`${frame.file}\t${(black * 100).toFixed(1)}%\t${frame.time}\tpending=${frame.pending}`,
);
}
const blackFrames = frames.filter((f) => f.black > 0.15).length;
const distinctTimes = new Set(frames.map((f) => f.time).filter(Boolean)).size;
const summary = {
url: rawUrl,
label,
layerId,
product,
frames: frames.length,
blackFrames,
maxBlack: Math.max(...frames.map((f) => f.black)),
distinctTimes,
capturedAt: new Date().toISOString(),
};
writeFileSync(
path.join(outDir, 'summary.json'),
JSON.stringify({ summary, frames }, null, 2),
);
console.log(
`frames=${frames.length} black(>15%)=${blackFrames} max=${(summary.maxBlack * 100).toFixed(1)}% distinctTimes=${distinctTimes}`,
);
} finally {
await browser.close();
}
+341
View File
@@ -0,0 +1,341 @@
/**
* Weather teardown and cross-layer regression probe.
*
* Against a running dev server: records a resource baseline, turns every
* weather layer on and exercises it (history playback, a wind reading, a
* storm selection), turns them all off, and asserts the scene is back to the
* baseline (imagery layers on the globe and the tileset, primitives, ground
* primitives, data sources, entities, camera/scene listeners, the weather
* panel, the wind reading marker). Then it enables a set of non-weather
* layers one at a time and samples the render rate, long tasks and console
* errors for each, and repeats the same non-weather sequence in a control
* page that never had weather on. Writes <out>/<label>/results.json.
*
* node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
? argv[i + 1]
: fallback;
};
const flag = (name) => argv.includes(name);
const rawUrl = arg('--url', 'http://localhost:4173');
const url = rawUrl.includes('welcome=')
? rawUrl
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
const label = arg('--label', 'teardown');
const outDir = path.resolve(arg('--out', 'qa-shots/weather-teardown'), label);
mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const WEATHER = [
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
];
const browser = await puppeteer.launch({
headless: flag('--headless') ? 'new' : false,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000,
defaultViewport: null,
args: [
'--no-sandbox',
'--window-size=1400,1000',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
const boot = async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
const errors = [];
page.on('pageerror', (e) =>
errors.push(`pageerror: ${e.message}`.slice(0, 200)),
);
page.on('console', (m) => {
if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200));
});
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
timeout: 120_000,
});
await sleep(10_000);
await page.evaluate(async () => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
for (const [id, e] of dm.layers)
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
const v = gev.viewer;
const d = Math.PI / 180;
v.camera.setView({
destination: v.scene.ellipsoid.cartographicToCartesian({
longitude: -95 * d,
latitude: 32 * d,
height: 4_500_000,
}),
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
});
});
await sleep(3000);
return { page, errors };
};
const snapshot = (page) =>
page.evaluate(() => {
const gev = window.__godsEyeView;
const v = gev.viewer;
const s = v.scene;
const t = gev.tileset;
const dm = gev.dataManager;
const weather = {};
for (const id of [
'weather-radar',
'weather-satellite',
'weather-lightning',
]) {
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
if (d)
weather[id] = {
imagery: d.imageryCount,
mosaics: d.cache?.mosaics ?? 0,
prefetching: !!d.cache?.prefetching,
};
}
const wind = dm.layers.get('wind')?.module?.getDiagnostics?.() || {};
return {
globeImagery: v.imageryLayers.length,
tilesetImagery: t?.imageryLayers?.length ?? null,
primitives: s.primitives.length,
groundPrimitives: s.groundPrimitives.length,
dataSources: v.dataSources.length,
entities: v.entities.values.length,
listeners: {
postRender: s.postRender.numberOfListeners,
preRender: s.preRender.numberOfListeners,
postUpdate: s.postUpdate.numberOfListeners,
moveEnd: v.camera.moveEnd.numberOfListeners,
moveStart: v.camera.moveStart.numberOfListeners,
changed: v.camera.changed.numberOfListeners,
tick: v.clock.onTick.numberOfListeners,
},
dom: {
weatherPanelHidden:
document.getElementById('weather-panel')?.hidden ?? null,
weatherCards: document.querySelectorAll('.weather-card').length,
windReading: !!document.querySelector(
'.gev-wind-reading:not([hidden])',
),
},
weather,
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
};
});
const sample = async (page, seconds) => {
await page.evaluate(() => {
window.__qaRenders = 0;
window.__qaLong = { count: 0, ms: 0 };
const s = window.__godsEyeView.viewer.scene;
window.__qaOff = s.postRender.addEventListener(() => window.__qaRenders++);
window.__qaObs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
window.__qaLong.count++;
window.__qaLong.ms += e.duration;
}
});
window.__qaObs.observe({ entryTypes: ['longtask'] });
window.__qaRaf = () => {
s.requestRender();
window.__qaRafId = requestAnimationFrame(window.__qaRaf);
};
window.__qaRaf();
});
await sleep(seconds * 1000);
return page.evaluate((seconds) => {
cancelAnimationFrame(window.__qaRafId);
window.__qaOff();
window.__qaObs.disconnect();
return {
fps: +(window.__qaRenders / seconds).toFixed(1),
longTasks: window.__qaLong.count,
longMs: Math.round(window.__qaLong.ms),
};
}, seconds);
};
const setLayer = (page, id, on) =>
page.evaluate(
async ({ id, on }) => {
const dm = window.__godsEyeView.dataManager;
const e = dm.layers.get(id);
if (e && Boolean(e.enabled) !== on)
await dm.setEnabled(id, on, { origin: 'user' });
return !!e;
},
{ id, on },
);
const diff = (a, b) => {
const out = {};
const walk = (x, y, prefix) => {
for (const key of Object.keys(x)) {
const p = prefix ? `${prefix}.${key}` : key;
if (x[key] && typeof x[key] === 'object' && !Array.isArray(x[key]))
walk(x[key], y?.[key] || {}, p);
else if (JSON.stringify(x[key]) !== JSON.stringify(y?.[key]))
out[p] = [x[key], y?.[key]];
}
};
walk(a, b, '');
return out;
};
const results = { url: rawUrl, label, startedAt: new Date().toISOString() };
try {
// ── Run A: weather on → exercised → off → baseline check → other layers ──
const a = await boot();
const others = await a.page.evaluate(() =>
[...window.__godsEyeView.dataManager.layers.keys()].filter((id) =>
/^flights$|^satellites$|vessel|firms|earthquake|cables|^military$/.test(
id,
),
),
);
console.log('other layers under test:', others.join(', '));
const baseline = await snapshot(a.page);
console.log('baseline', JSON.stringify(baseline));
for (const id of WEATHER) await setLayer(a.page, id, true);
await a.page
.waitForFunction(
() =>
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
(id) => {
const d = window.__godsEyeView.dataManager.layers
.get(id)
?.module?.getDiagnostics?.();
return d && !d.loading && d.time;
},
),
{ timeout: 120_000 },
)
.catch(() => console.log(' !! weather not ready'));
await sleep(2000);
// exercise: history playback, wind reading, storm selection, product switch
await a.page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
dm.setLayerParams('wind', { inspect: true }, { origin: 'user' });
dm.setLayerParams(
'weather-satellite',
{ product: 'clouds' },
{ origin: 'user' },
);
});
await sleep(8000);
await a.page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
const storms =
dm.layers.get('weather-cyclones')?.module?.getRowControls?.().list
?.items || [];
if (storms[1])
dm.setLayerParams('weather-cyclones', storms[1].params, {
origin: 'user',
});
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
});
await sleep(4000);
const weatherOn = await snapshot(a.page);
console.log('weather on', JSON.stringify(weatherOn));
results.weatherOnSample = await sample(a.page, 5);
for (const id of WEATHER) await setLayer(a.page, id, false);
await sleep(4000);
const afterOff = await snapshot(a.page);
const leak = diff(
{ ...baseline, enabled: undefined, weather: undefined },
{ ...afterOff, enabled: undefined, weather: undefined },
);
results.baseline = baseline;
results.afterOff = afterOff;
results.leak = leak;
console.log('after off', JSON.stringify(afterOff));
console.log(
Object.keys(leak).length
? `!! DIFFERENCES after teardown: ${JSON.stringify(leak)}`
: 'teardown: scene back to baseline',
);
results.afterWeather = [];
for (const id of others) {
const ok = await setLayer(a.page, id, true);
await sleep(9000);
const m = await sample(a.page, 5);
const snap = await snapshot(a.page);
results.afterWeather.push({
id,
ok,
...m,
primitives: snap.primitives,
dataSources: snap.dataSources,
});
console.log(
`after weather + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
);
}
results.errorsA = a.errors;
await a.page.screenshot({ path: path.join(outDir, 'a-others-on.png') });
await a.page.close();
// ── Run B: control (same others, never had weather) ──
const b = await boot();
results.control = [];
for (const id of others) {
const ok = await setLayer(b.page, id, true);
await sleep(9000);
const m = await sample(b.page, 5);
const snap = await snapshot(b.page);
results.control.push({
id,
ok,
...m,
primitives: snap.primitives,
dataSources: snap.dataSources,
});
console.log(
`control + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
);
}
results.errorsB = b.errors;
await b.page.screenshot({ path: path.join(outDir, 'b-control.png') });
await b.page.close();
console.log(
'\nerrors (weather run):',
results.errorsA.length ? results.errorsA : 'none',
);
console.log(
'errors (control run):',
results.errorsB.length ? results.errorsB : 'none',
);
writeFileSync(
path.join(outDir, 'results.json'),
JSON.stringify(results, null, 2),
);
console.log(`wrote ${path.join(outDir, 'results.json')}`);
} finally {
await browser.close();
}
+44
View File
@@ -0,0 +1,44 @@
/** Real-canvas wind regression. Run against this candidate's dev server. */
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), headless: false, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
try {
const page = await browser.newPage();
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' });
const result = await page.evaluate(async () => {
const { createWindRendering } = await import('/src/layers/wind/rendering.js');
const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame;
const callbacks = new Map(); let sequence = 0;
window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; };
window.cancelAnimationFrame = id => callbacks.delete(id);
const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px;z-index:9999;background:#102030';
document.body.appendChild(container);
const sceneCanvas = { clientWidth: 800, clientHeight: 600 };
const viewer = { scene: { canvas: sceneCanvas, camera: {} }, isDestroyed: () => false };
const cesium = { Cartesian3: { fromDegrees: (lon,lat) => ({ lon,lat }) }, SceneTransforms: { worldToWindowCoordinates: (scene, point) => ({ x: 400 + point.lon, y: 300 + point.lat }) } };
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
try {
rendering.attach(); rendering.start();
const idleCallbacks = callbacks.size;
rendering.setField({ grid: { nx:1,ny:1,lo1:0,la1:90,dx:360,dy:180 }, u: new Float32Array([10]), v: new Float32Array([0]) });
const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
const tick = time => { const [id, callback] = callbacks.entries().next().value; callbacks.delete(id); callback(time); };
tick(16); // First camera signature clears the surface.
context.fillStyle = '#ff0000'; context.fillRect(10,10,5,5);
tick(32);
const alpha = context.getImageData(12,12,1,1).data[3];
const count = rendering.getParticleCount();
sceneCanvas.clientWidth = 320; sceneCanvas.clientHeight = 240;
tick(48);
const resizedCount = rendering.getParticleCount();
rendering.stop(); rendering.clear();
return { idleCallbacks, alpha, count, resizedCount, pending: callbacks.size, afterClear: context.getImageData(12,12,1,1).data[3] };
} finally { rendering.destroy(); container.remove(); window.requestAnimationFrame = raf; window.cancelAnimationFrame = caf; }
});
assert.equal(result.idleCallbacks, 0);
assert.ok(result.alpha > 200, 'unchanged canvas dimensions preserve existing trail pixels');
assert.ok(result.resizedCount < result.count);
assert.equal(result.pending, 0); assert.equal(result.afterClear, 0);
console.log(JSON.stringify(result));
} finally { await browser.close(); }
+44
View File
@@ -88,6 +88,7 @@
* --source-base <path> Browser module prefix (default /src)
* --url <url> App URL (default http://localhost:4173)
* --headful Show the browser (debugging)
* --offline-imagery Use bundled texture for known Esri/OSM tile images only
* --keep-open Leave the browser open after the run (debugging)
*/
@@ -110,6 +111,7 @@ const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, '');
const APP_URL = getOpt('--url', 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
const HEADFUL = getFlag('--headful');
const OFFLINE_IMAGERY = getFlag('--offline-imagery');
const KEEP_OPEN = getFlag('--keep-open');
const CHROME_EXECUTABLE_CANDIDATES = [
@@ -201,6 +203,15 @@ async function main() {
console.log(` App URL : ${APP_URL}`);
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
// Tracking does not assert imagery geography. This optional fixture leaves
// provider metadata, terrain, models and every tracking assertion untouched.
// Read before launching so a missing bundled asset fails explicitly.
const offlineImageryTile = OFFLINE_IMAGERY
? fs.readFileSync(new URL('../node_modules/cesium/Build/Cesium/Assets/Textures/NaturalEarthII/0/0/0.jpg', import.meta.url))
: null;
let offlineImageryRequests = 0;
if (OFFLINE_IMAGERY) console.log(' Imagery : bundled NaturalEarthII fixture (tile images only)');
// Verify the dev server is up before launching a browser.
try {
const res = await fetch(APP_URL, { method: 'GET' });
@@ -256,6 +267,25 @@ async function main() {
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
// Reuse the one existing interceptor; do not install competing handlers.
// Exact HTTPS origins and image paths only: metadata, feature queries,
// other providers, terrain and local/model resources continue normally.
const knownImageryTile = request.method() === 'GET' && (
(url.origin === 'https://services.arcgisonline.com'
&& /^\/ArcGIS\/rest\/services\/World_Imagery\/MapServer\/tile\/\d+\/\d+\/\d+$/.test(url.pathname))
|| (url.origin === 'https://tile.openstreetmap.org'
&& /^\/\d+\/\d+\/\d+\.png$/.test(url.pathname))
);
if (OFFLINE_IMAGERY && knownImageryTile) {
offlineImageryRequests++;
request.respond({
status: 200,
contentType: 'image/jpeg',
headers: { 'Access-Control-Allow-Origin': '*' },
body: offlineImageryTile,
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
@@ -365,6 +395,19 @@ async function main() {
lastMessageAt: null,
}));
}
// Contacts also enables mapped installations. This tracking harness
// does not test installation acquisition; a valid empty viewport keeps
// public Overpass outages out of aircraft/satellite tracking results.
// Preserve the real response shape and all console-error assertions.
if (isAppRequest && url.pathname === '/api/military-installations') {
return Promise.resolve(jsonResponse({
elements: [],
saturated: false,
elementCap: 700,
retrievedAt: new Date().toISOString(),
status: 'ready',
}));
}
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
// the satellite assertions are deterministic and the run never depends
// on an upstream that rate-limits (it was answering 403 the night this
@@ -4082,6 +4125,7 @@ async function main() {
finishAndExit();
} finally {
if (OFFLINE_IMAGERY) console.log(` Bundled imagery tiles served: ${offlineImageryRequests}`);
if (!KEEP_OPEN) {
await browser.close();
} else {
+121 -3
View File
@@ -17,6 +17,7 @@ import {
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
@@ -40,6 +41,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
* for any catalog the proxy can actually serve. */
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
const puller = createHlsPuller();
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
const setHealth = (cameraId, patch) => {
@@ -127,6 +130,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
};
const installMiddleware = (server) => {
server.httpServer?.on('close', () => {
puller.shutdown();
});
server.middlewares.use('/api/cctv', async (req, res) => {
try {
const sources = await getCctvSources();
@@ -194,12 +200,124 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
}
if (url.pathname.startsWith('/media/')) {
const cameraId =
decodeURIComponent(url.pathname.replace('/media/', '').trim()) ||
'camera';
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(
url.pathname,
);
if (!match) {
res.writeHead(404);
res.end();
return;
}
const cameraId = decodeURIComponent(match[1]);
const source = sourceById.get(cameraId);
const mediaUrl = source?.url || '';
const feedType = normalizeFeedType(source?.feedType || 'image');
const leaseId = url.searchParams.get('lease');
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
res.writeHead(400);
res.end();
return;
}
if (req.method === 'DELETE') {
if (leaseId) puller.release(cameraId, leaseId);
res.writeHead(204);
res.end();
return;
}
if (req.method !== 'GET') {
res.writeHead(405);
res.end();
return;
}
if (feedType === 'hls') {
if (
!/^https?:\/\//i.test(mediaUrl) ||
!/\.m3u8(?:\?|$)/i.test(mediaUrl)
) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error:
'This stream requires an unsupported transport; use the frame fallback',
}),
);
return;
}
if (match[2]) {
const body = puller.getSegment(
cameraId,
url.searchParams.get('session'),
Number(match[3]),
leaseId,
);
res.writeHead(body ? 200 : 404, {
'Content-Type': 'video/mp2t',
'Cache-Control': 'no-store',
});
res.end(body || undefined);
return;
}
const downstream = watchDownstreamClose(res);
let entry;
const cancelPending = () => {
if (entry) puller.release(cameraId, leaseId);
};
try {
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
if (downstream.closed) {
cancelPending();
return;
}
downstream.signal.addEventListener('abort', cancelPending, {
once: true,
});
if (!(await puller.waitReady(entry, downstream.signal)))
throw new Error('Stream unavailable');
const playlist = await puller.buildPlaylist(
entry,
cameraId,
leaseId,
);
if (downstream.closed) return;
if (!playlist) throw new Error('Stream unavailable');
setHealth(cameraId, {
status: 'ok',
sourceKind: 'live',
label: source?.provider || 'Configured source',
message: 'Live HLS connected',
});
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-store',
'X-CCTV-Source': 'hls-pull',
'X-CCTV-Session': entry.token,
});
res.end(playlist);
} catch {
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'fallback',
label: source?.provider || 'Configured source',
message: 'Live HLS unavailable',
});
if (!downstream.closed) {
res.writeHead(503, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '2',
});
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
}
} finally {
downstream.signal.removeEventListener('abort', cancelPending);
}
return;
}
if (match[2] || req.method !== 'GET') {
res.writeHead(404);
res.end();
return;
}
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
setHealth(cameraId, {
+6
View File
@@ -17,6 +17,7 @@ import {
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -86,6 +87,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
load: loadCalgarySourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
load: loadDelDOTSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+8
View File
@@ -236,6 +236,14 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
* body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
export const DELDOT_ANCHORS = [
{ lat: 39.7459, lon: -75.5466 }, // Wilmington (New Castle)
{ lat: 39.1582, lon: -75.5244 }, // Dover (Kent)
{ lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex)
];
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
+107
View File
@@ -56,6 +56,9 @@ import {
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -1594,3 +1597,107 @@ export async function loadCalgarySourcesFromOpenData() {
return [];
}
}
export async function loadDelDOTSourcesFromOpenData() {
try {
const resp = await fetch(DELDOT_CCTV_URL, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
redirect: 'error',
});
if (!resp.ok) {
console.warn('[CCTV] DelDOT source download failed:', resp.status);
return [];
}
const payload = await readResponseJsonCapped(resp, 2 * 1024 * 1024);
const rows = Array.isArray(payload?.videoCameras)
? payload.videoCameras
: [];
if (!rows.length) return [];
const cameras = [];
for (const row of rows) {
if (String(row?.status).toLowerCase() !== 'active') continue;
const lat = toFiniteNumber(row?.lat);
const lon = toFiniteNumber(row?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Delaware bounding box — a bad upstream coord can't place a camera out of state.
if (lat < 38.4 || lat > 39.9 || lon < -75.85 || lon > -75.0) continue;
// Prefer the catalog's HTTPS HLS transport; no subprocess is required.
let stream;
try {
stream = new URL(String(row?.urls?.m3u8s || ''));
} catch {
continue;
}
if (
stream.origin !== 'https://video.deldot.gov' ||
stream.username ||
stream.password ||
!/^\/live\/[A-Za-z0-9_.-]+\/playlist\.m3u8$/.test(stream.pathname)
)
continue;
const streamUrl = stream.href;
const id = String(row?.id || '').trim();
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
const cameraId = `deldot-${id.toLowerCase()}`;
const title = String(row?.title || '').trim();
// Title carries an unambiguous travel token for ~1 in 5 cameras
// ("US 13 SB @ ..."). directionToHeading (allowBare=false) matches only
// NB/SB/EB/WB, never a bare "W"/"N" street prefix — "W NORTH ST" yields
// no heading and falls back.
const heading = directionToHeading(title);
const hasHeading = Number.isFinite(heading);
cameras.push({
id: cameraId,
name: title || `DelDOT ${id}`,
city: String(row?.county ? `${row.county} County` : 'Delaware'),
cityId: `deldot-${String(row?.county || 'de')
.toLowerCase()
.replace(/\s+/g, '-')}`,
provider: 'DelDOT',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Fabricated RAW PRIOR poses (same personalities as Austin/Caltrans);
// the client ground-snap + manual calibration own the truth.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: 10, // Estimated prior; client ground resolution owns placement.
feedType: 'hls',
url: streamUrl,
snapshotUrl: '',
sourceKind: 'deldot-open-data',
license: 'Public DelDOT traffic camera',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_DELDOT_MAX_SOURCES || DEFAULT_DELDOT_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_DELDOT_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, DELDOT_ANCHORS);
console.log(
`[CCTV] Loaded DelDOT camera sources: ${unique.length} Active (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] DelDOT source download error:',
error?.message || error,
);
return [];
}
}
+348
View File
@@ -0,0 +1,348 @@
/** Bounded, demand-owned HLS sessions. No subprocess, disk cache or idle sweep. */
import { randomUUID } from 'node:crypto';
export const HLS_LIMITS = Object.freeze({
sessions: 2,
leasesPerSession: 8,
playlistBytes: 256 * 1024,
segmentBytes: 4 * 1024 * 1024,
sessionBytes: 24 * 1024 * 1024,
segments: 12,
pollMs: 2000,
idleMs: 15000,
timeoutMs: 10000,
readyMs: 12000,
});
/** Refuse redirects; count actual streamed bytes, including chunked responses. */
export async function fetchHlsBytes(
url,
{
fetchImpl = fetch,
signal,
maxBytes,
timeoutMs = HLS_LIMITS.timeoutMs,
} = {},
) {
const controller = new AbortController();
const abort = () => controller.abort();
if (signal?.aborted) controller.abort();
else signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(abort, timeoutMs);
let reader;
let response;
try {
controller.signal.throwIfAborted();
response = await fetchImpl(url, {
redirect: 'error',
signal: controller.signal,
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
});
if (
!response.ok ||
response.redirected ||
(response.url && new URL(response.url).href !== new URL(url).href)
)
throw new Error('HLS upstream refused');
if (Number(response.headers.get('content-length')) > maxBytes)
throw new Error('HLS response too large');
reader = response.body?.getReader?.();
if (!reader) throw new Error('HLS response is not streamable');
const chunks = [];
let length = 0;
for (;;) {
controller.signal.throwIfAborted();
const { done, value } = await reader.read();
controller.signal.throwIfAborted();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('HLS response too large');
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, length);
} finally {
controller.abort();
if (reader) await reader.cancel().catch(() => {});
else await response?.body?.cancel().catch(() => {});
reader?.releaseLock();
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}
export function sameOriginHlsUrl(value, base) {
const url = new URL(value, base);
if (
!['https:', 'http:'].includes(url.protocol) ||
url.origin !== new URL(base).origin ||
url.username ||
url.password
)
throw new Error('HLS reference escapes registered origin');
return url.href;
}
/** Only clear MPEG-TS media playlists: encrypted, fMP4 and byte ranges fail closed. */
export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
if (
!text.startsWith('#EXTM3U') ||
/#EXT-X-(?:KEY|MAP|BYTERANGE|I-FRAMES-ONLY)/.test(text)
)
throw new Error('Unsupported HLS playlist');
let seq = 0;
let duration = null;
let discontinuity = false;
const segments = [];
for (const raw of text.split('\n')) {
const line = raw.trim();
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
seq = Number(line.slice(22));
if (!Number.isSafeInteger(seq) || seq < 0)
throw new Error('Invalid media sequence');
} else if (line === '#EXT-X-DISCONTINUITY') {
discontinuity = true;
} else if (line.startsWith('#EXTINF:')) {
duration = Number.parseFloat(line.slice(8));
if (!Number.isFinite(duration) || duration <= 0 || duration > 60)
throw new Error('Invalid segment duration');
} else if (line && !line.startsWith('#')) {
if (duration === null || !Number.isSafeInteger(seq))
throw new Error('Invalid HLS segment');
const uri = sameOriginHlsUrl(line, base);
if (!new URL(uri).pathname.endsWith('.ts'))
throw new Error('Only MPEG-TS segments are supported');
segments.push({ seq: seq++, duration, uri, discontinuity });
discontinuity = false;
duration = null;
}
}
return segments.slice(-limit);
}
export function createHlsPuller({
fetchImpl = fetch,
limits = HLS_LIMITS,
} = {}) {
const active = new Map();
let closed = false;
const stop = (cameraId, token) => {
const entry = active.get(cameraId);
if (!entry || (token && token !== entry.token)) return;
active.delete(cameraId);
entry.stopping = true;
entry.controller.abort();
clearTimeout(entry.timer);
for (const lease of entry.leases.values()) clearTimeout(lease.timer);
entry.leases.clear();
entry.segments.clear();
entry.upstream.clear();
entry.bytes = 0;
};
const release = (cameraId, leaseId) => {
const entry = active.get(cameraId);
const lease = entry?.leases.get(leaseId);
if (!lease) return;
clearTimeout(lease.timer);
entry.leases.delete(leaseId);
if (!entry.leases.size) stop(cameraId, entry.token);
};
const touch = (entry, leaseId, create = false) => {
let lease = entry.leases.get(leaseId);
if (!lease) {
if (!create || entry.leases.size >= limits.leasesPerSession)
throw new Error('HLS lease capacity reached');
lease = {};
entry.leases.set(leaseId, lease);
}
clearTimeout(lease.timer);
lease.timer = setTimeout(
() => release(entry.cameraId, leaseId),
limits.idleMs,
);
lease.timer.unref?.();
};
const read = (entry, url, maxBytes) =>
fetchHlsBytes(url, {
fetchImpl,
signal: entry.controller.signal,
maxBytes,
timeoutMs: limits.timeoutMs,
});
const poll = async (entry) => {
try {
let base = entry.chunklistUrl || entry.url;
let text = (await read(entry, base, limits.playlistBytes)).toString(
'utf8',
);
if (!text.includes('#EXTINF:')) {
const variant = text
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('#'));
if (!variant || !text.includes('#EXT-X-STREAM-INF:'))
throw new Error('Missing HLS variant');
base = sameOriginHlsUrl(variant, entry.url);
text = (await read(entry, base, limits.playlistBytes)).toString('utf8');
}
entry.chunklistUrl = base;
const segments = parseHlsMedia(text, base, limits.segments);
const newest = segments.at(-1)?.seq ?? -1;
const restarted =
newest < entry.upstreamNewest ||
segments.some((segment) => {
const prior = entry.upstream.get(segment.seq);
return prior && prior !== segment.uri;
});
if (restarted) {
entry.upstream.clear();
entry.pendingDiscontinuity = true;
}
entry.upstreamNewest = newest;
for (const segment of segments) {
if (entry.stopping) return;
if (entry.upstream.has(segment.seq)) continue;
const body = await read(entry, segment.uri, limits.segmentBytes);
if (entry.stopping) return;
// Evict BEFORE retaining the incoming segment. One bounded download may
// exist in addition to the cache; no unbounded disk output is possible.
while (
entry.segments.size &&
(entry.bytes + body.length > limits.sessionBytes ||
entry.segments.size >= limits.segments)
) {
const oldest = entry.segments.keys().next().value;
const removed = entry.segments.get(oldest);
entry.bytes -= removed.body.length;
if (removed.discontinuity) entry.discontinuitiesRemoved++;
entry.segments.delete(oldest);
}
if (body.length > limits.sessionBytes)
throw new Error('Segment exceeds session budget');
const seq = entry.nextSeq++;
entry.segments.set(seq, {
...segment,
seq,
body,
discontinuity: segment.discontinuity || entry.pendingDiscontinuity,
});
entry.pendingDiscontinuity = false;
entry.upstream.set(segment.seq, segment.uri);
while (entry.upstream.size > limits.segments * 2)
entry.upstream.delete(entry.upstream.keys().next().value);
entry.bytes += body.length;
}
entry.failures = 0;
} catch {
entry.chunklistUrl = null;
entry.failures++;
if (entry.failures >= 3) {
stop(entry.cameraId, entry.token);
return;
}
} finally {
if (!entry.stopping) {
entry.timer = setTimeout(() => {
entry.polling = poll(entry);
}, limits.pollMs);
entry.timer.unref?.();
}
}
};
const ensure = async (cameraId, url, leaseId = 'legacy') => {
if (closed) throw new Error('HLS service closed');
sameOriginHlsUrl(url, url);
let entry = active.get(cameraId);
if (entry && entry.url !== url) {
stop(cameraId);
entry = null;
}
if (entry) {
touch(entry, leaseId, true);
return entry;
}
// Reserve synchronously, before any await: concurrent creation is singleflight.
if (active.size >= limits.sessions)
throw new Error('HLS session capacity reached');
entry = {
cameraId,
url,
token: randomUUID(),
controller: new AbortController(),
segments: new Map(),
bytes: 0,
stopping: false,
failures: 0,
nextSeq: 0,
upstream: new Map(),
leases: new Map(),
upstreamNewest: -1,
pendingDiscontinuity: false,
discontinuitiesRemoved: 0,
};
active.set(cameraId, entry);
touch(entry, leaseId, true);
entry.polling = poll(entry);
return entry;
};
const waitReady = async (entry, signal) => {
const until = Date.now() + limits.readyMs;
while (!entry.stopping && !signal?.aborted && Date.now() < until) {
if (entry.segments.size >= 2) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
};
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
if (entry.stopping || entry.segments.size < 2) return null;
touch(entry, leaseId);
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
const lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((s) => s.duration)))}`,
`#EXT-X-MEDIA-SEQUENCE:${segments[0].seq}`,
`#EXT-X-DISCONTINUITY-SEQUENCE:${entry.discontinuitiesRemoved}`,
];
let previous;
for (const segment of segments) {
if (
segment.discontinuity ||
(previous !== undefined && segment.seq !== previous + 1)
)
lines.push('#EXT-X-DISCONTINUITY');
lines.push(
`#EXTINF:${segment.duration.toFixed(3)},`,
`/api/cctv/media/${encodeURIComponent(cameraId)}/seg_${segment.seq}.ts?session=${entry.token}&lease=${encodeURIComponent(leaseId)}`,
);
previous = segment.seq;
}
return lines.join('\n') + '\n';
};
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
const entry = active.get(cameraId);
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
return null;
const body = entry.segments.get(seq)?.body;
if (body) touch(entry, leaseId);
return body || null;
};
const shutdown = async () => {
closed = true;
const entries = [...active.values()];
for (const entry of entries) stop(entry.cameraId);
await Promise.allSettled(entries.map((entry) => entry.polling));
};
return {
ensure,
waitReady,
buildPlaylist,
getSegment,
stop,
release,
shutdown,
stats: () => ({
sessions: active.size,
bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0),
}),
};
}
+416
View File
@@ -0,0 +1,416 @@
import { readResponseTextCapped } from './common/http.js';
const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
const GIS =
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
const HOUR = 3600_000;
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const LAYERS = [
{ id: 5, cap: 512 * 1024, count: 500, kind: 'points' },
{ id: 6, cap: 512 * 1024, count: 32, kind: 'track' },
{ id: 7, cap: 2 * 1024 * 1024, count: 32, kind: 'cone' },
];
function invalid() {
return new Error('invalid_cyclone_data');
}
function text(value, max = 80) {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw invalid();
return value.trim();
}
function advisory(value) {
if (typeof value !== 'string' || !/^\d{1,3}[A-Z]?$/i.test(value))
throw invalid();
return value.replace(/^0+(?=\d)/, '').toUpperCase();
}
function number(value, min, max) {
if (
value === null ||
value === undefined ||
value === '' ||
!['string', 'number'].includes(typeof value)
)
return null;
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
function iso(value, now) {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)
)
throw invalid();
const n = Date.parse(value);
if (
!Number.isFinite(n) ||
n > now + 5 * 60_000 ||
now - n > 12 * HOUR ||
new Date(n).toISOString().replace('.000Z', 'Z') !==
value.replace('.000Z', 'Z')
)
throw invalid();
return new Date(n).toISOString();
}
function position(coordinates) {
if (
!Array.isArray(coordinates) ||
coordinates.length !== 2 ||
!coordinates.every((n) => typeof n === 'number' && Number.isFinite(n)) ||
Math.abs(coordinates[0]) > 180 ||
Math.abs(coordinates[1]) > 90
)
throw invalid();
return { longitude: coordinates[0], latitude: coordinates[1] };
}
function officialLink(value) {
if (typeof value !== 'string' || value.length > 256) return null;
try {
const url = new URL(value);
return url.origin === 'https://www.nhc.noaa.gov' &&
!url.username &&
!url.password &&
!url.search &&
!url.hash &&
/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
? url.href
: null;
} catch {
return null;
}
}
export function parseCycloneStatus(payload, now = Date.now()) {
if (!Array.isArray(payload?.activeStorms) || payload.activeStorms.length > 32)
throw invalid();
const seen = new Set();
return payload.activeStorms.map((raw) => {
if (
typeof raw?.id !== 'string' ||
!/^(?:al|ep|cp)\d{6}$/.test(raw.id) ||
seen.has(raw.id)
)
throw invalid();
seen.add(raw.id);
const forecast = raw.forecastAdvisory;
const issuedAt = iso(forecast?.issuance, now);
const advisoryNumber = advisory(forecast?.advNum);
const positionAt = iso(raw.lastUpdate, now);
return {
id: raw.id,
name: text(raw.name),
classification: text(raw.classification, 16),
basin: raw.id.slice(0, 2).toUpperCase(),
position: position([raw.longitudeNumeric, raw.latitudeNumeric]),
positionAt,
advisoryNumber,
issuedAt,
windKt: number(raw.intensity, 0, 300),
pressureHpa: number(raw.pressure, 800, 1100),
movement: {
directionDegrees: number(raw.movementDir, 0, 360),
speedKt: number(raw.movementSpeed, 0, 200),
},
advisoryUrl: officialLink(forecast.url),
outlookUrl: raw.id.startsWith('al')
? 'https://www.nhc.noaa.gov/gtwo.php?basin=atlc&fdays=7'
: raw.id.startsWith('cp')
? 'https://www.nhc.noaa.gov/gtwo.php?basin=cpac&fdays=7'
: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
};
});
}
function geometry(raw, kind, budget) {
const types =
kind === 'points'
? ['Point']
: kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!types.includes(raw?.type)) throw invalid();
const point = (value) => {
position(value);
if (++budget.count > 25_000) throw invalid();
return [...value];
};
const line = (value, ring = false) => {
if (
!Array.isArray(value) ||
value.length < (ring ? 4 : 2) ||
value.length > 10_000
)
throw invalid();
const result = value.map(point);
if (
ring &&
(result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
)
throw invalid();
return result;
};
const list = (value, read) => {
if (!Array.isArray(value) || !value.length || value.length > 128)
throw invalid();
return value.map(read);
};
const polygon = (value) => list(value, (ring) => line(ring, true));
const coordinates =
raw.type === 'Point'
? point(raw.coordinates)
: raw.type === 'LineString'
? line(raw.coordinates)
: raw.type === 'MultiLineString'
? list(raw.coordinates, (part) => line(part))
: raw.type === 'Polygon'
? polygon(raw.coordinates)
: list(raw.coordinates, polygon);
return { type: raw.type, coordinates };
}
/** Never relabel earlier GIS geometry with the newer status advisory. */
export function attachCycloneGeometry(storms, collections) {
const budget = { count: 0 };
const parsed = LAYERS.map((spec, index) => {
const payload = collections[index];
if (
payload?.type !== 'FeatureCollection' ||
payload.exceededTransferLimit ||
!Array.isArray(payload.features) ||
payload.features.length > spec.count
)
throw invalid();
return payload.features.map((feature) => {
if (feature?.type !== 'Feature') throw invalid();
const p = feature.properties;
const source =
typeof p?.idp_source === 'string' &&
p.idp_source.match(
/^((?:al|ep|cp)\d{6})-(\d{1,3}[a-z]?)_5day_(pts|lin|pgn)$/i,
);
if (!source || source[3].toLowerCase() !== ['pts', 'lin', 'pgn'][index])
throw invalid();
const adv = advisory(p.advisnum);
if (adv !== advisory(source[2])) throw invalid();
return {
id: source[1].toLowerCase(),
advisoryNumber: adv,
geometry: geometry(feature.geometry, spec.kind, budget),
tauHours: number(p.tau, 0, 168),
windKt: number(p.maxwind, 0, 300),
gustKt: number(p.gust, 0, 350),
};
});
});
return storms.map((storm) => {
const groups = parsed.map((items) =>
items.filter(
(item) =>
item.id === storm.id && item.advisoryNumber === storm.advisoryNumber,
),
);
if (groups[1].length > 1 || groups[2].length > 1) throw invalid();
if (!groups[0].length || groups[1].length !== 1 || groups[2].length !== 1)
return storm;
const taus = new Set();
const forecastPoints = groups[0]
.map((item) => {
if (item.tauHours === null || taus.has(item.tauHours)) throw invalid();
taus.add(item.tauHours);
return {
position: position(item.geometry.coordinates),
tauHours: item.tauHours,
windKt: item.windKt,
gustKt: item.gustKt,
};
})
.sort((a, b) => a.tauHours - b.tauHours);
return {
...storm,
geometryStatus: 'current',
geometryAdvisoryNumber: storm.advisoryNumber,
forecastPoints,
track: groups[1][0].geometry,
cone: groups[2][0].geometry,
};
});
}
/** Fixed official endpoints; one shared bounded refresh, no user destinations. */
export function cycloneProxy({
fetchImpl = fetch,
now = () => Date.now(),
timeoutMs = 12_000,
} = {}) {
let cache = null;
let operation = null;
let attemptedAt = -Infinity;
async function upstream(url, cap, signal) {
signal.throwIfAborted();
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: {
Accept: 'application/geo+json,application/json',
'User-Agent': 'Gods Eye View (public NOAA weather context)',
},
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('cyclone_upstream_unavailable');
}
const result = JSON.parse(
await readResponseTextCapped(response, cap, signal),
);
signal.throwIfAborted();
return result;
}
async function refresh(signal) {
let storms = parseCycloneStatus(
await upstream(STATUS_URL, 128 * 1024, signal),
now(),
);
if (storms.length) {
try {
const results = await Promise.allSettled(
LAYERS.map(async (spec) => {
const url = new URL(`${GIS}/${spec.id}/query`);
url.search = new URLSearchParams({
where: '1=1',
outFields:
spec.kind === 'points'
? 'idp_source,advisnum,tau,maxwind,gust'
: 'idp_source,advisnum',
outSR: '4326',
resultRecordCount: String(spec.count),
geometryPrecision: '4',
f: 'geojson',
}).toString();
return upstream(url.href, spec.cap, signal);
}),
);
if (results.some((result) => result.status === 'rejected'))
throw invalid();
storms = attachCycloneGeometry(
storms,
results.map((result) => result.value),
);
} catch {
storms = storms.map((storm) => ({
...storm,
geometryStatus: 'unavailable',
}));
}
}
signal.throwIfAborted();
cache = { storms, fetchedAt: now() };
return cache;
}
async function acquire(signal) {
signal.throwIfAborted();
if (cache && now() - cache.fetchedAt < 300_000) return cache;
if (operation?.controller.signal.aborted) operation = null;
if (!operation) {
if (now() - attemptedAt < 60_000) throw new Error('cyclone_retry_later');
attemptedAt = now();
const controller = new AbortController();
const owned = { controller, waiters: 0 };
const timer = setTimeout(() => controller.abort(), timeoutMs);
owned.promise = refresh(controller.signal).finally(() => {
clearTimeout(timer);
if (operation === owned) operation = null;
});
operation = owned;
}
const owned = operation;
if (owned.waiters >= 32)
throw Object.assign(new Error('cyclone_busy'), { status: 429 });
owned.waiters++;
let abort;
const cancelled = new Promise((_, reject) => {
abort = () => reject(signal.reason ?? new Error('cancelled'));
signal.addEventListener('abort', abort, { once: true });
});
try {
return await Promise.race([owned.promise, cancelled]);
} finally {
signal.removeEventListener('abort', abort);
if (--owned.waiters === 0 && operation === owned) {
owned.controller.abort();
if (signal.aborted) attemptedAt = -Infinity;
}
}
}
function describe(value, stale = false) {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution:
'NOAA/NWS National Hurricane Center / Central Pacific Hurricane Center',
coverage: COVERAGE,
fetchedAt: value?.fetchedAt ?? null,
stale: stale || !value,
unavailable: !value,
reason: !value
? 'Cyclone data unavailable'
: stale
? 'Cached cyclone advisory; upstream unavailable'
: null,
storms: value?.storms ?? [],
};
}
async function handler(req, res) {
const controller = new AbortController();
const close = () => controller.abort();
res.once?.('close', close);
const json = (status, value) => {
if (controller.signal.aborted) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 429 ? { 'Retry-After': '2' } : {}),
});
res.end(JSON.stringify(value));
};
try {
if (req.method !== 'GET')
return json(405, { error: 'method_not_allowed' });
if (req.url !== '/' && req.url !== '')
return json(400, { error: 'invalid_cyclone_query' });
try {
json(200, describe(await acquire(controller.signal)));
} catch (error) {
if (error.status === 429) return json(429, { error: 'cyclone_busy' });
const usable =
cache &&
now() - cache.fetchedAt <= 12 * HOUR &&
cache.storms.every(
(storm) => now() - Date.parse(storm.issuedAt) <= 12 * HOUR,
);
json(200, describe(usable ? cache : null, true));
}
} finally {
res.removeListener?.('close', close);
}
}
return {
name: 'cyclones',
configureServer({ middlewares }) {
middlewares.use('/api/cyclones', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/cyclones', handler);
},
};
}
+6
View File
@@ -20,6 +20,9 @@ import { trackBackfillProxies } from './aircraft/tracks.js';
import { openAiRealtimeProxy } from './openai.js';
import { googlePlacesContextProxy } from './places.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { weatherProxy } from './weather.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
/** Construct the local provider plugins in their established order. */
function localProviderPlugins() {
@@ -45,6 +48,9 @@ function localProviderPlugins() {
trackBackfillProxies(),
openAiRealtimeProxy(),
googlePlacesContextProxy(),
windProxy(),
weatherProxy(),
cycloneProxy(),
keySetupEndpoint(),
];
}
+5 -8
View File
@@ -1,4 +1,7 @@
import { keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js';
import {
HUD_SUMMARY_INSTRUCTIONS,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { readRequestBody } from '../common/request.js';
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
@@ -61,13 +64,7 @@ async function handleHudSummary(req, res) {
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'Do not infer from coordinates or invent a place.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
].join(' '),
instructions: HUD_SUMMARY_INSTRUCTIONS,
input: JSON.stringify(context),
reasoning: { effort: 'minimal' },
max_output_tokens: 100,
+2 -1
View File
@@ -18,7 +18,8 @@ function realtimeInstructions(
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
+7 -2
View File
@@ -505,7 +505,7 @@ export const ACTION_DESCRIPTIONS = {
},
analyst_query: {
description:
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes, satellites, datacenters, dams). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". For satellites and infrastructure, counts and ranks cover only bounded examined loaded records; omitted records can change nearest/count. Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
$position: 1,
parameters: {
properties: {
@@ -549,7 +549,7 @@ export const ACTION_DESCRIPTIONS = {
},
next_iss_pass: {
description:
"When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
"When the user asks when the ISS / the space station will next fly over: returns the next geometric ISS pass with estimated visibility for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
$position: 1,
parameters: {
properties: {
@@ -571,4 +571,9 @@ export const ACTION_DESCRIPTIONS = {
},
},
},
next_satellite_pass: {
description:
'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.',
$position: 1,
},
};
+610
View File
@@ -0,0 +1,610 @@
import { readResponseTextCapped } from './common/http.js';
import { readWindBody as readBytesCapped } from '../../src/sources/windBody.js';
const BASE = 'https://nowcoast.noaa.gov/geoserver/observations/';
const HOUR = 3600_000;
const PRODUCTS = Object.freeze({
lightning: Object.freeze({
service: 'lightning_detection',
layer: 'ldn_lightning_strike_density',
style: 'lightning_density',
title: 'Lightning density · 15 min',
coverage:
'Pacific and Americas: 110°E across the dateline to 0°, 25°S–80°N; not global coverage.',
description:
'Observed 15-minute lightning strike density on an approximately 8 km grid, scaled as strikes/km²/min ×10³. Ground-network density, not individual GLM flashes.',
attribution: 'NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360',
metadataTtlMs: 600_000,
image: Object.freeze({ width: 4096, height: 2048 }),
}),
radar: Object.freeze({
service: 'weather_radar',
layer: 'conus_base_reflectivity_mosaic',
style: 'weather_radar_base_reflectivity',
title: 'CONUS radar reflectivity',
coverage:
'Contiguous United States; gaps do not establish absence of precipitation.',
description:
'Observed MRMS radar base reflectivity (dBZ), approximately 1 km and 4-minute updates; not a rainfall forecast.',
image: Object.freeze({ width: 4096, height: 2048 }),
}),
clouds: Object.freeze({
service: 'satellite',
layer: 'global_longwave_imagery_mosaic',
style: 'reflectance',
title: 'Global satellite infrared',
coverage:
'Global mosaic with incomplete polar coverage; nominal coverage 60°S–60°N.',
description:
'Longwave infrared cloud and land/sea temperature patterns, approximately 3 km; hourly updates with 2–3 hour source latency. Not a cloud-only mask.',
image: Object.freeze({ width: 2048, height: 1024 }),
}),
'clouds-regional': Object.freeze({
service: 'satellite',
layer: 'goes_longwave_imagery',
style: 'goes-lir',
title: 'GOES regional satellite infrared',
coverage:
'GOES East/West regional North American coverage; not a global image.',
description:
'GOES-19/18 longwave infrared Band 14 cloud and surface temperature patterns, approximately 2 km and 5-minute updates. Not a cloud-only mask.',
image: Object.freeze({ width: 4096, height: 2048 }),
}),
});
// Whole-extent images: 2:1 sizes up to each product's largest (the default).
const IMAGE_SIZES = Object.freeze(['1024x512', '2048x1024', '4096x2048']);
// Detail windows: any product up to 4096×2048 (the default).
const DETAIL_IMAGE = Object.freeze({ width: 4096, height: 2048 });
const BBOX_STEP = 0.25;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
function failure(code, status = 503) {
return Object.assign(new Error(code), { code, status });
}
/** Canonicalize only explicit UTC observations; never expand time intervals. */
function observationTime(value) {
if (
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
)
return null;
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return null;
const canonical = date.toISOString();
return canonical.replace('.000Z', 'Z') === value.replace('.000Z', 'Z')
? canonical
: null;
}
/** Read the named leaf from bounded capabilities without resolving XML entities. */
export function parseWeatherCapabilities(xml, product, nowMs = Date.now()) {
const spec = PRODUCTS[product];
if (
!spec ||
typeof xml !== 'string' ||
xml.length > 512 * 1024 ||
/<!DOCTYPE|<!ENTITY/i.test(xml)
)
throw failure('invalid_weather_metadata');
const stack = [];
let leaf = null;
let tags = 0;
for (const match of xml.matchAll(/<\/?(?:[\w.-]+:)?Layer\b[^>]*>/g)) {
if (++tags > 2048) throw failure('invalid_weather_metadata');
if (!match[0].startsWith('</')) {
if (stack.length) stack.at(-1).nested = true;
if (stack.length >= 16 || match[0].endsWith('/>'))
throw failure('invalid_weather_metadata');
stack.push({ start: match.index + match[0].length, nested: false });
} else {
const opened = stack.pop();
if (!opened) throw failure('invalid_weather_metadata');
if (opened.nested) continue;
const candidate = xml.slice(opened.start, match.index);
const name = candidate
.match(/<(?:[\w.-]+:)?Name\s*>([^<]+)<\/(?:[\w.-]+:)?Name>/)?.[1]
?.trim();
if (name === spec.layer) {
if (leaf !== null) throw failure('invalid_weather_metadata');
leaf = candidate;
}
}
}
if (stack.length || !leaf) throw failure('invalid_weather_metadata');
const box = leaf.match(
/<(?:[\w.-]+:)?EX_GeographicBoundingBox\s*>([\s\S]*?)<\/(?:[\w.-]+:)?EX_GeographicBoundingBox>/,
)?.[1];
const bounds = {};
for (const [key, tag] of Object.entries({
west: 'westBoundLongitude',
south: 'southBoundLatitude',
east: 'eastBoundLongitude',
north: 'northBoundLatitude',
})) {
const value = box
?.match(
new RegExp(`<(?:[\\w.-]+:)?${tag}\\s*>([^<]+)</(?:[\\w.-]+:)?${tag}>`),
)?.[1]
?.trim();
bounds[key] = value ? Number(value) : NaN;
}
if (
!Object.values(bounds).every(Number.isFinite) ||
bounds.west < -180 ||
bounds.east > 180 ||
bounds.south < -90 ||
bounds.north > 90 ||
bounds.west >= bounds.east ||
bounds.south >= bounds.north
)
throw failure('invalid_weather_metadata');
const dimensions = [
...leaf.matchAll(
/<(?:[\w.-]+:)?Dimension\b([^>]*)>([^<]*)<\/(?:[\w.-]+:)?Dimension>/g,
),
].filter((entry) => /\bname\s*=\s*["']time["']/.test(entry[1]));
if (
dimensions.length !== 1 ||
!/\bunits\s*=\s*["']ISO8601["']/.test(dimensions[0][1])
)
throw failure('invalid_weather_metadata');
const raw = dimensions[0][2].trim().split(',');
if (!raw.length || raw.length > 512)
throw failure('invalid_weather_metadata');
const times = raw.map((value) => observationTime(value.trim()));
if (times.some((value) => !value || Date.parse(value) > nowMs + 5 * 60_000))
throw failure('invalid_weather_metadata');
const defaultTime = observationTime(
dimensions[0][1].match(/\bdefault\s*=\s*["']([^"']+)["']/)?.[1],
);
if (!defaultTime || !times.includes(defaultTime))
throw failure('invalid_weather_metadata');
const recent = [...new Set(times)]
.filter((value) => nowMs - Date.parse(value) <= 24 * HOUR)
.sort()
.slice(-26);
if (!recent.length) throw failure('weather_observations_expired');
return { bounds, times: recent.slice(-13), allowedTimes: recent };
}
/** A detail window `west,south,east,north` in degrees, rounded to 0.25° so cache
* keys repeat, with a 2:1 aspect within 1 %; null when absent. Containment in
* the product bounds is checked once the metadata is known. */
export function weatherImageBbox(value) {
if (value === null) return null;
const parts = value.split(',');
if (
parts.length !== 4 ||
parts.some((part) => !/^-?\d{1,3}(?:\.\d{1,6})?$/.test(part))
)
throw failure('invalid_weather_bbox', 400);
// `+ 0` turns a rounded -0 into 0 so equal windows share one key.
const [west, south, east, north] = parts.map(
(part) => Math.round(Number(part) / BBOX_STEP) * BBOX_STEP + 0,
);
if (
west < -180 ||
east > 180 ||
south < -90 ||
north > 90 ||
west >= east ||
south >= north ||
Math.abs((east - west) / (north - south) / 2 - 1) > 0.01
)
throw failure('invalid_weather_bbox', 400);
return [west, south, east, north];
}
/** GeographicTilingScheme's two longitude tiles and one latitude tile at level zero. */
export function weatherTileBounds(z, x, y) {
if (
![z, x, y].every(Number.isInteger) ||
z < 0 ||
z > 6 ||
x < 0 ||
y < 0 ||
x >= 2 ** (z + 1) ||
y >= 2 ** z
)
throw failure('invalid_weather_tile', 400);
const span = 180 / 2 ** z;
return [
-180 + x * span,
90 - (y + 1) * span,
-180 + (x + 1) * span,
90 - y * span,
];
}
function validatePng(bytes, width, height) {
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (
buffer.length < 33 ||
!buffer
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
buffer.readUInt32BE(8) !== 13 ||
buffer.toString('ascii', 12, 16) !== 'IHDR' ||
buffer.readUInt32BE(16) !== width ||
buffer.readUInt32BE(20) !== height
)
throw failure('invalid_weather_image');
return buffer;
}
/** Fixed NOAA observed-weather metadata and WMS tiles; no client-supplied destinations. */
export function weatherProxy({
fetchImpl = fetch,
now = () => Date.now(),
timeoutMs = 12_000,
} = {}) {
const metadata = new Map();
const attempts = new Map();
const operations = new Map();
const pending = [];
const tiles = new Map();
const tileFailures = new Map();
let tileBytes = 0;
let active = 0;
function pump() {
while (active < 8 && pending.length) {
const operation = pending.shift();
if (operation.controller.signal.aborted) continue;
active++;
operation.started = true;
Promise.resolve()
.then(() => operation.work(operation.controller.signal))
.then(operation.resolve, operation.reject)
.finally(() => {
active--;
pump();
});
}
}
async function shared(key, work, clientSignal) {
clientSignal.throwIfAborted();
let operation = operations.get(key);
if (operation?.controller.signal.aborted) {
operations.delete(key);
operation = null;
}
if (!operation) {
if (operations.size >= 120 || pending.length >= 96)
throw failure('weather_busy', 429);
const controller = new AbortController();
operation = { controller, work, waiters: 0, started: false };
operation.promise = new Promise((resolve, reject) => {
operation.resolve = resolve;
operation.reject = reject;
});
const cancel = () => {
const index = pending.indexOf(operation);
if (index >= 0) pending.splice(index, 1);
operation.reject(failure('weather_request_cancelled'));
};
controller.signal.addEventListener('abort', cancel, { once: true });
const timer = setTimeout(() => controller.abort(), timeoutMs);
operation.promise = operation.promise.finally(() => {
clearTimeout(timer);
controller.signal.removeEventListener('abort', cancel);
if (operations.get(key) === operation) operations.delete(key);
});
operations.set(key, operation);
pending.push(operation);
pump();
}
operation.waiters++;
const abort = () => {
if (--operation.waiters === 0) operation.controller.abort();
};
clientSignal.addEventListener('abort', abort, { once: true });
try {
const value = await operation.promise;
clientSignal.throwIfAborted();
return value;
} finally {
clientSignal.removeEventListener('abort', abort);
if (!clientSignal.aborted) operation.waiters--;
}
}
async function upstream(url, signal, image = null) {
signal.throwIfAborted();
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Accept: image ? 'image/png' : 'application/xml,text/xml' },
});
if (
!response.ok ||
(image &&
!/^image\/png(?:;|$)/i.test(response.headers.get('content-type') || ''))
) {
await response.body?.cancel();
throw failure('weather_upstream_unavailable');
}
const value = image
? validatePng(
await readBytesCapped(response, image.maxBytes, signal),
image.width,
image.height,
)
: await readResponseTextCapped(response, 512 * 1024, signal);
signal.throwIfAborted();
return value;
}
async function getMetadata(product, signal) {
signal.throwIfAborted();
const old = metadata.get(product);
const spec = PRODUCTS[product];
if (old && now() - old.fetchedAt < (spec.metadataTtlMs ?? 120_000))
return { ...old, stale: false };
try {
if (
now() - (attempts.get(product) ?? -Infinity) < 30_000 &&
!operations.has(`metadata:${spec.service}`)
)
throw failure('weather_upstream_unavailable');
attempts.set(product, now());
const url = `${BASE}${spec.service}/ows?service=WMS&version=1.3.0&request=GetCapabilities`;
const xml = await shared(
`metadata:${spec.service}`,
(activeSignal) => upstream(url, activeSignal),
signal,
);
const value = {
...parseWeatherCapabilities(xml, product, now()),
fetchedAt: now(),
};
metadata.set(product, value);
return { ...value, stale: false };
} catch (error) {
if (signal.aborted) attempts.delete(product);
signal.throwIfAborted();
if (error.status === 429) throw error;
if (old && now() - old.fetchedAt <= HOUR) return { ...old, stale: true };
throw failure('weather_metadata_unavailable');
}
}
function rememberTile(key, bytes) {
if (tiles.has(key)) tileBytes -= tiles.get(key).bytes.length;
tiles.delete(key);
while (tiles.size >= 128 || tileBytes + bytes.length > 16 * 1024 * 1024) {
const oldest = tiles.keys().next().value;
tileBytes -= tiles.get(oldest).bytes.length;
tiles.delete(oldest);
}
tiles.set(key, { bytes, at: now() });
tileBytes += bytes.length;
}
function describe(product, value) {
const spec = PRODUCTS[product];
const time = value?.times.at(-1) ?? null;
return {
schemaVersion: 1,
product,
title: spec.title,
coverage: spec.coverage,
description: spec.description,
source: 'NOAA nowCOAST',
attribution: spec.attribution ?? 'NOAA/NWS/NESDIS nowCOAST',
bounds: value?.bounds ?? null,
times: value?.times ?? [],
latest: time,
time,
observedAt: time,
fetchedAt: value?.fetchedAt ?? null,
stale: value?.stale ?? true,
unavailable: !value,
reason: !value
? 'Weather imagery unavailable'
: value.stale
? 'Cached weather metadata; upstream unavailable'
: null,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
tileTemplate: time
? `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}`
: null,
imageUrl: time
? `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`
: null,
imageSize: { ...spec.image },
};
}
function json(res, status, body) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 429 ? { 'Retry-After': '2' } : {}),
});
res.end(JSON.stringify(body));
}
async function handler(req, res) {
const controller = new AbortController();
const close = () => controller.abort();
res.once?.('close', close);
try {
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'GET')
return json(res, 405, { error: 'method_not_allowed' });
if (!['/manifest', '/tile', '/image'].includes(url.pathname))
return json(res, 404, { error: 'not_found' });
const allowed =
url.pathname === '/manifest'
? ['product']
: url.pathname === '/image'
? ['product', 'time', 'size', 'bbox']
: ['product', 'time', 'z', 'x', 'y', 'size'];
if (
req.url.length > 512 ||
[...url.searchParams.keys()].some(
(key) =>
!allowed.includes(key) || url.searchParams.getAll(key).length !== 1,
)
)
return json(res, 400, { error: 'invalid_weather_query' });
const product = url.searchParams.get('product');
if (!Object.hasOwn(PRODUCTS, product))
return json(res, 400, { error: 'unknown_weather_product' });
const wholeImage = url.pathname === '/image';
const detailBox = wholeImage
? weatherImageBbox(url.searchParams.get('bbox'))
: null;
const largest = detailBox ? DETAIL_IMAGE : PRODUCTS[product].image;
const size =
url.searchParams.get('size') ??
(wholeImage ? `${largest.width}x${largest.height}` : '256');
if (
wholeImage
? !IMAGE_SIZES.includes(size) ||
Number.parseInt(size, 10) > largest.width
: !['256', '512', '1024'].includes(size)
)
throw failure(
wholeImage
? 'invalid_weather_image_size'
: 'invalid_weather_tile_size',
400,
);
if (url.pathname === '/manifest') {
try {
const value = await getMetadata(product, controller.signal);
controller.signal.throwIfAborted();
return json(res, 200, describe(product, value));
} catch (error) {
if (controller.signal.aborted) return;
if (error.status === 429) throw error;
return json(res, 200, describe(product, null));
}
}
const coords = wholeImage
? null
: ['z', 'x', 'y'].map((key) => url.searchParams.get(key));
if (coords?.some((value) => !/^(?:0|[1-9]\d{0,2})$/.test(value ?? '')))
throw failure('invalid_weather_tile', 400);
const tileBounds = coords
? weatherTileBounds(...coords.map(Number))
: null;
const time = url.searchParams.get('time');
if (!time || observationTime(time) !== time)
throw failure('invalid_weather_time', 400);
const value = await getMetadata(product, controller.signal);
controller.signal.throwIfAborted();
if (
!value.allowedTimes.includes(time) ||
now() - Date.parse(time) > 24 * HOUR
)
throw failure('unknown_weather_time', 400);
if (
detailBox &&
(detailBox[0] < value.bounds.west ||
detailBox[1] < value.bounds.south ||
detailBox[2] > value.bounds.east ||
detailBox[3] > value.bounds.north)
)
throw failure('invalid_weather_bbox', 400);
// A single advertised extent keeps NOAA's global reflectance contrast
// consistent across the image; independently normalized tiles create seams.
const bbox =
detailBox ??
(wholeImage
? [
value.bounds.west,
value.bounds.south,
value.bounds.east,
value.bounds.north,
]
: tileBounds);
const [width, height] = wholeImage
? size.split('x').map(Number)
: [Number(size), Number(size)];
const imageShape = {
width,
height,
maxBytes: wholeImage
? MAX_IMAGE_BYTES
: Math.max(1024 * 1024, width ** 2 * 4 + 65_536),
};
// NOAA WMS capabilities advertise no MaxWidth/MaxHeight. One GetMap
// returns a whole 4096×2048 extent or window, or a 1024 px tile, without
// composition. Images are keyed by product, time, size and bbox.
const key = `${product}:${time}:${wholeImage ? `image:${size}:${bbox.join(',')}` : `tile:${size}:${coords.join('/')}`}`;
let cached = tiles.get(key);
if (cached && now() - cached.at <= 24 * HOUR) {
tiles.delete(key);
tiles.set(key, cached);
} else {
// Never ask nearestValue=1 WMS to silently replace an expired frame.
if (value.stale) throw failure('weather_metadata_stale');
if (now() - (tileFailures.get(key) ?? -Infinity) < 30_000)
throw failure('weather_upstream_unavailable');
const spec = PRODUCTS[product];
const upstreamUrl = new URL(`${BASE}${spec.service}/ows`);
upstreamUrl.search = new URLSearchParams({
service: 'WMS',
version: '1.1.1',
request: 'GetMap',
layers: spec.layer,
styles: spec.style,
srs: 'EPSG:4326',
bbox: bbox.join(','),
width: String(imageShape.width),
height: String(imageShape.height),
format: 'image/png',
transparent: 'true',
time,
}).toString();
try {
const bytes = await shared(
`image:${key}`,
(signal) => upstream(upstreamUrl.href, signal, imageShape),
controller.signal,
);
rememberTile(key, bytes);
cached = { bytes };
} catch (error) {
if (!controller.signal.aborted && error.status !== 429) {
tileFailures.delete(key);
tileFailures.set(key, now());
while (tileFailures.size > 128)
tileFailures.delete(tileFailures.keys().next().value);
}
throw error;
}
}
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, immutable',
'X-Content-Type-Options': 'nosniff',
});
res.end(cached.bytes);
} catch (error) {
if (!controller.signal.aborted)
json(
res,
error.status === 400 || error.status === 429 ? error.status : 503,
{
error:
error.status === 400 || error.status === 429
? error.code
: 'weather_upstream_unavailable',
},
);
} finally {
res.removeListener?.('close', close);
}
}
return {
name: 'weather',
configureServer({ middlewares }) {
middlewares.use('/api/weather', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/weather', handler);
},
};
}
+227
View File
@@ -0,0 +1,227 @@
import { fetchGfsWind } from './wind/gfs.js';
import { fetchIfsWind } from './wind/ifs.js';
import { decodeWindGribMessage } from './wind/decode.js';
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
export function windProxy({
fetchImpl = fetch,
now = () => Date.now(),
decodeImpl = decodeWindGribMessage,
targetDx = 1,
ttlMs = 3600_000,
timeoutMs = 40_000,
models = { gfs: fetchGfsWind, ifs: fetchIfsWind },
} = {}) {
// Request validation limits these maps to 2 models × 3 overlays.
const caches = new Map();
const loadings = new Map();
const grids = new Map();
const attempts = new Map();
const unavailable = (model, overlay) => ({
manifest: {
model,
schemaVersion: 1,
...(overlay === 'none' ? {} : { overlay }),
unavailable: true,
stale: true,
reason: 'Wind upstream unavailable',
},
});
const sendJson = (res, value, status = 200) => {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(value));
};
function refresh(model, overlay, key) {
const controller = new AbortController();
const operation = { controller, waiters: 0, promise: null };
loadings.set(key, operation);
attempts.set(key, now());
operation.promise = (async () => {
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const value = await models[model]({
fetchImpl,
now,
decodeImpl,
targetDx,
overlay,
signal: controller.signal,
});
controller.signal.throwIfAborted();
const { grid, cycle } = value;
const count = grid.nx * grid.ny;
const expectedScalar = weatherScalarMetadata(overlay);
const missingScalar =
expectedScalar &&
value.scalar === undefined &&
value.scalarError === weatherScalarError(overlay);
const scalar = missingScalar ? null : expectedScalar;
if (
!Number.isInteger(grid.nx) ||
!Number.isInteger(grid.ny) ||
grid.nx < 1 ||
grid.ny < 1 ||
count > 1_000_000 ||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
grid.dx <= 0 ||
grid.dy <= 0 ||
(scalar
? ['kind', 'units', 'level'].some(
(field) => value.scalar?.[field] !== scalar[field],
)
: value.scalar !== undefined || grid.scalar !== undefined) ||
(!expectedScalar && value.scalarError !== undefined) ||
(scalar && value.scalarError !== undefined)
)
throw new Error('Invalid wind grid');
for (const values of [
grid.u,
grid.v,
...(scalar ? [grid.scalar] : []),
]) {
if (
!(values instanceof Float32Array) ||
values.length !== count ||
!values.every(Number.isFinite)
)
throw new Error('Invalid wind grid');
}
const suffix =
overlay === 'none'
? ''
: `-${overlay}${missingScalar ? '-wind-only' : ''}`;
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}${suffix}`;
const manifest = {
schemaVersion: 1,
model,
cycle,
...(expectedScalar ? { overlay } : {}),
...(scalar ? { scalar } : {}),
...(missingScalar
? { scalarError: weatherScalarError(overlay) }
: {}),
fetchedAt: now(),
level: value.level,
units: value.units,
grid: {
nx: grid.nx,
ny: grid.ny,
lo1: grid.lo1,
la1: grid.la1,
dx: grid.dx,
dy: grid.dy,
},
stale: false,
unavailable: false,
reason: null,
gridUrl: `/api/wind/grid/${id}.bin?${query}`,
};
const state = { id, grid, manifest, fetchedAt: now() };
caches.set(key, state);
// Retain the previous issued grid as well to cover a manifest/grid rollover.
const history = grids.get(key) || new Map();
history.set(id, state);
while (history.size > 2) history.delete(history.keys().next().value);
grids.set(key, history);
return state;
} catch {
if (controller.signal.aborted && operation.waiters === 0) {
attempts.delete(key);
return unavailable(model, overlay);
}
const old = caches.get(key);
if (old) {
old.manifest = {
...old.manifest,
stale: true,
reason: 'Wind upstream unavailable',
};
return old;
}
return unavailable(model, overlay);
} finally {
clearTimeout(timer);
controller.abort(); // Cancel a sibling request if the other component failed.
if (loadings.get(key) === operation) loadings.delete(key);
}
})();
return operation;
}
const handler = async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const model = url.searchParams.get('model') || 'gfs';
const overlay = url.searchParams.get('overlay') || 'none';
const key = `${model}:${overlay}`;
if (req.method !== 'GET')
return sendJson(res, { error: 'method_not_allowed' }, 405);
if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model))
return sendJson(res, { error: 'unknown_model' }, 400);
if (!['none', 'temperature', 'pressure'].includes(overlay))
return sendJson(res, { error: 'unknown_overlay' }, 400);
if (url.pathname.startsWith('/grid/')) {
const id = url.pathname.slice(6).replace(/\.bin$/, '');
const state =
url.pathname === `/grid/${id}.bin` && grids.get(key)?.get(id);
if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
const components = [
state.grid.u,
state.grid.v,
...(state.grid.scalar ? [state.grid.scalar] : []),
];
const bytes = Buffer.concat(
components.map((values) =>
Buffer.from(values.buffer, values.byteOffset, values.byteLength),
),
);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Cache-Control': 'public, max-age=3600, immutable',
});
return res.end(bytes);
}
if (!['/', '/manifest', '/status'].includes(url.pathname))
return sendJson(res, { error: 'not_found' }, 404);
let state = caches.get(key);
if (!state || now() - state.fetchedAt >= ttlMs) {
let operation = loadings.get(key);
if (!operation && now() - (attempts.get(key) ?? -Infinity) >= 60_000)
operation = refresh(model, overlay, key);
if (operation) {
let disconnected = false;
operation.waiters += 1;
const close = () => {
disconnected = true;
if (--operation.waiters === 0) operation.controller.abort();
};
res.once?.('close', close);
try {
state = await operation.promise;
} finally {
res.removeListener?.('close', close);
if (!disconnected) operation.waiters -= 1;
}
if (disconnected) return;
}
}
const manifest = state?.manifest || unavailable(model, overlay).manifest;
if (url.pathname === '/status') {
const { gridUrl, ...status } = manifest;
return sendJson(res, status);
}
return sendJson(res, manifest);
};
return {
name: 'wind',
configureServer({ middlewares }) {
middlewares.use('/api/wind', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/wind', handler);
},
};
}
+34
View File
@@ -0,0 +1,34 @@
export const GFS_BUCKET = 'noaa-gfs-bdp-pds';
/** Build the public GFS object key. */
export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
const hh = String(hour).padStart(2, '0');
return `gfs.${date}/${hh}/atmos/gfs.t${hh}z.pgrb2.0p25.f${String(forecastHour).padStart(3, '0')}`;
}
/** Select the latest GFS cycle that should be available. */
export function selectLatestGfsCycle(
nowMs,
{ availabilityLagMs = 5 * 3600_000 } = {},
) {
const d = new Date(nowMs - availabilityLagMs);
const date = new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
const hour = Math.floor(d.getUTCHours() / 6) * 6;
date.setUTCHours(hour);
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
}
/**
* Round a time offset to the nearest published GFS forecast step so the field
* shown is the one valid closest to now, not always the analysis (f000).
* GFS 0.25° is hourly through f120, then every 3 h through f384.
* @param {number} hours - Hours elapsed since the cycle run time.
* @returns {number} A valid forecast step.
*/
export function nearestGfsStep(hours) {
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3;
return Math.max(0, Math.min(384, step));
}
+51
View File
@@ -0,0 +1,51 @@
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
let eccodesPromise = null;
function loadEccodes() {
eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm')
.then((module) => module.createEccodes())
.catch((error) => {
eccodesPromise = null;
throw error;
});
return eccodesPromise;
}
/** Decode one bounded message in WASM memory without mounting the host filesystem. */
export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
if (buffer.byteLength > 8 * 1024 * 1024)
throw new Error('Wind GRIB exceeds byte budget');
const eccodes = eccodesModule ?? (await loadEccodes());
// Everything after this await is synchronous. A shared virtual file is safe
// across U/V and model requests and its storage is released after each decode.
const file = '/gev-wind.grib2';
let handle = null;
try {
eccodes.writeFile(file, buffer);
handle = eccodes.openGrib(file);
const ni = handle.getLong('Ni');
const nj = handle.getLong('Nj');
if (
!Number.isInteger(ni) ||
!Number.isInteger(nj) ||
ni < 1 ||
nj < 1 ||
ni * nj > 2_000_000
)
throw new Error('Invalid wind GRIB geometry');
return {
ni,
nj,
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
di: handle.getDouble('iDirectionIncrementInDegrees'),
dj: handle.getDouble('jDirectionIncrementInDegrees'),
values: handle.getDoubleArray('values'),
shortName: handle.getString('shortName'),
level: handle.getLong('level'),
units: handle.getString('units'),
};
} finally {
handle?.delete();
eccodes.writeFile(file, new Uint8Array(0));
}
}
+221
View File
@@ -0,0 +1,221 @@
import { readWindBody } from '../../../src/sources/windBody.js';
/**
* Parse a GFS `.idx` inventory into ordered message records.
*
* Each line is `msgNumber:byteOffset:d=YYYYMMDDHH:VAR:LEVEL:...:`. Malformed
* lines are skipped; a payload with no valid lines is rejected so callers never
* treat an HTML error page as an empty inventory.
*
* @param {string} text - Raw `.idx` body.
* @returns {{ messages: Array<{index: number, offset: number, variable: string, level: string}> }}
*/
export function parseGfsIdx(text) {
const messages = [];
for (const line of String(text).split(/\r?\n/)) {
if (!line) continue;
const parts = line.split(':');
if (
parts.length < 6 ||
!/^\d+$/.test(parts[0]) ||
!/^\d+$/.test(parts[1]) ||
!parts[3] ||
!parts[4]
)
continue;
messages.push({
index: Number(parts[0]),
offset: Number(parts[1]),
variable: parts[3],
level: parts[4],
});
}
if (!messages.length) throw new Error('malformed GFS index');
return { messages };
}
/**
* Byte ranges for the two 10 m wind components. A message runs from its own
* offset to one byte before the next message in file order.
*
* @param {{messages: Array<object>}} parsed - {@link parseGfsIdx} result.
* @param {{level?: string}} [options]
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
*/
export function windMessageRanges(
parsed,
{ level = '10 m above ground', overlay = 'none' } = {},
) {
weatherScalarMetadata(overlay);
const range = (variable, fieldLevel = level) => {
const index = parsed.messages.findIndex(
(message) =>
message.variable === variable && message.level === fieldLevel,
);
const message = parsed.messages[index];
const next = parsed.messages[index + 1];
if (!message || !next)
throw new Error(`missing ${variable} at ${fieldLevel}`);
return { start: message.offset, end: next.offset - 1 };
};
return {
u: range('UGRD'),
v: range('VGRD'),
...(overlay === 'temperature'
? { scalar: range('TMP', '2 m above ground') }
: {}),
...(overlay === 'pressure'
? { scalar: range('PRMSL', 'mean sea level') }
: {}),
};
}
/** Fetch a bounded text response as a Buffer. */
export async function fetchText({ url, fetchImpl = fetch, signal }) {
const response = await fetchImpl(url, { signal, redirect: 'error' });
if (!response.ok) {
await response.body?.cancel();
throw new Error('Wind upstream unavailable');
}
return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
}
/**
* Fetch one bounded byte range. Rejects oversized ranges, non-206 responses
* (except a whole-object 200 starting at zero), and short bodies.
*/
export async function fetchRange({
url,
start,
end,
fetchImpl = fetch,
signal,
maxBytes = 8 * 1024 * 1024,
}) {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end < start ||
end - start + 1 > maxBytes
)
throw new Error('range too large');
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Range: `bytes=${start}-${end}` },
});
const validStatus =
response.status === 206 || (response.status === 200 && start === 0);
if (!validStatus || !response.ok) {
await response.body?.cancel();
throw new Error('Wind upstream unavailable');
}
const buffer = Buffer.from(
await readWindBody(response, end - start + 1, signal),
);
if (buffer.length !== end - start + 1)
throw new Error('invalid range length');
return buffer;
}
/** A bounded optional request may fail without cancelling valid wind siblings. */
export async function loadOptionalScalar({
range,
url,
fetchImpl,
decodeImpl,
signal,
timeoutMs = 12_000,
}) {
if (!range) return undefined;
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
signal?.throwIfAborted();
const bytes = await fetchRange({
url,
...range,
fetchImpl,
signal: controller.signal,
});
const value = await decodeImpl(bytes);
controller.signal.throwIfAborted();
return value;
} catch {
return undefined;
} finally {
clearTimeout(timer);
controller.abort();
signal?.removeEventListener('abort', abort);
}
}
import {
GFS_BUCKET,
gfsObjectKey,
selectLatestGfsCycle,
nearestGfsStep,
} from './catalog.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
export async function fetchGfsWind({
fetchImpl = fetch,
now = () => Date.now(),
targetDx = 1,
decodeImpl = decodeWindGribMessage,
signal,
overlay = 'none',
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
const cycle = selectLatestGfsCycle(nowMs);
const runMs = Date.UTC(
Number(cycle.date.slice(0, 4)),
Number(cycle.date.slice(4, 6)) - 1,
Number(cycle.date.slice(6, 8)),
cycle.hour,
);
// Pick the forecast step whose valid time is closest to now, so the layer
// shows the freshest field the cycle offers instead of the analysis.
const forecastHour = nearestGfsStep((nowMs - runMs) / 3600_000);
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey({ ...cycle, forecastHour })}`;
const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal });
const inventory = parseGfsIdx(index.toString());
const ranges = windMessageRanges(inventory);
let scalarRange;
if (overlay !== 'none') {
try {
scalarRange = windMessageRanges(inventory, { overlay }).scalar;
} catch {
/* A missing optional field does not discard valid wind. */
}
}
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
decodeImpl(await fetchRange({ url: base, ...range, fetchImpl, signal }));
const [u, v, scalar] = await Promise.all([
load(ranges.u),
load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url: base,
fetchImpl,
decodeImpl,
signal,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
cycle: { ...cycle, forecastHour, runIso, validIso },
level: '10 m above ground',
units: 'm/s',
...fields,
};
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Resample a GRIB wind grid into a compact equirectangular grid.
*
* Source grids are north-to-south (`la1` at the top, `dj` increasing toward the
* south) and start at `lo1`. Longitude wraps across the seam; latitude clamps.
*
* @param {{u: ArrayLike<number>, v: ArrayLike<number>, ni: number, nj: number,
* lo1: number, la1: number, di: number, dj: number, dx?: number, dy?: number}} input
* @returns {{nx: number, ny: number, lo1: number, la1: number, dx: number,
* dy: number, u: Float32Array, v: Float32Array}}
*/
export function resampleWindGrid({
u,
v,
scalar,
scalarScale = 1,
scalarOffset = 0,
ni,
nj,
lo1,
la1,
di,
dj,
dx = 1,
dy = 1,
}) {
if (
!Number.isInteger(ni) ||
!Number.isInteger(nj) ||
ni < 1 ||
nj < 1 ||
ni * nj > 2_000_000 ||
u?.length !== ni * nj ||
v?.length !== ni * nj ||
(scalar !== undefined && scalar?.length !== ni * nj) ||
![lo1, la1, di, dj, dx, dy, scalarScale, scalarOffset].every(
Number.isFinite,
) ||
di <= 0 ||
dj <= 0 ||
dx < 0.25 ||
dy < 0.25 ||
dx > 180 ||
dy > 180
)
throw new Error('Invalid wind grid geometry');
for (const values of [u, v, ...(scalar === undefined ? [] : [scalar])]) {
if (!Array.prototype.every.call(values, Number.isFinite))
throw new Error('Invalid wind grid values');
}
const nx = Math.round(360 / dx);
const ny = Math.round(180 / dy) + 1;
if (nx * ny > 1_000_000)
throw new Error('Resampled wind grid exceeds point budget');
const outU = new Float32Array(nx * ny);
const outV = new Float32Array(nx * ny);
const outScalar = scalar === undefined ? null : new Float32Array(nx * ny);
const sample = (source, lon, lat) => {
const x = ((lon - lo1) / di + ni) % ni;
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
const x0 = Math.floor(x);
const x1 = (x0 + 1) % ni;
const y0 = Math.floor(y);
const y1 = Math.min(nj - 1, y0 + 1);
const fx = x - x0;
const fy = y - y0;
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
return top * (1 - fy) + bottom * fy;
};
for (let y = 0; y < ny; y += 1) {
for (let x = 0; x < nx; x += 1) {
const index = y * nx + x;
const lon = x * dx;
const lat = 90 - y * dy;
outU[index] = sample(u, lon, lat);
outV[index] = sample(v, lon, lat);
if (outScalar)
outScalar[index] =
sample(scalar, lon, lat) * scalarScale + scalarOffset;
}
}
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
if (!values.every(Number.isFinite))
throw new Error('Invalid resampled wind values');
}
return {
nx,
ny,
lo1: 0,
la1: 90,
dx,
dy,
u: outU,
v: outV,
...(outScalar ? { scalar: outScalar } : {}),
};
}
/** Metadata for the two optional same-cycle weather fields. */
export function weatherScalarMetadata(overlay = 'none') {
if (overlay === 'none') return null;
if (overlay === 'temperature')
return { kind: overlay, units: '°C', level: '2 m above ground' };
if (overlay === 'pressure')
return { kind: overlay, units: 'hPa', level: 'mean sea level' };
throw new Error('Unknown weather overlay');
}
/** Reject shifted/mismatched components before sharing U's grid geometry. */
export function resampleWeatherFields({
u,
v,
scalar,
overlay = 'none',
targetDx = 1,
}) {
const metadata = weatherScalarMetadata(overlay);
const fields = [u, v, ...(metadata ? [scalar] : [])];
if (
!u ||
fields.some(
(field) =>
!field ||
['ni', 'nj', 'lo1', 'la1', 'di', 'dj'].some(
(key) => field[key] !== u[key],
),
)
)
throw new Error('Mismatched weather grid geometry');
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
throw new Error('Unexpected weather scalar units');
const grid = resampleWindGrid({
u: u.values,
v: v.values,
ni: u.ni,
nj: u.nj,
lo1: u.lo1,
la1: u.la1,
di: u.di,
dj: u.dj,
dx: targetDx,
dy: targetDx,
...(metadata
? {
scalar: scalar.values,
scalarScale: overlay === 'pressure' ? 0.01 : 1,
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
}
: {}),
});
return { grid, ...(metadata ? { scalar: metadata } : {}) };
}
/** Preserve usable same-cycle wind when only its optional companion fails. */
export function resampleWeatherSnapshot(options) {
try {
return resampleWeatherFields(options);
} catch (error) {
if ((options.overlay ?? 'none') === 'none') throw error;
// Revalidate wind alone: a wind defect must never become a partial success.
const wind = resampleWeatherFields({ ...options, overlay: 'none' });
return { ...wind, scalarError: weatherScalarError(options.overlay) };
}
}
export function weatherScalarError(overlay) {
weatherScalarMetadata(overlay);
return overlay === 'temperature'
? 'Temperature field unavailable'
: 'Mean sea level pressure field unavailable';
}
+166
View File
@@ -0,0 +1,166 @@
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
export const IFS_BASE = 'https://data.ecmwf.int/forecasts';
/**
* Select the latest IFS cycle that should be available.
* @param {number} nowMs - Reference epoch milliseconds.
* @param {{availabilityLagMs?: number}} [options]
* @returns {{date: string, hour: number}}
*/
export function selectLatestIfsCycle(
nowMs,
{ availabilityLagMs = 6 * 3600_000 } = {},
) {
const shifted = new Date(nowMs - availabilityLagMs);
return {
date: shifted.toISOString().slice(0, 10).replaceAll('-', ''),
hour: Math.floor(shifted.getUTCHours() / 6) * 6,
};
}
/**
* Build the IFS GRIB2 and `.index` URLs for one cycle and step.
* @param {{date: string, hour: number, step?: number}} cycle
* @returns {{grib: string, index: string}}
*/
export function ifsObjectUrls({ date, hour, step = 0 }) {
const hh = String(hour).padStart(2, '0');
const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`;
// The inventory sits beside the GRIB2 as `<stem>.index`, NOT `<grib2>.index`.
return { grib: `${stem}.grib2`, index: `${stem}.index` };
}
/**
* Parse the IFS JSON Lines inventory into `{param, offset, length}` records.
* Malformed lines are skipped; a payload with no valid lines is rejected.
* @param {string} text - Raw `.index` body.
* @returns {Array<{param: string, offset: number, length: number}>}
*/
export function parseIfsIndex(text) {
const entries = [];
for (const line of String(text).split(/\r?\n/)) {
if (!line) continue;
let value;
try {
value = JSON.parse(line);
} catch {
continue; // non-JSON line in the index
}
if (
value?.param &&
Number.isFinite(value._offset) &&
Number.isFinite(value._length)
)
entries.push({
param: value.param,
offset: value._offset,
length: value._length,
});
}
if (!entries.length) throw new Error('malformed IFS index');
return entries;
}
/**
* Byte ranges for the IFS 10 m wind components.
* @param {Array<{param: string, offset: number, length: number}>} entries
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
*/
export function ifsWindRanges(entries, { overlay = 'none' } = {}) {
weatherScalarMetadata(overlay);
const range = (param) => {
const item = entries.find((entry) => entry.param === param);
if (!item) throw new Error(`missing ${param}`);
return { start: item.offset, end: item.offset + item.length - 1 };
};
return {
u: range('10u'),
v: range('10v'),
...(overlay === 'temperature' ? { scalar: range('2t') } : {}),
...(overlay === 'pressure' ? { scalar: range('msl') } : {}),
};
}
/**
* Round a time offset to the nearest published IFS forecast step. IFS 0.25°
* oper is 3-hourly through f144, then 6-hourly to f360 for 00/12z.
* @param {number} hours - Hours elapsed since the cycle run time.
* @param {number} cycleHour - The cycle hour (0, 6, 12 or 18).
* @returns {number} A valid forecast step.
*/
export function nearestIfsStep(hours, cycleHour) {
const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360;
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
const step =
value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6;
return Math.max(0, Math.min(max, step));
}
/**
* Fetch and decode the IFS 10 m wind field valid closest to now.
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
* decodeImpl?: Function}} [options]
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
*/
export async function fetchIfsWind({
fetchImpl = fetch,
now = () => Date.now(),
targetDx = 1,
decodeImpl = decodeWindGribMessage,
signal,
overlay = 'none',
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
const cycle = selectLatestIfsCycle(nowMs);
const runMs = Date.UTC(
Number(cycle.date.slice(0, 4)),
Number(cycle.date.slice(4, 6)) - 1,
Number(cycle.date.slice(6, 8)),
cycle.hour,
);
// Show the field valid closest to now rather than always the analysis.
const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour);
const urls = ifsObjectUrls({ ...cycle, step: forecastHour });
const index = await fetchText({ url: urls.index, fetchImpl, signal });
const inventory = parseIfsIndex(index.toString());
const ranges = ifsWindRanges(inventory);
let scalarRange;
if (overlay !== 'none') {
try {
scalarRange = ifsWindRanges(inventory, { overlay }).scalar;
} catch {
/* A missing optional field does not discard valid wind. */
}
}
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
decodeImpl(
await fetchRange({ url: urls.grib, ...range, fetchImpl, signal }),
);
const [u, v, scalar] = await Promise.all([
load(ranges.u),
load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url: urls.grib,
fetchImpl,
decodeImpl,
signal,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
cycle: { ...cycle, forecastHour, runIso, validIso },
level: '10 m above ground',
units: 'm/s',
...fields,
};
}
+32 -1
View File
@@ -1,3 +1,7 @@
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -42,6 +46,9 @@ const SOURCE_METHODS = Object.freeze({
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
});
@@ -72,9 +79,11 @@ export function createApplicationCatalog({
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
@@ -128,6 +137,23 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind, clock: weatherClock }),
createWeatherLayer({
feed: sources.weather,
id: 'weather-radar',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-satellite',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -141,7 +167,12 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
return Object.freeze({
...catalog,
militaryRegistry,
surface,
weatherClock,
});
} catch (error) {
dispose();
throw error;
+17 -1
View File
@@ -39,7 +39,19 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.equal(first.layers.length, 26);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
first.get('wind').getRowControls().info,
/Forecast · does not follow history/,
);
assert.equal(second.get('wind').getRowControls().summary.status, null);
for (const id of ['weather-radar', 'weather-satellite', 'weather-lightning'])
assert.equal(
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -82,6 +94,10 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
false,
);
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
+3 -1
View File
@@ -20,7 +20,9 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
const presentation = new LayerPresentation(dataManager, {
weatherClock: catalog?.weatherClock,
});
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
+3
View File
@@ -7,11 +7,13 @@ export class LayerPresentation {
constructor(
manager,
{
weatherClock,
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this.weatherClock = weatherClock;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
@@ -35,6 +37,7 @@ export class LayerPresentation {
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
weatherClock: this.weatherClock,
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+22 -4
View File
@@ -205,6 +205,7 @@ const RECOGNIZED = new Set([
// rail
'#right-context-rail',
'#right-context-rail.layout-focus',
'#right-context-rail[data-rail-measuring]',
// tray
'#command-dock .dock-popover-content',
'#command-dock #location-bar .dock-popover-content',
@@ -400,20 +401,26 @@ test('the model refuses every cascade construct it cannot resolve', () => {
complaints.push(`nested media queries on "${part}": ${rule.media.join(' && ')}`);
}
for (const decl of guarded) {
if (decl.important) complaints.push(`!important on ${decl.prop} of "${part}"`);
// Measurement is synchronous and restored before returning or painting.
// Only these two exact declarations earn the exemption tested below.
const railMeasurement = part === '#right-context-rail[data-rail-measuring]'
&& ((decl.prop === 'height' && decl.value === 'auto')
|| (decl.prop === 'max-height' && decl.value === 'none'));
if (decl.important && !railMeasurement) complaints.push(`!important on ${decl.prop} of "${part}"`);
if (decl.prop === 'inset' || decl.prop === 'margin' || decl.prop === 'all'
|| decl.prop.startsWith('inset-') || decl.prop.startsWith('margin-block')) {
complaints.push(`shorthand ${decl.prop} on "${part}" — the model reads longhands only`);
}
if (decl.prop === 'height' || decl.prop === 'max-height') {
// A capped height can override `bottom` and invalidate the measured
// dock/credit constants. Two exemptions, each earned by a test below:
// dock/credit constants. Exemptions are each earned by a test below:
// the rail's own max-height (resolved to `none` across the whole
// modelled band by the rail clearance test) and `.layout-focus`
// (proven inapplicable at <=720px by the mobile-mode test).
// (proven inapplicable at <=720px by the mobile-mode test), plus
// synchronous measurement, which is removed before any paint.
const railOwn = part === '#right-context-rail' && decl.prop === 'max-height';
const railFocus = part === '#right-context-rail.layout-focus';
if (!railOwn && !railFocus) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
if (!railOwn && !railFocus && !railMeasurement) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
}
if (decl.prop === 'transform' && /translateY|translate3d|matrix|scale\(/.test(decl.value)) {
const identity = decl.value === 'translateY(0) scale(1)';
@@ -490,6 +497,17 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
});
test('right rail measurement cannot persist into a painted attribution layout', () => {
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const start = rail.indexOf("stack.setAttribute('data-rail-measuring', '')");
const end = rail.indexOf("stack.removeAttribute('data-rail-measuring')", start);
assert.ok(start > rail.indexOf("layoutMode = 'mobile'"));
assert.ok(end > start);
const measurement = rail.slice(start, end);
assert.match(measurement, /try \{[\s\S]*\} finally \{/);
assert.doesNotMatch(measurement, /\b(?:await|return|yield)\b|requestAnimationFrame|setTimeout/);
});
// ── Clearance ───────────────────────────────────────────────────────────────
test('every open dock tray clears the required credit at every modelled viewport', () => {
+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+43 -2
View File
@@ -24,6 +24,7 @@
* @module data/analystEngine
*/
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
@@ -61,6 +62,21 @@ export const ANALYST_LAYERS = {
text: ['place'],
flags: [],
},
satellites: {
numeric: ['altitudeM', 'speedMps'],
text: ['name', 'noradId', 'satelliteClass', 'group'],
flags: [],
},
'local-datacenters': {
numeric: [],
text: ['name', 'operator', 'capacity'],
flags: [],
},
'local-dams': {
numeric: [],
text: ['name', 'operator', 'river', 'output'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -163,12 +179,15 @@ export function createAnalystEngine(providers) {
// 1) Source records
let records;
let layersQueried;
let queriedSnapshots;
if (layers === null) {
records = lastResult.items.slice();
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
} else {
records = [];
layersQueried = [];
queriedSnapshots = [];
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
if (unknown.length) {
return {
@@ -180,7 +199,22 @@ export function createAnalystEngine(providers) {
for (const key of layers) {
if (!ANALYST_LAYERS[key]) continue;
const rows = providers.getRecords(key) || [];
layersQueried.push({ layerKey: key, records: rows.length });
const snapshot = providers.getLayerSnapshot?.(key);
if (snapshot) queriedSnapshots.push(snapshot);
layersQueried.push({
layerKey: key,
records: rows.length,
...(snapshot
? {
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
enabled: snapshot.enabled,
error: snapshot.error,
}
: {}),
...providers.getRecordCoverage?.(key, rows),
});
for (const row of rows) records.push({ layerKey: key, ...row });
}
}
@@ -292,8 +326,15 @@ export function createAnalystEngine(providers) {
coverage: {
layersQueried,
scope: scopeNote,
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
followUp: Boolean(spec.followUp && lastResult),
note: 'client-side data only — answers cover what the enabled layers currently hold',
note: layersQueried.some(
(layer) => layer.basis === 'bounded-loaded-records',
)
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
: 'client-side data only — answers cover what the enabled layers currently hold',
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
+79
View File
@@ -195,9 +195,88 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
});
test('analyst: satellites and local infrastructure are queryable layers', async () => {
const SATS = [
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
];
const DAMS = [
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
];
const DCS = [
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
];
const eng = createAnalystEngine({
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const sats = await eng.query({
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
});
assert.equal(sats.ok, true);
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
assert.equal(sats.items[0].noradId, '25544');
assert.ok(Number.isFinite(sats.items[0].distanceKm));
const nav = await eng.query({
layers: ['satellites'], scope: { kind: 'anywhere' },
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
});
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
const dams = await eng.query({
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
});
assert.equal(dams.count, 1);
assert.equal(dams.items[0].id, 'Austin Dam');
assert.equal(dams.items[0].river, 'Colorado');
const dcs = await eng.query({
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
});
assert.equal(dcs.count, 1);
assert.equal(dcs.items[0].id, 'AUS-1');
});
test('helpers: haversine sanity + scope radius', () => {
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
const engine = createAnalystEngine({
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const first = await engine.query();
state = 'nominal';
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.feedProvenance.overall, 'stale');
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
engine.reset();
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+32
View File
@@ -147,6 +147,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -212,6 +216,34 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
},
{
key: 'weather-cyclones',
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
},
{
key: 'weather-lightning',
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
},
{
key: 'wind-gfs',
html:
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
{
key: 'ecmwf-ifs',
html:
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+84 -1
View File
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -205,6 +213,52 @@ function integerOption(key, token, defaultValue) {
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -384,6 +438,35 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
Object.freeze({
id: 'weather-cyclones',
token: 'y',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'weather-lightning',
token: 'l',
disposition: 'enabled+options',
optionOwner: 'weather-lightning',
}),
Object.freeze({
id: 'weather-radar',
token: 'v',
disposition: 'enabled+options',
optionOwner: 'weather-radar',
}),
Object.freeze({
id: 'weather-satellite',
token: 'o',
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wind',
token: 'k',
disposition: 'enabled+options',
optionOwner: 'wind',
}),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(
+60 -2
View File
@@ -158,8 +158,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 21);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
assert.equal(REGISTERED_LAYER_IDS.length, 26);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 26);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
@@ -331,6 +331,7 @@ test('compact URL omits absent-meaning option state and still resolves to it', (
// below, and the divergence itself in the two codec tests above.
state.options.flights = { models3d: false, models3dMode: 'proximity', selectedFlightsTrackingId: null, selectedMilitaryTrackingId: null };
state.options.satellites = { catalog: 'core', showPoints: true, showOrbits: true, selectedSatTrackingId: null };
state.options.wind.overlay = 'speed'; // Frozen v2 omitted-token meaning; new boots use trails.
const params = encodeLayerStateParams(new URLSearchParams('v=2'), state);
assert.equal(params.has('lo'), false);
const roundTrip = decodeLayerStateParams(params);
@@ -1604,3 +1605,60 @@ test('the owner layer going away revokes the pending watch at any origin', async
f.coordinator.destroy();
}
});
test('wind appearance shares round trip while old links retain weather defaults', () => {
const state = normalizeLayerState({
enabledLayerIds: ['wind'],
options: {
wind: { model: 'ifs', overlay: 'pressure', units: 'mph', paused: true },
},
});
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(state))).options.wind,
{ model: 'ifs', overlay: 'pressure', units: 'mph', paused: true },
);
const defaults = createDefaultLayerState().options.wind;
assert.deepEqual(defaults, {
model: 'gfs',
overlay: 'none',
units: 'km/h',
paused: false,
});
const legacy = decodeLayerStateParams(new URLSearchParams('v=2&l=k'));
assert.equal(legacy.options.wind.overlay, 'speed', 'old links retain their authored field');
assert.equal(decodeLayerStateParams(new URLSearchParams(encode(createDefaultLayerState()))).options.wind.overlay, 'none', 'new default is encoded explicitly');
const old = normalizeLayerState({ options: { wind: { model: 'ifs' } } });
assert.deepEqual(old.options.wind, { ...defaults, model: 'ifs' });
const invalid = normalizeLayerState({
options: {
wind: {
model: 'unknown',
overlay: 'clouds',
units: '<script>',
paused: 'yes',
},
},
});
assert.deepEqual(invalid.options.wind, defaults);
});
test('observed weather round trips product and opacity without persisting historical playback', () => {
const state = normalizeLayerState({ enabledLayerIds: ['weather-radar', 'weather-satellite'], options: { 'weather-radar': { opacity: 'light', play: true }, 'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 } } });
const params = new URLSearchParams(encode(state));
const decoded = decodeLayerStateParams(params);
assert.deepEqual(decoded, state);
assert.equal(state.options['weather-satellite'].product, 'clouds');
assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false);
});
test('satellite infrared display mode round trips and invalid or absent values use filtered', () => {
for (const infrared of ['full', 'filtered', undefined, 'invalid']) {
const state = normalizeLayerState({ enabledLayerIds: ['weather-satellite'], options: {
'weather-satellite': { infrared, product: 'clouds', step: -1, play: true },
} });
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(state))), state);
assert.equal(state.options['weather-satellite'].infrared, infrared === 'full' ? 'full' : 'filtered');
assert.equal(Object.hasOwn(state.options['weather-satellite'], 'step'), false);
assert.equal(Object.hasOwn(state.options['weather-satellite'], 'play'), false);
}
});
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+107 -1
View File
@@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
+141
View File
@@ -2746,6 +2746,12 @@ function makeControlElement() {
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
@@ -3204,6 +3210,7 @@ test('an async layer pushes its own row refresh, and a busy chip refuses clicks'
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
@@ -3336,3 +3343,137 @@ test('row action chips use live disabled state and descriptive counts without wr
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
+458
View File
@@ -0,0 +1,458 @@
// src/data/satellitePass.js
/**
* Satellite pass prediction with naked-eye visibility.
* Look angles come from SGP4; rise/set are bisected to sub-second precision,
* the peak is refined with a parabola, and visibility uses the USNO
* low-precision Sun position with a cylindrical Earth shadow. Scan pattern from
* skylight (MIT) shared/src/celestial.ts nextISSPass.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
const EARTH_RADIUS_KM = 6371.0;
const AU_KM = 149597870.7;
/**
* Observer look angles at an instant, or null when propagation fails.
* @param {Object} satrec SGP4 satellite record
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @param {number} latDeg Observer latitude in degrees [-90, 90]
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
* @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null}
*/
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
let pv;
try {
pv = propagate(satrec, date);
} catch {
return null;
}
const pos = pv && pv.position;
if (
!pos ||
typeof pos === 'boolean' ||
![pos.x, pos.y, pos.z].every(Number.isFinite)
)
return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
satECI: pos,
};
}
/**
* Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates.
* Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0.
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }}
*/
export function solarPositionECI(dateMs) {
const JD = dateMs / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days since J2000.0
const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R;
const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360;
const L =
((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) *
D2R;
const e = (23.439 - 0.00000036 * D) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
return {
x: AU_KM * Math.cos(dec) * Math.cos(ra),
y: AU_KM * Math.cos(dec) * Math.sin(ra),
z: AU_KM * Math.sin(dec),
raDeg: (((ra * R2D) % 360) + 360) % 360,
decDeg: dec * R2D,
};
}
/**
* Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow).
* Uses a cylindrical Earth shadow model with mean radius 6,371 km.
* @param {{ x: number, y: number, z: number }} satECI Satellite position in km
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {boolean} True if sunlit, false if in eclipse (Earth shadow)
*/
export function isSatelliteSunlit(satECI, dateMs) {
if (!satECI || typeof satECI === 'boolean') return false;
const sun = solarPositionECI(dateMs);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz;
if (proj > 0) return true; // Sunward side of Earth
const dx = satECI.x - proj * sx;
const dy = satECI.y - proj * sy;
const dz = satECI.z - proj * sz;
return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM;
}
/**
* Compute the observer local solar elevation angle in degrees.
* @param {number} latDeg Observer latitude in degrees
* @param {number} lonDeg Observer longitude in degrees
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {number} Solar elevation in degrees [-90, 90]
*/
export function observerSolarElevation(latDeg, lonDeg, dateMs) {
const sun = solarPositionECI(dateMs);
const gmst = gstime(new Date(dateMs));
const gmstDeg = (((gmst * R2D) % 360) + 360) % 360;
const localHourAngleDeg =
(((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360;
const H = localHourAngleDeg * D2R;
const phi = latDeg * D2R;
const dec = sun.decDeg * D2R;
const sinElev =
Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H);
return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D;
}
/**
* Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night).
* Default threshold is -6° (civil twilight).
* @param {number} latDeg Observer latitude
* @param {number} lonDeg Observer longitude
* @param {number} dateMs UTC timestamp
* @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight)
* @returns {boolean} True if sun is at or below the threshold
*/
export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) {
return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg;
}
/**
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
* @private
*/
function _bisectBoundary(
satrec,
latDeg,
lonDeg,
tLow,
tHigh,
minElevDeg,
isRising,
steps = 10,
) {
let low = tLow;
let high = tHigh;
for (let i = 0; i < steps; i++) {
const mid = (low + high) / 2;
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (isRising) {
if (elev >= minElevDeg) {
high = mid;
} else {
low = mid;
}
} else {
if (elev >= minElevDeg) {
low = mid;
} else {
high = mid;
}
}
}
return isRising ? Math.ceil(high) : Math.floor(low);
}
/**
* Find the next pass of a satellite over an observer location.
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
* parabolic interpolation for the peak, and optional naked-eye visibility filtering.
*
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude [-90, 90]
* @param {number} options.lonDeg Observer longitude [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
* @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible
* @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight)
* @returns {{
* riseMs: number,
* setMs: number,
* maxElevDeg: number,
* maxElevMs: number,
* riseAzDeg: number,
* visible: boolean,
* sunlit: boolean,
* observerDark: boolean
* } | null} `visible` is true when any sampled part of the pass is sunlit under a
* dark sky; `sunlit` and `observerDark` describe the peak only.
*/
export function findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 20,
fineStepSec = 5,
requireVisible = false,
maxSunElevDeg = -6,
}) {
if (
!satrec ||
![
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
maxSunElevDeg,
].every(Number.isFinite) ||
Math.abs(latDeg) > 90 ||
Math.abs(lonDeg) > 180 ||
Math.abs(fromMs) > 8.63e15 ||
minElevDeg < 0 ||
minElevDeg > 90 ||
horizonHours <= 0 ||
horizonHours > 72 ||
coarseStepSec < 1 ||
coarseStepSec > 120 ||
fineStepSec < 0.1 ||
fineStepSec > 60
)
return null;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarseMs = coarseStepSec * 1000;
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
let searchCursor = fromMs;
while (searchCursor <= horizonMs) {
let tPrev = searchCursor;
let tHit = null;
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
// Check if we start already inside a pass
if (elevPrev >= minElevDeg) {
tHit = searchCursor;
} else {
// Coarse forward scan
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (elev >= minElevDeg) {
tHit = t;
break;
}
tPrev = t;
elevPrev = elev;
}
}
if (tHit == null) return null; // No pass found in remaining horizon
// Resolve rise
let riseMs;
if (tHit === searchCursor && elevPrev >= minElevDeg) {
riseMs = searchCursor;
} else {
riseMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tPrev,
tHit,
minElevDeg,
true,
);
}
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
let tCurr = riseMs;
let maxElevDeg = -90;
let maxElevMs = riseMs;
let tStepPrev = riseMs;
let setMs = null;
let hasVisibleSegment = false;
let elevPrevSample = null;
let peakLeftElev = null;
let peakRightElev = null;
let peakStepMs = fineMs;
// Visibility at an instant inside [riseMs, setMs], so no elevation gate
const checkVisibility = (tMs) => {
if (hasVisibleSegment) return;
const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg);
if (!look) return;
const sunlit = isSatelliteSunlit(look.satECI, tMs);
const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg);
if (sunlit && dark) {
hasVisibleSegment = true;
}
};
// Check visibility at rise
checkVisibility(riseMs);
// Transit forward in fine steps
while (tCurr <= horizonMs) {
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (look) {
const sunlit = isSatelliteSunlit(look.satECI, tCurr);
const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg);
if (sunlit && dark && elev >= minElevDeg) {
hasVisibleSegment = true;
}
}
if (elev < minElevDeg && tCurr > riseMs) {
if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
// Below threshold: bisect set between tStepPrev and tCurr
setMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tStepPrev,
tCurr,
minElevDeg,
false,
);
break;
}
if (elev > maxElevDeg) {
maxElevDeg = elev;
maxElevMs = tCurr;
peakLeftElev = elevPrevSample;
peakRightElev = null;
} else if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
elevPrevSample = elev;
tStepPrev = tCurr;
tCurr += fineMs;
}
if (setMs == null) {
setMs = Math.min(horizonMs, tCurr);
}
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
// using adjacent transit samples (zero additional SGP4 calls)
let refinedPeakMs = maxElevMs;
let refinedPeakElevDeg = maxElevDeg;
if (
Number.isFinite(peakLeftElev) &&
Number.isFinite(peakRightElev) &&
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
) {
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
if (Math.abs(shift) < peakStepMs) {
refinedPeakMs = Math.max(
riseMs,
Math.min(setMs, Math.round(maxElevMs + shift)),
);
refinedPeakElevDeg = Math.min(
90,
maxElevDeg -
Math.pow(peakLeftElev - peakRightElev, 2) /
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
);
}
}
// Sun and sky state at the peak
const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg);
const peakSunlit = peakLook
? isSatelliteSunlit(peakLook.satECI, refinedPeakMs)
: false;
const peakDark = isObserverDark(
latDeg,
lonDeg,
refinedPeakMs,
maxSunElevDeg,
);
// Check visibility at the refined peak and at set
checkVisibility(refinedPeakMs);
checkVisibility(setMs);
if (!hasVisibleSegment) {
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
return [
Boolean(look && isSatelliteSunlit(look.satECI, time)),
isObserverDark(latDeg, lonDeg, time, maxSunElevDeg),
];
});
}
// Skip passes with no visible part when requireVisible is set
if (requireVisible && !hasVisibleSegment) {
searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs);
continue;
}
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg: refinedPeakElevDeg,
maxElevMs: refinedPeakMs,
riseAzDeg: riseLook ? riseLook.azDeg : 0,
visible: hasVisibleSegment,
sunlit: peakSunlit,
observerDark: peakDark,
};
}
return null;
}
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
export function hasVisibleInterval(start, end, conditionsAt) {
const visible = (states) => states[0] && states[1];
for (let left = start; left < end; left += 5000) {
const right = Math.min(end, left + 5000);
const a = conditionsAt(left);
const b = conditionsAt(right);
if (visible(a) || visible(b)) return true;
const cuts = [left, right];
for (let condition = 0; condition < 2; condition++) {
if (a[condition] === b[condition]) continue;
let low = left;
let high = right;
while (high - low > 20) {
const mid = (low + high) / 2;
if (conditionsAt(mid)[condition] === a[condition]) low = mid;
else high = mid;
}
cuts.push((low + high) / 2);
}
cuts.sort((a, b) => a - b);
for (let i = 1; i < cuts.length; i++) {
if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true;
}
}
return false;
}
+282
View File
@@ -0,0 +1,282 @@
// src/data/satellitePass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec, propagate } from 'satellite.js';
import {
findNextSatellitePass,
hasVisibleInterval,
lookAnglesAt,
solarPositionECI,
isSatelliteSunlit,
observerSolarElevation,
isObserverDark,
} from './satellitePass.js';
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
test('solarPositionECI matches the Sun at J2000.0', () => {
// Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC
// gives RA 281.289°, Dec -23.033°.
const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0));
assert.ok(
Math.abs(j2000.raDeg - 281.289) < 0.05,
`RA ${j2000.raDeg}° within 0.05°`,
);
assert.ok(
Math.abs(j2000.decDeg - -23.033) < 0.05,
`Dec ${j2000.decDeg}° within 0.05°`,
);
});
test('solarPositionECI calculates reasonable solar coordinates across seasons', () => {
// Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0°
const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0));
assert.ok(
Math.abs(equinox.decDeg) < 1.0,
'declination near equinox must be close to 0°',
);
// Near Summer Solstice (June 21, 2020): declination ≈ +23.44°
const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeSummer.decDeg - 23.44) < 0.5,
'summer declination ≈ 23.44°',
);
// Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44°
const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeWinter.decDeg - -23.44) < 0.5,
'winter declination ≈ -23.44°',
);
});
test('observerSolarElevation distinguishes local noon from local midnight', () => {
// Greenwich noon on equinox: solar elevation should be close to 90 - lat
const greenwichNoon = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 12, 0, 0),
);
assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon');
// Greenwich midnight: solar elevation must be deeply negative
const greenwichMidnight = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 0, 0, 0),
);
assert.ok(
greenwichMidnight < -20,
'sun must be well below horizon at midnight',
);
assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6));
});
test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => {
const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0);
const sun = solarPositionECI(summerDate);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
// Point toward the Sun: must be sunlit
const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz };
assert.equal(isSatelliteSunlit(daysideSat, summerDate), true);
// Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed
const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false);
// Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit
const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(highAltSat, summerDate), true);
});
test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => {
const satrec = twoline2satrec(L1, L2);
// Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone
const tShadowed = FROM_MS + 17 * 60_000;
const pvShadowed = propagate(satrec, new Date(tShadowed));
assert.equal(
isSatelliteSunlit(pvShadowed.position, tShadowed),
false,
'ISS must be eclipsed in Earth umbra during orbital night',
);
// Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight
const tSunlit = FROM_MS + 18 * 60_000;
const pvSunlit = propagate(satrec, new Date(tSunlit));
assert.equal(
isSatelliteSunlit(pvSunlit.position, tSunlit),
true,
'ISS must be illuminated upon orbital sunrise',
);
});
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
});
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Verify bisection accuracy at rise time:
const beforeRise = lookAnglesAt(
satrec,
pass.riseMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atRise = lookAnglesAt(
satrec,
pass.riseMs,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const afterRise = lookAnglesAt(
satrec,
pass.riseMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeRise && beforeRise.elevDeg < 10.05,
'elevation before riseMs must be below threshold',
);
assert.ok(
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
'elevation at riseMs must be within 0.2° of 10°',
);
assert.ok(
afterRise && afterRise.elevDeg > 9.95,
'elevation after riseMs must be rising above threshold',
);
// Verify bisection accuracy at set time:
const beforeSet = lookAnglesAt(
satrec,
pass.setMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
const afterSet = lookAnglesAt(
satrec,
pass.setMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeSet && beforeSet.elevDeg > 9.95,
'elevation before setMs must be above threshold',
);
assert.ok(
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
'elevation at setMs must be within 0.2° of 10°',
);
assert.ok(
afterSet && afterSet.elevDeg < 10.05,
'elevation after setMs must be below threshold',
);
// Parabolic culmination accuracy: max elevation must be peak
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
});
test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => {
const satrec = twoline2satrec(L1, L2);
const geometricPass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
requireVisible: false,
});
assert.ok(geometricPass);
const visiblePass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
horizonHours: 72,
requireVisible: true,
});
assert.ok(visiblePass, 'expected at least one visible pass within horizon');
assert.equal(visiblePass.visible, true);
assert.ok(visiblePass.riseMs >= geometricPass.riseMs);
});
test('findNextSatellitePass samples the peak at fineStepSec', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
assert.ok(fine && coarse);
// The step is used: peak samples differ.
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
// Rise comes from bisection, so it does not depend on the step.
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
// Parabolic refinement keeps 10s sampling close to the 1s peak.
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
});
test('invalid numerical search options return promptly without unbounded scans', () => {
const satrec = twoline2satrec(L1, L2);
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
for (const change of [
{ coarseStepSec: 0 },
{ coarseStepSec: -1 },
{ horizonHours: Infinity },
{ horizonHours: 73 },
{ fineStepSec: NaN },
{ latDeg: 91 },
{ fromMs: NaN },
]) {
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
}
});
test('visibility catches a short overlap between shadow exit and dawn', () => {
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]),
true,
);
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]),
false,
);
});
test('a pass already underway clips rise to the requested start', () => {
const satrec = twoline2satrec(L1, L2);
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
const clipped = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: first.maxElevMs,
});
assert.equal(clipped.riseMs, first.maxElevMs);
assert.ok(clipped.setMs >= clipped.riseMs);
assert.ok(
clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs,
);
});
+154
View File
@@ -0,0 +1,154 @@
// Focused tests for the satellite analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import satellitesLayer, {
mapAnalystRecord,
_clearDenseCatalogStateForTest,
_setTrackedSatelliteRefreshStateForTest,
} from './satellites.js';
const ISS_NORAD = 25544;
const ISS_L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const ISS_L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const FULL_RAW = {
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'stations',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
};
test('satellite analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RAW);
assert.deepEqual(r, {
id: 'ISS (ZARYA)',
noradId: '25544',
name: 'ISS (ZARYA)',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
satelliteClass: 'STATION · ISS',
group: 'stations',
});
});
test('satellite analyst record: nameless row falls back to SAT-norad id', () => {
const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 });
assert.equal(r.id, 'SAT-25544');
assert.equal(r.name, null);
});
test('satellite analyst record: ISS class wins even when ingested as visual', () => {
const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' });
assert.equal(r.satelliteClass, 'STATION · ISS');
});
test('satellite analyst record: GPS group maps to NAV · GPS', () => {
const r = mapAnalystRecord({
noradId: 24876,
name: 'GPS BIIR-2 (PRN 13)',
group: 'gps-ops',
lat: 0,
lon: 0,
altitudeM: 20_200_000,
speedMps: 3870,
});
assert.equal(r.satelliteClass, 'NAV · GPS');
assert.equal(r.group, 'gps-ops');
});
test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined);
assert.equal(r.id, 'SAT-00000');
assert.equal(r.satelliteClass, null);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord({
...FULL_RAW,
position: { x: 1 },
satrec: { dummy: true },
});
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('position' in r, false);
assert.equal('satrec' in r, false);
});
test('satellite getAnalystRecords: disabled or empty catalog returns []', () => {
_clearDenseCatalogStateForTest();
assert.deepEqual(satellitesLayer.getAnalystRecords(), []);
});
test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const point = {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000),
};
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point,
viewer: {},
now: () => Date.now(),
neighbours: [
{
noradId: 99999,
name: 'STARLINK-TEST',
satrec,
group: 'dense',
point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) },
},
{
noradId: 24876,
name: 'GPS BIIR-2',
satrec,
group: 'gps-ops',
point: {
position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000),
},
},
],
});
try {
const capped = satellitesLayer.getAnalystRecords(2);
assert.equal(capped.length, 2);
assert.deepEqual(
capped.map((r) => r.noradId),
['25544', '24876'],
);
assert.equal(
capped.some((r) => r.group === 'dense'),
false,
);
const all = satellitesLayer.getAnalystRecords(10);
assert.equal(all.length, 3);
assert.equal(all[2].noradId, '99999');
assert.equal(all[2].satelliteClass, 'COMMS · STARLINK');
for (const row of all) {
assert.ok(
Number.isFinite(row.lat) && Number.isFinite(row.lon),
`${row.id} needs a geodetic position`,
);
assert.deepEqual(JSON.parse(JSON.stringify(row)), row);
}
} finally {
_clearDenseCatalogStateForTest();
}
});
+3
View File
@@ -41,6 +41,7 @@ export const _clearSatelliteLabelLifecycleForTest =
export const applySatellitePointFocusDeemphasis =
layer.applySatellitePointFocusDeemphasis;
export const getNextIssPass = layer.getNextIssPass;
export const getNextSatellitePass = layer.getNextSatellitePass;
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
@@ -49,3 +50,5 @@ export {
ISS_OVERLAY_SOURCE_OPTIONS,
} from '../layers/satellites/index.js';
export default layer;
export { mapAnalystRecord } from '../layers/satellites/records.js';
+3 -2
View File
@@ -135,7 +135,7 @@ export function applyVesselOverlayPolicy(
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
@@ -144,7 +144,8 @@ export function applyVesselOverlayPolicy(
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
+1
View File
@@ -75,6 +75,7 @@ test('vessel host policy uses always-on shared fade and protected selected lane'
id: 'vessel:2', position, title: 'SELECTED', gapPx: 12, selected: true,
});
assert.equal(selected.variant, 'selected');
assert.equal(selected.verticalOnly, false, 'selected detail can clear panels on either side');
assert.equal(selected.protected, true);
assert.equal(selected.collisionGroup, 'ambient-card');
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
createWindSource(),
['getSnapshot'],
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gfsObjectKey,
selectLatestGfsCycle,
} from '../../server/providers/wind/catalog.js';
import {
parseGfsIdx,
windMessageRanges,
fetchRange,
fetchGfsWind,
loadOptionalScalar,
} from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(
gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }),
'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003',
);
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
const p = parseGfsIdx(
'1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z',
);
assert.deepEqual(windMessageRanges(p), {
u: { start: 10, end: 19 },
v: { start: 20, end: 29 },
});
const b = await fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
});
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => {
await assert.rejects(
fetchRange({ url: 'x', start: -1, end: 2 }),
/range too large/,
);
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(10));
},
cancel() {
cancelled = true;
},
});
await assert.rejects(
fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () => new Response(body, { status: 206 }),
}),
/budget/,
);
assert.equal(cancelled, true);
});
const WEATHER_IDX = [
'1:0:d=2026091506:UGRD:10 m above ground:6 hour fcst:',
'2:2:d=2026091506:VGRD:10 m above ground:6 hour fcst:',
'3:4:d=2026091506:TMP:850 mb:6 hour fcst:',
'4:6:d=2026091506:TMP:2 m above ground:6 hour fcst:',
'5:8:d=2026091506:PRMSL:mean sea level:6 hour fcst:',
'6:10:d=2026091506:HGT:surface:6 hour fcst:',
].join('\n');
test('GFS overlays select exact levels from the same issue and forecast object', async () => {
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'temperature' })
.scalar,
{ start: 6, end: 7 },
);
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'pressure' }).scalar,
{ start: 8, end: 9 },
);
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchGfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.idx')) return new Response(WEATHER_IDX);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 6 ? 293.15 : bytes[0] === 8 ? 101325 : bytes[0],
),
units: bytes[0] === 6 ? 'K' : bytes[0] === 8 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('.f006.idx'));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 20 : 1013.25),
) < 0.0001,
);
}
assert.throws(
() =>
windMessageRanges(
parseGfsIdx(WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')),
{ overlay: 'temperature' },
),
/missing TMP/,
);
});
test('GFS preserves wind when its optional temperature is missing or malformed', async () => {
for (const failure of ['missing', 'fetch', 'decode', 'geometry', 'units']) {
const result = await fetchGfsWind({
overlay: 'temperature',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
if (url.endsWith('.idx'))
return new Response(
failure === 'missing'
? WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')
: WEATHER_IDX,
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 6 && failure === 'fetch')
throw new Error('private provider failure');
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => {
if (bytes[0] === 6 && failure === 'decode')
throw new Error('bad message');
return {
ni: 4,
nj: 3,
lo1: bytes[0] === 6 && failure === 'geometry' ? 90 : 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0] === 6 ? 293.15 : bytes[0]),
units: bytes[0] === 6 && failure !== 'units' ? 'K' : 'm/s',
};
},
});
assert.equal(result.scalar, undefined);
assert.equal(result.grid.scalar, undefined);
assert.equal(result.scalarError, 'Temperature field unavailable');
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
}
});
test('optional scalar timeout cancels only its own fetch and honors parent cancellation', async () => {
const parent = new AbortController();
let active;
const value = await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'https://fixed.test/field',
timeoutMs: 5,
signal: parent.signal,
decodeImpl: () => {
throw new Error('not reached');
},
fetchImpl: (_, { signal }) => {
active = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
});
assert.equal(value, undefined);
assert.equal(active.aborted, true);
assert.equal(parent.signal.aborted, false);
parent.abort();
let calls = 0;
assert.equal(
await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'x',
signal: parent.signal,
fetchImpl: () => {
calls++;
},
decodeImpl: () => {},
}),
undefined,
);
assert.equal(calls, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resampleWindGrid,
resampleWeatherFields,
} from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
v: new Array(12).fill(1),
dx: 90,
dy: 90,
});
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0);
assert.equal(r.u[3], 3);
assert.equal(r.u[4], 4);
});
const component = (values, extra = {}) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(values),
...extra,
});
test('weather resampling converts temperature and MSL pressure on the wind geometry', () => {
const u = component(3);
const v = component(-4);
for (const [overlay, raw, units, expected, outUnits] of [
['temperature', 293.15, 'K', 20, '°C'],
['pressure', 101325, 'Pa', 1013.25, 'hPa'],
]) {
const result = resampleWeatherFields({
u,
v,
scalar: component(raw, { units }),
overlay,
targetDx: 90,
});
assert.equal(result.scalar.kind, overlay);
assert.equal(result.scalar.units, outUnits);
assert.deepEqual([...result.grid.u], Array(12).fill(3));
assert.deepEqual([...result.grid.v], Array(12).fill(-4));
assert.ok(result.grid.scalar.every((n) => Math.abs(n - expected) < 0.0001));
}
assert.equal(
'scalar' in resampleWeatherFields({ u, v, targetDx: 90 }).grid,
false,
);
});
test('weather components cannot share a grid when shifted, truncated or non-finite', () => {
const u = component(1);
const v = component(2);
const scalar = component(273.15, { units: 'K' });
for (const bad of [
{ ...v, lo1: 90 },
{ ...v, ni: 3 },
{ ...v, dj: 45 },
]) {
assert.throws(() => resampleWeatherFields({ u, v: bad }), /Mismatched/);
}
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, la1: 89 },
overlay: 'temperature',
}),
/Mismatched/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, units: '°C' },
overlay: 'temperature',
}),
/units/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: [1] },
overlay: 'temperature',
}),
/geometry/,
);
const invalid = component(1);
invalid.values[5] = NaN;
assert.throws(
() => resampleWeatherFields({ u, v: invalid, targetDx: 180 }),
/values/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: invalid.values },
overlay: 'temperature',
}),
/values/,
);
assert.throws(
() => resampleWeatherFields({ u, v, overlay: 'dewpoint' }),
/Unknown/,
);
});
test('optional degradation cannot hide invalid wind geometry', async () => {
const { resampleWeatherSnapshot } =
await import('../../server/providers/wind/grid.js');
const u = component(1);
const v = component(2);
const result = resampleWeatherSnapshot({
u,
v,
overlay: 'pressure',
targetDx: 90,
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.throws(
() =>
resampleWeatherSnapshot({ u, v: { ...v, la1: 89 }, overlay: 'pressure' }),
/Mismatched/,
);
assert.throws(
() =>
resampleWindGrid({ ...u, u: u.values, v: v.values, dx: 0.25, dy: 0.25 }),
/budget/,
);
});
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ifsObjectUrls,
parseIfsIndex,
ifsWindRanges,
nearestIfsStep,
selectLatestIfsCycle,
fetchIfsWind,
} from '../../server/providers/wind/ifs.js';
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
assert.match(urls.index, /9h-oper-fc\.index$/);
assert.equal(urls.index.includes('.grib2.index'), false);
assert.deepEqual(
ifsWindRanges(
parseIfsIndex(
'{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}',
),
),
{ u: { start: 20, end: 29 }, v: { start: 40, end: 54 } },
);
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
assert.equal(nearestIfsStep(8, 6), 9);
assert.equal(nearestIfsStep(200, 6), 144);
assert.equal(nearestIfsStep(151, 0), 150);
assert.equal(nearestGfsStep(8.2), 8);
});
test('IFS 2t and msl companions use the same issue/valid object as 10u and 10v', async () => {
const entries = ['10u', '10v', '2t', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchIfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 4 ? 283.15 : bytes[0] === 6 ? 100000 : bytes[0],
),
units: bytes[0] === 4 ? 'K' : bytes[0] === 6 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('-9h-oper-fc.index'));
assert.equal(result.cycle.runIso, '2026-09-15T06:00:00.000Z');
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 10 : 1000),
) < 0.0001,
);
}
assert.throws(
() =>
ifsWindRanges(
parseIfsIndex(
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join('\n'),
),
{ overlay: 'pressure' },
),
/missing msl/,
);
});
test('IFS optional pressure refusal preserves same-cycle wind', async () => {
const entries = ['10u', '10v', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
const result = await fetchIfsWind({
overlay: 'pressure',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 4) return new Response('Unavailable', { status: 503 });
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0]),
}),
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
});
+657
View File
@@ -0,0 +1,657 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { windProxy } from '../../server/providers/wind.js';
/** Boot a mounted provider middleware without a real server. */
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
assert.equal(routes.size, 1);
const handler = [...routes.values()][0];
return async (url = '/', method = 'GET') => {
const res = {
statusCode: 200,
headers: {},
headersSent: false,
writeHead(status, headers) {
this.statusCode = status;
for (const [name, value] of Object.entries(headers || {}))
this.headers[name.toLowerCase()] = value;
this.headersSent = true;
},
end(body) {
this.body = body;
this.headersSent = true;
},
};
await handler({ url, method }, res);
return res;
};
}
const IDX = [
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
'3:10:d=2026091400:HGT:surface:anl:',
].join('\n');
const DECODED_U = {
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const DECODED_V = {
...DECODED_U,
values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
};
/** Resampled-grid metadata shared by the injected model fakes. */
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
/** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => {
if (counter) counter.calls += 1;
if (fail) throw new Error('gfs upstream down');
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
const length = Number(match[2]) - Number(match[1]) + 1;
return new Response(new Uint8Array(length), { status: 206 });
};
}
function proxy(options = {}) {
return windProxy({
now: () => Date.UTC(2026, 8, 14, 12),
targetDx: 90,
fetchImpl: makeFetch(),
decodeImpl: async (buffer) =>
buffer[0] === undefined ? DECODED_U : DECODED_U,
...options,
});
}
test('wind manifest describes the GFS cycle and resampled grid', async () => {
const request = install(proxy());
const res = await request('/');
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.body);
assert.equal(body.schemaVersion, 1);
assert.equal(body.model, 'gfs');
assert.equal(body.cycle.hour, 6);
assert.equal(body.cycle.date, '20260914');
assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, {
nx: 4,
ny: 3,
lo1: 0,
la1: 90,
dx: 90,
dy: 90,
});
assert.match(
body.gridUrl,
/^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/,
);
assert.equal(body.stale, false);
});
test('wind grid route returns Float32 U then V', async () => {
let u = 0;
const request = install(
proxy({
decodeImpl: async () => {
u += 1;
return u === 1 ? DECODED_U : DECODED_V;
},
}),
);
const manifest = JSON.parse((await request('/')).body);
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-type'], 'application/octet-stream');
assert.equal(res.body.length, 4 * 3 * 4 * 2);
const floats = new Float32Array(
res.body.buffer,
res.body.byteOffset,
res.body.length / 4,
);
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
});
test('wind status omits the grid url', async () => {
const request = install(proxy());
await request('/');
const body = JSON.parse((await request('/status')).body);
assert.equal('gridUrl' in body, false);
assert.equal(body.model, 'gfs');
});
test('wind refresh is cached within the TTL and refetches after it', async () => {
const counter = { calls: 0 };
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
);
await request('/');
const afterFirst = counter.calls;
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
await request('/');
assert.equal(
counter.calls,
afterFirst,
'a second request inside the TTL must not refetch',
);
clock += 2 * 3600_000;
await request('/');
assert.equal(
counter.calls,
afterFirst + 3,
'a request past the TTL refetches',
);
});
test('wind serves last-good with stale on upstream failure', async () => {
let failing = false;
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({
now: () => clock,
fetchImpl: async (...args) => {
if (failing) throw new Error('gfs upstream down');
return makeFetch()(...args);
},
}),
);
const good = JSON.parse((await request('/')).body);
failing = true;
clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true);
assert.equal(degraded.reason, 'Wind upstream unavailable');
const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
});
test('wind returns a JSON 404 for an unknown grid', async () => {
const request = install(proxy());
await request('/');
const res = await request('/grid/nope.bin');
assert.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
});
test('wind grid ids and URLs are model-scoped', async () => {
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
ifs: async () => ({
cycle: { date: '20260914', hour: 0 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
},
}),
);
const gfs = JSON.parse((await request('/')).body);
const ifs = JSON.parse((await request('/?model=ifs')).body);
assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/);
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/);
// Each model's grid resolves through its own model parameter.
assert.equal(
(await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
assert.equal(
(await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind rejects a non-finite grid instead of caching it', async () => {
const bad = new Float32Array(12);
bad[3] = Number.POSITIVE_INFINITY;
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: bad, v: new Float32Array(12) },
}),
},
}),
);
const body = JSON.parse((await request('/')).body);
assert.equal(body.unavailable, true);
assert.equal(body.reason, 'Wind upstream unavailable');
});
test('wind rejects unknown models, paths and methods before acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error();
},
},
}),
);
assert.equal((await request('/?model=toString')).statusCode, 400);
assert.equal((await request('/secret')).statusCode, 404);
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
assert.equal(calls, 0);
});
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(step),
v: new Float32Array(12),
},
}),
},
}),
);
const first = JSON.parse((await request('/')).body);
clock += 3600_000;
step = 7;
const second = JSON.parse((await request('/')).body);
assert.notEqual(first.gridUrl, second.gridUrl);
assert.equal(
(await request(first.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error('private upstream detail');
},
},
}),
);
const results = await Promise.all([request('/'), request('/')]);
assert.equal(calls, 1);
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
await request('/');
assert.equal(calls, 1);
});
test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => {
const { EventEmitter } = await import('node:events');
let handler;
let signal;
let loads = 0;
const plugin = proxy({
models: {
gfs: ({ signal: next }) => {
loads++;
signal = next;
return new Promise((resolve, reject) =>
next.addEventListener('abort', () => reject(next.reason), {
once: true,
}),
);
},
},
});
plugin.configureServer({
middlewares: {
use: (_path, value) => {
handler = value;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
const a = handler({ url: '/manifest', method: 'GET' }, first);
const b = handler({ url: '/manifest', method: 'GET' }, second);
assert.equal(loads, 1);
first.emit('close');
assert.equal(signal.aborted, false);
second.emit('close');
assert.equal(signal.aborted, true);
await Promise.all([a, b]);
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
function weatherModel({ overlay = 'none', step = 6 } = {}) {
const scalar =
overlay === 'none'
? null
: {
kind: overlay,
units: overlay === 'temperature' ? '°C' : 'hPa',
level:
overlay === 'temperature' ? '2 m above ground' : 'mean sea level',
};
return {
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m above ground',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(1),
v: new Float32Array(12).fill(2),
...(scalar
? {
scalar: new Float32Array(12).fill(
overlay === 'temperature' ? 20 : 1013.25,
),
}
: {}),
},
...(scalar ? { scalar } : {}),
};
}
test('weather variants cache independently, bind binary URLs, and retain two generations per slot', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const calls = [];
const model =
(name) =>
async ({ overlay }) => {
calls.push(`${name}:${overlay}`);
return weatherModel({ overlay, step });
};
const request = install(
proxy({
now: () => clock,
models: { gfs: model('gfs'), ifs: model('ifs') },
}),
);
const slots = [];
for (const name of ['gfs', 'ifs'])
for (const overlay of ['none', 'temperature', 'pressure']) {
const query = `?model=${name}&overlay=${overlay}`;
const [a, b] = await Promise.all([
request(`/manifest${query}`),
request(`/manifest${query}`),
]);
const manifest = JSON.parse(a.body);
assert.deepEqual(JSON.parse(b.body), manifest);
assert.equal(
manifest.scalar?.kind,
overlay === 'none' ? undefined : overlay,
);
const binary = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(binary.body.length, 12 * (overlay === 'none' ? 8 : 12));
const floats = new Float32Array(
binary.body.buffer,
binary.body.byteOffset,
binary.body.length / 4,
);
assert.deepEqual(
[...floats.slice(0, 24)],
[...Array(12).fill(1), ...Array(12).fill(2)],
);
if (overlay !== 'none') {
assert.deepEqual(
[...floats.slice(24)],
Array(12).fill(overlay === 'temperature' ? 20 : 1013.25),
);
assert.equal(
(
await request(
manifest.gridUrl
.replace('/api/wind', '')
.replace(`&overlay=${overlay}`, ''),
)
).statusCode,
404,
);
}
slots.push({ query, first: manifest.gridUrl });
}
assert.equal(
calls.length,
6,
'one shared acquisition for each bounded variant',
);
for (const slot of slots) await request(`/manifest${slot.query}`);
assert.equal(calls.length, 6);
clock += 3600_000;
step = 7;
for (const slot of slots)
slot.second = JSON.parse(
(await request(`/manifest${slot.query}`)).body,
).gridUrl;
for (const slot of slots)
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
200,
);
clock += 3600_000;
step = 8;
for (const slot of slots) {
await request(`/manifest${slot.query}`);
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
404,
);
assert.equal(
(await request(slot.second.replace('/api/wind', ''))).statusCode,
200,
);
}
});
test('overlay failures retain only their own last-good scalar and unknown overlays never acquire', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let failing = false;
let calls = 0;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
calls++;
if (failing && overlay !== 'none') throw new Error('private detail');
return weatherModel({ overlay });
},
},
}),
);
const first = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
await request('/manifest');
failing = true;
clock += 3600_000;
const stale = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(stale.stale, true);
assert.equal(stale.gridUrl, first.gridUrl);
assert.equal(stale.scalar.kind, 'temperature');
assert.equal(
JSON.parse((await request('/manifest?overlay=pressure')).body).unavailable,
true,
);
assert.equal(JSON.parse((await request('/manifest')).body).stale, false);
const before = calls;
for (const overlay of ['toString', 'humidity', 'temperature,pressure']) {
assert.equal(
(await request(`/manifest?overlay=${overlay}`)).statusCode,
400,
);
}
assert.equal(calls, before);
});
test('weather provider rejects malformed scalar metadata, lengths and non-finite values', async () => {
const mutations = [
(value) => {
value.scalar.units = 'K';
},
(value) => {
value.grid.scalar = new Float32Array(11);
},
(value) => {
value.grid.scalar[3] = Infinity;
},
(value) => {
value.grid.v = new Float32Array(1);
},
];
for (const mutate of mutations) {
const request = install(
proxy({
models: {
gfs: async ({ overlay }) => {
const value = weatherModel({ overlay });
mutate(value);
return value;
},
},
}),
);
const manifest = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(manifest.unavailable, true);
assert.equal(manifest.gridUrl, undefined);
}
});
test('disconnecting an overlay request does not abort a different variant', async () => {
const { EventEmitter } = await import('node:events');
let handler;
const pending = new Map();
proxy({
models: {
gfs: ({ overlay, signal }) =>
new Promise((resolve, reject) => {
pending.set(overlay, { signal, resolve });
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
},
}).configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
second.writeHead = () => {};
second.end = (body) => {
second.body = body;
};
const a = handler(
{ url: '/manifest?overlay=temperature', method: 'GET' },
first,
);
const b = handler(
{ url: '/manifest?overlay=pressure', method: 'GET' },
second,
);
first.emit('close');
assert.equal(pending.get('temperature').signal.aborted, true);
assert.equal(pending.get('pressure').signal.aborted, false);
pending.get('pressure').resolve(weatherModel({ overlay: 'pressure' }));
await Promise.all([a, b]);
assert.equal(JSON.parse(second.body).scalar.kind, 'pressure');
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
test('optional scalar failure serves wind and recovery has a distinct immutable grid URL', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let missing = true;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
if (!missing) return weatherModel({ overlay });
return {
...weatherModel(),
scalarError: 'Temperature field unavailable',
};
},
},
}),
);
const partial = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(partial.overlay, 'temperature');
assert.equal(partial.unavailable, false);
assert.equal(partial.stale, false);
assert.equal(partial.scalar, undefined);
assert.equal(partial.scalarError, 'Temperature field unavailable');
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
clock += 3600_000;
missing = false;
const complete = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.deepEqual(
complete.cycle,
partial.cycle,
'recovery is the same cycle without mixing components',
);
assert.equal(complete.scalar.kind, 'temperature');
assert.equal(complete.scalarError, undefined);
assert.notEqual(
complete.gridUrl,
partial.gridUrl,
'8-byte and 12-byte payloads cannot reuse immutable URL',
);
assert.equal(
(await request(complete.gridUrl.replace('/api/wind', ''))).body.length,
12 * 12,
);
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
});
+3 -3
View File
@@ -657,15 +657,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
// ALPR deliberately adds its ID to the two layer menus and visibility aliases.
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
assert.equal(block.length, 26208, 'serialized tool schema length drifted');
assert.equal(block.length, 27271, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+24 -4
View File
@@ -24,7 +24,12 @@ import {
geoidHeight,
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -649,7 +654,11 @@ export class IntelHUD {
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
);
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
return provenance ? `${line} | ${provenance}` : line;
}
/**
@@ -734,7 +743,12 @@ export class IntelHUD {
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
this._setSummaryText(data.summary, animate);
this._setSummaryText(
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
? data.summary
: fallbackText,
animate,
);
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('[HUD] AI summary unavailable:', error);
@@ -775,6 +789,7 @@ export class IntelHUD {
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers,
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
};
}
@@ -904,7 +919,12 @@ export class IntelHUD {
this._dataManager = dataManager || null;
if (typeof this._dataManager?.subscribe === 'function') {
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
if (change?.type === 'visibility') this._markSummaryDirty();
if (
['visibility', 'refresh-transition', 'refresh-cancelled'].includes(
change?.type,
)
)
this._markSummaryDirty();
});
}
this._markSummaryDirty();
+74
View File
@@ -1,3 +1,8 @@
import {
feedProvenanceEnvelope,
layerSnapshots,
} from './data/layerSnapshot.js';
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
/**
@@ -36,3 +41,72 @@ export function isHudSummaryUnconfigured(status, data) {
data?.summary === null
);
}
/**
* HUD summary model instructions. The five-word line may only describe
* supplied labels, and must carry feed-state when any enabled layer is not
* nominal.
*/
export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
'Do not infer from coordinates or invent a place or feed state.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
].join(' ');
/**
* Stamp HUD summary context with the same layer snapshots voice tools use.
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{
* enabledLayerLabels: string[],
* enabledLayers: Array<{id: string|null, name: string|null, feedState: string, source: string|null, count: number}>,
* feedProvenance: object,
* }}
*/
export function hudSummaryLayerContext(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
return {
enabledLayerLabels: snapshots.map((s) => s.name).filter(Boolean),
enabledLayers: snapshots.map((s) => ({
id: s.id,
name: s.name,
feedState: s.feedState,
source: s.source,
count: s.count,
})),
feedProvenance: envelope,
};
}
/**
* Deterministic telemetry suffix when an enabled feed is not nominal.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {string|null}
*/
export function hudTelemetryProvenanceTag(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
const names = envelope.layers
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
.slice(0, 2);
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
}
/** Require the supplied non-nominal state before showing an AI summary. */
export function hudSummaryMatchesProvenance(summary, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return String(summary || '')
.toUpperCase()
.split(/\W+/)
.includes(String(state).toUpperCase());
}
+49
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
@@ -128,3 +132,48 @@ test('the installed keyless HUD route stays successful after the voice quota is
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
}
});
test('HUD summary context and telemetry tag carry chip-identical feed-state', () => {
const now = 1_000_000;
const layers = [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 3, lastUpdate: now },
},
];
const context = hudSummaryLayerContext(layers, { now });
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
assert.equal(context.enabledLayers[0].feedState, 'stale');
assert.equal(context.feedProvenance.overall, 'stale');
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
assert.equal(hudTelemetryProvenanceTag([{
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
}], { now }), null);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
});
+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+3
View File
@@ -83,6 +83,9 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+6 -1
View File
@@ -215,7 +215,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
+111
View File
@@ -0,0 +1,111 @@
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});
+447
View File
@@ -0,0 +1,447 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let viewer = null,
rendering = null,
snapshot = null,
request = null,
listener = null,
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
loading = false,
error = null,
destroyed = false,
runNavigation = null;
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
rendering?.setSelection(id);
}
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
};
const capture = (event) => {
capturedHit = null;
const point = event.changedTouches?.[0] || event;
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
return;
const rect = canvas.getBoundingClientRect();
const x = point.clientX - rect.left,
y = point.clientY - rect.top;
capturedHit = { x, y, ...overlayHit(x, y) };
};
const resetCapture = () => {
capturedHit = null;
};
const resetEvents = [
'pointerdown',
'mousedown',
'touchstart',
'pointercancel',
'touchcancel',
];
for (const type of resetEvents)
canvas.addEventListener?.(type, resetCapture, { capture: true });
const events = ['pointerup', 'mouseup', 'touchend'];
for (const type of events)
canvas.addEventListener?.(type, capture, { capture: true });
removeClickCapture = () => {
for (const type of events)
canvas.removeEventListener?.(type, capture, { capture: true });
for (const type of resetEvents)
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
// the pointer, camera, or tracking state.
if (
!enabled ||
destroyed ||
clickHandler !== owner ||
!isPointerFree() ||
!click?.position
)
return;
// AIS cards paint above the globe on a pointer-events:none canvas. The
// vessel handler resolves this same topmost hit before cyclone geometry.
const captureMatches =
nativeHit &&
Math.abs(nativeHit.x - click.position.x) < 1 &&
Math.abs(nativeHit.y - click.position.y) < 1;
const hit = captureMatches
? nativeHit
: overlayHit(click.position.x, click.position.y);
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
// Storm cards and lead-hour labels paint on the same canvas; a click on
// one selects its storm. An id from a superseded advisory changes nothing.
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
const id = cycloneStormIdFromEntryId(hit.entryId);
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
}
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
},
attachShellServices(services) {
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
if (!destroyed && !enabled) {
enabled = true;
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
);
installSelection();
}
},
disable() {
enabled = false;
unregisterPickOwner('weather-cyclones');
removeSelection();
request?.abort();
request = null;
loading = false;
error = null;
snapshot = null;
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
if (!enabled || destroyed) return false;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const next = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
if (
!applied ||
!enabled ||
controller.signal.aborted ||
request !== controller
)
return false;
snapshot = next;
error = null;
if (!snapshot.storms.some((storm) => storm.id === selectedId))
select(
selectionIntent === 'cleared'
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
selectedId = null;
++navigationGeneration;
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
if (!enabled || destroyed) return;
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
notify();
} else if (
typeof params.stormId === 'string' &&
snapshot?.storms.some((storm) => storm.id === params.stormId)
) {
selectionIntent = 'user';
select(params.stormId);
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
if (
!enabled ||
destroyed ||
generation !== navigationGeneration ||
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
: 1.4,
});
});
}
}
if (params.advisory === true && storm?.advisoryUrl)
openLink(storm.advisoryUrl);
},
getRowControls() {
const storm = selected();
const empty =
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
const status =
error ||
(snapshot?.stale
? 'Cached advisory · stale source'
: loading
? 'Loading advisories…'
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
href: storm.advisoryUrl,
},
]
: [],
settings: [],
lines: storm
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
},
{ id: 'geometry', text: geometry, muted: true },
]
: [],
detail,
status,
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
},
chips: [],
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
};
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
const storm = selected();
return {
count: snapshot?.storms.length || 0,
lastUpdate: storm
? Date.parse(storm.issuedAt)
: snapshot?.fetchedAt || null,
loading,
error,
stale: Boolean(snapshot?.stale),
source: 'NOAA NHC / CPHC',
advisoryAt: storm?.issuedAt || null,
empty: Boolean(
snapshot && !snapshot.unavailable && !snapshot.storms.length,
),
};
},
getDiagnostics() {
return {
...rendering?.getDiagnostics(),
enabled,
loading,
requestPending: !!request,
selectionActive: clickHandler !== null,
selectedId,
selectionIntent,
timerActive: false,
};
},
destroy() {
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
listener = null;
runNavigation = null;
},
};
return layer;
}
+673
View File
@@ -0,0 +1,673 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import {
claimPointer,
releasePointer,
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
id,
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
});
const snapshot = (storms = [storm()]) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: 'Atlantic and eastern/central North Pacific',
reason: null,
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
true,
'flight tracking recognizes the cyclone pick and leaves the camera alone',
);
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
'a different click cannot reuse the captured card',
);
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
'3D Tiles surface content is empty map',
);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(
isPointerFree(),
true,
'ambient selection never claims the pointer',
);
layer.disable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
);
assert.equal(handlers.length, 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: () => null,
ownsPickId: () => false,
getDiagnostics: () => ({}),
};
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
) {
const applied = [],
navigation = [],
opened = [];
let cleared = 0,
destroyed = 0,
selection = null;
const rendering = {
async setSnapshot(value) {
applied.push(value);
return true;
},
setSelection(id) {
selection = id;
},
getFocusSphere: () => ({ radius: 500000 }),
clear() {
cleared++;
},
destroy() {
destroyed++;
},
getDiagnostics: () => ({
entities: applied.at(-1)?.storms.length || 0,
timerActive: false,
}),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
return fn();
},
});
return {
layer,
applied,
navigation,
opened,
get cleared() {
return cleared;
},
get destroyed() {
return destroyed;
},
get selection() {
return selection;
},
};
}
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm('ep162026')]);
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm(), storm('ep162026')]);
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep162026' });
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
assert.equal(h.navigation.length, 0);
next = snapshot([storm()]);
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
for (const clear of [{ stormId: null }, { clear: true }]) {
h.layer.setParams({ stormId: 'ep152026' });
h.layer.setParams(clear);
assert.equal(h.selection, null);
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
await h.layer.update();
assert.equal(h.selection, null);
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
}
h.layer.disable();
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
});
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
const staged = deferred();
const staging = deferred();
let calls = 0;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot() },
createRendering: () => ({
setSnapshot() {
if (++calls === 1) return true;
staging.resolve();
return staged.promise;
},
setSelection() {},
getDiagnostics: () => ({}),
clear() {},
destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const pending = layer.update();
await staging.promise;
layer.setParams({ clear: true });
staged.resolve(true);
await pending;
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
layer.destroy();
});
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
if (change === 'select') {
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep152026' });
} else if (change === 'clear') h.layer.setParams({ stormId: null });
else if (change === 'fallback') {
next = snapshot([storm('ep162026')]);
await h.layer.update();
} else h.layer[change]();
callbacks.shift()();
assert.equal(h.navigation.length, 0, change);
h.layer.destroy();
}
});
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
const h = harness();
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
h.layer.setParams({ stormId: 'ep152026', focus: true });
await h.layer.update();
callbacks[0]();
assert.equal(h.navigation.length, 0);
callbacks[1]();
assert.equal(h.navigation.length, 1);
assert.equal(h.navigation[0].options.duration, 1.4);
h.layer.destroy();
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
const record = { ...storm(), classification: code };
const h = harness({ getSnapshot: async () => snapshot([record]) });
try {
h.layer.enable();
await h.layer.update();
const controls = h.layer.getRowControls();
assert.ok(controls.summary.detail.includes(` · ${label} · `));
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
assert.equal(record.classification, code);
assert.equal(h.applied[0].storms[0].classification, code);
} finally {
h.layer.destroy();
}
}
});
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
const h = harness(
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
{ reducedMotion: true },
);
h.layer.enable();
assert.equal(await h.layer.update(), true);
const controls = h.layer.getRowControls();
assert.equal(controls.readout, true);
assert.deepEqual(controls.chips, []);
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
assert.equal(controls.summary.lines.length, 3);
assert.deepEqual(controls.summary.settings, []);
assert.deepEqual(controls.summary.actions, [
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
]);
assert.match(controls.summary.compact, /2 active storms · .* selected/);
assert.equal(controls.summary.sections, undefined);
assert.match(controls.list.ariaLabel, /NHC/);
assert.equal(controls.list.items.length, 2);
assert.equal(controls.list.items[0].active, true);
assert.match(controls.info, /awaiting advisory 10/);
assert.equal(
controls.info.split('Track/cone awaiting advisory 10').length - 1,
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
assert.equal(
controls.chips.some((chip) => chip.id === 'focus'),
false,
);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.selection, 'ep162026');
assert.equal(h.navigation[0], 'claimed');
assert.equal(h.navigation[1].options.duration, 0);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.navigation.length, 4, 'selected row focuses again');
h.layer.setParams({ advisory: true });
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
h.layer.disable();
h.layer.setParams({ focus: true, advisory: true });
assert.equal(h.navigation.length, 4);
assert.equal(h.opened.length, 1);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.layer.getDiagnostics().timerActive, false);
h.layer.destroy();
assert.equal(h.destroyed, 1);
});
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
let next = snapshot([]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().empty, true);
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
next = { ...snapshot(), stale: true };
await h.layer.update();
assert.match(h.layer.getRowControls().summary.status, /stale/);
assert.match(
h.layer.getRowControls().info,
/Track\/cone awaiting advisory 10/,
);
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.equal(h.layer.getStats().empty, false);
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
const first = deferred(),
second = deferred();
const signals = [];
let calls = 0;
const h = harness({
getSnapshot: ({ signal }) => {
signals.push(signal);
return (++calls === 1 ? first : second).promise;
},
});
h.layer.enable();
const old = h.layer.update(),
latest = h.layer.update();
assert.equal(signals[0].aborted, true);
second.resolve(snapshot());
assert.equal(await latest, true);
first.resolve(snapshot([storm('ep162026')]));
assert.equal(await old, false);
assert.equal(h.applied.length, 1);
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
const delayed = deferred();
const disabled = harness({ getSnapshot: () => delayed.promise });
disabled.layer.enable();
const work = disabled.layer.update();
disabled.layer.disable();
delayed.resolve(snapshot());
assert.equal(await work, false);
assert.equal(disabled.applied.length, 0);
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
disabled.layer.destroy();
});
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return snapshot();
},
});
h.layer.enable();
assert.equal(
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
false,
);
assert.equal(h.applied.length, 0);
await h.layer.update();
fail = true;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
assert.match(h.layer.getStats().error, /Network/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
+180
View File
@@ -0,0 +1,180 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
/**
* @module cyclones/labels
* @description Storm cards and lead-hour labels on the shared world-overlay
* host. The renderer supplies the anchors its points are drawn and
* horizon-culled at, plus the selection; the host owns projection, the same
* ellipsoid horizon test, collisions, fades and paint.
*/
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
/** Lead-hour labels show within 4,000 km of the camera. */
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
/** Advisory centre and forecast track; single source for points and cards. */
export const CYCLONE_ACCENT = '#7fe6ed';
/** Selected advisory centre. */
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
/** Point sizes the cards keep clear of. */
export const CYCLONE_MARKER_PX = Object.freeze({
storm: 9,
selected: 12,
forecast: 5,
});
const STORM_PRIORITY = 1000;
const LEAD_PRIORITY = 500;
const CYCLONE_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
function markerClearance(markerPx) {
const gapPx = Math.max(12, markerPx + 8);
return {
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
};
}
const SHARED_CARD = Object.freeze({
cardStyle: 'tactical',
collisionGroup: 'ambient-card',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// A click on a card selects its storm.
interactive: true,
});
/**
* One card per storm: name, then the source classification code.
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
),
id: `storm:${storm.id}`,
position: storm.position,
variant: selected ? 'selected' : 'card',
title: storm.name,
details: storm.classification ? [storm.classification] : [],
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
selected,
protected: selected,
priority: selected
? Number.MAX_SAFE_INTEGER
: STORM_PRIORITY + (Number(storm.windKt) || 0),
maxDistance: Number.POSITIVE_INFINITY,
};
}
/**
* Lead-hour label at one forecast point of the selected storm. Earlier lead
* times win collisions.
* @param {string} stormId
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
id: `lead:${stormId}:${point.tauHours}`,
position: point.position,
variant: 'card',
title: `${point.tauHours} h`,
details: [],
accent: CYCLONE_ACCENT,
selected: false,
protected: false,
priority: LEAD_PRIORITY - point.tauHours,
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
// Hard cutoff at the limit, without a fade.
distanceFadeStartRatio: 1,
};
}
/**
* Storm cards for every storm and lead-hour labels for the selected one.
* @param {Array<object>} storms Renderer anchors with `forecasts`.
* @param {?string} selectedId
* @returns {Array<object>}
*/
export function cycloneOverlayEntries(storms, selectedId) {
const entries = [];
for (const storm of storms) {
const selected = storm.id === selectedId;
entries.push(cycloneStormEntry(storm, selected));
if (!selected) continue;
for (const point of storm.forecasts || [])
entries.push(cycloneLeadEntry(storm.id, point));
}
return entries;
}
/** Storm id behind a published card or lead-hour label id, else null. */
export function cycloneStormIdFromEntryId(entryId) {
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
return match ? match[1] : null;
}
/**
* Publish the renderer's committed anchors and selection to the overlay host.
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
*/
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
let storms = [];
let selectedId = null;
let published = false;
function publish() {
const entries = cycloneOverlayEntries(storms, selectedId);
let ambient = 0;
for (const entry of entries) if (!entry.protected) ambient++;
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
moving: false,
});
published = true;
}
return {
/** Replace every anchor after a snapshot commits. */
setSnapshot(nextStorms, nextSelectedId = selectedId) {
storms = nextStorms;
selectedId = nextSelectedId;
publish();
},
/** Republish only when the selection changes a published snapshot. */
setSelection(id) {
if (id === selectedId) return;
selectedId = id;
if (published) publish();
},
clear() {
storms = [];
selectedId = null;
if (!published) return;
published = false;
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
},
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
CYCLONE_OVERLAY_COHORT_LIMIT,
CYCLONE_OVERLAY_SOURCE_ID,
createCycloneLabels,
cycloneOverlayEntries,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
WORLD_OVERLAY_PAINT_LANES,
} from '../../overlays/worldOverlay.js';
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
const anchor = (longitude, latitude) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
const storms = () => [
{
id: 'al062026',
name: 'Fay',
classification: 'TS',
windKt: 50,
position: anchor(-60, 20),
forecasts: [
{ tauHours: 12, position: anchor(-61, 21) },
{ tauHours: 24, position: anchor(-62, 22) },
],
},
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
windKt: null,
position: anchor(-125, 15),
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
},
];
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
const input = storms();
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
assert.deepEqual(
[...entries.keys()],
[
'storm:al062026',
'lead:al062026:12',
'lead:al062026:24',
'storm:ep152026',
],
'lead-hour labels belong to the selected storm only',
);
const selected = entries.get('storm:al062026');
const ambient = entries.get('storm:ep152026');
for (const card of [selected, ambient]) {
assert.equal(card.cardStyle, 'tactical');
assert.equal(card.collisionGroup, 'ambient-card');
assert.equal(card.verticalOnly, true);
assert.equal(card.horizonCull, true);
assert.equal(card.edgeFade, 'keyhole');
assert.equal(card.interactive, true);
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
}
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
assert.equal(selected.title, 'Fay');
assert.deepEqual(selected.details, ['TS']);
assert.equal(selected.variant, 'selected');
assert.equal(selected.selected, true);
assert.equal(selected.protected, true);
assert.equal(selected.accent, '#ffe19a');
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
assert.equal(selected.gapPx, 20);
assert.equal(selected.leaderOffsetPx, 14);
assert.equal(ambient.title, 'Fifteen-E');
assert.deepEqual(ambient.details, ['PTC']);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.accent, '#7fe6ed');
assert.equal(ambient.gapPx, 17);
assert.equal(ambient.leaderOffsetPx, 11);
const lead = entries.get('lead:al062026:24');
assert.equal(lead.position, input[0].forecasts[1].position);
assert.equal(lead.title, '24 h');
assert.deepEqual(lead.details, []);
assert.equal(lead.cardStyle, 'tactical');
assert.equal(lead.collisionGroup, 'ambient-card');
assert.equal(lead.protected, false);
assert.equal(lead.interactive, true);
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
assert.equal(lead.gapPx, 13);
assert.equal(lead.leaderOffsetPx, 7);
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
const none = cycloneOverlayEntries(input, null);
assert.deepEqual(
none.map((entry) => [entry.id, entry.variant]),
[
['storm:al062026', 'card'],
['storm:ep152026', 'card'],
],
);
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
});
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
);
const lane = (entry) =>
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
const [selected, lead] = entries;
assert.equal(lane(selected), 'selected');
assert.equal(lane(lead), 'ambient-card');
assert.equal(lane(entries.at(-1)), 'ambient-card');
for (const entry of entries) {
assert.equal(entry.collisionGroup, 'ambient-card');
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.horizonCull, true);
}
const range = {
minDistance: lead.minDistance,
maxDistance: lead.maxDistance,
fadeStartRatio: lead.distanceFadeStartRatio,
};
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
assert.equal(
distanceFade(40_000_000, {
minDistance: selected.minDistance,
maxDistance: selected.maxDistance,
fadeStartRatio: selected.distanceFadeStartRatio,
}),
1,
'storm cards have no distance limit',
);
});
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
const calls = [];
const host = {
setEntries: (...args) => calls.push(['entries', ...args]),
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const labels = createCycloneLabels({ host });
labels.setSelection('al062026');
labels.clear();
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
labels.setSnapshot(storms(), 'al062026');
assert.deepEqual(
calls.map(([kind, source]) => [kind, source]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID],
['entries', CYCLONE_OVERLAY_SOURCE_ID],
],
);
assert.equal(calls[0][2], true);
const [, , entries, options] = calls[1];
assert.equal(entries.length, 4);
assert.deepEqual(options, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: 3,
moving: false,
});
calls.length = 0;
labels.setSelection('al062026');
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
labels.setSelection('ep152026');
const published = calls.find(([kind]) => kind === 'entries')[2];
assert.deepEqual(
published.map((entry) => entry.id),
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
);
assert.equal(published[1].variant, 'selected');
calls.length = 0;
labels.clear();
assert.deepEqual(calls, [
['clear', CYCLONE_OVERLAY_SOURCE_ID],
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
]);
labels.clear();
labels.setSelection('al062026');
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
});
test('storm identity resolves from card and lead-hour ids only', () => {
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
});
+314
View File
@@ -0,0 +1,314 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
return (
storm.geometryStatus === 'current' &&
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
+669
View File
@@ -0,0 +1,669 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCycloneRendering } from './rendering.js';
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
/** Records what the renderer publishes to the shared overlay host. */
function overlayRecorder() {
const calls = [];
return {
calls,
host: {
setEntries: (source, entries, options) =>
calls.push({ kind: 'entries', source, entries, options }),
setVisible: (source, visible) =>
calls.push({ kind: 'visible', source, visible }),
clearSource: (source) => calls.push({ kind: 'clear', source }),
},
get publishes() {
return calls.filter(({ kind }) => kind === 'entries').length;
},
published() {
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
},
entry(id) {
return this.published().find((entry) => entry.id === id) || null;
},
};
}
function harness({ deferred = false } = {}) {
const sources = [],
completions = [],
pointOccluders = [],
sphereOccluders = [];
const listeners = new Set();
const visibility = {
points: new Map(),
spheres: new Map(),
pointCalls: [],
sphereCalls: [],
writes: 0,
};
const color = (value) => ({
value,
withAlpha: (alpha) => ({ value, alpha }),
});
const cesium = {
Color: {
fromCssColorString: color,
WHITE: color('white'),
BLACK: color('black'),
},
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Cartesian3: {
ZERO: { x: 0, y: 0, z: 0 },
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
},
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
EllipsoidalOccluder: class {
constructor(ellipsoid, cameraPosition) {
this.ellipsoid = ellipsoid;
this.cameraPosition = cameraPosition;
pointOccluders.push(this);
}
isPointVisible(position) {
visibility.pointCalls.push(position);
return visibility.points.get(position) ?? true;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
this.sphere = sphere;
this.cameraPosition = cameraPosition;
sphereOccluders.push(this);
}
isBoundingSphereVisible(sphere) {
visibility.sphereCalls.push(sphere);
return visibility.spheres.get(sphere) ?? true;
}
},
PolygonHierarchy: class {
constructor(positions, holes = []) {
this.positions = positions;
this.holes = holes;
}
},
BoundingSphere: class {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
static fromPoints(points) {
return { points, radius: 10 };
}
},
LabelStyle: { FILL_AND_OUTLINE: 1 },
HorizontalOrigin: { LEFT: 1 },
ArcType: { GEODESIC: 1 },
HeightReference: Cesium.HeightReference,
ClassificationType: Cesium.ClassificationType,
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
CustomDataSource: class {
constructor() {
const values = [];
this.entities = {
values,
add: (value) => {
let show = true;
Object.defineProperty(value, 'show', {
get: () => show,
set: (next) => {
show = next;
visibility.writes++;
},
});
values.push(value);
return value;
},
removeAll: () => {
values.length = 0;
},
};
}
},
};
let renders = 0;
const overlay = overlayRecorder();
const viewer = {
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
scene: {
requestRender: () => renders++,
preRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
dataSources: {
add(value) {
if (deferred)
return new Promise((resolve) =>
completions.push(() => {
sources.push(value);
resolve(value);
}),
);
sources.push(value);
return Promise.resolve(value);
},
remove(value) {
const i = sources.indexOf(value);
if (i >= 0) sources.splice(i, 1);
},
},
};
return {
rendering: createCycloneRendering({
viewer,
cesium,
overlayHost: overlay.host,
}),
overlay,
sources,
completions,
viewer,
cesium,
visibility,
pointOccluders,
sphereOccluders,
listeners,
frame() {
for (const listener of listeners) listener();
},
get renders() {
return renders;
},
};
}
const storm = () => ({
id: 'ep152026',
name: 'Fifteen-E',
advisoryNumber: '10',
geometryAdvisoryNumber: '10',
geometryStatus: 'current',
position: { longitude: 179, latitude: 15 },
forecastPoints: [
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
cone: {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
},
});
test('horizon culling updates only changed entities and keeps selection independent', async () => {
const h = harness();
await h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near' }],
});
const entities = h.sources[0].entities.values;
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
const sphere = h.rendering.getFocusSphere('ep152026');
for (const entity of far)
if (entity.position) h.visibility.points.set(entity.position, false);
h.visibility.spheres.set(sphere, false);
h.rendering.setSelection('ep152026');
const publishes = h.overlay.publishes;
const before = h.renders;
h.frame();
assert.ok(far.every((e) => e.show === false));
assert.ok(near.every((e) => e.show === true));
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
forecast.position,
'the host culls labels at the same anchors the points use',
);
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
assert.equal(h.visibility.pointCalls.length, 4);
assert.deepEqual(h.visibility.sphereCalls, [
sphere,
h.rendering.getFocusSphere('near'),
]);
h.frame();
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
// A visible portion of the extent keeps all tracks/cones shown even when
// the centre and an individual forecast point remain beyond the horizon.
h.visibility.spheres.set(sphere, true);
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
h.frame();
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
assert.equal(h.renders, before + 2);
assert.equal(h.pointOccluders.length, 1);
assert.equal(h.sphereOccluders.length, 1);
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
h.rendering.setSelection('near');
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(
h.overlay.entry('lead:near:12').position,
near.find((e) => e.id.endsWith('forecast:0')).position,
);
h.visibility.points.set(forecast.position, true);
const beforeReveal = h.renders;
const revealPublishes = h.overlay.publishes;
h.frame();
assert.equal(forecast.show, true);
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(h.overlay.publishes, revealPublishes);
assert.equal(far[0].show, false);
assert.equal(h.renders, beforeReveal + 1);
h.rendering.destroy();
});
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
const h = harness();
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
const old = [...h.sources[0].entities.values];
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.frame();
assert.ok(
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
assert.equal(h.listeners.size, 0);
const before = h.renders;
h.frame();
assert.equal(h.renders, before);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.rendering.destroy();
h.rendering.destroy();
assert.equal(h.listeners.size, 0);
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
assert.equal(h.listeners.size, 0);
});
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
for (const method of ['clear', 'destroy']) {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 0);
h.rendering[method]();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.listeners.size, 0);
}
});
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
for (const globeShow of [true, false]) {
const sources = [];
const viewer = {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
scene: {
globe: { show: globeShow },
preRender: new Cesium.Event(),
requestRender() {},
},
dataSources: {
async add(source) {
sources.push(source);
},
remove() {},
},
};
const overlay = overlayRecorder();
const rendering = createCycloneRendering({
viewer,
cesium: Cesium,
overlayHost: overlay.host,
});
const at = (id, longitude) => ({
...storm(),
id,
position: { longitude, latitude: 0 },
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
track: {
type: 'LineString',
coordinates: [
[longitude, 0],
[longitude, 1],
],
},
cone: {
type: 'Polygon',
coordinates: [
[
[longitude, 0],
[longitude + 1, 0],
[longitude, 1],
[longitude, 0],
],
],
},
});
await rendering.setSnapshot({
storms: [at('near', 0), at('far', 180), at('limb', 5)],
});
rendering.setSelection('near');
viewer.scene.preRender.raiseEvent();
const entities = sources[0].entities.values;
for (const entity of entities.filter((e) => e.position)) {
assert.equal(
entity.point.heightReference.getValue(),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
assert.equal(entity.label, undefined, 'no Cesium label graphics');
const published = overlay.entry(
entity.id.includes(':forecast:')
? `lead:${entity.id.split(':')[1]}:24`
: `storm:${entity.id.split(':')[1]}`,
);
if (entity.id.startsWith('cyclone:near:')) {
assert.ok(
Cesium.Cartesian3.equals(
published.position,
entity.position.getValue(),
),
);
assert.equal(published.horizonCull, true);
}
if (entity.id === 'cyclone:near:forecast:0')
assert.equal(published.maxDistance, 4_000_000);
}
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:near:'))
.every((e) => e.show),
);
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:far:'))
.every((e) => !e.show),
);
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
rendering.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
}
});
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const oldEntities = [...h.sources[0].entities.values];
assert.equal(oldEntities.length, 8);
for (const entity of oldEntities) {
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
assert.equal(h.rendering.ownsPickId(entity.id), true);
assert.equal(
h.rendering.pickStorm({ id: { ...entity } }),
null,
'copied ID is not ownership',
);
assert.equal(
h.rendering.pickStorm({ id: entity.id }),
null,
'string prefix is not ownership',
);
}
assert.equal(h.rendering.pickStorm(undefined), null);
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
await h.rendering.setSnapshot({ storms: [storm()] });
for (const entity of oldEntities)
assert.equal(h.rendering.pickStorm({ id: entity }), null);
const current = h.sources[0].entities.values[0];
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
h.rendering.clear();
assert.equal(h.rendering.pickStorm({ id: current }), null);
assert.equal(h.rendering.ownsPickId(current.id), false);
h.rendering.destroy();
});
test('registry IDs switch only when the next coherent data source commits', async () => {
const h = harness({ deferred: true });
const first = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
h.completions.shift()();
await first;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
const next = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'ep162026' }],
});
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
h.completions.shift()();
await next;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
h.rendering.destroy();
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
});
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const entities = h.sources[0].entities.values;
const cones = entities.filter((e) => e.polygon);
assert.equal(cones.length, 2);
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
for (const cone of cones) {
assert.equal(
cone.polygon.classificationType,
h.cesium.ClassificationType.BOTH,
);
assert.equal(cone.polygon.height, undefined);
assert.equal(cone.polygon.extrudedHeight, undefined);
assert.equal(cone.polygon.outline, undefined);
}
const outlines = entities.filter((e) => e.id.includes(':outline:'));
assert.equal(outlines.length, 3);
assert.deepEqual(
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
storm().cone.coordinates.flat(),
);
for (const outline of outlines) {
assert.deepEqual(outline.polyline.material, {
value: '#7fe6ed',
alpha: 0.55,
});
}
for (const { polyline } of entities.filter((e) => e.polyline)) {
assert.equal(polyline.clampToGround, true);
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
assert.ok(polyline.positions.every((p) => p.height === 0));
}
assert.equal(
entities.find((e) => e.polyline).polyline.positions[1].lon,
-179,
);
assert.deepEqual(h.rendering.getDiagnostics(), {
storms: 1,
tracks: 1,
cones: 2,
forecastPoints: 1,
dataSources: 1,
entities: 8,
selectedId: null,
timerActive: false,
});
h.rendering.setSelection('ep152026');
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
entities.find((e) => e.id.endsWith('forecast:0')).position,
);
assert.ok(entities.every((e) => e.label === undefined));
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
h.rendering.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().entities, 0);
});
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
for (const changed of [
{ geometryStatus: 'pending' },
{ geometryAdvisoryNumber: '9' },
]) {
const h = harness();
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.rendering.getDiagnostics().tracks, 0);
h.rendering.destroy();
}
});
test('a data-source add settling after disable is removed, without a new owner', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
});
test('superseded asynchronous additions cannot replace newer geometry', async () => {
const h = harness({ deferred: true });
const old = h.rendering.setSnapshot({ storms: [storm()] });
const latest = h.rendering.setSnapshot({ storms: [] });
h.completions[1]();
assert.equal(await latest, true);
h.completions[0]();
assert.equal(await old, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 0);
h.rendering.destroy();
});
test('aborting a pending add retains the prior complete source', async () => {
const h = harness({ deferred: true });
const initial = h.rendering.setSnapshot({ storms: [storm()] });
h.completions.shift()();
await initial;
const controller = new AbortController();
const update = h.rendering.setSnapshot(
{ storms: [] },
{ signal: controller.signal },
);
controller.abort();
h.completions.shift()();
assert.equal(await update, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 1);
h.rendering.destroy();
});
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
});
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
h.completions.shift()();
assert.equal(await pending, true);
const entities = h.sources[0].entities.values;
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
assert.deepEqual(
h.overlay.calls
.slice(0, 2)
.map(({ kind, source, visible }) => [kind, source, visible]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
],
);
assert.deepEqual(
h.overlay.published().map((entry) => entry.id),
['storm:ep152026', 'storm:near'],
'no selection, no lead-hour labels',
);
assert.equal(h.overlay.entry('storm:near').title, 'Near');
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
h.rendering.setSelection('near');
assert.equal(center('near').point.pixelSize, 12);
assert.equal(center('ep152026').point.pixelSize, 9);
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
assert.ok(h.overlay.entry('lead:near:12'));
const publishes = h.overlay.publishes;
h.rendering.setSelection('near');
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
// A refresh republishes the new anchors with the retained selection.
const refresh = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'near', name: 'Near' }],
});
h.completions.shift()();
await refresh;
assert.deepEqual(
h.overlay.published().map((entry) => [entry.id, entry.variant]),
[
['storm:near', 'selected'],
['lead:near:12', 'card'],
],
);
assert.equal(
h.overlay.entry('storm:near').position,
h.sources[0].entities.values[0].position,
);
// A superseded addition never publishes, and clear hides the source.
const stale = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
const afterClear = h.overlay.calls.slice(-2);
assert.deepEqual(afterClear, [
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
]);
h.completions.shift()();
assert.equal(await stale, false);
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
h.rendering.destroy();
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
});
+245
View File
@@ -0,0 +1,245 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw malformed();
return value;
};
const number = (value, low, high) => {
if (value === null) return null;
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
return value;
};
const time = (value) => {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw malformed();
return value;
};
const advisory = (value) => {
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
throw malformed();
return value;
};
const position = (value) => {
if (!value || value.longitude === null || value.latitude === null)
throw malformed();
return {
longitude: number(value.longitude, -180, 180),
latitude: number(value.latitude, -90, 90),
};
};
function officialLink(value, outlook = false) {
if (value === null) return null;
const url = new URL(text(value, 256));
if (
url.origin !== 'https://www.nhc.noaa.gov' ||
url.username ||
url.password ||
url.hash
)
throw malformed();
if (
outlook
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
url.pathname + url.search,
)
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
)
throw malformed();
return url.href;
}
function geometry(value, kind, budget) {
if (value === null) return null;
const allowed =
kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!allowed.includes(value?.type)) throw malformed();
const point = (pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
pair.some((v) => typeof v !== 'number')
)
throw malformed();
position({ longitude: pair[0], latitude: pair[1] });
if (++budget.coordinates > 25_000) throw malformed();
return [...pair];
};
const list = (items, read, min = 1, max = 128) => {
if (!Array.isArray(items) || items.length < min || items.length > max)
throw malformed();
return items.map(read);
};
const line = (pairs) => list(pairs, point, 2, 10_000);
const ring = (pairs) => {
const result = list(pairs, point, 4, 10_000);
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
throw malformed();
return result;
};
const polygon = (rings) => list(rings, ring);
const coordinates =
value.type === 'LineString'
? line(value.coordinates)
: value.type === 'MultiLineString'
? list(value.coordinates, line)
: value.type === 'Polygon'
? polygon(value.coordinates)
: list(value.coordinates, polygon);
return { type: value.type, coordinates };
}
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
export function validateCycloneSnapshot(value) {
if (
!value ||
value.schemaVersion !== 1 ||
typeof value.stale !== 'boolean' ||
typeof value.unavailable !== 'boolean' ||
!Array.isArray(value.storms) ||
value.storms.length > 32
)
throw malformed();
const seen = new Set(),
budget = { coordinates: 0, points: 0 };
const storms = value.storms.map((raw) => {
if (
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
seen.has(raw.id) ||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
)
throw malformed();
seen.add(raw.id);
const advisoryNumber = advisory(raw.advisoryNumber);
const geometryAdvisoryNumber =
raw.geometryAdvisoryNumber === null
? null
: advisory(raw.geometryAdvisoryNumber);
if (
raw.geometryStatus === 'current' &&
geometryAdvisoryNumber !== advisoryNumber
)
throw malformed();
if (
!Array.isArray(raw.forecastPoints) ||
(budget.points += raw.forecastPoints.length) > 500
)
throw malformed();
const taus = new Set();
const forecastPoints = raw.forecastPoints.map((point) => {
const tauHours = number(point.tauHours, 0, 168);
if (tauHours === null || taus.has(tauHours)) throw malformed();
taus.add(tauHours);
if (++budget.coordinates > 25_000) throw malformed();
return {
position: position(point.position),
tauHours,
windKt: number(point.windKt, 0, 300),
gustKt: number(point.gustKt, 0, 350),
};
});
const track = geometry(raw.track, 'track', budget),
cone = geometry(raw.cone, 'cone', budget);
if (
raw.geometryStatus !== 'current' &&
(track || cone || forecastPoints.length)
)
throw malformed();
if (
raw.geometryStatus === 'current' &&
(!track || !cone || !forecastPoints.length)
)
throw malformed();
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
return {
id: raw.id,
name: text(raw.name, 80),
classification: text(raw.classification, 16),
basin: raw.basin,
position: position(raw.position),
positionAt: time(raw.positionAt),
advisoryNumber,
issuedAt: time(raw.issuedAt),
windKt: number(raw.windKt, 0, 300),
pressureHpa: number(raw.pressureHpa, 800, 1100),
movement: {
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
speedKt: number(raw.movement?.speedKt, 0, 200),
},
advisoryUrl: officialLink(raw.advisoryUrl),
outlookUrl: officialLink(raw.outlookUrl, true),
geometryStatus: raw.geometryStatus,
geometryAdvisoryNumber,
forecastPoints,
track,
cone,
};
});
if (value.unavailable && storms.length) throw malformed();
return {
schemaVersion: 1,
source: text(value.source),
attribution: text(value.attribution, 240),
coverage: text(value.coverage, 240),
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
stale: value.stale,
unavailable: value.unavailable,
reason: value.reason === null ? null : text(value.reason, 240),
storms,
};
}
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
export function createCycloneSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ signal } = {}) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl('/api/cyclones', {
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Cyclone HTTP ${response.status}`);
}
const result = await readResponseJsonCapped(
response,
CYCLONE_RESPONSE_LIMIT,
controller.signal,
);
controller.signal.throwIfAborted();
return validateCycloneSnapshot(result);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCycloneSource,
validateCycloneSnapshot,
CYCLONE_RESPONSE_LIMIT,
} from './source.js';
const issuedAt = '2026-09-16T03:00:00.000Z';
function fixture() {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution: 'NOAA/NWS',
coverage: 'Atlantic and eastern/central North Pacific',
fetchedAt: Date.parse(issuedAt),
stale: false,
unavailable: false,
reason: null,
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: issuedAt,
issuedAt,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
movement: { directionDegrees: null, speedKt: null },
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
},
],
};
}
function current() {
const value = fixture(),
storm = value.storms[0];
storm.geometryStatus = 'current';
storm.geometryAdvisoryNumber = '10';
storm.forecastPoints = [
{
position: { longitude: 179, latitude: 15 },
tauHours: 12,
windKt: 30,
gustKt: 40,
},
];
storm.track = {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
[
[170, 14],
[179, 15],
],
],
};
storm.cone = {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
};
return value;
}
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
const value = current();
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
value.storms[0].cone.properties = {
html: '<script>',
href: 'https://example.invalid',
};
const result = validateCycloneSnapshot(value);
assert.deepEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
assert.equal(result.storms[0].cone.properties, undefined);
assert.notEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
});
test('successful empty coverage is distinct from unavailable', () => {
const value = fixture();
value.storms = [];
assert.equal(validateCycloneSnapshot(value).unavailable, false);
assert.equal(
validateCycloneSnapshot({
...value,
unavailable: true,
stale: true,
fetchedAt: null,
reason: 'Unavailable',
}).unavailable,
true,
);
assert.throws(
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
/Malformed/,
);
});
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
const changes = [
(storm) => {
storm.geometryAdvisoryNumber = '9';
},
(storm) => {
storm.geometryStatus = 'pending';
},
(storm) => {
storm.position.longitude = 181;
},
(storm) => {
storm.track.coordinates[0][0][0] = NaN;
},
(storm) => {
storm.cone.coordinates[0][0].pop();
},
(storm) => {
storm.forecastPoints.push(storm.forecastPoints[0]);
},
(storm) => {
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
},
(storm) => {
storm.issuedAt = '2026-02-31T00:00:00.000Z';
},
(storm) => {
storm.windKt = 9999;
},
];
for (const change of changes) {
const value = current();
change(value.storms[0]);
assert.throws(() => validateCycloneSnapshot(value));
}
const oversized = current();
oversized.storms[0].track = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () =>
Array.from({ length: 9000 }, () => [1, 2]),
),
};
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
assert.throws(
() =>
validateCycloneSnapshot({
...fixture(),
storms: Array(33).fill(fixture().storms[0]),
}),
/Malformed/,
);
});
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
const calls = [];
const source = createCycloneSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json(fixture());
},
});
assert.equal(calls.length, 0);
await source.getSnapshot();
assert.equal(calls[0].url, '/api/cyclones');
assert.equal(calls[0].options.redirect, 'error');
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls.length, 1);
const oversized = createCycloneSource({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
}),
});
await assert.rejects(oversized.getSnapshot(), /too large/);
});
test('source deadline cancels request and removes timer on completion', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const source = createCycloneSource({
timeoutMs: 10,
fetchImpl: async (_url, options) => {
signal = options.signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot();
const rejected = assert.rejects(pending, /timed out/);
t.mock.timers.tick(11);
await rejected;
assert.equal(signal.aborted, true);
});
+5
View File
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
import { createInteraction } from './interaction.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createRecords } from './records.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
parts.catalog = createCatalog(context);
parts.labels = createLabels(context);
parts.orbits = createOrbits(context);
parts.records = createRecords(context);
parts.rendering = createRendering(context);
parts.tracking = createTracking(context);
parts.testing = createTesting(context);
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
applySatellitePointFocusDeemphasis:
parts.rendering.applySatellitePointFocusDeemphasis,
getNextIssPass: parts.orbits.getNextIssPass,
getNextSatellitePass: parts.orbits.getNextSatellitePass,
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
getAnalystRecords: parts.records.getAnalystRecords,
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
+84 -6
View File
@@ -7,7 +7,7 @@ import {
degreesLat,
twoline2satrec,
} from 'satellite.js';
import { findNextIssPass } from '../../data/issPass.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Next ISS pass for an observer. Requires the catalog to have loaded (the
* satellites layer enabled at least once this session).
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
*/
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
const sat = layerState._catalog.get(ISS_NORAD);
function getNextIssPass({
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
}) {
return getNextSatellitePass(ISS_NORAD, {
latDeg,
lonDeg,
minElevDeg,
requireVisible,
fromMs,
});
}
/**
* Next pass of any catalog satellite for an observer.
* @param {number} noradId NORAD satellite catalog ID
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
*/
function getNextSatellitePass(
noradId,
{
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
},
) {
const id = Number(noradId);
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
const sat = layerState._catalog.get(id);
if (!sat || !sat.satrec) return { status: 'no-tle' };
const pass = findNextIssPass({
const pass = findNextSatellitePass({
satrec: sat.satrec,
latDeg,
lonDeg,
fromMs: Date.now(),
fromMs,
minElevDeg,
requireVisible,
});
return pass ? { status: 'ok', pass } : { status: 'none' };
}
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
function resolveSatelliteForPass(query) {
const text = String(query ?? '').trim();
if (!text || text.length > 120) return { status: 'not-found' };
const exactId = /^\d+$/.test(text) ? Number(text) : null;
if (exactId !== null) {
const sat = layerState._catalog.get(exactId);
return sat?.satrec
? { status: 'ok', noradId: exactId, name: sat.name }
: { status: 'not-found' };
}
const q = text.toLowerCase();
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
const exact = rows.filter(
([, sat]) => String(sat.name).toLowerCase() === q,
);
const matches = exact.length
? exact
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
if (!matches.length) return { status: 'not-found' };
if (matches.length > 1)
return {
status: 'ambiguous',
candidates: matches
.slice(0, 8)
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
totalMatches: matches.length,
};
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
}
/**
* Score a mission-to-catalog name match. Compact identifier containment handles
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
orbitalPeriodSeconds,
computeOrbitPath,
getNextIssPass,
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { ISS_NORAD } from './policy.js';
/**
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. `id` is the display name (same
* convention as vessels); `noradId` is the track_entity key.
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
* @returns {{id: string, noradId: string|null, name: string|null,
* lat: number|null, lon: number|null, altitudeM: number|null,
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
*/
export function mapAnalystRecord(raw) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const noradNum =
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
const noradId = Number.isFinite(noradNum)
? String(Math.trunc(noradNum))
: text(raw?.noradId);
const name = text(raw?.name);
const group = text(raw?.group);
const isIss = noradNum === ISS_NORAD;
return {
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
noradId,
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
altitudeM: num(raw?.altitudeM),
speedMps: num(raw?.speedMps),
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
group,
};
}
/** Create on-demand analyst snapshots owned by one layer instance. */
export function createRecords({ state, parts }) {
function analystGeo(noradId, sat, now) {
const pos = sat?.satrec
? parts.orbits.propagatePosition(sat.satrec, now)
: null;
if (
pos &&
Number.isFinite(pos.latitude) &&
Number.isFinite(pos.longitude)
) {
return {
lat: pos.latitude,
lon: pos.longitude,
altitudeM: pos.altitude,
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
};
}
const point = state._points.get(noradId);
if (!point?.position) return null;
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
speedMps: null,
};
}
function getAnalystRecords(maxCount = 2000) {
if (!state._enabled || !state._catalog.size) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = new Date();
const result = [];
const emit = (noradId, sat) => {
if (result.length >= limit) return false;
const pos = analystGeo(noradId, sat, now);
if (!pos) return true;
result.push(
mapAnalystRecord({
noradId,
name: sat?.name,
group: sat?.group,
lat: pos.lat,
lon: pos.lon,
altitudeM: pos.altitudeM,
speedMps: pos.speedMps,
}),
);
return result.length < limit;
};
for (const [noradId, sat] of state._catalog) {
if (sat?.group === 'dense') continue;
if (!emit(noradId, sat)) break;
}
if (result.length < limit) {
for (const [noradId, sat] of state._catalog) {
if (sat?.group !== 'dense') continue;
if (!emit(noradId, sat)) break;
}
}
return result;
}
return { getAnalystRecords };
}
+108
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { createSatelliteSource } from './source.js';
import { createSatellitesLayer } from './index.js';
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
assert.equal(second.getParams().showPoints, true);
assert.notEqual(first.getStats(), second.getStats());
});
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
const source = { readGroup() {} };
const services = Object.fromEntries(
[
'picking',
'focus',
'readout',
'overlays',
'context',
'render',
'layerState',
].map((key) => [key, {}]),
);
services.layerState.isExplicitLayerStateOrigin = () => false;
const layer = createSatellitesLayer({ source, services });
// Cold catalog (no satellites loaded yet)
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
status: 'no-tle',
});
assert.deepEqual(
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// String NORAD ID coercion
assert.deepEqual(
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Invalid / non-finite ID handling
assert.deepEqual(
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
assert.deepEqual(
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Populated catalog: verify numeric and string coercion succeed
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const satrec = twoline2satrec(L1, L2);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
});
const FROM_MS = 1221868800000;
const numericResult = layer.getNextSatellitePass(25544, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(numericResult.status, 'ok');
assert.ok(numericResult.pass);
const stringResult = layer.getNextSatellitePass('25544', {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(stringResult.status, 'ok');
assert.deepEqual(stringResult, numericResult);
const issResult = layer.getNextIssPass({
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(issResult.status, 'ok');
assert.deepEqual(issResult, numericResult);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
neighbours: [
{ noradId: 10, name: 'STARLINK ONE', satrec },
{ noradId: 11, name: 'STARLINK TWO', satrec },
],
});
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
layer._clearDenseCatalogStateForTest();
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
// Non-existent satellite ID in populated catalog
assert.deepEqual(
layer.getNextSatellitePass(99999, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
}),
{ status: 'no-tle' },
);
});
+13 -2
View File
@@ -54,7 +54,14 @@ export function createSelection({
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
const siblingOwned =
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
// Cyclone geometry is ambient context behind painted cards. Its handler
// yields to the same foreground AIS hit. Keep moving-contact precedence
// unchanged: those handlers may issue their own camera command.
const cycloneBackground =
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
if (siblingOwned && !cycloneBackground) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
@@ -64,10 +71,14 @@ export function createSelection({
click.position?.x,
click.position?.y,
{
sourceId: VESSEL_OVERLAY_SOURCE_ID,
...(cycloneBackground
? {}
: { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
},
)
: null;
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
return;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
+218
View File
@@ -0,0 +1,218 @@
export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** Transient observed time. Products must honor the signal before installing a frame. */
export function createWeatherClock({
now = Date.now,
setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout,
} = {}) {
const registry = new Map();
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
let generation = 0;
let timer = null;
let pending = null;
let destroyed = false;
const timesFor = (product) =>
[...new Set(product.getTimes())]
.filter((time) => Number.isFinite(Date.parse(time)))
.sort((a, b) => Date.parse(a) - Date.parse(b));
const getTimeline = () =>
[
...new Set(
[...registry.values()].flatMap(({ product }) =>
product.isSuspended() ? [] : timesFor(product),
),
),
].sort((a, b) => Date.parse(a) - Date.parse(b));
function selectFor(id, time) {
const product = registry.get(id)?.product;
if (!product) return null;
const at = Date.parse(time);
return (
timesFor(product).findLast((candidate) => {
const age = at - Date.parse(candidate);
return age >= 0 && age <= product.maxGapMs;
}) ?? null
);
}
const selectedFor = (product) =>
mode === 'latest'
? (timesFor(product).at(-1) ?? null)
: selectFor(product.id, target);
const getState = () => ({
mode,
target,
playing,
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
shown: product.getShownTime() ?? null,
selected: selectedFor(product),
suspended: Boolean(product.isSuspended()),
})),
});
const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState());
};
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
playing = false;
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() {
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
if (!canPlay()) playing = false;
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
entry.controller = new AbortController();
const { signal } = entry.controller;
const time = selectedFor(entry.product);
return Promise.resolve().then(() => {
if (signal.aborted || owner !== generation || destroyed) return false;
return entry.product.apply(time, { signal });
});
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
pending = null;
if (!canPlay()) playing = false;
notify();
schedule();
return true;
});
pending = result;
notify();
return result;
}
const api = {
register(product) {
if (destroyed) return () => {};
if (registry.has(product.id))
throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null };
registry.set(product.id, entry);
void api.refresh();
return () => {
if (registry.get(product.id) !== entry) return;
entry.controller?.abort();
registry.delete(product.id);
void api.refresh();
};
},
getTimeline,
selectFor,
setTarget(time) {
if (destroyed) return Promise.resolve(false);
if (!Number.isFinite(Date.parse(time)))
throw new TypeError('Weather target must be a UTC time');
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
mode = 'history';
target = normalized;
return transition();
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
mode = 'latest';
target = null;
playing = false;
return transition();
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
return api.setTarget(
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
);
}
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) return;
playing = false;
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
refresh() {
if (destroyed) return Promise.resolve(false);
if (!canPlay()) {
playing = false;
cancelTimer();
}
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
},
getState,
subscribe(listener) {
if (!destroyed) listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
++generation;
playing = false;
cancelTimer();
for (const entry of registry.values()) entry.controller?.abort();
registry.clear();
listeners.clear();
pending = null;
},
};
return api;
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherClock,
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
const p = {
id,
maxGapMs,
times: minutes.map(time),
shown: null,
suspended: false,
calls: [],
getTimes: () => p.times,
getShownTime: () => p.shown,
isSuspended: () => p.suspended,
async apply(selected, { signal }) {
p.calls.push({ selected, signal });
if (signal.aborted) return false;
p.shown = selected;
return true;
},
};
return p;
}
function harness(t) {
let at = Date.parse(time(20));
const timers = new Map();
let id = 0;
const clock = createWeatherClock({
now: () => at,
setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
});
t.after(() => clock.destroy());
return {
clock,
timers,
tick(ms) {
at += ms;
for (const [id, timer] of [...timers]) {
if (timer.due > at) continue;
timers.delete(id);
timer.fn();
}
},
};
}
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
const { clock } = harness(t);
const radar = product('radar', [10, 0, 10]);
const regional = product('regional', [5, 10]);
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
const hidden = product('hidden', [99]);
hidden.suspended = true;
for (const p of [radar, regional, global, hidden]) clock.register(p);
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
assert.equal(clock.selectFor('radar', time(9)), time(0));
assert.equal(clock.selectFor('radar', time(-1)), null);
assert.equal(clock.selectFor('radar', time(40)), time(10));
assert.equal(clock.selectFor('radar', time(41)), null);
assert.equal(clock.selectFor('global', time(120)), time(-60));
assert.equal(clock.selectFor('global', time(121)), null);
assert.equal(clock.selectFor('missing', time(10)), null);
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
const satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p);
await clock.setTarget(time(10));
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(10), time(5), null],
);
assert.deepEqual(
clock.getState().products.map(({ selected }) => selected),
[time(10), time(5), null],
);
await clock.latest();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)],
);
assert.equal(clock.getState().mode, 'latest');
assert.equal(clock.getState().target, null);
assert.equal(clock.getState().playing, false);
});
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
const { clock, timers } = harness(t);
const p = product('radar', [0, 10, 20]);
const loads = [];
p.apply = (selected, { signal }) =>
new Promise((resolve) => {
loads.push({
selected,
signal,
finish() {
if (!signal.aborted) p.shown = selected;
resolve(!signal.aborted);
},
});
});
clock.register(p);
let notifications = 0;
clock.subscribe(() => notifications++);
const old = clock.setTarget(time(0));
await flush();
const current = clock.setTarget(time(10));
await flush();
assert.equal(loads[0].signal.aborted, true);
loads[1].finish();
assert.equal(await current, true);
const count = notifications;
loads[0].finish();
assert.equal(await old, false);
assert.equal(notifications, count);
assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
});
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
const { clock } = harness(t);
clock.register(product('radar', [0, 10, 20]));
clock.register(product('satellite', [5, 15]));
await clock.step(-1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(12));
await clock.step(-1);
assert.equal(clock.getState().target, time(10));
await clock.setTarget(time(12));
await clock.step(1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(0));
await clock.step(-1);
assert.equal(clock.getState().target, time(0));
await clock.setTarget(time(20));
await clock.step(1);
assert.equal(clock.getState().target, time(20));
});
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
const { clock, tick, timers } = harness(t);
const a = product('radar', [0, 10]);
const b = product('satellite', [5]);
const loads = [];
b.apply = () =>
new Promise((resolve, reject) => loads.push({ resolve, reject }));
clock.register(a);
clock.register(b);
const start = clock.play();
await flush();
assert.equal(clock.getState().target, time(10));
tick(10_000);
assert.equal(timers.size, 0);
assert.equal(a.calls.length, 1);
loads[0].reject(new Error('unavailable'));
await start;
assert.equal(timers.size, 1);
tick(1999);
assert.equal(clock.getState().target, time(10));
tick(1);
await flush();
assert.equal(clock.getState().target, time(0));
assert.equal(timers.size, 0);
tick(10_000);
assert.equal(clock.getState().target, time(0));
loads[1].resolve(true);
await flush();
tick(2000);
await flush();
assert.equal(clock.getState().target, time(5));
clock.pause();
loads[2].resolve(false);
await flush();
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
});
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
const { clock, tick, timers } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.play();
await clock.play();
assert.equal(timers.size, 1);
clock.pause();
tick(2000);
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
await clock.togglePlay();
p.times = [time(0)];
await clock.refresh();
assert.equal(clock.getState().playing, false);
await clock.play();
assert.equal(clock.getState().playing, false);
p.times = [time(0), time(10)];
await clock.refresh();
await clock.play();
p.suspended = true;
await clock.refresh();
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(timers.size, 0);
});
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
const { clock } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.refresh();
assert.equal(p.calls.length, 0);
await clock.setTarget(time(10));
p.times = [time(0), time(20)];
await clock.refresh();
assert.equal(p.shown, time(0));
p.times = [time(20)];
await clock.refresh();
assert.equal(p.shown, null);
assert.equal(clock.getState().target, time(10));
});
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
const { clock, timers, tick } = harness(t);
const p = product('radar', [0, 10]);
const unregister = clock.register(p);
await clock.setTarget(time(0));
const b = product('satellite', [0, 5]);
clock.register(b);
await flush();
assert.equal(b.shown, time(0));
await clock.play();
unregister();
unregister();
await flush();
assert.equal(p.calls.at(-1).signal.aborted, true);
assert.deepEqual(
clock.getState().products.map(({ id }) => id),
['satellite'],
);
let notifications = 0;
const off = clock.subscribe(() => notifications++);
clock.pause();
off();
const before = notifications;
await clock.play();
assert.equal(notifications, before);
clock.destroy();
clock.destroy();
tick(2000);
assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true);
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
});
+15
View File
@@ -0,0 +1,15 @@
/** Resolve the visible surface that can own weather imagery without scene mutation. */
export function resolveImageryHost({ viewer, tileset }) {
if (viewer?.scene?.globe?.show === true)
return { collection: viewer.imageryLayers, kind: 'globe' };
if (tileset?.imageryLayers)
return { collection: tileset.imageryLayers, kind: 'tileset' };
return { collection: null, kind: 'none' };
}
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
/** Report imagery suspension without changing or detaching the resolved host. */
export function imageryHostStatus(host) {
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveImageryHost,
imageryHostStatus,
NO_IMAGERY_HOST,
} from './imageryHost.js';
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
});
test('visible globe takes precedence over a tileset', () => {
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
assert.deepEqual(
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
{ collection: viewer.imageryLayers, kind: 'globe' },
);
});
test('hidden globe uses the supplied tileset imagery collection', () => {
const tileset = { imageryLayers: {} };
const viewer = { scene: { globe: { show: false } } };
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
collection: tileset.imageryLayers,
kind: 'tileset',
});
});
test('hidden globe without an imagery-capable tileset has no host', () => {
for (const tileset of [null, {}])
assert.deepEqual(
resolveImageryHost({
viewer: { scene: { globe: { show: false } } },
tileset,
}),
{ collection: null, kind: 'none' },
);
});
+17
View File
@@ -0,0 +1,17 @@
const priorities = new WeakMap();
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
export function orderWeatherImagery(collection, layer, priority) {
priorities.set(layer, priority);
if (typeof collection.raiseToTop !== 'function') return;
const weather = [];
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (priorities.has(item)) weather.push(item);
}
const ordered = [...weather].sort(
(a, b) => priorities.get(a) - priorities.get(b),
);
if (weather.every((item, i) => item === ordered[i])) return;
for (const item of ordered) collection.raiseToTop(item);
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orderWeatherImagery } from './imageryOrder.js';
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
const base = { name: 'basemap' },
radar = { name: 'radar' },
cloud = { name: 'cloud' },
wind = { name: 'wind' },
lightning = { name: 'lightning' };
const items = [base];
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const [layer, priority] of [
[radar, 2],
[cloud, 1],
[lightning, 3],
[wind, 0],
]) {
items.push(layer);
orderWeatherImagery(collection, layer, priority);
}
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
const next = { name: 'new radar history frame' };
items.push(next);
orderWeatherImagery(collection, next, 2);
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
});
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
const tileset = { imageryLayers: new ImageryLayerCollection() };
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
for (const priority of [3, 2, 0, 1]) {
tileset.imageryLayers.add(layers[priority]);
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
}
assert.deepEqual(
layers.map((_, i) => tileset.imageryLayers.get(i)),
layers,
);
tileset.imageryLayers.destroy();
});
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
const items = [];
let raises = 0;
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
raises++;
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const priority of [0, 1, 1, 2, 3]) {
const layer = {};
items.push(layer);
orderWeatherImagery(collection, layer, priority);
orderWeatherImagery(collection, layer, priority);
}
assert.equal(raises, 0);
});
+727
View File
@@ -0,0 +1,727 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const STOPS = [
[10, '#00ecec'],
[20, '#0000f6'],
[30, '#00c800'],
[40, '#ffff00'],
[50, '#ff9000'],
[60, '#dc0000'],
[70, '#ff00ff'],
[80, '#05ede0'],
];
const LIGHTNING_STOPS = [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
];
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
/** A per-application observation layer, using the existing layer lifecycle and
* row controls. History is transient: shared links always open latest imagery. */
export function createWeatherLayer({
feed,
clock,
id = 'weather-radar',
cesium = Cesium,
createRendering = createWeatherRendering,
documentRef = globalThis.document,
eventTarget = globalThis.window,
matchMedia = globalThis.matchMedia?.bind(globalThis),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Weather requires a snapshot source');
const radar = id === 'weather-radar';
const lightning = id === 'weather-lightning';
const satellite = !radar && !lightning;
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
let opacity = 'strong';
let infrared = 'filtered';
let viewer = null,
rendering = null,
manifest = null,
request = null,
listener = null;
let enabled = false,
loading = false,
playing = false,
followLatest = true;
let generation = 0,
timer = null,
error = null,
removeCamera = null;
let motion = null;
let runNavigation = null;
let imageryHost = null;
let hostCollection;
let hostHidden = false;
let hostStatus = null;
let unregisterClock = null;
let frameRequest = null;
let noFrame = false;
const maxGap = () =>
radar
? RADAR_MAX_GAP_MS
: lightning
? LIGHTNING_MAX_GAP_MS
: product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS;
const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () =>
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
notify();
});
const observationDelayed = () =>
manifest?.latest &&
Date.now() - Date.parse(manifest.latest) >
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => {
if (clock) return;
playing = false;
rendering?.cancelPrefetch?.();
clearTimeout(timer);
timer = null;
};
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
const onVisibility = () => {
if (suspended()) rendering?.cancelPrefetch?.();
if (clock) void clock.refresh();
else if (suspended()) stop();
notify();
};
function checkHost(resume = true) {
const host = getHost();
const status = imageryHostStatus(host);
const changed = host.collection !== hostCollection || hostStatus !== status;
hostCollection = host.collection;
hostStatus = status;
hostHidden = status !== null;
// Keep playback intent and the displayed time while the host is unavailable.
if (!changed || !rendering) return;
++generation;
rendering.rehome?.();
clearTimeout(timer);
timer = null;
loading = Boolean(request && !request.signal.aborted);
if (clock && enabled) {
if (resume) {
void clock.refresh();
if (!hostHidden && manifest && isLatest()) void show(manifest.latest);
}
notify();
return;
}
if (resume && enabled && !hostHidden && manifest) {
const time =
followLatest || !manifest.times.includes(shownTime())
? manifest.latest
: shownTime();
if (
time !== shownTime() ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
void show(time);
else schedule();
}
notify();
}
const onMapStackChanged = () => checkHost();
function schedule() {
if (clock) return;
clearTimeout(timer);
timer = null;
if (!enabled || !playing || suspended()) return;
timer = setTimeout(() => {
timer = null;
const times = manifest?.times || [];
const next = (times.indexOf(shownTime()) + 1) % times.length;
if (times[next]) void show(times[next]);
else stop();
}, 2000);
}
async function applyClockTime(time, { signal }) {
if (!enabled || signal.aborted) return false;
if (time !== null) return show(time, signal);
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = true;
rendering?.setHidden(true);
loading = Boolean(request && !request.signal.aborted);
notify();
return true;
}
function registerClock() {
if (!clock || unregisterClock || !rendering || !enabled) return;
unregisterClock = clock.register({
id,
get maxGapMs() {
return maxGap();
},
getTimes: () => manifest?.times ?? [],
getShownTime: shownTime,
apply: applyClockTime,
isSuspended: suspended,
});
}
async function warmNext(time) {
if (
!enabled ||
suspended() ||
!(clock ? clock.getState().playing : playing)
)
return;
const times = manifest?.times ?? [];
const next = times[(times.indexOf(time) + 1) % times.length];
if (!next || next === time) return;
try {
await rendering.prefetch?.(manifest, next, { infrared });
} catch {
// Speculative work must not change the displayed frame or playback state.
}
}
async function show(time, signal) {
checkHost(false);
if (!enabled || hostHidden || !manifest?.times?.includes(time))
return false;
signal?.throwIfAborted();
frameRequest?.abort();
const controller = new AbortController();
frameRequest = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const owner = ++generation;
loading = true;
notify();
try {
const ok = await rendering.setFrame(manifest, time, {
signal: controller.signal,
infrared,
});
if (owner !== generation || !enabled || controller.signal.aborted)
return false;
if (ok) {
noFrame = false;
rendering.setHidden?.(false);
void warmNext(time);
}
error = ok ? null : 'Frame unavailable; previous observation retained';
if (!ok) stop();
return ok;
} catch {
if (owner === generation && !controller.signal.aborted) {
error = 'Weather imagery unavailable';
stop();
}
return false;
} finally {
signal?.removeEventListener('abort', abort);
if (frameRequest === controller) frameRequest = null;
if (owner === generation) {
loading = Boolean(request && !request.signal.aborted);
notify();
if (clock && isLatest()) void clock.refresh();
schedule();
}
}
}
const layer = {
id,
name: radar
? 'Rain radar'
: lightning
? 'Lightning density'
: 'Satellite clouds',
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
source: 'NOAA nowCOAST · OBSERVED',
updateInterval: lightning ? 600_000 : 120_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({
viewer,
cesium,
getHost,
onChange: notify,
});
checkHost(false);
eventTarget?.addEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
checkHost();
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
enabled = true;
registerClock();
},
disable() {
enabled = false;
unregisterClock?.();
unregisterClock = null;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
rendering?.clear();
manifest = null;
loading = false;
error = null;
followLatest = true;
},
async update(_viewer, { signal } = {}) {
if (!enabled) return false;
checkHost(false);
clearTimeout(timer);
timer = null;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => {
controller.abort(signal.reason);
stop();
};
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const snapshot = await feed.getSnapshot({
product,
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (snapshot.unavailable) {
error = 'Weather source unavailable; previous observation retained';
stop();
return true;
}
manifest = snapshot;
error = null;
if (clock) {
await clock.refresh();
if (
isLatest() &&
request === controller &&
!controller.signal.aborted
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
);
}
const time =
followLatest || !snapshot.times.includes(shownTime())
? snapshot.latest
: shownTime();
if (
shownTime() !== time ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Weather unavailable';
stop();
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = Boolean(rendering?.getDiagnostics().loading);
notify();
schedule();
}
}
},
setParams(params = {}) {
const infraredChanged =
satellite &&
['filtered', 'full'].includes(params.infrared) &&
params.infrared !== infrared;
if (infraredChanged) infrared = params.infrared;
if (['light', 'strong'].includes(params.opacity)) {
opacity = params.opacity;
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
}
if (
satellite &&
['clouds', 'clouds-regional'].includes(params.product) &&
params.product !== product
) {
product = params.product;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
manifest = null;
error = null;
loading = false;
followLatest = true;
rendering?.clear();
if (clock) void clock.refresh();
if (enabled) void layer.update(viewer);
}
if (infraredChanged && enabled && manifest) {
clearTimeout(timer);
timer = null;
if (clock && !isLatest()) void clock.refresh();
else void show(shownTime() || manifest.latest);
}
if (
params.focus === true &&
enabled &&
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
b.west,
b.south,
b.east,
b.north,
),
duration: motion?.matches ? 0 : 1.4,
}),
);
}
if (clock && enabled) {
if (params.play === true) void clock.togglePlay();
if (params.latest === true) void clock.latest();
if ([-1, 1].includes(params.step)) void clock.step(params.step);
}
if (
!clock &&
params.play === true &&
enabled &&
manifest?.times?.length > 1 &&
!suspended()
) {
playing = !playing;
followLatest = false;
if (playing) schedule();
else stop();
}
if (!clock && params.latest === true && manifest) {
stop();
followLatest = true;
void show(manifest.latest);
}
if (!clock && [-1, 1].includes(params.step) && manifest && !loading) {
stop();
followLatest = false;
const at = manifest.times.indexOf(shownTime());
const next = Math.max(
0,
Math.min(manifest.times.length - 1, at + params.step),
);
void show(manifest.times[next]);
}
notify();
},
getParams() {
return radar
? { opacity }
: satellite
? { product, opacity, infrared }
: { product, opacity };
},
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
const missing =
noFrame &&
shared?.mode === 'history' &&
shared.products.find((entry) => entry.id === id)?.selected === null
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
: null;
const time = shownTime();
const relation =
shared?.mode === 'history' && time
? Date.parse(time) === Date.parse(shared.target)
? ' · synced'
: Date.parse(time) < Date.parse(shared.target)
? ' · nearest'
: ''
: '';
const times = manifest?.times || [];
const index = times.indexOf(time);
const current = index >= 0;
const age = time
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
: null;
const lag =
age === null
? ''
: age >= 60
? `${Math.floor(age / 60)}h ${age % 60}m ago`
: `${age}m ago`;
const diagnostic = rendering?.getDiagnostics();
const b = manifest?.bounds;
const canvas = viewer?.scene?.canvas;
const point =
canvas &&
viewer.camera.pickEllipsoid?.(
new cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe.ellipsoid,
);
const camera = point
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
: null;
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
const outside =
b &&
camera &&
(lon < b.west ||
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
const controls = {
readout: true,
summary: {
label: radar
? 'Rain radar · US'
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
coverage: radar
? 'CONUS'
: lightning
? 'Americas + Pacific'
: product === 'clouds'
? 'Global · 60°S–60°N'
: 'North America',
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail: time
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
: missing
? 'Observation unavailable'
: 'Waiting for observation',
status:
missing ||
hostStatus ||
error ||
diagnostic?.error ||
(observationDelayed()
? 'Source observations delayed'
: manifest?.stale
? 'Stale source'
: loading
? 'Loading next frame…'
: outside
? 'Map center outside coverage'
: null),
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
},
chips: [
...(satellite
? [
['clouds-regional', 'N. America'],
['clouds', 'Global'],
].map(([value, label]) => ({
id: value,
label,
active: product === value,
params: { product: value },
title:
value === 'clouds'
? 'Hourly global mosaic; usually 2–3 hours delayed'
: 'GOES regional clouds; approximately 5-minute updates',
}))
: []),
...(satellite
? [
{
id: 'filtered',
label: 'Clouds only',
active: infrared === 'filtered',
params: { infrared: 'filtered' },
title:
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
},
{
id: 'full',
label: 'Full',
active: infrared === 'full',
params: { infrared: 'full' },
title: 'The complete infrared image at the chosen opacity',
},
]
: []),
...['light', 'strong'].map((value) => ({
id: `opacity-${value}`,
label: value === 'light' ? 'Soft' : 'Vivid',
active: opacity === value,
params: { opacity: value },
title: 'Image opacity; does not alter the observed values',
})),
{
id: 'coverage',
label: radar
? 'View US radar'
: lightning
? 'View Americas & Pacific'
: 'View coverage',
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? `${label} strikes/km²/min ×10³ (15-minute density)`
: `${label} dBZ radar reflectivity`,
}))
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
};
controls.summary.settings = [
...(satellite
? [
{
id: 'region',
label: 'REGION',
chips: controls.chips.filter(({ params }) => params.product),
},
{
id: 'image',
label: 'IMAGE',
chips: controls.chips.filter(({ params }) => params.infrared),
},
]
: []),
{
id: 'opacity',
label: 'OPACITY',
chips: controls.chips.filter(({ params }) => params.opacity),
},
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'coverage',
);
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? 'Observed' : 'History',
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
observedAt: shownTime(),
};
},
getDiagnostics() {
const shared = clock?.getState();
return {
...rendering?.getDiagnostics(),
playing: shared ? shared.playing : playing && !hostHidden,
followLatest: isLatest(),
...(shared
? {
clock: {
mode: shared.mode,
target: shared.target,
playing: shared.playing,
},
}
: {}),
historyFrames: manifest?.times?.length || 0,
timerActive: timer !== null,
};
},
destroy() {
layer.disable();
unsubscribeClock?.();
removeCamera?.();
removeCamera = null;
motion?.removeEventListener?.('change', onVisibility);
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
eventTarget?.removeEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
runNavigation = null;
imageryHost = null;
viewer = null;
rendering = null;
listener = null;
},
};
return layer;
}
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more