mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge main into pr-594
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
714d3dd1bb
492 files changed
+88532
-7676
No files matched your search
+15
-6
@@ -129,18 +129,27 @@ AISSTREAM_API_KEY=
|
||||
# 0 disables the silence watch entirely.
|
||||
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
|
||||
|
||||
# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile
|
||||
# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||
# Optional: local ADS-B decoder feeds for the Local ADS-B layer. SERVER-SIDE
|
||||
# ONLY. Comma-separated band=url entries; band is 1090 or 978 (UAT). Each host
|
||||
# must be loopback, a private (RFC1918) address, localhost or a *.local name;
|
||||
# the path must end in aircraft.json. Invalid entries are logged and skipped.
|
||||
# See docs/LOCAL-RECEIVERS.md.
|
||||
# LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json
|
||||
|
||||
# Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000
|
||||
# Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card
|
||||
# required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||
# the browser fetches same-origin /api/tomtom/* and never sees the key.
|
||||
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
|
||||
# hardcoded per-road-class speeds) — no key required for the layer to work.
|
||||
# TOMTOM_API_KEY=
|
||||
# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a
|
||||
# Optional soft cap on upstream tile fetches per UTC day (default 6000) — a
|
||||
# configurable application safety ceiling, not a guarantee of staying within
|
||||
# TomTom's free allowance (currently 200K tile requests/month:
|
||||
# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale
|
||||
# tiles instead of hitting upstream.
|
||||
# TOMTOM_DAILY_TILE_BUDGET=40000
|
||||
# https://docs.tomtom.com/pricing/). 6000/day keeps a 31-day month at 186K,
|
||||
# inside that allowance. Over the cap the proxy serves cached/stale tiles
|
||||
# instead of hitting upstream.
|
||||
# TOMTOM_DAILY_TILE_BUDGET=6000
|
||||
|
||||
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
|
||||
# unless you're customizing the camera source pack.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
# Credential files. `.env` alone is not enough: Vite's loadEnv (see
|
||||
# server/standalone/vite.config.js) and scripts/setup-doctor.mjs both read the
|
||||
# full dotenv ladder — .env, .env.local, .env.<mode> and .env.<mode>.local —
|
||||
# so a key placed in any of them is live for the app. Only .env was ignored,
|
||||
# leaving the other three committable. .env.example is the tracked template.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
screenshots/
|
||||
qa-shots/
|
||||
|
||||
+362
-9
@@ -1,5 +1,325 @@
|
||||
# Changelog
|
||||
|
||||
- Release CCTV media streams whose upstream falls silent after answering. The
|
||||
15-second media deadline covered only the wait for response headers, so a
|
||||
camera that replied and then stopped sending held both the proxy connection
|
||||
and its upstream socket open for as long as the camera host allowed; the
|
||||
declared `Content-Length` ceiling was the only body bound, and a chunked or
|
||||
length-less body had none. A 30-second idle deadline now bounds the gap
|
||||
between upstream chunks and releases a body that has gone silent. It is
|
||||
rescheduled while the response is still waiting to drain, so a viewer on a
|
||||
slow link is not mistaken for a dead camera; live feeds are unaffected
|
||||
(Ethan Stoner, #688).
|
||||
|
||||
- Aircraft track backfill (`/api/opensky-track`, `/api/adsblol/trace`) now
|
||||
answers 502 when the upstream body exceeds the 5 MB cap, instead of a 200
|
||||
whose error body the client read as an empty track. The failure is cached
|
||||
like other upstream errors, so retries inside the 60 s window do not spend
|
||||
OpenSky credits (Raushankumar0720, #720, #722).
|
||||
|
||||
- Saving a key from Provider Settings works again on Macs where Nix or
|
||||
Homebrew coreutils sit ahead of `/bin` on `PATH`. The credential hardener
|
||||
now spawns Apple's `/bin/chmod -N` by absolute path; GNU `chmod` has no `-N`,
|
||||
so the ACL strip failed closed and every save was refused (Arthur Bogaart, #694).
|
||||
|
||||
- Render on iPad and iPhone instead of stopping with "An error occurred while
|
||||
rendering." Cesium's per-vertex model atmosphere binds shader `out`
|
||||
parameters directly to varyings, which Apple's Metal/ANGLE backend cannot
|
||||
link, so the program failed and the render loop was torn down. The stage is
|
||||
now kept out of the pipeline on affected devices by clearing
|
||||
`scene.fog.renderable`, which leaves `fog.enabled` — and the fog density that
|
||||
drives 3D Tiles refinement — untouched. Detection is a WebGL2 link probe of
|
||||
the same pattern, so a future driver fix restores the effect with no code
|
||||
change, with iOS/iPadOS detection as a backstop. Sky atmosphere and the
|
||||
ground-atmosphere fragment path route through locals and are unaffected;
|
||||
affected devices lose distance fog on 3D tiles and on the globe basemaps.
|
||||
`src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and
|
||||
the `renderable`-not-`enabled` choice (KnottyDyes, #705).
|
||||
|
||||
- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over
|
||||
the Alps") work again in the dev server: the bundled Natural Earth and
|
||||
neighborhood packs are fetched as JSON in the browser
|
||||
(`src/data/bundledJson.js`). When a region is not in the bundled packs, the
|
||||
geocode and admin-boundary fallback answers `region-timeout` after 3 s
|
||||
instead of holding the reply.
|
||||
|
||||
- Transit and Directions rows repaint as soon as their data lands again:
|
||||
`refreshLayerStats()` now lives on the layer lifecycle, not only on the
|
||||
compatibility facade. `scripts/qa-radio.mjs` uses it instead of a private
|
||||
panel method.
|
||||
- On phones the title bar sits 16 px from the top so both Radio broadcast
|
||||
waves stay on-screen.
|
||||
- Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context
|
||||
headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact
|
||||
player. Add Cyber Sonar voice controls with settings and effect-state readback.
|
||||
Keep keyboard-focus outlines visible inside Cyber's clipped map and cockpit
|
||||
expand/collapse buttons, with a matching red hover border.
|
||||
|
||||
- Add the opt-in Cyber HUD layout with coordinated map and cockpit panel
|
||||
styling. Display exposes Sonar on/off, rings, range, power, opacity and sector.
|
||||
Native point, billboard and label highlighting uses GPU draw commands; there
|
||||
is no scene-dimming effect selector. Unsupported shaders retain native contact
|
||||
rendering, and leaving Cyber restores the standard shell and contact treatment.
|
||||
|
||||
- Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless).
|
||||
Select a box (drag, the current view, or around a pin; up to 1,000 km a
|
||||
side) and the right-rail panel lists the last 30 days of Sentinel-2 /
|
||||
Landsat (30 m) and, when switched on, VIIRS daily overview imagery over it,
|
||||
with thumbnails and scene cloud. Three modes: IMAGE shows one day, VS
|
||||
BASEMAP swipes it against the map, A / B swipes two days; a SHOW or A / B
|
||||
chip on each day card pins it, arrow keys preview the focused day while a
|
||||
slot is empty, and no control moves when anything changes. While imagery is
|
||||
shown on Google 3D the map switches to Esri and comes back when it is
|
||||
cleared. Either image exports as a PNG; box, pins, mode and split travel in
|
||||
share links; the NASA acknowledgement is in the credits and
|
||||
`DATA_SOURCES.md`. The swipe is now shared with the Nepal scene
|
||||
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
|
||||
`MapSourceController.subscribe()` reports every settled map switch.
|
||||
|
||||
## Unreleased — local receiver feeds
|
||||
|
||||
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
|
||||
feeds: the `aircraft.json` that dump1090-fa, readsb, tar1090 or skyaware978
|
||||
serves. Configure them server-side with `LOCAL_RECEIVER_FEEDS`
|
||||
(`band=url`, comma-separated); there is no feed editing in the browser.
|
||||
Hosts must be loopback, RFC1918, `localhost` or `*.local`, the scheme http or
|
||||
https and the path must end in `aircraft.json`; any other entry is logged at
|
||||
startup, reported `invalid` and never fetched.
|
||||
- Add `GET /api/local-receivers/aircraft`. It reads every configured feed in
|
||||
parallel (2 s timeout, redirects refused, 2 MB body cap, about 1 s of shared
|
||||
cache) and reports each feed `live`, `stale` (its own `now` is over 10 s old),
|
||||
`unreachable` or `invalid`. Unconfigured, it answers
|
||||
`{ configured: false }` and fetches nothing. Upstream error text is never
|
||||
returned.
|
||||
- The layer merges browser-SDR and feed aircraft by ICAO, keeping the newest
|
||||
position, polls the route every second only while it is enabled, and remembers
|
||||
which bands and sources heard each aircraft in the last 60 s. The click card
|
||||
names them (for example "Heard by your receiver · 978 MHz UAT · decoder
|
||||
feed"); aircraft heard only on 978 MHz carry a thin ring. The row status
|
||||
covers both inputs ("2 feeds live · 14 heard", "feed 978 unreachable"), and
|
||||
the Local RTL-SDR card shows a read-only decoder-feed line.
|
||||
- While any input is producing aircraft (the browser receiver streaming, or a
|
||||
feed live), the Local ADS-B row stays ON and lists feeds that are not live as
|
||||
a trailing note ("3 heard · USB 5.8 msg/s · feed 1090 stale") instead of
|
||||
showing DEGRADED.
|
||||
- Records carry `band` (`1090`/`978`) and `source` (`webusb`/`feed`).
|
||||
- See `docs/LOCAL-RECEIVERS.md`.
|
||||
|
||||
## Unreleased — local RTL-SDR and Local ADS-B
|
||||
|
||||
- Add a Local RTL-SDR card to the Radio panel. It connects a USB RTL-SDR in
|
||||
desktop Chrome or Edge through WebUSB and receives broadcast FM (tune, seek,
|
||||
volume) or 1090 MHz ADS-B. Local FM and internet-radio playback never play
|
||||
together: starting one stops the other.
|
||||
- Add a gain control: AUTO or a manual R820T step, remembered per mode. ADS-B
|
||||
defaults to 28.0 dB (the earlier 20.7 dB gave about 1 msg/s against about 9
|
||||
at 28.0 dB on the same antenna; a stored choice still wins), FM to AUTO;
|
||||
changes apply without reconnecting. In
|
||||
ADS-B mode the card shows CRC-valid messages per second, aircraft heard,
|
||||
aircraft positioned and the IQ level.
|
||||
- Prefer the ADS-B/1090 MHz channel of a dual-channel receiver, remember an
|
||||
explicitly chosen device per mode, and add CHANGE DEVICE to reopen the
|
||||
WebUSB picker.
|
||||
- Add the Local ADS-B layer (`local-adsb`, off by default, not part of share
|
||||
links). Aircraft heard by the receiver draw in magenta beside public Flights;
|
||||
a marker drops when its position is 60 s old and the aircraft is forgotten
|
||||
after 60 s without a message. Clicking one opens a card; markers are not
|
||||
camera-followed. Voice `set_layer_visibility` accepts `local-adsb`.
|
||||
- Normalize local ADS-B into one record shape, with a pure adapter for
|
||||
dump1090/readsb `aircraft.json` documents.
|
||||
- Add `@jtarrio/webrtlsdr` and `@jtarrio/signals` (Apache-2.0); see
|
||||
`THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
## Unreleased — weather review
|
||||
|
||||
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
|
||||
observed-weather shell except global infrared, sampled by the shell's own
|
||||
surface. It follows the view on camera move end, keeps its place while the
|
||||
view stays near its centre, and hides until its image is ready after a move;
|
||||
an older frame's detail stays over at most one newer frame. The image proxy
|
||||
accepts a 2:1 `bbox` inside the product bounds, rounded to 0.25°.
|
||||
Lightning's whole-extent image is now 4096×2048; each shell caches up to
|
||||
128 MiB of decoded images.
|
||||
|
||||
- On 3D Tiles, show observed weather and the wind color field as raised,
|
||||
translucent shells (5.0–6.6 km, lightning highest) with one full-extent image
|
||||
per frame instead of draping onto tiles; they show at any camera height.
|
||||
Globe hosts are unchanged. The weather image proxy serves every product
|
||||
(radar and regional infrared up to 4096×2048, lightning and global infrared
|
||||
up to 2048×1024) with a size parameter and a 16 MiB cap. Accept bounded tile
|
||||
sizes in the proxy with separate immutable cache entries.
|
||||
|
||||
- Cache exact-time weather images and tiles for 24 hours. Retain up to 6 decoded
|
||||
global mosaics per renderer and warm the next observation during playback;
|
||||
tile prefetch is bounded to eight requests and cancels when suspended.
|
||||
|
||||
- Move weather times, coverage, legends and cyclone advisory details into keyed
|
||||
right-rail cards; left rows keep status and configuration. Use one native
|
||||
observed-history timeline with local preview and coalesced drag commits.
|
||||
- Add reusable rail card and timeline components. Keep panel collapse, count,
|
||||
hidden-empty behavior and first-appearance expansion.
|
||||
- Show wind unit controls beside speed legends and inside inspection readings;
|
||||
changing units preserves the sampled location and open reading.
|
||||
- Name satellite imagery Satellite clouds, with Clouds only / Full image modes
|
||||
and explicit regional coverage. Existing share parameters are unchanged.
|
||||
|
||||
- Share one observed history clock across radar, infrared and lightning. Step
|
||||
through their union timeline with bounded nearest-at-or-before selection;
|
||||
hide products without an eligible frame. Latest uses each product's newest
|
||||
observation; playback waits for all frame loads to settle before advancing.
|
||||
- Label wind as a forecast that does not follow observed history. Keep history
|
||||
transient and product readouts synchronized.
|
||||
|
||||
- Keep Google 3D Tiles drawing while draped weather imagery loads and retain the
|
||||
old observation until the replacement has rendered.
|
||||
- Decode one bounded global infrared mosaic per frame on both map hosts, then
|
||||
crop local tiles to avoid request-dependent brightness seams.
|
||||
- Add Clouds only / Full image controls and share-link state. Filtered mode uses
|
||||
a soft linear-brightness alpha ramp from 0.40 to 0.70 around the old 0.55 cut.
|
||||
|
||||
- Dock the weather summary in the right rail with standard panel collapse and drag chrome.
|
||||
|
||||
- Filter infrared brightness so cold cloud tops stand out; this is a display
|
||||
filter, not a cloud mask.
|
||||
- Retry throttled weather tiles up to three times per tile.
|
||||
|
||||
- Cull regional wind batches per frame, fade curves below 60 km, and stop idle
|
||||
rendering when no curve is visible.
|
||||
- Fade the wind color field below ~1,200 km camera height and hide it at 200 km.
|
||||
On 3D Tiles, update alpha in 0.1 steps only at camera move end or installation;
|
||||
globe imagery keeps its smooth per-frame fade.
|
||||
|
||||
- Hide cyclone markers, labels, tracks and cones beyond the horizon on every map source.
|
||||
- Reserve stable weather status space and coalesce panel refreshes per frame.
|
||||
|
||||
- Add keyless NOAA/NHC cyclone advisory positions, coherent forecast tracks and
|
||||
uncertainty cones, plus NOAA's observed 15-minute lightning density imagery.
|
||||
Preserve source clocks, basin coverage and explicit pending/stale states.
|
||||
- Make wind default to trails, preserve earlier share-link appearance, retain
|
||||
geometry across scalar changes, and show a compact weather summary with a
|
||||
location marker and selected-field emphasis for forecast inspection.
|
||||
|
||||
- Add keyless NOAA observed rain radar and infrared satellite layers to Weather,
|
||||
with explicit coverage/freshness, recent observation playback and native Cesium tiles.
|
||||
- Increase desktop wind density to 7,200 paths and improve temperature contrast
|
||||
while retaining the 1,200-path narrow-screen budget and unchanged forecast values.
|
||||
|
||||
- Expand Wind into a surface-weather prototype: globe-draped speed shading,
|
||||
optional same-run 2 m temperature and mean sea-level pressure, GFS/ECMWF model
|
||||
selection, a numeric legend, wind units, Pause, and a dismissible map-center
|
||||
reading. Keep wind visible when an optional field is unavailable; respect
|
||||
reduced motion and stop animation while hidden or disabled. Bake bounded
|
||||
forecast-following curves once per field and animate their phase on the GPU,
|
||||
with a canvas fallback and globe view lighting owned only while Wind is enabled.
|
||||
Display lift does not
|
||||
change the 10 m forecast level; this adds no cloud volume, radar or forecast-time
|
||||
playback. Native hardware GPU behavior remains unverified.
|
||||
|
||||
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
|
||||
|
||||
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
|
||||
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
|
||||
wildfire incident perimeters as ground-clamped polygons with a
|
||||
containment-colored fire line, refreshed every 5 minutes from the public
|
||||
keyless feature service with truncation paging. Clicking a perimeter shows
|
||||
an incident card (acreage, containment, cause, behavior, personnel, county,
|
||||
cost, complex membership) and, when the incident has a state- and
|
||||
recency-verified InciWeb page, a click-through link to it. Recency uses
|
||||
incident page origin and update times because the publication API was retired.
|
||||
The layer is reachable from the panel, voice control, share links (token `2`), and the
|
||||
analyst query engine. WFIGS and InciWeb requests use a capped, cached
|
||||
same-origin proxy with timeouts and a per-client limit. Unchanged refreshes
|
||||
retain geometry; incident-link checks abort on disable or selection change,
|
||||
and the row includes a containment legend.
|
||||
|
||||
- Remove the spurious scrollbars that appeared on both panel stacks at narrow
|
||||
widths (720px and below) as soon as a panel was expanded. The stacks scroll
|
||||
vertically there, and each panel's decorative glow, absolutely positioned
|
||||
with a negative inset, became 18–20px of scrollable overflow on both axes: a
|
||||
horizontal scrollbar band under the expanded CCTV, Context, Data Layers or
|
||||
Scenes panel plus a vertical scrollbar that scrolled nothing but glow. The
|
||||
narrow-screen rules now pin the glow to its panel box; the stacks still
|
||||
scroll for genuinely tall content such as an expanded DISPLAY panel, and the
|
||||
Context radio popover is not clipped. `src/ui/panelRails.test.mjs` pins the
|
||||
rule against every 720px media block.
|
||||
|
||||
- Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode
|
||||
`Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification,
|
||||
while ordinary wheel, line-mode and touch-pinch inputs retain their existing
|
||||
behavior; the listener is removed with the application scene.
|
||||
|
||||
- Report AIS speed and course that carry the standard "not available" code as
|
||||
unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a
|
||||
stopped vessel's zero and the highest encodable values, are unchanged.
|
||||
|
||||
- Render native `<select>` option lists in the dark UI palette. The closed
|
||||
controls were already skinned, but the browser-painted popups fell back to the
|
||||
platform light palette, leaving near-white option text on a white surface in
|
||||
the HUD layout, Scenes, CCTV camera, Radio filter and Draw colour menus.
|
||||
|
||||
- Credit adsbdb, which supplies the aircraft type, model name and registration
|
||||
on enriched flights and the airline and origin/destination pair behind the
|
||||
tracked contact's route strip. `DATA_SOURCES.md` now records adsbdb's
|
||||
published credits and route-data restriction, along with the request bounds
|
||||
and gitignored 24-hour local cache. A matching `DATA_CREDITS` entry surfaces
|
||||
the credit in the in-app Data attribution popover, and a test protects it
|
||||
against accidental removal.
|
||||
|
||||
- Size the TomTom daily tile budget to the provider's real free allowance.
|
||||
`TOMTOM_DAILY_TILE_BUDGET` defaulted to 40,000/day against an allowance
|
||||
granted monthly (200,000 tile requests/month), exhausting a month in five
|
||||
days and leaving the traffic layer dead for the rest of the period. The
|
||||
default is now 6,000/day (186,000 over a 31-day month). Corrects the stale
|
||||
"~50k/day" free-tier figure in the proxy, `.env.example` and
|
||||
`DATA_SOURCES.md`. Still an application-side ceiling, not a billing cap.
|
||||
|
||||
- Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with
|
||||
accepted-record counts and unchanged retention, freshness and outage safeguards.
|
||||
|
||||
- Keep Nepal provider media inside the Pinokio compatibility boundary: use
|
||||
source-linked fallback cards instead of automatic embeds or hidden preloads
|
||||
that launch an external browser. Ordinary browsers retain embedded playback.
|
||||
Source-only timed shots use their authored card dwell and continue normally.
|
||||
Media autoplay now requires a live Play Scene or Play Shot action; passive
|
||||
loading, saved state and seeking do not grant playback authority.
|
||||
|
||||
- Add Director import previews, validated scene/shot detail drafts and selected-scene
|
||||
JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes
|
||||
before admission and release staged work on cancellation or teardown.
|
||||
|
||||
- Director scene documents now support bounded data-pack manifests, per-shot
|
||||
selection and registered GeoJSON/PNG/media loaders with explicit placement,
|
||||
visible attribution and cancellation/disposal on Stop or replacement.
|
||||
|
||||
- Director version 4 adds named camera anchors and explicit pose-to-pose moves
|
||||
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
|
||||
and diagnostic ownership while preserving voice controls and protocol behavior.
|
||||
Revoke stale connection offers/capture callbacks and release failed or stopped audio
|
||||
meters. Discard late action continuations after Stop or restart; add recorded
|
||||
push-to-talk acceptance alongside click-start voice.
|
||||
|
||||
- Separate application-shell responsibilities and state ownership while preserving
|
||||
the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal
|
||||
and detach old Directions services when replacing a data manager.
|
||||
- Transit: keep the normal vehicle silhouettes under NVG, thermal and noir (opaque white, CRT sizing, a 2 px dark halo) instead of solid bodies; drape the selected vehicle's trail onto Google 3D tiles and terrain so retained history is actually visible, with the head clipped to the sprite and markers recovering as soon as the camera arrives; place Transit in the Movement panel between Street Traffic and Bike Share.
|
||||
|
||||
- Extract a portable Director shot runner and connect existing scene playback to
|
||||
it. Preserve authored content, project files, camera/layer behavior and source
|
||||
attribution; document the planned timeline, scene-file and data-pack boundaries.
|
||||
|
||||
- Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports.
|
||||
|
||||
- Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution.
|
||||
@@ -60,6 +380,10 @@
|
||||
|
||||
- Expose portable radio, camera-type and regional source helpers; keep HTTP transport separate from record normalization.
|
||||
|
||||
- The Realtime debug-log endpoint is bounded on every axis it was not: an always-on per-client rate limit, asynchronous appends through a serialized queue instead of a synchronous write on the request path, and rotation of the log file at 32 MB keeping one prior generation. The 8 MB cap applied to a single request body and never to the file those requests accumulated into, so any local page could grow it for as long as the dev server ran. A malformed record and a failed write are now told apart, 400 from 500, and neither answer carries the error text.
|
||||
|
||||
- Three proxy paths no longer relay upstream or JS error text to the client. The HUD summary passed OpenAI's own `error.message` through whenever upstream was not ok, carrying request ids and quota wording; the Realtime token route passed through non-success response bodies and echoed JS errors, which can expose upstream details; and a failed CCTV media fetch stored the raw errno as the camera's health message, which reaches the screen through `GET /api/cctv/health` rather than through the sanitized response beside it. Logs now name the failure and the upstream status without the text.
|
||||
|
||||
- Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation.
|
||||
|
||||
- Separate military-flight records and acquisition from rendering while preserving ground-model ownership, source units and follow behavior.
|
||||
@@ -74,20 +398,25 @@
|
||||
|
||||
- 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.
|
||||
- Allow compatible endpoints and server-selected models through construction options.
|
||||
- Cancel pending token/SDP requests on Stop or teardown and reject expired secrets.
|
||||
|
||||
|
||||
## Configurable geospatial services
|
||||
|
||||
- Compose geocoding, place context and routes through independent providers.
|
||||
- Allow compatible endpoint configuration without changing voice tools or annotation behavior.
|
||||
- Isolate configured source caches and reject results after cancellation.
|
||||
|
||||
|
||||
## ALPR camera locations
|
||||
|
||||
- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets,
|
||||
@@ -167,6 +496,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
|
||||
|
||||
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
|
||||
|
||||
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
|
||||
|
||||
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
||||
@@ -193,6 +526,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep traffic-road bounds crossing the antimeridian monotonic and inside the
|
||||
longitude range accepted by the Overpass request path, preserving the small
|
||||
wrapped span instead of producing an inverted or rejected box (#392 — thanks
|
||||
@Ashfaqbs).
|
||||
- Make `npm run doctor` report keyless anonymous OpenSky access for explicit
|
||||
`OPENSKY_AUTH_MODE=anon` and OAuth mode without a client pair, retain the
|
||||
existing OAuth-pair capability wording, and identify selected Basic or auto
|
||||
modes without guessing their eventual credential choice. OpenSky proxy
|
||||
authentication is unchanged.
|
||||
- Bikeshare stations load again. The extracted station source addressed the
|
||||
proxy as `/api/gbfs?url=`, but the proxy reads its upstream target from the
|
||||
path, so every request answered 400 and the layer reported a fetch error for
|
||||
@@ -251,6 +593,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
### Added
|
||||
|
||||
- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the
|
||||
existing `FIRMS_MAP_KEY` and 30-minute cache.
|
||||
- Two map-orientation controls sit beside Share in the top-center globe
|
||||
actions. Tilt Map swings between a straight-down map and a 35-degree oblique
|
||||
around the point under the centre of the view, keeping that point and the
|
||||
@@ -285,6 +629,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).
|
||||
@@ -317,7 +667,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo.
|
||||
Typing fields retain the key; monitoring stops when hidden.
|
||||
|
||||
|
||||
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
|
||||
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
|
||||
- Cancel pending vessel history during selection and layer teardown.
|
||||
@@ -401,10 +750,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
- Bound CCTV media response headers to 15 seconds and cancel error bodies.
|
||||
Cap buffered snapshot downloads at 16 MiB while streaming.
|
||||
|
||||
|
||||
- Cancel the active location lookup when its controls are disposed.
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DATA_SOURCES.md` states what the project does with camera frame content: a
|
||||
@@ -455,7 +802,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
- GBFS rejects upstream redirects, caps streamed responses at 5 MiB, and keeps
|
||||
its deadline active through body reads. Rejected downloads are cancelled.
|
||||
|
||||
|
||||
- Split Overpass/installation search, regional briefing/weather, local voice
|
||||
handlers and standalone key setup into focused modules. Preserve routes,
|
||||
source behavior, tool schemas and credential restrictions.
|
||||
@@ -485,7 +831,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
fetching, AIS records/tracks and shared request helpers; preserve existing
|
||||
routes, local setup, fallback behavior and rendering.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **Directions layer** — keyless A→B directions without a geocoder or a
|
||||
@@ -529,7 +874,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
- Rename standalone browser startup to `src/standalone/` and add a Node-only
|
||||
`gods-eye-view/build/vite` export with checked package ownership.
|
||||
|
||||
|
||||
### Development
|
||||
|
||||
- The CCTV launcher and preview-server tests resolve their temporary fixture
|
||||
@@ -588,7 +932,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
First-run QA now checks the existing attribution Escape-close/focus-return
|
||||
behavior while preserving the launcher-underneath regression checks.
|
||||
|
||||
|
||||
- Datacenter and dam factories are available through scoped package exports with
|
||||
explicit context, overlay and render callbacks. The standalone app uses the
|
||||
same implementation and bundled datasets.
|
||||
@@ -751,6 +1094,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
## [0.1.0] — 2026-08-31 — One-click install, keyless boot, Provider Settings
|
||||
|
||||
### Added
|
||||
|
||||
- **One-click install** via Pinokio. Keyless boot lands on a live Esri World
|
||||
Imagery satellite globe with keyless terrain; OSM takes over automatically if
|
||||
Esri is unreachable, and the globe continues without terrain if its source is
|
||||
@@ -766,6 +1110,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
test suite out of the box (#81 — thanks @ethanstoner).
|
||||
|
||||
### Changed
|
||||
|
||||
- README rewritten keyless-first around the provider ladder: zero keys → free
|
||||
Cesium ion (eligible personal, non-commercial use) → billing-enabled Google
|
||||
Maps.
|
||||
@@ -775,6 +1120,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
and say so plainly when enrichment is unavailable instead of guessing.
|
||||
|
||||
### Security
|
||||
|
||||
- Provider Settings answers only local, unproxied requests and disables itself
|
||||
entirely whenever the server is shared. Public datacenter and dam datasets
|
||||
omit contact-oriented fields (see the dataset READMEs).
|
||||
@@ -951,3 +1297,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.
|
||||
+34
-1
@@ -37,6 +37,39 @@ configuration through the development app or environment file. Unknown API
|
||||
paths return JSON 404 responses. Vite preview is for checking a local build;
|
||||
it is not a production server.
|
||||
|
||||
## Feature regression gates
|
||||
|
||||
Those three are the baseline, not the whole story. Most features also have a
|
||||
dedicated headless gate under `scripts/qa-*.mjs` that drives the real app and
|
||||
asserts that feature's contract. **Run the gate covering whatever you touched**,
|
||||
and say which one you ran in the PR.
|
||||
|
||||
Feature gates and a few supporting modules live under the same filename pattern:
|
||||
|
||||
```bash
|
||||
ls scripts/qa-*.mjs # gates and their supporting modules
|
||||
head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run it
|
||||
```
|
||||
|
||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||
Most need a dev server; some also need a specific provider key or port. Follow
|
||||
the entrypoint's header rather than assuming every matching file accepts the
|
||||
same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an
|
||||
`npm run` alias.
|
||||
|
||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||
for the feature: it names the gate for many of them, and it's the authoritative
|
||||
runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader
|
||||
release-candidate checks and selected harnesses; it does not replace the focused
|
||||
gate for the feature you changed.
|
||||
|
||||
> **CI does not cover this for you.** The workflow runs the setup policy
|
||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||
> production build, plus a Windows onboarding job. It runs neither
|
||||
> `npm run test:track` nor any `qa-*.mjs` gate — both need a live dev server
|
||||
> and a browser. Include the applicable local run in your PR's validation
|
||||
> evidence.
|
||||
|
||||
## Good first contributions
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
@@ -82,7 +115,7 @@ ownership and adoption process.
|
||||
## Pull requests
|
||||
|
||||
1. Branch off `main`.
|
||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors.
|
||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
|
||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR.
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||
|
||||
+154
-3
@@ -15,16 +15,25 @@ How to read this:
|
||||
|
||||
| Source | Used for | License / terms | Attribution |
|
||||
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **NOAA GFS (wind)** (`noaa-gfs-bdp-pds.s3.amazonaws.com`) | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind. Keyless; latest 6-hourly 0.25° cycle, byte-range GRIB2 reads, cached for an hour | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
|
||||
| **ECMWF IFS (wind)** (`data.ecmwf.int/forecasts`, ECMWF Open Data) | The alternative Wind model: 10 m wind, optional 2 m temperature and mean sea-level pressure. Keyless; latest 6-hourly 0.25° run, byte-range GRIB2 reads, cached for an hour | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) plus the [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) | "This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF)", with the CC BY 4.0 link, the modification notice and ECMWF's liability disclaimer — shown in-app |
|
||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
|
||||
@@ -40,6 +49,7 @@ How to read this:
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) |
|
||||
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **DelDOT (video.deldot.gov)** | CCTV camera catalog (`tmc.deldot.gov/json/videocamera.json`, cached for 15 minutes) + live HTTPS HLS video, Delaware. Keyless | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) | "DelDOT — Delaware Department of Transportation" (courtesy) |
|
||||
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
|
||||
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
|
||||
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
|
||||
@@ -57,14 +67,20 @@ How to read this:
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
|
||||
The optional Local ADS-B layer and the broadcast-FM receiver use radio you receive yourself: a USB RTL-SDR opened in the browser through WebUSB, or the `aircraft.json` of a decoder you run on your own machine or network (`LOCAL_RECEIVER_FEEDS`). Radio samples, audio and the optional receiver location stay in the browser. Decoder feeds are read by your local GEV server and relayed only to your browser, with about a second of in-memory caching. When you select a local aircraft, its ICAO address (and an airline-style callsign) is looked up, and aircraft that qualify for a 3D model are looked up by ICAO address, through the same adsbdb proxy the public flight layers use; your server caches those answers on disk and treats them as fresh for 24 hours. Received radio data is not stored or redistributed. ADS-B is an unauthenticated public broadcast and may be incomplete, delayed, spoofed or decoded in error; it is not suitable for navigation or operational use.
|
||||
|
||||
### Notes on the live sources
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **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.
|
||||
@@ -76,7 +92,7 @@ How to read this:
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
|
||||
@@ -146,7 +162,7 @@ The OSM-derived datasets are under the **Open Database License**. ODbL's share-a
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
@@ -189,6 +205,141 @@ Douglas-Peucker simplification, 6-decimal rounding).
|
||||
|
||||
---
|
||||
|
||||
## Weather sources
|
||||
|
||||
### ECMWF IFS wind
|
||||
|
||||
Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and byte-range fetches 10u/10v GRIB messages, optionally adding 2t (2 m temperature) or msl (mean sea-level pressure) from the same run and forecast time. It resamples these fields to the approximately 1° display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) also apply. In-app attribution identifies this service as based on ECMWF data and products, links the CC BY 4.0 licence, identifies resampling and animation as modifications, and retains the required ECMWF liability disclaimer. Both model issue time and the selected forecast valid time appear in the WEATHER panel. These are model forecasts, not observations.
|
||||
|
||||
### NOAA GFS wind
|
||||
|
||||
The optional **Wind** layer animates the global 10 m wind field from NOAA's
|
||||
Global Forecast System (GFS). The `/api/wind` server-side proxy selects the
|
||||
latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only
|
||||
the `UGRD`/`VGRD` 10 m GRIB2 messages, optionally adding `TMP` at 2 m or
|
||||
`PRMSL` at mean sea level from the same run and forecast time. It decodes the
|
||||
selected messages with ecCodes (WASM), resamples to a compact grid (1° by
|
||||
default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached
|
||||
per cycle for an hour, and is a **forecast, not an observation**: the particles
|
||||
show model flow, not measured wind. The renderer bakes bounded curves through
|
||||
the sampled field and animates their phase on the GPU, with a canvas fallback.
|
||||
Any display lift is a visual aid and does not change the forecast's 10 m level.
|
||||
NOAA GFS data is U.S. public domain; the credit above is a courtesy and does not
|
||||
imply endorsement.
|
||||
|
||||
For both models, optional temperature is normalized from K to °C and pressure
|
||||
from Pa to hPa before delivery. A missing, malformed or timed-out companion field
|
||||
does not discard valid wind: the manifest marks that field unavailable, and the
|
||||
UI retains wind without inventing scalar values. The proxy caches by model and
|
||||
requested field, with bounded current/previous grids and shared in-flight loads.
|
||||
Wind speed shading is computed locally from U/V and needs no companion download.
|
||||
The legend and map-center reading explain units and forecast validity; the
|
||||
animation is a visual flow through one forecast, not advancing forecast time.
|
||||
Optional globe relief uses existing terrain vertex normals for view lighting;
|
||||
without those normals it shades global globe curvature only, not local terrain
|
||||
relief. Neither mode represents measured sunlight or a new elevation source.
|
||||
This Wind prototype adds no cloud-volume or radar data. Mapped.earth's public
|
||||
bundles were studied for rendering ideas, but
|
||||
no code or assets were reused and no application licence granting reuse was
|
||||
found. Its presentation is not a weather-data source for this implementation.
|
||||
|
||||
### Observed weather: NOAA nowCOAST
|
||||
|
||||
The Weather section provides keyless, observed **MRMS radar reflectivity** for the
|
||||
contiguous United States and **infrared satellite imagery**. Fixed upstream WMS
|
||||
services: `https://nowcoast.noaa.gov/geoserver/observations/weather_radar/ows` and
|
||||
`https://nowcoast.noaa.gov/geoserver/observations/satellite/ows`.
|
||||
|
||||
- `conus_base_reflectivity_mosaic`: approximately 1 km, usually 4-minute updates.
|
||||
dBZ measures radar reflectivity, not rainfall rate, probability or future rain.
|
||||
Coverage gaps do not mean no precipitation. The numeric legend follows NOAA's
|
||||
`weather_radar_base_reflectivity` style.
|
||||
- `goes_longwave_imagery`: GOES-19/18 Band 14, approximately 2 km, 5-minute updates,
|
||||
regional North America. **Clouds only** softly dims pixels with a linear
|
||||
brightness ramp from 0.40 to 0.70; **Full image** retains source alpha. This
|
||||
is a display filter, not a cloud mask or measured cloud volume.
|
||||
- `global_longwave_imagery_mosaic`: approximately 3 km, hourly, nominal 60°S–60°N
|
||||
coverage and typically 2–3-hour latency. It is slower global context.
|
||||
|
||||
The UI displays the exact advertised observation time separately from acquisition;
|
||||
latest means the newest available observation, not zero-delay real time. Up to 13
|
||||
recent advertised frames can be replayed as history. No nowcast is synthesized.
|
||||
Tiles use WMS 1.1.1 EPSG:4326 longitude/latitude bounds; Cesium's geographic 2×1
|
||||
root grid is capped at level 6 for radar and regional GOES. Global infrared uses
|
||||
one fixed 2048×1024 geographic image on both hosts: the source's request-dependent
|
||||
contrast otherwise creates brightness seams between tiles. The browser decodes
|
||||
and filters it once, then crops 256 px tiles bounded to the manifest extent at
|
||||
maximum level 3. This broad context view has a coarser display resolution than
|
||||
its 3 km source. No browser reprojection is used. Browser imagery owns a displayed
|
||||
frame and a staging frame, retaining the outgoing layer for one render after
|
||||
admission.
|
||||
|
||||
On Google 3D Tiles each observed product is instead a raised shell showing one
|
||||
whole-extent image per frame (up to 4096×2048; global infrared 2048×1024). Every
|
||||
product except global infrared also draws a 4096×2048 detail window around the
|
||||
view, requested with a 2:1 `bbox` inside the product bounds, rounded to 0.25°.
|
||||
The proxy has an 8-request concurrency budget and a 12-second deadline; tiles
|
||||
(256, 512 or 1024 px) are capped at 1 MiB or 4 bytes per pixel plus 64 KiB,
|
||||
whichever is larger, and whole-extent and detail images at 16 MiB. Its shared
|
||||
cache holds up to 128 images and 16 MiB, each exact-time image for at most 24
|
||||
hours.
|
||||
Metadata refreshes every two minutes with explicitly stale last-good fallback.
|
||||
No key, new dependency, image reprojection job, or full-disk image download is needed.
|
||||
|
||||
Credit: NOAA nowCOAST, NWS/OAR MRMS, NESDIS GOES and global satellite partners.
|
||||
[NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html).
|
||||
Community context: [#85](https://github.com/bilawalsidhu/gods-eye-view/issues/85),
|
||||
[#588 radar](https://github.com/bilawalsidhu/gods-eye-view/pull/588), and
|
||||
[#457 clouds](https://github.com/bilawalsidhu/gods-eye-view/pull/457).
|
||||
This implementation is original; those contributions have not been merged here.
|
||||
|
||||
|
||||
### Weather: NHC/CPHC cyclone advisories
|
||||
|
||||
- **Sources:** [NHC current tropical cyclone status](https://www.nhc.noaa.gov/CurrentStorms.json) and
|
||||
[NOAA tropical weather summary GIS](https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer).
|
||||
- **Rights/credit:** NOAA/NWS National Hurricane Center / Central Pacific Hurricane Center;
|
||||
[NWS public-data terms](https://www.weather.gov/disclaimer). Fetch official products at runtime;
|
||||
no bundled advisory archive and no NOAA endorsement implied.
|
||||
- **Coverage/meaning:** Atlantic and eastern/central North Pacific, not global cyclone coverage.
|
||||
Status position time and advisory issue time remain separate. Forecast track, points and cone
|
||||
require matching advisory numbers across all GIS parts. Cone means uncertainty in the
|
||||
forecast center track, not storm size or a complete hazard boundary.
|
||||
- **Delivery:** keyless same-origin `/api/cyclones`, five-minute singleflight cache,
|
||||
fixed upstream endpoints, bounded bodies/geometry/deadline, explicit unavailable/stale states.
|
||||
Direct status fetching is unsuitable in browsers because NHC does not advertise CORS.
|
||||
|
||||
### Weather: observed lightning density
|
||||
|
||||
- **Source:** [NOAA nowCOAST lightning detection WMS](https://nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows),
|
||||
fixed `ldn_lightning_strike_density` layer and `lightning_density` style.
|
||||
- **Rights/credit:** NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360.
|
||||
[Official product description and public distribution terms](https://ocean.weather.gov/lightning/lightning_pdd.php)
|
||||
permit distribution of this NOAA Level-5 derived product, not raw Vaisala detections.
|
||||
- **Meaning:** 15-minute accumulated density on an approximately 8 km grid;
|
||||
source color scale is strikes per km² per minute ×10³. Not individual GLM optical flashes,
|
||||
a live ground-strike counter, an all-clear indication, or global coverage.
|
||||
- **Coverage:** 110°E across the Pacific/Americas to 0°, 25°S–80°N. Display exact advertised
|
||||
observations; ten-minute metadata refresh, source-matched colors, bounded shared imagery cache.
|
||||
|
||||
### Community weather prior art
|
||||
|
||||
The weather design builds on Gustavo Beneduzi's retained GFS/ECMWF contribution
|
||||
commits ([#459](https://github.com/bilawalsidhu/gods-eye-view/pull/459),
|
||||
[#464](https://github.com/bilawalsidhu/gods-eye-view/pull/464)). Related community
|
||||
proposals informed the observed-weather experience: Sandiv D's on-demand radar
|
||||
and imagery controls ([#588](https://github.com/bilawalsidhu/gods-eye-view/pull/588));
|
||||
Gustavo Beneduzi's GOES and GLM source work
|
||||
([#457](https://github.com/bilawalsidhu/gods-eye-view/pull/457),
|
||||
[#458](https://github.com/bilawalsidhu/gods-eye-view/pull/458)); and HadiMuhammed's
|
||||
NOAA hazard provenance and freshness work
|
||||
([#414](https://github.com/bilawalsidhu/gods-eye-view/pull/414)).
|
||||
The fixed nowCOAST imagery and NHC advisory implementations are original maintainer
|
||||
work; this acknowledgement does not represent merging those four PRs or equate
|
||||
NOAA density imagery with the raw GLM product.
|
||||
|
||||
---
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, 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.
|
||||
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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 🌐 God's Eye View
|
||||
|
||||
[](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml)
|
||||
[](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [](https://x.com/bilawalsidhu/status/2093798887815348521)
|
||||
|
||||
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
|
||||
|
||||
@@ -18,8 +18,6 @@ _No place left behind._
|
||||
|
||||
▶️ **From the project behind the viral God's Eye View series** _(formerly WorldView)_ — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
|
||||
|
||||
[](https://x.com/bilawalsidhu/status/2093798887815348521)
|
||||
|
||||
🏆 **Reached #1 on GitHub Trending, daily and weekly · August 2026**
|
||||
|
||||
**[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag
|
||||
@@ -34,7 +32,7 @@ _“pretty cool”_ — [Brendan Eich](https://x.com/BrendanEich/status/20945920
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)**
|
||||
**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys) · [Contributing](CONTRIBUTING.md)**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -70,6 +68,14 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
- **🎥 Scene director:** Capture cinematic camera tours for clips and demos.
|
||||
- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark.
|
||||
- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth.
|
||||
- **🌦️ Weather:** Animate GFS or ECMWF forecast wind, replay observed radar, satellite clouds and lightning on one timeline, and follow NHC/CPHC cyclone tracks. No key needed.
|
||||
- **📷 Mapped ALPR cameras:** License-plate-reader camera locations tagged in OpenStreetMap, one city at a time. Locations and tags only; no key needed.
|
||||
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
|
||||
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
|
||||
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
|
||||
- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim.
|
||||
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
|
||||
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
|
||||
|
||||
---
|
||||
|
||||
@@ -90,7 +96,7 @@ cameras, radio, and launches are available without keys.
|
||||
|
||||
For photorealistic 3D, add a **Cesium ion token** for eligible personal,
|
||||
non-commercial use, or a **Google Maps key** for the direct, metered route and
|
||||
in-app place search. Provider terms and quotas apply. Add keys through the
|
||||
Google place search. Provider terms and quotas apply. Add keys through the
|
||||
app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
|
||||
|
||||
### Path 1 — One click, no terminal
|
||||
@@ -153,8 +159,8 @@ reopens the same panel.
|
||||
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
|
||||
(eligible personal, non-commercial use; current terms and quotas apply) for
|
||||
photorealistic 3D and world terrain; a Google Maps key only for the
|
||||
billing-enabled, metered route + place search; OpenAI when you want to talk
|
||||
to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
|
||||
billing-enabled, metered route + Google place search; OpenAI when you want to
|
||||
talk to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
|
||||
|
||||
<details>
|
||||
<summary>Older Pinokio versions and credential storage</summary>
|
||||
@@ -188,7 +194,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
|
||||

|
||||
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project _into_ the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
4. **Look through a public camera.** Turn on **Cameras** over Austin, London, California, Finland, or Delaware (live video). The feeds aren't webcam embeds — they project _into_ the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
|
||||

|
||||
|
||||
@@ -203,7 +209,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
|
||||
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
@@ -235,7 +241,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
|
||||
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
|
||||
|
||||
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
Twenty-nine tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
|
||||
@@ -253,7 +259,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
|
||||
|
||||
**🔎 Interrogate it** — analyst queries against the live layers:
|
||||
|
||||
> 🗣️ _"How many flights are over Texas right now?"_ · _"Which ships are headed to Oakland?"_ · _"What is the biggest fire near Los Angeles?"_ · _"Is anything flying above forty thousand feet?"_ · _"When does the ISS pass over next?"_
|
||||
> 🗣️ _"How many flights are over Texas right now?"_ · _"Which ships are headed to Oakland?"_ · _"What is the biggest fire near Los Angeles?"_ · _"Is anything flying above forty thousand feet?"_ · _"When does the ISS pass over next?"_ · _"How many datacenters are in view?"_
|
||||
|
||||
**🎛️ Operate it** — the whole console, hands-free:
|
||||
|
||||
@@ -271,7 +277,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
@@ -282,14 +288,22 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| 🚌 **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 | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
||||
|
||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
@@ -297,7 +311,7 @@ Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer add
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
|
||||
| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
|
||||
| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |
|
||||
| 🔴 A Google Maps key | The same 3D direct from Google, plus Google place search — the billing-enabled, metered route |
|
||||
|
||||

|
||||
|
||||
@@ -364,7 +378,8 @@ src/
|
||||
├── hud.js # Intelligence HUD + AI scene summary
|
||||
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
|
||||
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
|
||||
├── voice/ # OpenAI Realtime session + 28 voice tools
|
||||
├── voice/ # OpenAI Realtime session + 29 voice tools
|
||||
├── layers/ # Layer components — weather, wind, cyclones, transit, ALPR, …
|
||||
├── data/ # One module per layer + orchestration + context store
|
||||
│ ├── iconOrientation.js # Screen-projected headings + horizon cull
|
||||
│ └── local_data/ # Bundled datasets (per-folder provenance)
|
||||
@@ -384,6 +399,11 @@ each provider enables; none is required to start. See the
|
||||
[setup instructions](#then-power-it-up--in-the-app-not-in-a-file) for storage
|
||||
and configuration details.
|
||||
|
||||
No key needed for Wind, Rain radar, Satellite clouds, Lightning density,
|
||||
Cyclone advisories, Mapped ALPR Cameras, Transit, Directions, Delaware live
|
||||
video, and coordinate or bundled-place search. A layer row waiting on a key
|
||||
names it.
|
||||
|
||||
### Choose the capabilities you want
|
||||
|
||||
Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
@@ -414,8 +434,10 @@ Add these if you need higher polling allowances.
|
||||
where each configured provider was found without printing credential values.
|
||||
On macOS its Keychain-aware result previews `./scripts/dev-fresh.sh`; plain
|
||||
`npm run dev` reads only explicit environment and Vite dotenv values. The
|
||||
OpenSky summary reports only OAuth client-pair presence, not the resolved
|
||||
runtime mode or credential validity; Basic and credentials-file modes remain
|
||||
OpenSky summary reports keyless anonymous access for explicit `anon` or an
|
||||
OAuth mode without a client pair, retains presence-only wording for a complete
|
||||
OAuth pair, and identifies selected Basic or auto mode without guessing which
|
||||
credentials runtime will accept. Basic and credentials-file modes remain
|
||||
advanced `dev-fresh.sh` configuration.
|
||||
|
||||
<details>
|
||||
@@ -439,15 +461,35 @@ 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:
|
||||
|
||||
| | Cost reality |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
|
||||
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, bundled datasets. |
|
||||
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
|
||||
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
|
||||
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for Google place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
|
||||
| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
|
||||
|
||||
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
|
||||
|
||||
@@ -62,9 +62,11 @@ browser-key fallback for existing single-key setups.
|
||||
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<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.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
|
||||
+17
@@ -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.
|
||||
@@ -237,3 +249,8 @@ FIRMS refactoring can be checked without a configured server key using
|
||||
fixture mode exercises populated display, aggregation, cards, keyless and stale
|
||||
responses, and selection/camera handoff. It does not establish live-source
|
||||
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
|
||||
|
||||
Director authoring and sharing acceptance: `node scripts/qa-director-sharing.mjs`
|
||||
checks installed import previews, draft validation, file-bundle round trips,
|
||||
cancellation, resource ownership and narrow-screen controls. Run alongside the
|
||||
scene-controls, camera, pack, interaction and timing harnesses.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Third-party software notices
|
||||
|
||||
The project depends on third-party npm packages whose own licenses apply. This
|
||||
file records the packages added for the browser-local SDR feature; the complete
|
||||
resolved dependency inventory remains in `package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
|
||||
- Package: `@jtarrio/webrtlsdr` 3.0.6
|
||||
- Author: Jacobo Tarrio Barreiro; portions copyright Google Inc.
|
||||
- Source: <https://github.com/jtarrio/webrtlsdr>
|
||||
- License: Apache License 2.0
|
||||
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
|
||||
## Signals
|
||||
|
||||
- Package: `@jtarrio/signals` 0.10.1
|
||||
- Author: Jacobo Tarrio Barreiro
|
||||
- Source: <https://github.com/jtarrio/signals>
|
||||
- License: Apache License 2.0
|
||||
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
@@ -9,10 +9,25 @@ export function createBrowserViteConfig({
|
||||
cesiumToken,
|
||||
host = 'localhost',
|
||||
port = 4173,
|
||||
command,
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
|
||||
...(publicDir === undefined ? {} : { publicDir }),
|
||||
// A production build must not clean the dependency cache a running dev
|
||||
// server is still serving optimized module URLs from.
|
||||
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
|
||||
optimizeDeps: {
|
||||
// First reached through the SDR worker or a dynamic import. Pre-bundle
|
||||
// them at startup so first use cannot invalidate already-transformed
|
||||
// URLs with Vite's "Outdated Optimize Dep" 504 response.
|
||||
include: [
|
||||
'@jtarrio/signals/demod/demodulator.js',
|
||||
'@jtarrio/signals/demod/modes.js',
|
||||
'@jtarrio/webrtlsdr/rtlsdr.js',
|
||||
'egm96-universal',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
port: parseInt(port, 10) || 4173,
|
||||
|
||||
+16
-9
@@ -105,14 +105,17 @@ checks when changing this wiring.
|
||||
|
||||
`src/search` owns forward/reverse geocoding, place search and route provider
|
||||
interfaces. `createStandaloneApplication({ geospatial })` accepts `endpoints`
|
||||
and `providers`; the defaults preserve Google/Photon search, Google place context
|
||||
and the local OSRM route proxy. Location, annotations, HUD labels and voice use
|
||||
the composed service. Provider-specific credentials belong in the selected
|
||||
transport; server secrets never belong in browser configuration.
|
||||
and `providers`. Default forward search tries decimal coordinates and bundled
|
||||
place names (`presets`) first, without a network request, then Google when a key
|
||||
is configured, Photon, and the local `/api/geocode` Nominatim route. The
|
||||
defaults also keep Google place context and the local OSRM route proxy.
|
||||
Location, annotations, HUD labels and voice use the composed service.
|
||||
Provider-specific credentials belong in the selected transport; server secrets
|
||||
never belong in browser configuration.
|
||||
|
||||
For a compatible protocol, configure `geocode`, `photon`, `reverse`, `textSearch`,
|
||||
`nearby` or `route` endpoints. These are developer-selected configuration, not
|
||||
URLs accepted from page queries or model arguments. A different protocol supplies
|
||||
For a compatible protocol, configure `geocode`, `photon`, `nominatim`, `reverse`,
|
||||
`textSearch`, `nearby` or `route` endpoints. These are developer-selected
|
||||
configuration, not URLs accepted from page queries or model arguments. A different protocol supplies
|
||||
an adapter function instead. The exported `createDefaultPlaceSearch` constructor
|
||||
supports the same options for direct composition:
|
||||
|
||||
@@ -181,10 +184,14 @@ separate constructors. Pass these to `createApplication`; use its `signal` and
|
||||
`defer` for ownership. The compatibility catalog is still page-scoped: construct
|
||||
one application per page. Controls, actions and renderers use the same layer
|
||||
instances. Configure sources with `application/sources` before registration or
|
||||
state restoration, and release them after the consumers stop.
|
||||
state restoration, and release them after the consumers stop. The catalog
|
||||
constructor (`application/layers`) also requires `wind`, `weather` and
|
||||
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the
|
||||
observed-history clock those layers share for the catalog's lifetime.
|
||||
|
||||
`application/services` accepts boundary, terrain, regional-context, weather and
|
||||
summary services. `application/requests` supplies the existing HTTP protocols
|
||||
summary services; its weather service supplies cockpit local conditions, not the
|
||||
Weather layers. `application/requests` supplies the existing HTTP protocols
|
||||
with configurable endpoints and a scoped transport. Changing an endpoint works
|
||||
only for a compatible protocol; another protocol supplies a service adapter.
|
||||
Cancellation discards late response bodies, and replacing a source invalidates
|
||||
|
||||
@@ -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
|
||||
|
||||
+660
-92
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,110 @@
|
||||
# Director camera directions
|
||||
|
||||
Version 4 scene documents can name geographic anchors and describe an explicit
|
||||
camera move followed by a hold. Existing camera-pose shots still use the ordinary
|
||||
camera flight. Import, playback, Load, Replay and seek accept the new directions;
|
||||
[EDIT DETAILS](DIRECTOR-SHARING.md) captures anchors and move starts; a graphical
|
||||
path editor remains a later extension.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 4,
|
||||
"scenes": [{
|
||||
"id": "austin",
|
||||
"title": "Across Austin",
|
||||
"anchors": [
|
||||
{ "id": "west", "title": "West viewpoint", "lat": 30.2672,
|
||||
"lon": -97.76, "alt": 6000, "altitudeReference": "ellipsoid" },
|
||||
{ "id": "east", "title": "East viewpoint", "lat": 30.2672,
|
||||
"lon": -97.73, "alt": 7000, "altitudeReference": "ellipsoid" }
|
||||
],
|
||||
"shots": [{
|
||||
"id": "crossing",
|
||||
"title": "Cross the city",
|
||||
"durationSec": 4,
|
||||
"holdSec": 2,
|
||||
"camera": { "anchorId": "east", "heading": 10, "pitch": -45, "roll": 0 },
|
||||
"move": {
|
||||
"from": { "anchorId": "west", "heading": 350, "pitch": -45, "roll": 0 },
|
||||
"easing": "cubic-in-out"
|
||||
},
|
||||
"visual": { "style": "normal" },
|
||||
"layers": {}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## One owner for each authored value
|
||||
|
||||
An anchor is a scene-local geographic position, with a stable `id`, optional
|
||||
`title`, latitude, longitude, height and explicit altitude reference. Each scene
|
||||
accepts at most 1,024 anchors. IDs must be unique within that scene. References to
|
||||
missing anchors fail validation before replacing a project.
|
||||
|
||||
The shot's `camera` is its destination. It can be an inline pose or an
|
||||
`anchorId` plus orientation. The optional `move.from` uses the same pose/reference
|
||||
shape. References cannot also carry inline coordinates. They do not form chains.
|
||||
Heading, pitch and roll belong to each camera pose, so two shots can look in
|
||||
different directions from the same geographic anchor. Omitted orientation uses
|
||||
heading 0, pitch −35 and roll 0 degrees.
|
||||
|
||||
An explicit move requires `move.from`, `move.easing`, `durationSec` and `holdSec`.
|
||||
Its duration is 0.2–86,400 seconds; the hold is 0–86,400 seconds. The destination
|
||||
is not copied into another field. `linear` and `cubic-in-out` are the supported
|
||||
easings. A shot without `move` retains the existing ordinary flight, even in a
|
||||
version-4 file. Its destination may also reference an anchor.
|
||||
|
||||
Capture continues to create ordinary camera-pose shots. Update Shot stores the
|
||||
current viewport as an inline destination, preserving an explicit move's start
|
||||
and easing; it does not change a shared anchor and thereby move other shots.
|
||||
Editing imported directions and anchors currently means editing the scene JSON.
|
||||
|
||||
## Coordinates and altitude
|
||||
|
||||
Latitude/longitude use WGS84 degrees. Orientation uses the application's Cesium
|
||||
heading/pitch/roll convention in degrees: heading rotates from local north,
|
||||
negative pitch looks down, and zero pitch is horizontal. Camera positions use
|
||||
meters above the WGS84 ellipsoid, matching `Cartesian3.fromDegrees` and captured
|
||||
camera poses. These heights are not mean-sea-level elevations or heights above
|
||||
the visible surface.
|
||||
|
||||
Every anchor and every inline endpoint of an explicit move must say
|
||||
`"altitudeReference": "ellipsoid"`. Ordinary legacy poses without that field
|
||||
already use the same reference. Terrain-relative and mean-sea-level references
|
||||
are rejected; no terrain request, default ground estimate or implicit conversion
|
||||
is made. Authors must provide ellipsoidal heights with enough clearance for their
|
||||
scene. The format does not promise collision avoidance or terrain following.
|
||||
|
||||
During an explicit move, latitude and altitude interpolate between endpoints.
|
||||
Longitude takes the shortest signed arc across the dateline; heading and roll
|
||||
also take their shortest signed arcs. An exact 180-degree tie takes the negative
|
||||
arc. This is an authored coordinate interpolation, not a geodesic or a camera
|
||||
flight with an automatically raised arc. Endpoints are exact, including zero
|
||||
pitch. Curved paths and look-at targets are later extensions.
|
||||
|
||||
## Time, seeking and cancellation
|
||||
|
||||
Load and Replay of an explicit move start at its authored `from` pose, regardless
|
||||
of the current viewport. Play uses that same start. Both live playback and seek
|
||||
use `sampleCameraMove`; a seek into the hold uses the exact destination. Backward
|
||||
seeks do not depend on a prior camera position. Existing scene-pack media/reveal
|
||||
rules can extend holds as before.
|
||||
|
||||
Each active explicit move owns one animation-frame callback. Stop, replacement,
|
||||
import, teardown, a navigation handoff or manual pointer/wheel input revokes that
|
||||
callback and settles the move. Navigation also cancels a run during an authored
|
||||
hold, so the next shot cannot retake the camera. Camera claims still respect
|
||||
Cockpit's refusal. Already queued callbacks cannot move a replacement scene.
|
||||
The timing diagnostic includes the active authored camera animation.
|
||||
|
||||
The renderer-independent `gods-eye-view/director` export provides
|
||||
`resolveCameraPose(scene, camera)`, `resolveCameraMove(scene, shot)` and
|
||||
`sampleCameraMove(move, progress)`. These expect validated/normalized data.
|
||||
Rendering and frame ownership live in `src/scenes/cameraMotion.js`; the existing
|
||||
scene controller supplies camera application and lifecycle cancellation.
|
||||
|
||||
Version-1/2/3 projects migrate to version 4 without converting their ordinary
|
||||
shots into explicit moves. IDs, positions, durations, visual settings, packs and
|
||||
source attribution survive. The bloom scale remains independent of the document
|
||||
version; version-3 bloom is never interpreted as the older inverted scale.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Director data packs
|
||||
|
||||
Version-5 scene documents can declare `dataPacks` on a scene and select them by
|
||||
`dataPackIds` on each shot. Import validates declarations without loading assets.
|
||||
Loading, seeking or playing a shot acquires only its selected packs. A shot with
|
||||
no pack IDs clears the preceding shot's packs. Existing scene recipes and assets
|
||||
keep their current paths, notices and behavior.
|
||||
|
||||
A pack manifest is version 1 and requires an ID, format, source, attribution and
|
||||
placement. Sources describe storage; placement describes geography. Source names
|
||||
refer to trusted application registrations, never scripts to import. A document
|
||||
cannot supply request headers, keys, cookies, endpoints or executable handlers.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 5,
|
||||
"scenes": [{
|
||||
"id": "example",
|
||||
"title": "My geographic notes",
|
||||
"dataPacks": [{
|
||||
"id": "notes",
|
||||
"version": 1,
|
||||
"format": "geojson",
|
||||
"source": { "adapter": "assets", "path": "my-scene/notes.geojson" },
|
||||
"attribution": { "text": "Your name", "license": "Your asset's actual license" },
|
||||
"placement": { "altitudeReference": "ellipsoid" }
|
||||
}],
|
||||
"shots": [{
|
||||
"id": "overview",
|
||||
"camera": { "lat": 30.2672, "lon": -97.742, "alt": 2600, "pitch": -70 },
|
||||
"durationSec": 4,
|
||||
"holdSec": 2,
|
||||
"dataPackIds": ["notes"]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
For the standalone application, put that asset at
|
||||
`public/scene-assets/my-scene/notes.geojson`. The registered `assets` source has
|
||||
an explicit `/scene-assets/` base on the application's origin. Importing a file
|
||||
from disk does not grant access to adjacent local files or infer a remote base.
|
||||
Relative paths cannot contain traversal, query strings, fragments, URL syntax,
|
||||
percent escapes or empty segments. Asset directories are served as static files;
|
||||
this is not a new arbitrary-URL proxy.
|
||||
|
||||
## Formats and placement
|
||||
|
||||
- **`geojson`:** a UTF-8 `FeatureCollection`, served as `application/geo+json` or
|
||||
`application/json`. Each feature needs a unique string `id`. Point, LineString
|
||||
and Polygon (including holes) are supported. Coordinates are WGS84
|
||||
`[longitude, latitude, ellipsoidHeightMeters]`; missing height means zero,
|
||||
which can be underground. Supply appropriate heights. Geometry properties
|
||||
are ignored: they cannot select external icons, HTML, styling or scripts.
|
||||
Placement is `{ "altitudeReference": "ellipsoid" }`.
|
||||
- **`image`:** PNG served as `image/png`, at most 4096×4096 pixels, checked before
|
||||
decoding. Placement is `{ "bounds": [west, south, east, north], "height": 300,
|
||||
"altitudeReference": "ellipsoid" }`. Bounds must increase and cannot cross
|
||||
the dateline. The image is a horizontal geographic rectangle at that height.
|
||||
- **`media`:** MP4/WebM video or MPEG/Ogg/WAV/WebM audio, with the matching standard
|
||||
MIME type. Placement is `{ "anchorId": "media-location" }`, referring to a
|
||||
scene-local anchor with explicit ellipsoid height. A labelled marker locates
|
||||
the media; an attribution card provides native, user-operated playback
|
||||
controls. Media never autoplays. Codec support depends on the browser. D5 does
|
||||
not synchronize media time with scene time or embed third-party players.
|
||||
|
||||
Pack attribution always includes nonempty `text` and `license`; an optional
|
||||
HTTPS `url` opens as a source link with no opener/referrer. Importing or sharing
|
||||
an asset never changes its license. D5 adds no third-party content and preserves
|
||||
all existing Nepal content and contributor notices.
|
||||
|
||||
Optional `byteLength` specifies the exact expected byte count; optional `sha256`
|
||||
is a lowercase hexadecimal SHA-256 digest checked before rendering. There are at
|
||||
most 8 packs per scene, 8 MiB per asset, and 32 MiB of selected assets per shot.
|
||||
GeoJSON additionally allows at most 2,000 features and 50,000 positions.
|
||||
Transforms, terrain-relative placement, terrain collision avoidance, arbitrary
|
||||
GeoJSON styles and archive extraction are not implemented. Explicit local files
|
||||
can be shared through [JSON asset bundles](DIRECTOR-SHARING.md).
|
||||
|
||||
## Loading and resource ownership
|
||||
|
||||
The `director` package export provides `createAssetDirectorySource` and
|
||||
`createDataPackSession`. The former receives an explicit trusted HTTP(S)
|
||||
directory `baseUrl` and optional `fetchImpl`; it confines every relative asset
|
||||
path to that directory. Cross-origin directories require CORS. Requests omit
|
||||
credentials and referrers, reject redirects, and use `cache: 'no-store'`.
|
||||
There is no persistent pack cache, offline fallback or silent substitute. A
|
||||
missing, invalid, oversized or integrity-mismatched asset fails the shot's pack
|
||||
load, removes partial results and reports a stable error without exposing a URL.
|
||||
Successful loading leaves the attribution cards visible with the assets.
|
||||
|
||||
`createApplicationTools({ sceneDataPacks: { sources } })` and
|
||||
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a
|
||||
registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
not scene document fields. Without registrations, referenced sources fail closed.
|
||||
|
||||
The portable session receives format adapters separately. Each adapter receives
|
||||
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
||||
`dispose()`. Core's scene composition supplies Cesium geometry/image rendering
|
||||
and text-only attribution/native media controls. The session applies a 15-second
|
||||
load deadline and rejects late results, even when an adapter ignores abort.
|
||||
|
||||
Stop, replacement, next-shot selection, scene release and destruction cancel
|
||||
pending work, remove owned entities/cards, stop media and revoke object URLs.
|
||||
Run completion releases packs with its run signal. Direct LOAD and seek retain
|
||||
packs until the next operation or Stop. Resources are reacquired per shot;
|
||||
there are no disabled pack refresh loops. `getDataPackState()` returns copied
|
||||
status and resource counts for lifecycle diagnostics.
|
||||
|
||||
Run `node scripts/qa-director-packs.mjs` against a credentialed local server to
|
||||
exercise the real import/load/seek/Stop path with self-authored synthetic assets.
|
||||
See [actions](DIRECTOR-INTERACTIONS.md) and [authoring/sharing](DIRECTOR-SHARING.md)
|
||||
for the controls built on this manifest/loading boundary.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Director scene actions
|
||||
|
||||
Version-6 scene documents can attach actions to stable GeoJSON feature IDs in a
|
||||
shot's selected data packs. Existing versions 1–5 migrate without adding actions.
|
||||
Import validates data and references; it does not load assets or execute actions.
|
||||
|
||||
Add `interactions` to a shot, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "read-outline",
|
||||
"label": "About this outline",
|
||||
"target": { "packId": "outline", "featureId": "boundary" },
|
||||
"action": {
|
||||
"type": "card",
|
||||
"text": "An authored explanation, displayed as plain text.",
|
||||
"url": "https://example.org/source"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The target must be a feature in a GeoJSON pack selected by that shot. Points,
|
||||
lines and polygons retain stable pack/feature identities. Assets load before
|
||||
feature existence is checked; a missing feature refuses the entire action set.
|
||||
Images and native media are not interaction targets in this version.
|
||||
|
||||
| Action | Fields | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `card` | `text`, optional `url` | Show plain text and an optional HTTPS source link; never HTML |
|
||||
| `focus` | `anchorId` | Set a top-down camera pose at a scene anchor through camera ownership arbitration |
|
||||
| `shot` | `shotId` | Load another shot in the same scene at its authored start |
|
||||
| `layer` | `layerId`, boolean `enabled` | Request an explicit state through the ordinary layer admission path |
|
||||
|
||||
There are at most 64 interactions per shot. Card text is limited to 4,096
|
||||
characters; source links cannot contain credentials, query strings or fragments.
|
||||
No script, module, arbitrary fetch endpoint or request header is accepted.
|
||||
|
||||
Actions appear after LOAD or seek finishes. Click a pack feature to select its
|
||||
actions and focus the first button; choose a button explicitly to execute it.
|
||||
Tab traverses buttons and Enter/Space activates them. The selected buttons have
|
||||
visible outlines, and status feedback is announced to assistive technology.
|
||||
There are no global keyboard shortcuts to collide with navigation or typing.
|
||||
Drawing ownership blocks both pointer selection and action execution. Camera
|
||||
focus uses existing navigation admission, including Cockpit restrictions.
|
||||
|
||||
Actions are inactive during automatic playback and camera travel. They do not
|
||||
introduce automatic branching or loops. A chain of user-triggered shot changes
|
||||
is limited to 64 transitions; an explicit LOAD starts a new chain. Import/Stop,
|
||||
replacement, playback and teardown remove selection, cards and picking handlers,
|
||||
and abort pending layer requests. No per-frame interaction loop runs.
|
||||
|
||||
A layer action must name a layer with an explicit baseline in the current shot.
|
||||
A target shot must declare a baseline for every layer the departing shot can
|
||||
change. LOAD and seek reapply those baselines and clear selection. Stop cancels
|
||||
pending transitions; already completed layer changes remain visible, consistent
|
||||
with ordinary scene controls. Camera focus is an immediate anchor pose, not a
|
||||
new timeline segment; seek restores the authored camera pose. Scene JSON is not
|
||||
modified by executing an action.
|
||||
|
||||
The portable interaction session owns action admission/cancellation. The scene
|
||||
interaction owner supplies accessible DOM and picking; pack renderers own the
|
||||
feature identities and release them with their geometry. Layer/source admission
|
||||
and camera arbitration remain with their existing owners.
|
||||
|
||||
Author actions in scene JSON or the validated EDIT DETAILS draft.
|
||||
[Import preview and file sharing](DIRECTOR-SHARING.md) preserve those declarations. Existing content, assets, notices and contributor credit are preserved.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Director authoring and file sharing
|
||||
|
||||
Open Scenes and select a scene and shot. Existing capture, update, playback and
|
||||
whole-project export controls remain available. Authored scene content, source
|
||||
links, assets and contributor credit are preserved.
|
||||
|
||||
## Review imports before applying
|
||||
|
||||
IMPORT accepts scene JSON (including older supported versions) or a
|
||||
`.gevbundle.json` asset bundle. A preview lists scenes, shots, data-pack attribution,
|
||||
configured or unavailable sources, missing layer IDs and bundled bytes. Preview
|
||||
makes no asset requests and does not change the current project. Configured
|
||||
sources do not guarantee that a file exists: ordinary assets are checked on LOAD.
|
||||
Bundle preview verifies the manifest and byte integrity; format decoding and
|
||||
geometry limits are still enforced when rendering.
|
||||
|
||||
Apply replaces the current project after validation and playback cleanup. Export
|
||||
your current project first if you want to keep both. Cancel or Escape discards
|
||||
the staged import. A changed project invalidates an open draft rather than being
|
||||
overwritten. Invalid files leave the existing project and browser save intact.
|
||||
The programmatic `importProjectFile(file)` API still applies trusted callers'
|
||||
imports directly; the installed file chooser supplies the review step.
|
||||
|
||||
## Edit scene details
|
||||
|
||||
EDIT DETAILS provides validated JSON fields for:
|
||||
|
||||
- Scene anchors and data-pack manifests.
|
||||
- The selected shot's camera, optional move, duration, hold, pack IDs and actions.
|
||||
|
||||
Capture camera as anchor adds a named geographic position to the draft. Set move
|
||||
start to current camera creates a pose-to-pose move ending at the shot's existing
|
||||
camera; Use ordinary flight removes that explicit move. Heights are meters above
|
||||
the WGS84 ellipsoid. Apply details validates the complete project, preserves IDs,
|
||||
layer declarations and content notices, and keeps the selected shot. Cancel
|
||||
leaves the project unchanged. See [camera directions](DIRECTOR-CAMERA.md),
|
||||
[data packs](DIRECTOR-DATA-PACKS.md) and [actions](DIRECTOR-INTERACTIONS.md) for
|
||||
field definitions. This is a basic draft editor, not a node graph or path editor.
|
||||
|
||||
## Share a selected scene
|
||||
|
||||
SHARE SCENE exports only the selected scene. Download scene JSON preserves its
|
||||
camera, timing, layers, anchors, manifests, interactions and attribution, but
|
||||
contains no asset bytes. The recipient needs the referenced source registrations
|
||||
and files. Existing EXPORT PRESETS continues to export the entire project.
|
||||
|
||||
For a bundle, select the declared pack files or a folder, then Download asset
|
||||
bundle. Folder paths match a manifest path either directly or after removing the
|
||||
chosen root directory. Individual filenames must be unambiguous across all pack
|
||||
sources; ambiguous or missing matches fail without downloading a partial bundle.
|
||||
Previously imported bundle files can be reused. Export never automatically fetches
|
||||
remote files or copies registered recipe media. Existing integrity declarations
|
||||
must match the files you supply.
|
||||
|
||||
Bundles include only declared data-pack files. Registered scene content, linked
|
||||
media, map tiles and live data are not included. Source notices and licenses remain
|
||||
in the document; packaging a file does not grant permission to redistribute it.
|
||||
|
||||
## Bundle format and limits
|
||||
|
||||
A bundle is uncompressed JSON with these fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "gev-scene-bundle",
|
||||
"version": 1,
|
||||
"project": { "version": 6, "scenes": [] },
|
||||
"assets": []
|
||||
}
|
||||
```
|
||||
|
||||
Each asset contains `path`, `mimeType`, `base64` and lowercase hexadecimal `sha256`.
|
||||
Export copies the document and rewrites pack references to the reserved
|
||||
`scene-bundle` source, adding exact `byteLength` and `sha256`. Paths are confined
|
||||
relative names; URLs, traversal, duplicate or unreferenced assets, unknown fields,
|
||||
missing packs and mismatched hashes are rejected. Scene documents remain bounded
|
||||
at 5 MiB. A bundle allows 50 MiB of JSON, 64 files, 8 MiB per file and 32 MiB of
|
||||
asset bytes in total. Supported MIME types are JSON/GeoJSON, PNG, MP4/WebM video
|
||||
and MPEG/Ogg/WAV/WebM audio. Normal pack format and geometry checks still apply.
|
||||
There are no executable modules, request headers or credentials in the format.
|
||||
|
||||
Imported bytes stay in memory for the current project and session. Stop releases
|
||||
rendered resources while retaining these bytes for replay. Project replacement
|
||||
or application teardown releases them. Only scene JSON is saved in browser
|
||||
storage: **reimport the bundle after reloading the app**. Missing bundle bytes
|
||||
fail explicitly, with no network fallback. This is file sharing, not a storage
|
||||
service or an offline basemap.
|
||||
|
||||
Portable helpers are exported through `gods-eye-view/director`: `parseSceneShare`,
|
||||
`readSceneShare`, `createSceneBundle`, `createBundleAssets`, `describeSceneShare`,
|
||||
`editSceneDetails` and `selectSceneDocument`. Loading and UI remain separate owners.
|
||||
`getSharingState()` reports copied dialog/asset counts for lifecycle diagnostics.
|
||||
|
||||
Run `node scripts/qa-director-sharing.mjs` against the local server for installed
|
||||
controls, cancellation, stale drafts, export/import round trips, resource cleanup
|
||||
and narrow-screen acceptance. Fixtures are synthetic and carry their own notices.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Director: reusable scene playback
|
||||
|
||||
Director is the scene playback system. Ordered shot execution, authored timing and playback clocks are separated
|
||||
from the scene editor and application rendering. This document
|
||||
records implemented camera directions, data packs, declarative interactions and
|
||||
file sharing, along with the remaining extensions.
|
||||
|
||||
## Implemented boundary
|
||||
|
||||
`src/director/playback.js`, exported as `gods-eye-view/director`, provides:
|
||||
|
||||
- `buildPlaybackQueue(scenes, startSceneId, { single })`: ordered scenes/shots,
|
||||
retaining the existing round-robin behavior.
|
||||
- `playSceneQueue(queue, { token, adapter, previousScene, releaseOnFinish })`:
|
||||
sequential execution, cancellation checks and scene release.
|
||||
|
||||
The runner imports no renderer, UI, storage, recipe or dataset. An adapter supplies
|
||||
these phases, in order: `selectShot`, `applyVisual`, `applyLayers`, `travel`,
|
||||
`settle`, `hold`, `completeShot`. Each receives `{ scene, shot, index, total,
|
||||
token }`. It also supplies `releaseScene(scene, token?)` and optionally `complete()`.
|
||||
Callbacks may return promises. Each playback invocation requires its own adapter
|
||||
state and token; the caller owns camera arbitration and concurrent-run refusal.
|
||||
|
||||
The runner checks cancellation after each phase. Adapters must also pass the
|
||||
abort signal or liveness predicate into pending work: a check between phases
|
||||
cannot stop a camera flight or network request already in progress. Cleanup
|
||||
between scenes and on preview exit runs without an aborted token. A refused
|
||||
initial handoff starts no shot. Final cleanup runs on completion, Stop or failure;
|
||||
`releaseOnFinish: false` retains the final scene for ordinary playback. Exceptions
|
||||
propagate so the caller can restore its controls. Empty/already-cancelled queues
|
||||
acquire no resources. This low-level API expects normalized scene objects;
|
||||
untrusted file validation belongs at the import boundary.
|
||||
|
||||
`src/scenes/playbackAdapter.js` connects this runner to the existing
|
||||
`SceneDirector`. The latter still owns editor state, saved-project import/export,
|
||||
camera ownership and asynchronous load/seek arbitration. Pure seek calculations
|
||||
live in `src/director/timeline.js`; `src/director/clock.js` owns the run/load/shot
|
||||
tickers, hold deadlines, snapshots and clock subscriptions. Stop settles pending
|
||||
holds immediately, and revoked tick callbacks cannot affect a replacement clock.
|
||||
`getPlaybackTimingState()` exposes copied snapshots and an active-timer count for
|
||||
lifecycle diagnostics without exposing timer handles.
|
||||
|
||||
The `director` export also provides `sceneTimingForShot`, `sceneSeekState`,
|
||||
`cameraAtProgress` and `createPlaybackClock`. Timeline functions receive duration
|
||||
and hold resolvers; the clock receives timing, running-state and progress callbacks.
|
||||
They import no renderer, storage, recipe or dataset.
|
||||
|
||||
`src/scenes/shotPresentation.js` prepares layer/seek context and resolves holds
|
||||
and map preferences through a scene-pack registry. The `scenes` export provides
|
||||
`createScenePackRegistry({ recipes, adapters })`; pass the result as `scenePacks`
|
||||
in the SceneDirector constructor options. Trusted adapters can supply
|
||||
`minimumHoldSec(states)`, `resolveVisual(shot, visual, isMapStackAvailable)` and
|
||||
`cancelMotion(getLayerModule)`. Default composition registers the existing Nepal
|
||||
presentation rules. These are code-level composition hooks, not executable
|
||||
modules imported from scene files. Existing media-owner waits remain bounded.
|
||||
|
||||
All authored scenes, assets, IDs, source links, attribution and existing JSON
|
||||
projects are preserved. The Nepal sequence remains the contribution introduced
|
||||
by @manjunath22466 in #590. Its clips use source links/embeds; comparison images
|
||||
and river coordinates are bundled. See the
|
||||
[event pack provenance](../public/events/bhote-koshi-2026/README.md).
|
||||
|
||||
## Next changes, in order
|
||||
|
||||
1. **Timeline and content separation — implemented.** Pure duration/seek
|
||||
calculations, an owned clock and registered presentation rules preserve
|
||||
existing recipes and content. Tests cover direct load, forward/backward seek,
|
||||
replay, bounded media waits and cancellation at pending transitions.
|
||||
2. **Versioned scene document — implemented for existing camera-pose shots.**
|
||||
[The scene document](SCENE-DOCUMENT.md) preserves version-1/2/3 imports. Bounded validation
|
||||
rejects malformed files before replacing current state; legacy migration
|
||||
preserves IDs, pack bindings and visual edits. Unreadable saved files are
|
||||
protected from fallback writes. Camera anchors, pack manifests and actions
|
||||
receive stable IDs with their respective later format extensions.
|
||||
3. **Camera directions — implemented for pose-to-pose moves.**
|
||||
[Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit
|
||||
start/destination poses, easing, duration and holds. Playback and seek share
|
||||
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
|
||||
is rejected. Existing shots retain ordinary flights. Basic anchor/move authoring is available through EDIT DETAILS; curved paths and
|
||||
look-at targets remain later extensions.
|
||||
4. **Data packs and placement — implemented for bounded assets.**
|
||||
[Version 5](DIRECTOR-DATA-PACKS.md) defines a pack manifest with format/version,
|
||||
source, attribution/license and optional integrity/size metadata. It distinguishes
|
||||
registered asset directories from geographic coordinates, image bounds and
|
||||
media anchors. GeoJSON, PNG and native media adapters load with byte/time
|
||||
limits and explicit ellipsoid heights. Stop and replacement release resources.
|
||||
Paths resolve against a trusted directory base; missing/offline assets fail
|
||||
explicitly. File bundles are available; transforms and terrain-relative placement
|
||||
remain future extensions. Scene files carry no credentials or executable modules.
|
||||
5. **Declarative interactions — implemented.** [Version 6](DIRECTOR-INTERACTIONS.md)
|
||||
connects selected feature IDs to accessible text/source cards, anchor focus,
|
||||
shot seek and admitted layer changes. References are validated and pending
|
||||
actions cancel on Stop, replacement or teardown. Scene files contain no
|
||||
JavaScript or arbitrary HTML; actions require explicit user input.
|
||||
6. **Sharing and basic authoring — implemented.** [Authoring and sharing](DIRECTOR-SHARING.md)
|
||||
adds import preview, missing-source/layer diagnostics, validated scene/shot
|
||||
drafts and selected-scene JSON or explicit-file bundles. Pack attribution is
|
||||
preserved. Bundle bytes last for the session and must be reimported after a
|
||||
reload. A future node graph can author the same document without becoming a
|
||||
playback or format requirement.
|
||||
|
||||
Each step must preserve existing scene editing, keyboard playback, camera
|
||||
arbitration, layer cleanup, media timing and provider fallback. Acceptance includes
|
||||
portable runner tests, existing Director regressions and browser playback with
|
||||
Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures
|
||||
for failures and cancellation, alongside live visual checks for actual content.
|
||||
|
||||
Declarative actions are implemented in version 6; see [scene actions](DIRECTOR-INTERACTIONS.md) for supported targets, execution and reset semantics.
|
||||
@@ -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.
|
||||
|
||||
+62
-3
@@ -1,6 +1,6 @@
|
||||
# KNOWN ISSUES
|
||||
|
||||
Updated: July 8, 2026
|
||||
Updated: September 22, 2026
|
||||
|
||||
This file tracks active runtime issues only.
|
||||
|
||||
@@ -78,6 +78,56 @@ Status: Open (owner-accepted 2026-07-08, documented)
|
||||
|
||||
---
|
||||
|
||||
### Weather layers: coverage and meaning
|
||||
Status: Open (source limits, by design)
|
||||
|
||||
- **Rain radar covers the contiguous United States only.** It shows MRMS
|
||||
reflectivity in dBZ, not rainfall rate or a forecast; a gap in coverage does not
|
||||
mean no precipitation.
|
||||
- **Lightning density is a ground-network grid, not GLM flashes.** It is NOAA's
|
||||
15-minute density product derived from Vaisala NLDN/GLD360 on an approximately
|
||||
8 km grid, for the Americas and Pacific only (110°E across the dateline to 0°,
|
||||
25°S–80°N). It is not a live strike counter or an all-clear.
|
||||
- **Cyclone advisories cover NOAA's basins only:** the Atlantic and the
|
||||
eastern/central North Pacific (NHC and CPHC). Storms elsewhere do not appear.
|
||||
The cone is forecast-center uncertainty, not storm size.
|
||||
- **Satellite clouds:** global infrared is hourly with typically 2–3 hours of
|
||||
source latency. Clouds only is a brightness filter, not a cloud mask.
|
||||
- **Wind is a forecast.** The animation flows through one forecast on an
|
||||
approximately 1° grid and does not advance forecast time or follow the
|
||||
observed-history timeline.
|
||||
- **History is not shared.** Share links carry the weather layers and their
|
||||
settings but open at Latest.
|
||||
- **3D Tiles detail window during history playback:** when a frame's detail image
|
||||
is slow to arrive, the window may fall back to the coarser full-extent image
|
||||
until it does.
|
||||
- Native hardware GPU behavior of the wind and weather renderers is not yet
|
||||
verified.
|
||||
|
||||
---
|
||||
|
||||
### Satellite passes and analyst answers
|
||||
Status: Open (by design)
|
||||
|
||||
- Pass predictions search the next 24 hours for satellites in the loaded catalog.
|
||||
Visibility is a geometric estimate (satellite sunlit, observer's Sun at or
|
||||
below −6°); it ignores weather, satellite brightness and orbital-element age.
|
||||
- Analyst counts and ranks cover a bounded set of loaded records (by default
|
||||
2,000 per layer for satellites, datacenters and dams), so omitted records can
|
||||
change a nearest or count answer. Satellite distance is ground distance.
|
||||
|
||||
---
|
||||
|
||||
### Live camera video (HLS)
|
||||
Status: Open (limits)
|
||||
|
||||
- At most two live-video sessions run at once. RTMP-only sources and encrypted,
|
||||
fMP4 or byte-range playlists are not supported.
|
||||
- When live video fails, the camera falls back to its labeled still, Street View
|
||||
or placeholder frame, which is not live video.
|
||||
|
||||
---
|
||||
|
||||
## Closed / Intentional (for clarity)
|
||||
|
||||
### Proxy SSRF and error-surface hardening gaps
|
||||
@@ -118,5 +168,14 @@ Context:
|
||||
converted to **live NASA FIRMS data** on 2026-07-16: the `/api/firms` proxy merges
|
||||
three VIIRS NRT sources (trailing 24 h, 30 min cache, serve-stale-on-failure) and the
|
||||
bundled snapshot was deleted. Requires a free server-side `FIRMS_MAP_KEY`; without it
|
||||
the layer shows a KEY REQUIRED state.
|
||||
- Weather radar is still held out of OSS v1 after QA found the previous overlay did not provide reliable visible value.
|
||||
the layer row reads "Needs FIRMS_MAP_KEY — add it in Provider Settings".
|
||||
|
||||
---
|
||||
|
||||
### Weather radar held out of the open-source release
|
||||
Status: Closed — observed rain radar shipped (September 2026)
|
||||
|
||||
Context:
|
||||
- Rain radar, Satellite clouds, Lightning density, Wind and Cyclone advisories
|
||||
are in the Weather group of Data Layers. Their current limits are listed under
|
||||
"Weather layers: coverage and meaning" above.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Local receivers
|
||||
|
||||
The Local ADS-B layer draws aircraft heard by your own receivers. It has two
|
||||
inputs, and it can use both at once.
|
||||
|
||||
| Input | What it is | Bands |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Browser SDR | A USB RTL-SDR opened by the browser through WebUSB (Radio panel, Local RTL-SDR card). Decoding runs in the page. | 1090 MHz |
|
||||
| Decoder feed | The `aircraft.json` served by a decoder you run: dump1090-fa, readsb or tar1090 (1090 MHz), or dump978-fa + skyaware978 (978 MHz). | 1090 MHz, 978 MHz UAT |
|
||||
|
||||
Aircraft from both inputs are merged by ICAO address. The newest position
|
||||
wins, and the click card names every band and source that heard the aircraft
|
||||
in the last 60 s. Aircraft heard only on 978 MHz UAT draw with a thin ring.
|
||||
|
||||
Local aircraft usually lead their public twins. The public Flights layer
|
||||
renders positions about one poll interval (~30 s) behind so it can interpolate
|
||||
smoothly between fixes, while local receiver aircraft are shown as heard. An
|
||||
aircraft in both layers therefore draws twice, the magenta local marker ahead
|
||||
of the public one; both markers are drawn on purpose.
|
||||
|
||||
## Which to use
|
||||
|
||||
- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR.
|
||||
Nothing else to install.
|
||||
- **A dual-band board, a Raspberry Pi feeder, or a dongle already used by a
|
||||
decoder:** use a decoder feed. A USB dongle can be opened by only one program
|
||||
at a time, so a dongle that dump1090 or dump978 owns cannot also be opened by
|
||||
the browser.
|
||||
|
||||
## Configuring feeds
|
||||
|
||||
Feeds are set in the server environment only (`.env`), never in the browser:
|
||||
|
||||
```sh
|
||||
LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/data/aircraft.json,978=http://localhost:8978/data/aircraft.json
|
||||
```
|
||||
|
||||
Each entry is `band=url`, where `band` is `1090` or `978`. The URL must:
|
||||
|
||||
- use `http` or `https`;
|
||||
- point at loopback, a private (RFC1918) address, `localhost` or a `*.local`
|
||||
name. Public addresses, link-local addresses (including `169.254.169.254`),
|
||||
IPv6 and other host names are refused;
|
||||
- have a path ending in `aircraft.json`, with no credentials, query or
|
||||
fragment.
|
||||
|
||||
An entry that breaks a rule is logged when the server starts, shown as
|
||||
`invalid` and never fetched. Restart the server after changing the value.
|
||||
|
||||
The server reads every feed about once a second while the Local ADS-B layer is
|
||||
on. A feed is `live`, `stale` (its own `now` is more than 10 s old, usually a
|
||||
stopped decoder), `unreachable` or `invalid`. The Layers panel row and the
|
||||
Local RTL-SDR card show these states.
|
||||
|
||||
## Example: dump1090-fa and dump978-fa on macOS or Linux
|
||||
|
||||
Write each decoder's JSON to a directory, then serve the directory on
|
||||
localhost. Each command keeps running, so start each in its own terminal:
|
||||
|
||||
```sh
|
||||
# 1090 MHz
|
||||
dump1090-fa --device-index 0 --write-json /tmp/adsb1090
|
||||
python3 -m http.server 8080 --bind 127.0.0.1 --directory /tmp/adsb1090
|
||||
|
||||
# 978 MHz UAT: dump978-fa decodes, skyaware978 writes aircraft.json
|
||||
dump978-fa --sdr driver=rtlsdr,rtl=1 --raw-port 30978
|
||||
skyaware978 --connect localhost:30978 --json-dir /tmp/adsb978
|
||||
python3 -m http.server 8978 --bind 127.0.0.1 --directory /tmp/adsb978
|
||||
```
|
||||
|
||||
```sh
|
||||
LOCAL_RECEIVER_FEEDS=1090=http://localhost:8080/aircraft.json,978=http://localhost:8978/aircraft.json
|
||||
```
|
||||
|
||||
A board with two identical-serial channels, such as the Nooelec FlyCatcher,
|
||||
cannot be told apart by serial number. Select each channel by index:
|
||||
`--device-index <n>` for dump1090-fa and `--sdr driver=rtlsdr,rtl=<n>` for
|
||||
dump978-fa. `rtl_test` lists the indexes.
|
||||
|
||||
A tar1090 or readsb install on a Raspberry Pi already serves
|
||||
`http://<pi-address>/tar1090/data/aircraft.json`. Use the Pi's private address
|
||||
or its `.local` name.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Scene document format
|
||||
|
||||
Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
|
||||
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
|
||||
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
|
||||
a field path before the current project, selection or saved bytes change.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 5,
|
||||
"scenes": [{
|
||||
"id": "my-scene",
|
||||
"title": "My scene",
|
||||
"shots": [{
|
||||
"id": "my-shot",
|
||||
"title": "Arrival",
|
||||
"durationSec": 4,
|
||||
"holdSec": 1,
|
||||
"camera": { "lat": 51.5, "lon": -0.12, "alt": 800,
|
||||
"heading": 0, "pitch": -35, "roll": 0 },
|
||||
"visual": { "style": "normal" },
|
||||
"layers": { "traffic": { "enabled": true } }
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Fields and migration
|
||||
|
||||
The root requires `scenes` (an array, which may be empty). Optional root fields
|
||||
are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs).
|
||||
Each scene requires a `shots` array; optional fields are `id`, `title`,
|
||||
`releaseLayerIds`, `appliedShotPacks` and version-4 `anchors`. A pack marker has `id`, optional numeric
|
||||
`version` and `shotBindings` (original shot title to saved shot ID). These markers
|
||||
retain the editor's existing pack-update and rename behavior.
|
||||
|
||||
Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`,
|
||||
`sourcePackId`, `sourcePackVersion` and version-4 `move`. Scene IDs are unique within the project;
|
||||
shot IDs are unique within their scene. Existing IDs and pack bindings survive
|
||||
migration. Missing IDs are generated once and persisted by the next save/export.
|
||||
Missing titles and optional camera/visual fields receive the editor's defaults.
|
||||
Empty scenes and intentionally empty projects remain empty.
|
||||
|
||||
Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses
|
||||
meters above the WGS84 ellipsoid. Import preserves finite heights below
|
||||
100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference;
|
||||
terrain-relative inputs are rejected. See the camera contract for exact semantics.
|
||||
Flight durations retain the editor's minimum of 0.2 seconds and its 4-second
|
||||
fallback for zero; holds may be zero.
|
||||
|
||||
Visual fields are `style`, `mapStack`, `styleParams`, `bloom`, `sharpen`, `hud`, and
|
||||
`detection`, and `scope`. Bloom has `enabled`, `intensity`, `version`; sharpen has `enabled`,
|
||||
`intensity`; HUD has `visible`, `variant`; detection has `mode`, `density`,
|
||||
`allocation`, `fadePct` and `outsideOpacityPct`. Scope has `enabled`, `featherPct`.
|
||||
The latter settings were written by Update Shot but previously lost during
|
||||
normalization; they now survive import and capture.
|
||||
Version-1/2 bloom without an explicit scale version uses the original inverted
|
||||
scale and migrates once to scale 2. The scene document and bloom scale versions
|
||||
are separate. Legacy numeric strings are accepted for numeric fields.
|
||||
|
||||
Layer entries accept a boolean shorthand or `{ "enabled": true, "params": {} }`.
|
||||
Parameters and style parameters retain nested JSON data. They are interpreted by
|
||||
already registered components; importing a file does not fetch its URLs, register
|
||||
modules, evaluate code or apply its layers. Unsupported structural fields are
|
||||
rejected instead of silently dropping potential edits. Map-stack selection
|
||||
retains the existing registered-stack behavior.
|
||||
|
||||
## Validation limits
|
||||
|
||||
`gods-eye-view/director` exports `parseSceneDocument(text)`,
|
||||
`validateSceneDocument(project)`, `stringifySceneDocument(project)`,
|
||||
`SceneDocumentError`, `SCENE_DOCUMENT_VERSION` and `SCENE_DOCUMENT_LIMITS`.
|
||||
Validation is independent of the renderer, storage and recipes. Parsing checks
|
||||
UTF-8 size before JSON decoding; the file importer also checks file size before
|
||||
reading it. Migration/defaults live separately in `src/scenes/project.js`.
|
||||
|
||||
Limits are 5 MiB, 256 scenes, 10,000 total shots, 10,000 entries per collection,
|
||||
24 levels of nesting, 200,000 visited values, 65,536 characters per JSON string,
|
||||
256 characters per field/ID, and 4,096 characters per title. Numeric values must
|
||||
be finite. Camera limits are latitude ±90, longitude ±180, altitude −12,000 to
|
||||
1 billion meters, heading/roll ±360 and pitch ±90; durations/holds are at most
|
||||
86,400 seconds. Post-processing values are bounded and normalized to the existing
|
||||
slider ranges. Object prototype keys are refused at every depth.
|
||||
|
||||
An invalid import leaves the current project and playback untouched. A valid
|
||||
import cancels and settles pending playback before replacing the project. If
|
||||
multiple files are read concurrently, only the newest request can replace it.
|
||||
Disposal prevents a pending import from publishing.
|
||||
|
||||
The browser storage key remains `godsEyeView.sceneProject.v2` for compatibility;
|
||||
that key's suffix is not the document version. An unreadable saved project stays
|
||||
in storage. The app supplies temporary defaults and a visible warning, but blocks
|
||||
saving over the original bytes until a valid file is explicitly imported. Export
|
||||
can preserve temporary edits. Storage writes use the same validation before replacing saved bytes; invalid
|
||||
edits and storage write failures surface an unsaved toast.
|
||||
|
||||
Only authored project fields belong in this file. Runtime clocks, camera flights,
|
||||
loading progress and run diagnostics remain separate. Camera anchors and authored
|
||||
moves arrived in version 4; data packs follow in version 5. Declarative
|
||||
interactions remain a planned extension. No scene assets, source links, licenses or contributor attribution
|
||||
change with this document boundary.
|
||||
|
||||
Version 5 additionally accepts scene `dataPacks` and shot `dataPackIds`, as
|
||||
defined in [data packs](DIRECTOR-DATA-PACKS.md). Version-4 camera documents
|
||||
continue to import without adding any packs.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Application UI ownership
|
||||
|
||||
`src/ui/applicationShell.js` constructs controls, connects their explicit inputs
|
||||
and coordinates shutdown. `shellFacade.js` preserves the methods used by layers,
|
||||
scene playback and voice. It delegates; new behavior belongs with its owner.
|
||||
|
||||
| Owner | Responsibility |
|
||||
| --- | --- |
|
||||
| `navigationController.js` | Navigation generation, tracking release and camera authority |
|
||||
| `locationNavigation.js` | Destination lookup/selection, orbit, world jumps and globe-reset completion |
|
||||
| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal |
|
||||
| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime |
|
||||
| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration |
|
||||
| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls |
|
||||
| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners |
|
||||
| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime |
|
||||
| `shareRestoration.js` | Initial share/layer restoration and cancellation |
|
||||
| `shellFeedback.js` | Loading feedback, notices and toast lifetime |
|
||||
|
||||
Owners receive named services, elements, operations and readers. They do not
|
||||
receive the application shell itself. Readers resolve a replaceable collaborator
|
||||
at use time; operations cross an ownership boundary without copying its state.
|
||||
The facade exposes existing properties as delegates rather than duplicate state.
|
||||
|
||||
Shutdown has two phases. First revoke input, deferred navigation, camera-entry
|
||||
listeners, lookups, timers and rendering work. Then await Context restoration,
|
||||
release sensor overrides while the manager is still attached, detach layer
|
||||
services and destroy scene resources. Late callbacks cannot acquire the camera.
|
||||
Manager replacement also releases subscriptions and the previous Directions
|
||||
module even if the replacement does not provide a Directions layer.
|
||||
|
||||
Source-contract tests follow both sides of delegated wiring. Lifecycle tests
|
||||
exercise pending reset cancellation, stale camera callbacks, replacement and
|
||||
idempotent teardown. Browser acceptance covers the composed application, including
|
||||
share restoration, scene commands, navigation, Cockpit, display and voice.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Realtime voice ownership
|
||||
|
||||
`realtimeController.js` composes the session and owns its status, UI bindings and
|
||||
overall lifetime. It coordinates startup and shutdown. `realtimeFacade.js`
|
||||
preserves existing methods and properties by delegation, without copying state.
|
||||
The session adapter, action runner and backend interfaces remain unchanged.
|
||||
|
||||
| Owner | State and responsibility |
|
||||
| --- | --- |
|
||||
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
||||
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
||||
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
||||
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
||||
| `realtimeCost.js` | Next-session preferences and the current session's model-bound meter |
|
||||
| `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation |
|
||||
| `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics |
|
||||
|
||||
Pure input policy, preferences and protocol-response policy have separate modules.
|
||||
Owners receive named readers, operations or focused collaborators. Cross-owner
|
||||
cancellation uses operations; response handling does not own Radio's reservations
|
||||
or viewport deletion bookkeeping.
|
||||
|
||||
Shutdown first invalidates acquisition and action work, then closes transport,
|
||||
stops audio analysis, releases media and clears conversation/input state. Full
|
||||
removal also detaches UI, keyboard, annotation and Radio observers. Radio takeover
|
||||
retains confirmed playback through that same shutdown path. A late offer, peer callback,
|
||||
viewport capture or cancelled meter frame cannot enter a replacement session.
|
||||
Action and post-capture continuations retain their conversation identity before
|
||||
publishing output, queueing a response or changing status.
|
||||
|
||||
Behavioral invariants remain in force: a 500 ms Space hold claims push-to-talk;
|
||||
short control taps and text entry remain native; a click-started session stays
|
||||
open-mic. Every accepted or refused tool call receives its terminal output once.
|
||||
New typed input supersedes old intent. Radio waits for the spoken confirmation and
|
||||
verified muted playback. A live cost meter stays bound to its negotiated model,
|
||||
even when the next-session preference changes.
|
||||
|
||||
Run the unit suite and both live recorded-audio modes against a configured local
|
||||
server:
|
||||
|
||||
```sh
|
||||
node scripts/qa-voice-wav.mjs http://localhost:4173
|
||||
node scripts/qa-voice-wav.mjs http://localhost:4173 --push-to-talk
|
||||
```
|
||||
|
||||
The second mode uses the actual Space shortcut, verifies microphone acquisition
|
||||
and release, and checks the same globe/Radio outcome. Tracking and scene-control
|
||||
acceptance exercise voice's shared application operations separately.
|
||||
Generated
+50
@@ -9,9 +9,13 @@
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"satellite.js": "^6.0.2"
|
||||
@@ -1059,6 +1063,22 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@jtarrio/signals": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz",
|
||||
"integrity": "sha512-X6tJOugEATYH9KJy6j2feEBVCUmRv1U9SSbg9ITQZCx/Qruw2fE2zsS1HKH3yOsxrZBE+eQTGM+d0roX4xuDdw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@jtarrio/webrtlsdr": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@jtarrio/webrtlsdr/-/webrtlsdr-3.0.6.tgz",
|
||||
"integrity": "sha512-+P8dsbXQ/6Mwru/vV613yHH68nHFyUJ9zPK68hE/Hh2F3jwyirUAjjBtIAfN/MlJbFioMJ3NS6dMGrRj9deFvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@types/w3c-web-usb": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
@@ -1076,6 +1096,24 @@
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@meri-imperiumi/eccodes-wasm": {
|
||||
"version": "2.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@meri-imperiumi/eccodes-wasm/-/eccodes-wasm-2.48.2.tgz",
|
||||
"integrity": "sha512-Z7rD3xInomPG7FS97ORD1UGKAdgeeRP9ofa9k4m8d+k2sDfDQeAjTzvs9UOndyaZovaSbEQeIoNX8gTmQNhiSA==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
@@ -1518,6 +1556,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/w3c-web-usb": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
|
||||
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zip.js/zip.js": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz",
|
||||
@@ -1944,6 +1988,12 @@
|
||||
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
||||
+9
-1
@@ -2,7 +2,7 @@
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
@@ -47,9 +47,13 @@
|
||||
"qa:transit": "node scripts/qa-transit.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"satellite.js": "^6.0.2"
|
||||
@@ -142,6 +146,7 @@
|
||||
"./ui/scenes": "./src/ui/scenes.js",
|
||||
"./ui/shell": "./src/ui/applicationShell.js",
|
||||
"./ui/styles": "./src/ui/styles.js",
|
||||
"./director": "./src/director/playback.js",
|
||||
"./scenes": "./src/scenes/director.js",
|
||||
"./sources/live": "./src/sources/live/index.js",
|
||||
"./layers/flights": "./src/layers/flights/index.js",
|
||||
@@ -149,6 +154,8 @@
|
||||
"./aircraft": "./src/layers/aircraft/index.js",
|
||||
"./layers/vessels": "./src/layers/vessels/index.js",
|
||||
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
||||
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
|
||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||
"./layers/firms": "./src/layers/firms/index.js",
|
||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||
@@ -220,6 +227,7 @@
|
||||
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
|
||||
"./layers/cctv/source": "./src/layers/cctv/source.js",
|
||||
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||
|
||||
@@ -35,9 +35,12 @@ const portableExport = (key) =>
|
||||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
|
||||
key,
|
||||
) ||
|
||||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
|
||||
key,
|
||||
);
|
||||
[
|
||||
'./director',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
].includes(key);
|
||||
|
||||
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
|
||||
export function checkImportDirections(root) {
|
||||
|
||||
@@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) {
|
||||
const seen = new Set();
|
||||
await build({
|
||||
root,
|
||||
// Keep this gate out of a running dev server's dependency cache
|
||||
// (node_modules/.vite): a build there re-optimizes dependencies and the
|
||||
// dev server's workers then fail with 504 "Outdated Optimize Dep".
|
||||
cacheDir: path.join(root, 'node_modules/.vite-boundaries'),
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
@@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
input,
|
||||
external: group.external,
|
||||
// A declared dependency also owns its package subpath exports
|
||||
// (for example `@jtarrio/webrtlsdr/rtlsdr.js`).
|
||||
external: (id) =>
|
||||
group.external.some(
|
||||
(name) => id === name || id.startsWith(`${name}/`),
|
||||
),
|
||||
// Unused imports must still obey ownership; tree shaking is not a boundary.
|
||||
treeshake: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
|
||||
+114
-1
@@ -31,6 +31,7 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
"src/standalone/data.js",
|
||||
@@ -99,6 +100,8 @@
|
||||
"src/tooling/previewServing.test.mjs",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
"src/data/tapAddress.test.mjs",
|
||||
"src/data/tapAddress.js",
|
||||
"server/providers/local.js",
|
||||
"server/providers/military-installations.js",
|
||||
"server/providers/military-installations/cache.js",
|
||||
@@ -142,6 +145,8 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/standalone/placeSearch.js",
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/ui/imagerySplit.test.mjs",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/surfaceKeyboard.test.mjs",
|
||||
"src/ui/panelRails.js",
|
||||
@@ -181,6 +186,7 @@
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/layerKeyRequirement.test.mjs",
|
||||
"src/ui/layerPanel.test.mjs",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"scripts/qa-layer-panel.mjs",
|
||||
"scripts/qa-directions.mjs",
|
||||
@@ -333,6 +339,16 @@
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
"src/layers/earthquakes/ownership.test.mjs",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/perimeters/records.test.mjs",
|
||||
"src/layers/perimeters/source.test.mjs",
|
||||
"src/layers/perimeters/cards.test.mjs",
|
||||
"src/layers/perimeters/inciweb.test.mjs",
|
||||
"src/layers/perimeters/ownership.test.mjs",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
@@ -382,6 +398,10 @@
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/data/localGeojson.analyst.test.mjs",
|
||||
"src/data/satellites.analyst.test.mjs",
|
||||
"src/data/layerSnapshot.test.mjs",
|
||||
"src/data/satellitePass.test.mjs",
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
@@ -494,6 +514,7 @@
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
@@ -542,11 +563,13 @@
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/layers/submarineCables/bundledSource.test.mjs",
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/controller.test.mjs",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
@@ -594,6 +617,7 @@
|
||||
"src/app/layers/flights.js",
|
||||
"src/app/layers/radio.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/satellites.js",
|
||||
@@ -702,6 +726,7 @@
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/lifecycle.test.mjs",
|
||||
"scripts/qa-transit.mjs",
|
||||
"scripts/qa-transit-heading.mjs",
|
||||
"src/tooling/transitProvider.test.mjs",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitIcons.js",
|
||||
@@ -724,7 +749,95 @@
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"src/tooling/transitQa.test.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"scripts/qa-transit-recovery.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/sources/overpassFeatures.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",
|
||||
"scripts/qa-director-timing.mjs",
|
||||
"src/director/document.test.mjs",
|
||||
"src/director/camera.test.mjs",
|
||||
"src/scenes/cameraMotion.test.mjs",
|
||||
"scripts/qa-director-camera.mjs",
|
||||
"src/director/packs/packs.test.mjs",
|
||||
"scripts/qa-director-packs.mjs",
|
||||
"src/scenes/dataPacks/presentation.test.mjs",
|
||||
"src/director/interactions/interactions.test.mjs",
|
||||
"scripts/qa-director-interactions.mjs",
|
||||
"src/director/sharing/sharing.test.mjs",
|
||||
"scripts/qa-director-sharing.mjs",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/weatherPanel.test.mjs",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCards.test.mjs",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/railTimeline.test.mjs",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryOrder.test.mjs",
|
||||
"server/providers/cyclones.js",
|
||||
"server/providers/firePerimeters.js",
|
||||
"src/data/cyclonesProxy.test.mjs",
|
||||
"src/data/firePerimetersProxy.test.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/layers/cyclones/rendering.test.mjs",
|
||||
"src/layers/cyclones/labels.test.mjs",
|
||||
"src/layers/cyclones/index.test.mjs",
|
||||
"src/layers/cyclones/source.test.mjs",
|
||||
"src/data/aisStreamSentinels.test.mjs",
|
||||
"scripts/qa-weather-perf.mjs",
|
||||
"scripts/qa-weather-journey.mjs",
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/sdr/adsbDecoder.test.mjs",
|
||||
"src/sdr/controller.test.mjs",
|
||||
"src/sdr/gain.test.mjs",
|
||||
"src/sdr/spectrum.test.mjs",
|
||||
"src/sdr/usbDevices.test.mjs",
|
||||
"src/sources/adsbRecords.test.mjs",
|
||||
"src/layers/localAdsb/index.test.mjs",
|
||||
"src/ui/localSdrControls.test.mjs",
|
||||
"src/ui/localSdrMarkup.test.mjs",
|
||||
"src/ui/localSdrPresentation.test.mjs",
|
||||
"src/data/localReceiversProxy.test.mjs",
|
||||
"src/layers/localAdsb/feeds.test.mjs",
|
||||
"src/ui/railScrollers.test.mjs",
|
||||
"src/layers/localAdsb/motion.test.mjs",
|
||||
"src/data/modelEligibility.test.mjs",
|
||||
"src/layers/recentImagery/model.test.mjs",
|
||||
"src/layers/recentImagery/catalog.test.mjs",
|
||||
"src/maps/imageryHost.test.mjs",
|
||||
"src/layers/recentImagery/rendering.test.mjs",
|
||||
"src/layers/recentImagery/thumbnails.test.mjs",
|
||||
"src/layers/recentImagery/index.test.mjs",
|
||||
"src/ui/imageryBoxTool.test.mjs",
|
||||
"src/ui/recentImagery.test.mjs",
|
||||
"src/layers/recentImagery/testDoubles.mjs",
|
||||
"scripts/qa-recent-imagery.mjs"
|
||||
]
|
||||
+381
-16
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
@@ -161,6 +161,7 @@
|
||||
"server/providers/cctv/normalize.js",
|
||||
"server/providers/cctv/range.js",
|
||||
"server/providers/cctv/sources.js",
|
||||
"server/providers/cctv/stream.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
@@ -256,6 +257,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"src/hudLayoutPolicy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
@@ -268,6 +270,10 @@
|
||||
"server/providers/openai/realtime.js",
|
||||
"server/providers/openai/tools.js",
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
@@ -322,6 +328,7 @@
|
||||
"exports": ["./ui/layout"],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
@@ -375,11 +382,18 @@
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -413,6 +427,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -449,6 +464,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -474,6 +490,10 @@
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/bloom.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
@@ -504,6 +524,9 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -511,6 +534,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -519,6 +543,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -538,6 +563,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -574,7 +605,16 @@
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/worldFocus.js",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/ui/localSdrPresentation.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -602,7 +642,32 @@
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/provider-settings.css"
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/weather.css",
|
||||
"src/ui/styles/recent-imagery.css"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"director": {
|
||||
"exports": ["./director"],
|
||||
"modules": [
|
||||
"src/director/playback.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/director/document.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -615,6 +680,8 @@
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/bloom.js",
|
||||
@@ -622,7 +689,36 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/contextModePolicy.js"
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/scenes/shotPresentation.js",
|
||||
"src/scenes/packs/registry.js",
|
||||
"src/scenes/packs/defaults.js",
|
||||
"src/scenes/packs/nepal.js",
|
||||
"src/director/document.js",
|
||||
"src/scenes/project.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/scenes/cameraMotion.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -640,6 +736,7 @@
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -671,13 +768,16 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/modelEligibility.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -706,7 +806,8 @@
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/modelEligibility.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -768,6 +869,18 @@
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"recent-imagery": {
|
||||
"exports": ["./layers/recent-imagery"],
|
||||
"modules": [
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"fire-layer": {
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
@@ -796,6 +909,7 @@
|
||||
"modules": [
|
||||
"src/data/issPass.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
@@ -804,6 +918,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -817,6 +932,7 @@
|
||||
"launches-layer": {
|
||||
"exports": ["./layers/launches"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/index.js",
|
||||
@@ -863,6 +979,7 @@
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -965,6 +1082,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -975,7 +1093,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/layers/cctv/sourcePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
"alpr-cameras": {
|
||||
"exports": ["./layers/alpr"],
|
||||
@@ -1056,6 +1174,7 @@
|
||||
"modules": [
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
@@ -1076,13 +1195,15 @@
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/keySetupCore.mjs"
|
||||
"src/keySetupCore.mjs",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1105,7 +1226,18 @@
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js"
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1129,8 +1261,15 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/cyberSonarGpu.js",
|
||||
"src/scenes/nepalEvidencePack.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
@@ -1174,6 +1313,7 @@
|
||||
"src/app/surfaceServices.js",
|
||||
"src/app/tools.js",
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
"src/cameraVerbs.js",
|
||||
@@ -1208,6 +1348,19 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/wind.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/layers/wind/source.js",
|
||||
"src/sources/windBody.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
@@ -1257,10 +1410,12 @@
|
||||
"src/data/regionalBrief.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/satellites.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
@@ -1343,6 +1498,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1448,6 +1604,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -1503,6 +1660,7 @@
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
@@ -1518,6 +1676,8 @@
|
||||
"src/renderGovernor.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/scopeMask.js",
|
||||
@@ -1547,6 +1707,9 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -1554,6 +1717,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -1562,6 +1726,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -1582,6 +1747,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -1630,6 +1801,7 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -1652,8 +1824,15 @@
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/sources/reference.js",
|
||||
"src/search/nominatim.js",
|
||||
@@ -1682,6 +1861,7 @@
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
@@ -1691,15 +1871,99 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"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",
|
||||
"src/scenes/packs/registry.js",
|
||||
"src/scenes/packs/defaults.js",
|
||||
"src/scenes/packs/nepal.js",
|
||||
"src/director/document.js",
|
||||
"src/scenes/project.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/scenes/cameraMotion.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/app/layers/localAdsb.js",
|
||||
"src/layers/localAdsb/index.js",
|
||||
"src/layers/localAdsb/policy.js",
|
||||
"src/layers/localAdsb/card.js",
|
||||
"src/layers/localAdsb/feeds.js",
|
||||
"src/layers/localAdsb/status.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
"src/sdr/spectrum.js",
|
||||
"src/sdr/gain.js",
|
||||
"src/sdr/usbDevices.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/ui/localSdrPresentation.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/ui/recentImagery.js",
|
||||
"src/ui/imageryBoxTool.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"@mapbox/vector-tile",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
"mgrs",
|
||||
"pbf",
|
||||
"satellite.js"
|
||||
"satellite.js",
|
||||
"@jtarrio/webrtlsdr"
|
||||
]
|
||||
},
|
||||
"application-catalog": {
|
||||
@@ -1710,7 +1974,10 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
@@ -1720,6 +1987,16 @@
|
||||
"src/annotations/resolver.js",
|
||||
"src/app/catalog.js",
|
||||
"src/app/constructCatalog.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/app/layers/aisLiveVessels.js",
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
@@ -1767,6 +2044,7 @@
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/issPass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/localGeojson.js",
|
||||
@@ -1784,6 +2062,7 @@
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/satelliteClass.js",
|
||||
@@ -1858,6 +2137,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1963,6 +2243,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -2026,6 +2307,7 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -2049,6 +2331,12 @@
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
@@ -2085,15 +2373,56 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/app/layers/localAdsb.js",
|
||||
"src/layers/localAdsb/index.js",
|
||||
"src/layers/localAdsb/policy.js",
|
||||
"src/layers/localAdsb/card.js",
|
||||
"src/layers/localAdsb/feeds.js",
|
||||
"src/layers/localAdsb/status.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
"src/sdr/spectrum.js",
|
||||
"src/sdr/gain.js",
|
||||
"src/sdr/usbDevices.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
"satellite.js",
|
||||
"@mapbox/vector-tile",
|
||||
"pbf",
|
||||
"mgrs"
|
||||
"mgrs",
|
||||
"@jtarrio/webrtlsdr"
|
||||
]
|
||||
},
|
||||
"application-operations": {
|
||||
@@ -2111,6 +2440,7 @@
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keylessGeocoder.js",
|
||||
@@ -2156,7 +2486,11 @@
|
||||
},
|
||||
"ui-navigation": {
|
||||
"exports": ["./ui/navigation"],
|
||||
"modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
|
||||
"modules": [
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/navigationController.js",
|
||||
"src/app/stateChannel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"ui-share-restoration": {
|
||||
@@ -2174,6 +2508,10 @@
|
||||
"ui-visual-settings": {
|
||||
"exports": ["./ui/visual-settings"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -2198,6 +2536,8 @@
|
||||
"ui-panel-chrome": {
|
||||
"exports": ["./ui/panel-chrome"],
|
||||
"modules": [
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/panelStackLayout.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/panelChrome.js",
|
||||
@@ -2288,7 +2628,7 @@
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
"exports": ["./voice/action-schemas"],
|
||||
"modules": ["src/voice/actionSchemas.js"],
|
||||
"modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"],
|
||||
"external": []
|
||||
},
|
||||
"voice-session": {
|
||||
@@ -2377,6 +2717,9 @@
|
||||
"src/sources/reference.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/submarineCables/bundledSource.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -2447,6 +2790,7 @@
|
||||
"transit-standalone": {
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/app/sourceSlot.js",
|
||||
"src/celestialRing.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
@@ -2572,5 +2916,26 @@
|
||||
"src/sources/featureSource.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"perimeters": {
|
||||
"exports": ["./layers/perimeters"],
|
||||
"modules": [
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"perimeters-source": {
|
||||
"exports": ["./layers/perimeters/source"],
|
||||
"modules": [
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -1943,6 +1943,98 @@ try {
|
||||
}),
|
||||
);
|
||||
|
||||
// Cyber relocates its collapsed briefing header into the expanded utility
|
||||
// lane. Exercise actual height changes without reload and measure both cards.
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setHudVariant('cyber');
|
||||
manager.hud.setMode('on');
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', true);
|
||||
});
|
||||
const cyberRadioOrder = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const display = document
|
||||
.getElementById('cockpit-display-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const radio = document
|
||||
.getElementById('cockpit-radio-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const displayRect = display.getBoundingClientRect();
|
||||
const radioRect = radio.getBoundingClientRect();
|
||||
return {
|
||||
display: displayRect.toJSON(),
|
||||
radio: radioRect.toJSON(),
|
||||
displayExpanded: display.classList.contains('is-expanded'),
|
||||
radioExpanded: radio.classList.contains('is-expanded'),
|
||||
gap: radioRect.top - displayRect.bottom,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Cyber expanding Radio preserves the authored Display-then-Radio order',
|
||||
!cyberRadioOrder.displayExpanded
|
||||
&& cyberRadioOrder.radioExpanded
|
||||
&& cyberRadioOrder.display.height > 0
|
||||
&& cyberRadioOrder.radio.height > 0
|
||||
&& cyberRadioOrder.gap >= 7,
|
||||
JSON.stringify(cyberRadioOrder),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, 'cyber-radio-order.png') });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
});
|
||||
const cyberStyleStates = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const states = [];
|
||||
for (const style of ['normal', 'surveillance', 'thermal', 'retro']) {
|
||||
document.querySelector(`.style-btn[data-style="${style}"]`).click();
|
||||
states.push({
|
||||
requestedStyle: style,
|
||||
activeStyle: manager.activeStyle,
|
||||
hud: manager.hud.getVariant(),
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
selector: document.getElementById('hud-layout-select').value,
|
||||
savedHud: manager.getVisualState().hud.variant,
|
||||
});
|
||||
}
|
||||
return states;
|
||||
});
|
||||
check('Cockpit visual-style controls retain Cyber HUD, selector, shell and saved layout',
|
||||
cyberStyleStates.every(state => state.activeStyle === state.requestedStyle
|
||||
&& state.hud === 'cyber' && state.theme === 'cyber'
|
||||
&& state.selector === 'cyber' && state.savedHud === 'cyber'),
|
||||
JSON.stringify(cyberStyleStates));
|
||||
for (const height of [987, 720, 640, 900]) {
|
||||
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const geometry = await page.evaluate(() => {
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
const utility = document.getElementById('cockpit-utility-controls');
|
||||
const expanded = utility.querySelector('.cockpit-utility-control.is-expanded');
|
||||
const s = signal.getBoundingClientRect();
|
||||
const u = expanded.getBoundingClientRect();
|
||||
const toggle = document.getElementById('cockpit-signal-toggle');
|
||||
const t = toggle.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2);
|
||||
return { height: innerHeight, theme: document.documentElement.dataset.uiTheme,
|
||||
collapsed: signal.dataset.collapsed, signal: s.toJSON(), utility: u.toJSON(),
|
||||
reachable: Boolean(hit && toggle.contains(hit)),
|
||||
contained: u.bottom <= s.top - 7 && s.bottom < innerHeight,
|
||||
};
|
||||
});
|
||||
check(`Cyber collapsed Live Signals remains visible and reachable after resize to ${height}px`,
|
||||
geometry.theme === 'cyber' && geometry.collapsed === 'true'
|
||||
&& geometry.signal.width > 0 && geometry.signal.height > 0
|
||||
&& geometry.reachable && geometry.contained, JSON.stringify(geometry));
|
||||
if (height === 640 || height === 900) {
|
||||
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
|
||||
|
||||
const signalTransition = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const cockpit = manager.cockpitView;
|
||||
@@ -2171,7 +2263,8 @@ try {
|
||||
);
|
||||
|
||||
const resetResult = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
// Both the UI and compatibility facade delegate to this navigation owner.
|
||||
const manager = window.__godsEyeView.styleManager._locationNavigation;
|
||||
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
@@ -2203,7 +2296,7 @@ try {
|
||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||
const height = gev.viewer.camera.positionCartographic?.height;
|
||||
gev.styleManager.resetToGlobeView = qa.original;
|
||||
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||
delete window.__qaCockpitReset;
|
||||
return {
|
||||
calls: qa.calls,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/** Verify imported camera directions through real rendering and camera authority handoffs. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = path.resolve('qa-shots/director-camera');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failed = 0;
|
||||
function check(name, passed) {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failed++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
||||
const file = {
|
||||
version: 4,
|
||||
scenes: [
|
||||
{
|
||||
id: 'directions',
|
||||
title: 'Camera directions',
|
||||
anchors: [
|
||||
{
|
||||
id: 'start',
|
||||
title: 'Austin west',
|
||||
lat: 30.2672,
|
||||
lon: -97.76,
|
||||
alt: 6000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
title: 'Austin east',
|
||||
lat: 30.2672,
|
||||
lon: -97.73,
|
||||
alt: 7000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'move',
|
||||
title: 'Across Austin',
|
||||
durationSec: 1,
|
||||
holdSec: 0.2,
|
||||
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
|
||||
move: {
|
||||
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
|
||||
easing: 'linear',
|
||||
},
|
||||
layers: {},
|
||||
visual: { style: 'normal' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(file)], 'directions.json'),
|
||||
);
|
||||
const scene = d._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
const imported =
|
||||
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
|
||||
await d.seekScene(scene.id, 0.5 / 1.2);
|
||||
const middle = style.getCameraState();
|
||||
const seekMidpoint =
|
||||
Math.abs(middle.lon - -97.745) < 1e-6 &&
|
||||
Math.abs(middle.alt - 6500) < 0.01;
|
||||
await d.seekScene(scene.id, 0);
|
||||
const back = style.getCameraState();
|
||||
const backward = Math.abs(back.lon - -97.76) < 1e-6;
|
||||
const replay = await d.replayShot(scene.id, shot.id);
|
||||
const end = style.getCameraState();
|
||||
const replayEnd =
|
||||
replay?.started &&
|
||||
Math.abs(end.lon - -97.73) < 1e-6 &&
|
||||
Math.abs(end.alt - 7000) < 0.01;
|
||||
d.stopScene();
|
||||
await d.startScene(scene.id, { single: true, preview: false });
|
||||
const completed =
|
||||
!d.running && d.getPlaybackTimingState().activeTimers === 0;
|
||||
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
|
||||
// Updating the target creates an explicit inline pose while retaining the authored start.
|
||||
d.updateSelectedShot();
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
const updated =
|
||||
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
|
||||
saved.scenes[0].shots[0].move.from.anchorId === 'start';
|
||||
shot.durationSec = 30;
|
||||
const run = d.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!d._cameraMotion.active && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const started = d._cameraMotion.active;
|
||||
style.runImmediateNavigation('location', () => true);
|
||||
await run;
|
||||
const revoked =
|
||||
started &&
|
||||
!d.running &&
|
||||
!d._cameraMotion.active &&
|
||||
d.getPlaybackTimingState().activeTimers === 0;
|
||||
const stoppedPose = style.getCameraState();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const stable =
|
||||
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
|
||||
return {
|
||||
imported,
|
||||
seekMidpoint,
|
||||
backward,
|
||||
replayEnd,
|
||||
completed,
|
||||
settled,
|
||||
updated,
|
||||
revoked,
|
||||
stable,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
await page.screenshot({ path: path.join(output, 'austin.png') });
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const pose = d.styleManager.getCameraState();
|
||||
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
|
||||
d.viewer.scene.requestRender();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await page.screenshot({ path: path.join(output, 'angle.png') });
|
||||
const inputStarted = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes[0];
|
||||
window.__qaCameraRun = d.startScene(scene.id, {
|
||||
single: true,
|
||||
preview: false,
|
||||
});
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!d._cameraMotion.active && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return d._cameraMotion.active;
|
||||
});
|
||||
const canvas =
|
||||
(await page.$('canvas.cesium-widget-canvas')) ||
|
||||
(await page.$('.cesium-widget canvas'));
|
||||
const rect = await canvas.boundingBox();
|
||||
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
const input =
|
||||
inputStarted &&
|
||||
(await page.evaluate(async () => {
|
||||
await window.__qaCameraRun;
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
|
||||
}));
|
||||
check('manual canvas input revokes the move and releases clocks', input);
|
||||
const hold = await page.evaluate(async () => {
|
||||
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
||||
const scene = d._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.durationSec = 0.2;
|
||||
shot.holdSec = 30;
|
||||
const run = d.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (
|
||||
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
|
||||
Date.now() < deadline
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const holding = d.running && !d._cameraMotion.active;
|
||||
const began = performance.now();
|
||||
style.runImmediateNavigation('location', () => true);
|
||||
await run;
|
||||
return (
|
||||
holding &&
|
||||
performance.now() - began < 1000 &&
|
||||
d.getPlaybackTimingState().activeTimers === 0
|
||||
);
|
||||
});
|
||||
check('navigation during an authored hold settles immediately', hold);
|
||||
|
||||
if (errors.length)
|
||||
console.error(
|
||||
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = 'qa-shots/director-interactions';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
|
||||
void request
|
||||
.respond({
|
||||
status: 200,
|
||||
contentType: 'application/geo+json',
|
||||
body: JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.catch(() => {});
|
||||
else void request.continue().catch(() => {});
|
||||
});
|
||||
const action = (id) => `[data-director-action="${id}"]`;
|
||||
const load = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
|
||||
flyDuration: 0.2,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
const target = { packId: 'data', featureId: 'point' };
|
||||
const interaction = (id, label, action) => ({ id, label, target, action });
|
||||
window.__interactionProject = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'interactive',
|
||||
title: 'Interactive fixture',
|
||||
anchors: [
|
||||
{
|
||||
id: 'view',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
alt: 5000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
{
|
||||
id: 'data',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'qa/actions.json' },
|
||||
attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'one',
|
||||
title: 'Choose an action',
|
||||
camera,
|
||||
durationSec: 0.2,
|
||||
holdSec: 0,
|
||||
move: {
|
||||
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||
easing: 'linear',
|
||||
},
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
interaction('card', 'Read source', {
|
||||
type: 'card',
|
||||
text: '<b>Plain text only</b>',
|
||||
url: 'https://example.org/source',
|
||||
}),
|
||||
interaction('focus', 'Focus anchor', {
|
||||
type: 'focus',
|
||||
anchorId: 'view',
|
||||
}),
|
||||
interaction('layer', 'Show traffic', {
|
||||
type: 'layer',
|
||||
layerId: 'traffic',
|
||||
enabled: true,
|
||||
}),
|
||||
interaction('next', 'Next shot', {
|
||||
type: 'shot',
|
||||
shotId: 'two',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
title: 'Next',
|
||||
camera: { ...camera, heading: 30 },
|
||||
durationSec: 0.2,
|
||||
holdSec: 0,
|
||||
layers: { traffic: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
|
||||
);
|
||||
});
|
||||
check(
|
||||
'import installs no action handlers or UI',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await load();
|
||||
check(
|
||||
'settled LOAD activates four actions',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
|
||||
),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const point = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const entity = d.viewer.entities.values.find(
|
||||
(e) => e.name === 'data / point',
|
||||
);
|
||||
const pos = entity.position.getValue(d.viewer.clock.currentTime);
|
||||
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
|
||||
const rect = d.viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
check(
|
||||
'real globe pick selects the feature and focuses its keyboard action',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.dataset.directorAction === 'card' &&
|
||||
document
|
||||
.querySelector('[data-director-interactions] [role=status]')
|
||||
.textContent.includes('point'),
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'Enter shows literal text and a safe source link',
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('[data-director-action-card]');
|
||||
return (
|
||||
card?.textContent.includes('<b>Plain text only</b>') &&
|
||||
!card.querySelector('b') &&
|
||||
card.querySelector('a')?.rel === 'noopener noreferrer'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: `${output}/selected.png` });
|
||||
fs.writeFileSync(
|
||||
`${output}/report.json`,
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Space');
|
||||
check(
|
||||
'Tab/Space focuses the authored anchor through camera ownership',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||
.height - 5000,
|
||||
) < 1,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: `${output}/anchor.png` });
|
||||
const sourceBase = process.env.QA_SOURCE_BASE || '/src';
|
||||
// Resolve ownership from the same module graph as the active application.
|
||||
await page.evaluate(async (base) => {
|
||||
window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
|
||||
window.__lease = window.__inputOwner.claimPointer('qa-draw');
|
||||
}, sourceBase);
|
||||
await page.click(action('card'));
|
||||
check(
|
||||
'drawing ownership refuses actions',
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelector('[data-director-interactions] [role=status]')
|
||||
.textContent.includes('unavailable'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
|
||||
// Hold the actual manager admission call to exercise Stop before an enable settles.
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector,
|
||||
m = d.dataManager;
|
||||
window.__originalEnable = m.setEnabled;
|
||||
m.setEnabled = function (id, enabled, options) {
|
||||
if (id !== 'traffic' || !enabled)
|
||||
return window.__originalEnable.call(this, id, enabled, options);
|
||||
window.__actionSignal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
window.__lateAction = () => resolve(true);
|
||||
options.signal.addEventListener('abort', () => resolve(false), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
await page.click(action('layer'));
|
||||
await page.waitForFunction(() => window.__actionSignal);
|
||||
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||
check(
|
||||
'Stop aborts pending layer admission and removes handlers/cards/selection',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__actionSignal.aborted &&
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__lateAction();
|
||||
window.__godsEyeView.sceneDirector.dataManager.setEnabled =
|
||||
window.__originalEnable;
|
||||
});
|
||||
await load();
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
|
||||
);
|
||||
check(
|
||||
'same-shot seek restores pack geometry and resets action state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||
null &&
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__oldActionButton = document.querySelector(
|
||||
'[data-director-action=card]',
|
||||
);
|
||||
});
|
||||
await page.click(action('next'));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
|
||||
);
|
||||
check(
|
||||
'explicit shot transition replaces resources and removes actions',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
|
||||
),
|
||||
);
|
||||
await load();
|
||||
await page.evaluate(() => window.__oldActionButton.click());
|
||||
check(
|
||||
'detached buttons cannot invoke replacement actions with the same ID',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||
null &&
|
||||
!document.querySelector('[data-director-action-card]').textContent,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.sceneDirector._interactionTransitions = 64;
|
||||
});
|
||||
await page.click(action('next'));
|
||||
check(
|
||||
'bounded transition chain cannot take another branch',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
|
||||
),
|
||||
);
|
||||
await load();
|
||||
await page.evaluate(async () => {
|
||||
const p = structuredClone(window.__interactionProject);
|
||||
p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
|
||||
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||
});
|
||||
check(
|
||||
'unknown loaded feature refuses the whole action set',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
|
||||
);
|
||||
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||
await d.destroy();
|
||||
});
|
||||
check(
|
||||
'teardown releases interactive resources',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env node
|
||||
/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
const output = path.resolve('qa-shots/director-packs');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 80, b: 30, alpha: 0.65 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const wav = Buffer.alloc(44 + 16000);
|
||||
wav.write('RIFF');
|
||||
wav.writeUInt32LE(wav.length - 8, 4);
|
||||
wav.write('WAVEfmt ', 8);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24);
|
||||
wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(16000, 40);
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'line',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.745, 30.268, 400],
|
||||
[-97.735, 30.268, 400],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'polygon',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.746, 30.265, 300],
|
||||
[-97.74, 30.265, 300],
|
||||
[-97.74, 30.267, 300],
|
||||
[-97.746, 30.265, 300],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [],
|
||||
requests = [];
|
||||
let pending,
|
||||
failures = 0;
|
||||
const check = (name, passed) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.startsWith('/scene-assets/'))
|
||||
return void request.continue();
|
||||
requests.push(url.pathname);
|
||||
if (url.pathname.endsWith('/slow.json')) {
|
||||
pending = request;
|
||||
return;
|
||||
}
|
||||
const reply = url.pathname.endsWith('/outline.json')
|
||||
? { contentType: 'application/geo+json', body: JSON.stringify(geojson) }
|
||||
: url.pathname.endsWith('/image.png')
|
||||
? { contentType: 'image/png', body: png }
|
||||
: url.pathname.endsWith('/audio.wav')
|
||||
? { contentType: 'audio/wav', body: wav }
|
||||
: { status: 404, body: 'missing' };
|
||||
void request.respond(reply).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const manifest = (id, format, file, placement) => ({
|
||||
id,
|
||||
version: 1,
|
||||
format,
|
||||
source: { adapter: 'assets', path: `qa/${file}` },
|
||||
attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' },
|
||||
placement,
|
||||
});
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
};
|
||||
const project = {
|
||||
version: 5,
|
||||
scenes: [
|
||||
{
|
||||
id: 'packs',
|
||||
title: 'Data packs QA',
|
||||
anchors: [
|
||||
{
|
||||
id: 'media',
|
||||
lat: 30.269,
|
||||
lon: -97.737,
|
||||
alt: 450,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
manifest('outline', 'geojson', 'outline.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('image', 'image', 'image.png', {
|
||||
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||
height: 300,
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('media', 'media', 'audio.wav', { anchorId: 'media' }),
|
||||
manifest('slow', 'geojson', 'slow.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('missing', 'geojson', 'missing.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
],
|
||||
shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
camera,
|
||||
durationSec: 0.3,
|
||||
holdSec: 0.2,
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: {},
|
||||
dataPackIds:
|
||||
id === 'all'
|
||||
? ['outline', 'image', 'media']
|
||||
: id === 'empty'
|
||||
? []
|
||||
: [id],
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(project)], 'packs.json'),
|
||||
);
|
||||
window.__packBaseline = d.viewer.entities.values.length;
|
||||
window.__packPrimitiveBaseline = d.viewer.scene.primitives.length;
|
||||
window.__packRevoked = [];
|
||||
const revoke = URL.revokeObjectURL.bind(URL);
|
||||
URL.revokeObjectURL = (url) => {
|
||||
window.__packRevoked.push(url);
|
||||
revoke(url);
|
||||
};
|
||||
});
|
||||
check('import does not acquire unselected assets', requests.length === 0);
|
||||
const loaded = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 });
|
||||
window.__packMaterials = Array.from(
|
||||
{
|
||||
length:
|
||||
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
|
||||
},
|
||||
(_, i) =>
|
||||
d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i)
|
||||
.appearance?.material,
|
||||
).filter(Boolean);
|
||||
return {
|
||||
started: result?.started,
|
||||
state: d.getDataPackState(),
|
||||
count: d.viewer.entities.values.length - window.__packBaseline,
|
||||
primitives:
|
||||
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
|
||||
cards: document.querySelectorAll('[data-director-pack]').length,
|
||||
paused: document.querySelector('[data-director-pack] audio')?.paused,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'GeoJSON, PNG and media load only through registered sources',
|
||||
loaded.started && loaded.state.count === 3 && requests.length === 3,
|
||||
);
|
||||
check(
|
||||
'geometry and image batches plus anchored markers render separately',
|
||||
loaded.count === 2 && loaded.primitives === 3,
|
||||
);
|
||||
check(
|
||||
'attribution is visible and media does not autoplay',
|
||||
loaded.cards === 3 && loaded.paused === true,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const evidence = await page.evaluate(() => ({
|
||||
mapStack: window.__godsEyeView.mapStackController.getActiveId(),
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'report.json'),
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
check(
|
||||
'static geometry is ready before visual acceptance',
|
||||
await page.evaluate(() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
return Array.from(
|
||||
{ length: primitives.length - window.__packPrimitiveBaseline },
|
||||
(_, i) => primitives.get(window.__packPrimitiveBaseline + i),
|
||||
).every((p) => p.ready !== false);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'packs.png') });
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
d._setCameraView({
|
||||
...d.styleManager.getCameraState(),
|
||||
heading: 30,
|
||||
pitch: -85,
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await page.screenshot({ path: path.join(output, 'angle.png') });
|
||||
const life = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const media = document.querySelector('[data-director-pack] audio');
|
||||
await media.play();
|
||||
const played = !media.paused;
|
||||
await d.loadShot('packs', 'empty', { flyDuration: 0.3 });
|
||||
const cleared =
|
||||
d.getDataPackState().count === 0 &&
|
||||
!document.querySelector('[data-director-packs]') &&
|
||||
d.viewer.entities.values.length === window.__packBaseline &&
|
||||
d.viewer.scene.primitives.length === window.__packPrimitiveBaseline &&
|
||||
media.paused &&
|
||||
!media.getAttribute('src') &&
|
||||
window.__packRevoked.length === 2 &&
|
||||
window.__packMaterials.length === 1 &&
|
||||
window.__packMaterials.every((material) => material.isDestroyed());
|
||||
await d.seekScene('packs', 0.1);
|
||||
const seek = d.getDataPackState().count === 3;
|
||||
d.stopScene();
|
||||
const stopped =
|
||||
d.getDataPackState().count === 0 &&
|
||||
d.viewer.entities.values.length === window.__packBaseline;
|
||||
const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 });
|
||||
return {
|
||||
played,
|
||||
cleared,
|
||||
seek,
|
||||
stopped,
|
||||
missing: missing?.started === false && d.getDataPackState().count === 0,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(life)) check(name, passed);
|
||||
await page.evaluate(() => {
|
||||
window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot(
|
||||
'packs',
|
||||
'slow',
|
||||
);
|
||||
});
|
||||
const deadline = Date.now() + 15000;
|
||||
while (!pending && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
check('pending transport reached', !!pending);
|
||||
const cancelled = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const start = performance.now();
|
||||
d.stopScene();
|
||||
await window.__pendingPack;
|
||||
return performance.now() - start < 1000 && d.getDataPackState().count === 0;
|
||||
});
|
||||
check('Stop promptly settles a pending transport', cancelled);
|
||||
if (pending)
|
||||
await pending
|
||||
.respond({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(geojson),
|
||||
})
|
||||
.catch(() => {});
|
||||
check(
|
||||
'late response cannot reintroduce entities',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.entities.values.length ===
|
||||
window.__packBaseline &&
|
||||
window.__godsEyeView.viewer.scene.primitives.length ===
|
||||
window.__packPrimitiveBaseline,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.loadShot('packs', 'all', { flyDuration: 0.3 });
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'),
|
||||
);
|
||||
});
|
||||
check(
|
||||
'replacement import clears every pack resource',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 0 &&
|
||||
!document.querySelector('[data-director-packs]'),
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
if (errors.length)
|
||||
console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]')));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = path.resolve('qa-shots/director-sharing');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const geometry = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] },
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry));
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
};
|
||||
const project = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'share',
|
||||
title: 'Sharing fixture',
|
||||
dataPacks: [
|
||||
{
|
||||
id: 'data',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'example/data.geojson' },
|
||||
attribution: { text: 'Synthetic <b>fixture</b>', license: 'CC0-1.0' },
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'shot',
|
||||
title: 'First',
|
||||
camera,
|
||||
durationSec: 0.3,
|
||||
holdSec: 0,
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: {},
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
{
|
||||
id: 'info',
|
||||
label: 'Read fixture',
|
||||
target: { packId: 'data', featureId: 'point' },
|
||||
action: { type: 'card', text: 'Synthetic scene note' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'second', title: 'Other scene', shots: [] },
|
||||
],
|
||||
};
|
||||
const file = path.join(output, 'project.json');
|
||||
fs.writeFileSync(file, JSON.stringify(project));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const errors = [],
|
||||
requests = [];
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('request', (r) => {
|
||||
if (new URL(r.url()).pathname.startsWith('/scene-assets/'))
|
||||
requests.push(r.url());
|
||||
});
|
||||
async function click(label) {
|
||||
const handle = await page.evaluateHandle(
|
||||
(label) =>
|
||||
[...document.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === label,
|
||||
),
|
||||
label,
|
||||
);
|
||||
await handle.asElement().click();
|
||||
await handle.dispose();
|
||||
}
|
||||
async function value(label, text) {
|
||||
await page.$eval(
|
||||
`[aria-label="${label}"]`,
|
||||
(node, text) => {
|
||||
node.value = text;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.click('[data-collapse-target="scene-panel"]');
|
||||
const before = await page.evaluate(() =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||
);
|
||||
const input = await page.$('#scene-import-file');
|
||||
await input.uploadFile(file);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'preview parses metadata without acquiring assets or replacing the current project',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
(before) =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) ===
|
||||
before,
|
||||
before,
|
||||
)),
|
||||
);
|
||||
check(
|
||||
'preview renders author attribution as literal text',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog]')
|
||||
.textContent.includes('Synthetic <b>fixture</b>') &&
|
||||
!document.querySelector('[data-director-dialog] b'),
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
check(
|
||||
'Escape cancels preview without altering saved work',
|
||||
await page.evaluate(
|
||||
(before) =>
|
||||
!document.querySelector('[data-director-dialog]') &&
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) === before,
|
||||
before,
|
||||
),
|
||||
);
|
||||
await input.uploadFile(file);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'Apply import replaces metadata but does not start playback or fetch a pack',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes.length === 2 &&
|
||||
!window.__godsEyeView.sceneDirector.running,
|
||||
)),
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await click('Capture camera as anchor');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Anchor added'),
|
||||
);
|
||||
await click('Set move start to current camera');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Move added'),
|
||||
);
|
||||
const draft = await page.$eval(
|
||||
'[aria-label="Shot camera, timing, packs and actions"]',
|
||||
(n) => n.value,
|
||||
);
|
||||
const bad = JSON.parse(draft);
|
||||
bad.camera.lat = 91;
|
||||
await value('Shot camera, timing, packs and actions', JSON.stringify(bad));
|
||||
const saved = await page.evaluate(() =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('camera.lat'),
|
||||
);
|
||||
check(
|
||||
'invalid draft preserves saved bytes and existing authored content',
|
||||
await page.evaluate(
|
||||
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
|
||||
saved,
|
||||
),
|
||||
);
|
||||
await value('Shot camera, timing, packs and actions', draft);
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'author controls preserve selected shot, pack attribution and action definitions',
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector,
|
||||
s = d._getSelectedScene();
|
||||
return (
|
||||
d._selectedShotId === 'shot' &&
|
||||
s.anchors.length === 1 &&
|
||||
s.shots[0].move.easing === 'cubic-in-out' &&
|
||||
s.shots[0].interactions.length === 1 &&
|
||||
s.dataPacks[0].attribution.license === 'CC0-1.0'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__shareExports = [];
|
||||
const create = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
if (blob.type === 'application/json')
|
||||
window.__shareExports.push(blob.text());
|
||||
return create(blob);
|
||||
};
|
||||
});
|
||||
await click('SHARE SCENE');
|
||||
await click('Download scene JSON');
|
||||
const exported = await page.evaluate(async () =>
|
||||
JSON.parse(await window.__shareExports.at(-1)),
|
||||
);
|
||||
check(
|
||||
'scene JSON exports only the selected scene and keeps its credit',
|
||||
exported.scenes.length === 1 &&
|
||||
exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0',
|
||||
);
|
||||
await click('Download asset bundle');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Could not complete'),
|
||||
);
|
||||
check(
|
||||
'missing explicit files produce no partial download or network fallback',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(() => window.__shareExports.length === 1)),
|
||||
);
|
||||
const files = await page.$('[aria-label="Choose data-pack files"]');
|
||||
await files.uploadFile(path.join(output, 'data.geojson'));
|
||||
await click('Download asset bundle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent === 'Asset bundle downloaded',
|
||||
);
|
||||
const bundle = await page.evaluate(
|
||||
async () => await window.__shareExports.at(-1),
|
||||
);
|
||||
const bundleFile = path.join(output, 'scene.gevbundle.json');
|
||||
fs.writeFileSync(bundleFile, bundle);
|
||||
check(
|
||||
'bundle contains the selected bytes and preserves attribution',
|
||||
JSON.parse(bundle).assets.length === 1 &&
|
||||
JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license ===
|
||||
'CC0-1.0',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await input.uploadFile(bundleFile);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'bundle preview verifies bytes and explains session-only storage',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog]')
|
||||
.textContent.includes('bundled bytes verified') &&
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'preview.png') });
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.loadShot('share', 'shot', {
|
||||
flyDuration: 0.3,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'imported bundle renders and enables feature actions with zero asset requests',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 1 &&
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 1,
|
||||
)),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'rendered.png') });
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'report.json'),
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
})),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||
check(
|
||||
'Stop releases rendered resources while retaining reusable project bytes',
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
return (
|
||||
d.getDataPackState().count === 0 &&
|
||||
d.getSharingState().assets.count === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.seekScene('share', 0),
|
||||
);
|
||||
check(
|
||||
'seek reloads the bundle without a network fallback',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() => window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||
)),
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit';
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
check(
|
||||
'stale author draft cannot overwrite a newer edit',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes[0].title ===
|
||||
'Newer edit',
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await click('SHARE SCENE');
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
check(
|
||||
'modal remains within a narrow viewport',
|
||||
await page.$eval('[data-director-dialog]', (n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return (
|
||||
r.left >= 0 &&
|
||||
r.right <= innerWidth &&
|
||||
r.top >= 0 &&
|
||||
r.bottom <= innerHeight
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'narrow.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
const pending = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
let finish;
|
||||
const work = d._sharing.preview({
|
||||
name: 'pending.json',
|
||||
text: () => new Promise((r) => (finish = r)),
|
||||
});
|
||||
d._sharing.close();
|
||||
await work;
|
||||
finish(JSON.stringify({ version: 6, scenes: [] }));
|
||||
return !d.getSharingState().open;
|
||||
});
|
||||
check(
|
||||
'cancel settles a pending file read and suppresses its late completion',
|
||||
pending,
|
||||
);
|
||||
const cancelledApply = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const before = JSON.stringify(d._project);
|
||||
const saved = JSON.stringify({ ...localStorage });
|
||||
let finish;
|
||||
const pendingWork = new Promise((resolve) => (finish = resolve));
|
||||
d._pendingWork.add(pendingWork);
|
||||
const owner = new AbortController();
|
||||
const work = d.importProjectFile(
|
||||
{ name: 'cancelled.json' },
|
||||
{
|
||||
prepared: { project: { version: 6, scenes: [] }, assets: new Map() },
|
||||
expectedProject: JSON.stringify(d._project, null, 2),
|
||||
signal: owner.signal,
|
||||
},
|
||||
);
|
||||
owner.abort();
|
||||
finish();
|
||||
const applied = await work;
|
||||
d._pendingWork.delete(pendingWork);
|
||||
return (
|
||||
applied === false &&
|
||||
JSON.stringify(d._project) === before &&
|
||||
JSON.stringify({ ...localStorage }) === saved &&
|
||||
d.getSharingState().assets.count === 1
|
||||
);
|
||||
});
|
||||
check(
|
||||
'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes',
|
||||
cancelledApply,
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await page.evaluate(() => {
|
||||
const fields = [
|
||||
...document.querySelectorAll('[data-director-dialog] textarea'),
|
||||
];
|
||||
const scene = JSON.parse(fields[0].value),
|
||||
shot = JSON.parse(fields[1].value);
|
||||
scene.dataPacks = [];
|
||||
shot.dataPackIds = [];
|
||||
shot.interactions = [];
|
||||
fields[0].value = JSON.stringify(scene);
|
||||
fields[1].value = JSON.stringify(shot);
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'removing a pack through authoring releases its retained bytes',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.evaluate(
|
||||
(text) =>
|
||||
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||
new File([text], 'again.gevbundle.json'),
|
||||
),
|
||||
bundle,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||
new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'replacement releases stored bundle bytes',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
void d._sharing.preview({
|
||||
name: 'pending.json',
|
||||
text: () => new Promise(() => {}),
|
||||
});
|
||||
await d.destroy();
|
||||
});
|
||||
check(
|
||||
'teardown removes pending dialogs and authoring controls',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('[data-director-dialog]') &&
|
||||
!document.querySelector('[data-director-authoring]') &&
|
||||
!window.__godsEyeView.sceneDirector.getSharingState().open,
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const shots = path.resolve('qa-shots/director-timing');
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
function check(name, passed) {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async () => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const camera = director.styleManager.getCameraState();
|
||||
const visual = director.styleManager.getVisualState();
|
||||
const scene = {
|
||||
id: 'timing-qa',
|
||||
title: 'Timing QA',
|
||||
shots: [0, 1].map((index) => ({
|
||||
id: `timing-${index}`,
|
||||
title: `Timing ${index}`,
|
||||
durationSec: 0.4,
|
||||
holdSec: 0.3,
|
||||
camera: { ...camera, lon: camera.lon + index * 0.002 },
|
||||
visual,
|
||||
layers: {},
|
||||
})),
|
||||
};
|
||||
director._project.scenes.push(scene);
|
||||
const load = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||
flyDuration: 0.3,
|
||||
});
|
||||
const forward = await director.seekScene(scene.id, 0.9);
|
||||
const forwardSnapshot = director.getPlaybackTimingState().snapshot;
|
||||
const backward = await director.seekScene(scene.id, 0.1);
|
||||
const backwardSnapshot = director.getPlaybackTimingState().snapshot;
|
||||
const replay = await director.replayShot(scene.id, scene.shots[1].id);
|
||||
director.stopScene();
|
||||
await director.startScene(scene.id, { single: true, preview: false });
|
||||
const completed =
|
||||
!director.running && director.getPlaybackTimingState().activeTimers === 0;
|
||||
scene.shots[0].holdSec = 60;
|
||||
const run = director.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (
|
||||
director.getPlaybackTimingState().activeTimers < 3 &&
|
||||
Date.now() < deadline
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const holding = director.getPlaybackTimingState().activeTimers >= 3;
|
||||
const stoppedAt = performance.now();
|
||||
director.stopScene('QA Stop during hold');
|
||||
await run;
|
||||
const stoppedPromptly = performance.now() - stoppedAt < 1000;
|
||||
const noTimers = director.getPlaybackTimingState().activeTimers === 0;
|
||||
director._project.scenes = director._project.scenes.filter(
|
||||
({ id }) => id !== scene.id,
|
||||
);
|
||||
return {
|
||||
loaded: load?.started,
|
||||
forward: forward && forwardSnapshot.shotId === 'timing-1',
|
||||
backward: backward && backwardSnapshot.shotId === 'timing-0',
|
||||
replay: replay?.started,
|
||||
completed,
|
||||
holding,
|
||||
stoppedPromptly,
|
||||
noTimers,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
const content = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find(({ title }) =>
|
||||
/Nepal Flood Incident/i.test(title),
|
||||
);
|
||||
if (!scene) return { exists: false };
|
||||
const shot =
|
||||
scene.shots.find(({ title }) => /Regional Approach/i.test(title)) ||
|
||||
scene.shots[0];
|
||||
const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 });
|
||||
return {
|
||||
exists: true,
|
||||
shots: scene.shots.length,
|
||||
loaded: loaded?.started === true,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'existing Nepal scene and authored shots load',
|
||||
content.exists && content.shots >= 25 && content.loaded,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
await page.screenshot({ path: path.join(shots, 'nepal-regional.png') });
|
||||
await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
g.sceneDirector.stopScene();
|
||||
const pose = g.styleManager.getCameraState();
|
||||
g.sceneDirector._setCameraView({
|
||||
...pose,
|
||||
heading: pose.heading + 45,
|
||||
pitch: -55,
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await page.screenshot({ path: path.join(shots, 'nepal-angle.png') });
|
||||
check(
|
||||
'no pending clocks after Stop',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getPlaybackTimingState()
|
||||
.activeTimers === 0,
|
||||
),
|
||||
);
|
||||
check('no browser exceptions', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
+15
-11
@@ -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',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture,
|
||||
// not native-host acceptance; the YouTube SDK clock below is deterministic.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const output = path.resolve(
|
||||
process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media',
|
||||
);
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: process.env.QA_HEADFUL !== '1',
|
||||
args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [],
|
||||
});
|
||||
let assertions = 0;
|
||||
const check = (name, value) => {
|
||||
assert.ok(value, name);
|
||||
assertions++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
};
|
||||
try {
|
||||
for (const pinokio of [true, false]) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
if (pinokio)
|
||||
await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40');
|
||||
const popups = [];
|
||||
page.on('popup', (popup) => popups.push(popup.url()));
|
||||
// Do not depend on provider availability to prove our startup/trim/exit handoff.
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) {
|
||||
void request.respond({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<p>Player clock fixture</p>',
|
||||
});
|
||||
} else void request.continue();
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__mediaFixture = { starts: [], stops: [] };
|
||||
window.YT = {
|
||||
Player: class {
|
||||
constructor(_frame, { events }) {
|
||||
this.events = events;
|
||||
this.began = null;
|
||||
this.stoppedAt = null;
|
||||
this.timer = setTimeout(
|
||||
() => events.onReady({ target: this }),
|
||||
1200,
|
||||
);
|
||||
}
|
||||
mute() {}
|
||||
playVideo() {
|
||||
if (this.began != null) return;
|
||||
this.began = Date.now();
|
||||
window.__mediaFixture.starts.push(this.began);
|
||||
this.events.onStateChange({ target: this, data: 1 });
|
||||
}
|
||||
getCurrentTime() {
|
||||
return this.began == null
|
||||
? 0
|
||||
: ((this.stoppedAt || Date.now()) - this.began) / 1000;
|
||||
}
|
||||
getPlayerState() {
|
||||
return this.began == null ? -1 : this.stoppedAt ? 2 : 1;
|
||||
}
|
||||
pauseVideo() {
|
||||
if (this.began != null && !this.stoppedAt) {
|
||||
this.stoppedAt = Date.now();
|
||||
window.__mediaFixture.stops.push(this.getCurrentTime());
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
clearTimeout(this.timer);
|
||||
this.pauseVideo();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async (pinokio) => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find((s) => /Nepal/.test(s.title));
|
||||
const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title));
|
||||
const following = scene.shots[scene.shots.indexOf(shot) + 1];
|
||||
const previous = scene.shots[scene.shots.indexOf(shot) - 1];
|
||||
const frames = () =>
|
||||
[...document.querySelectorAll('iframe')].map((f) => f.src);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
|
||||
const openingFrames = frames();
|
||||
await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 });
|
||||
const passiveFrames = frames();
|
||||
const started = Date.now();
|
||||
const run = d.startScene(scene.id, {
|
||||
single: true,
|
||||
afterShotId: previous.id,
|
||||
preview: false,
|
||||
});
|
||||
let advanced = null;
|
||||
while (Date.now() - started < 25000) {
|
||||
if (d._selectedShotId === following.id) {
|
||||
advanced = Date.now() - started;
|
||||
break;
|
||||
}
|
||||
if (!d.running) break;
|
||||
await sleep(100);
|
||||
}
|
||||
const runningAtAdvance = d.running;
|
||||
const clipStops = [...window.__mediaFixture.stops];
|
||||
const status = document.querySelector('#scene-status')?.textContent;
|
||||
d.stopScene();
|
||||
await run;
|
||||
await sleep(250);
|
||||
const stoppedFrames = frames();
|
||||
let replayAutoplay = false;
|
||||
let replacementSilent = false;
|
||||
if (!pinokio) {
|
||||
await d.replayShot(scene.id, shot.id);
|
||||
replayAutoplay = frames().some((src) => /autoplay=1/.test(src));
|
||||
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
|
||||
const starts = window.__mediaFixture.starts.length;
|
||||
await sleep(1600);
|
||||
replacementSilent =
|
||||
frames().length === 0 &&
|
||||
starts === window.__mediaFixture.starts.length;
|
||||
}
|
||||
const gl = d.viewer.scene.context._gl;
|
||||
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
openingFrames,
|
||||
passiveFrames,
|
||||
advanced,
|
||||
runningAtAdvance,
|
||||
status,
|
||||
clipStops,
|
||||
stoppedFrames,
|
||||
replayAutoplay,
|
||||
replacementSilent,
|
||||
renderer: info
|
||||
? gl.getParameter(info.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER),
|
||||
};
|
||||
}, pinokio);
|
||||
console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result }));
|
||||
check(
|
||||
'opening shot creates no unrelated provider frames',
|
||||
result.openingFrames.length === 0,
|
||||
);
|
||||
check(
|
||||
'passive LOAD never requests autoplay',
|
||||
result.passiveFrames.every((src) => !/autoplay=1/.test(src)),
|
||||
);
|
||||
check(
|
||||
'actual next-shot identity reached while scene remains running',
|
||||
result.advanced > 0 && result.runningAtAdvance,
|
||||
);
|
||||
check('no scene timeout', !/timed out|Error:/i.test(result.status || ''));
|
||||
check('Stop removes provider resources', result.stoppedFrames.length === 0);
|
||||
check('no automatic external window', popups.length === 0);
|
||||
if (pinokio) {
|
||||
check(
|
||||
'suppressed providers allocate no iframe',
|
||||
result.passiveFrames.length === 0,
|
||||
);
|
||||
check(
|
||||
'fallback keeps authored dwell',
|
||||
result.advanced >= 7500 && result.advanced < 18000,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'supported clip follows seven-second source clock',
|
||||
result.clipStops.some((sec) => sec >= 7 && sec < 7.6),
|
||||
);
|
||||
check(
|
||||
'explicit Play Shot still requests autoplay',
|
||||
result.replayAutoplay,
|
||||
);
|
||||
check(
|
||||
'replacement disowns pending player startup',
|
||||
result.replacementSilent,
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'),
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
console.log(`PASS: ${assertions} assertions`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -374,7 +374,7 @@ async function main() {
|
||||
enabled: true,
|
||||
uncertain: true,
|
||||
});
|
||||
gev.dataManager._refreshTogglePanel();
|
||||
gev.dataManager.refreshLayerStats();
|
||||
const staticResult = module.setTuningStatic(true);
|
||||
const filterBefore = module.getUIState().filter;
|
||||
const filterControl = document.getElementById('radio-filter');
|
||||
@@ -447,7 +447,7 @@ async function main() {
|
||||
enabled: true,
|
||||
uncertain: false,
|
||||
});
|
||||
gev.dataManager._refreshTogglePanel();
|
||||
gev.dataManager.refreshLayerStats();
|
||||
module.cancelTuning();
|
||||
camera.flyTo = originalFlyTo;
|
||||
return result;
|
||||
@@ -4048,7 +4048,13 @@ async function main() {
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const expectedEntryId = `selected:${selected.id}`;
|
||||
let painted = null;
|
||||
for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) {
|
||||
// Radio's horizon pass reveals the selected entity on its own timer
|
||||
// after the camera jump, so a stationary view waits for both to settle.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 20 && !(painted?.rect && selectedEntity?.show === true);
|
||||
attempt += 1
|
||||
) {
|
||||
painted = await new Promise((resolve) => {
|
||||
let removePostRender = null;
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,9 @@ try {
|
||||
);
|
||||
promptValue = '<b>QA shot</b>';
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||
await page.type('.scene-shot-rename', promptValue);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.scene-shot-label')?.textContent ===
|
||||
@@ -151,7 +154,20 @@ try {
|
||||
const projectFile = path.join(shots, 'project.json');
|
||||
fs.writeFileSync(projectFile, JSON.stringify(fixture));
|
||||
const input = await page.$('#scene-import-file');
|
||||
const beforePreview = await page.evaluate(() =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||
);
|
||||
await input.uploadFile(projectFile);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'Import preview leaves the current project untouched',
|
||||
await page.evaluate(
|
||||
(saved) =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved,
|
||||
beforePreview,
|
||||
),
|
||||
);
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('scene-status').textContent ===
|
||||
@@ -259,8 +275,8 @@ try {
|
||||
await input.uploadFile(badFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.getElementById('scene-status')
|
||||
.textContent.includes('invalid JSON'),
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('invalid JSON'),
|
||||
);
|
||||
check(
|
||||
'Invalid import reports failure and preserves the current project',
|
||||
@@ -271,6 +287,45 @@ try {
|
||||
'QA Scene',
|
||||
),
|
||||
);
|
||||
const savedBefore = await page.evaluate(() =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
const futureFile = path.join(shots, 'future.json');
|
||||
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
|
||||
await input.uploadFile(futureFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('$.version'),
|
||||
);
|
||||
check(
|
||||
'Unsupported versions leave authored state and saved bytes unchanged',
|
||||
await page.evaluate(
|
||||
(saved) =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2') === saved &&
|
||||
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
|
||||
'QA Scene',
|
||||
savedBefore,
|
||||
),
|
||||
);
|
||||
const malformed = structuredClone(fixture);
|
||||
malformed.scenes[0].shots[0].camera.lat = 91;
|
||||
const malformedFile = path.join(shots, 'malformed.json');
|
||||
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
|
||||
await input.uploadFile(malformedFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('camera.lat'),
|
||||
);
|
||||
check(
|
||||
'Invalid camera field identifies its path without replacing the project',
|
||||
await page.evaluate(
|
||||
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
|
||||
savedBefore,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
|
||||
+107
-37
@@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) {
|
||||
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
const readLuma = (cx, cy) => {
|
||||
const px = Math.floor(cx * scale);
|
||||
const py = Math.floor(canvas.height - 1 - cy * scale);
|
||||
const py = canvas.height - 1 - Math.floor(cy * scale);
|
||||
const buf = new Uint8Array(4);
|
||||
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
return luma(buf[0], buf[1], buf[2]);
|
||||
@@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Pick nine points from a solid patch of the source silhouette,
|
||||
// before examining framebuffer brightness. Avoid panels/window gaps.
|
||||
let corePoints = null,
|
||||
widestBand = -1;
|
||||
const cosCore = Math.cos(s.rotation),
|
||||
sinCore = Math.sin(s.rotation);
|
||||
// A tram's parallel sides have equally wide bands. Prefer its upper
|
||||
// body band over the geometric centre when those widths tie.
|
||||
const offsets =
|
||||
s.mode === 'tram'
|
||||
? [-0.2, 0.2, -0.1, 0.1, 0]
|
||||
: [0, -0.1, 0.1, -0.2, 0.2];
|
||||
for (const offset of offsets) {
|
||||
const candidates = [];
|
||||
let solid = true,
|
||||
bandWidth = Infinity;
|
||||
// Rank source geometry, never the observed framebuffer brightness.
|
||||
// A thin tram needs the widest opaque white band, with room for
|
||||
// the pixel filter on each side of the sampled patch.
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const ry = Math.floor(
|
||||
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
|
||||
s.raster.height,
|
||||
);
|
||||
const middle = Math.floor(s.raster.width / 2);
|
||||
const white = (x) =>
|
||||
x >= 0 &&
|
||||
x < s.raster.width &&
|
||||
[0, 1, 2, 3].every(
|
||||
(c) =>
|
||||
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
|
||||
);
|
||||
let left = middle,
|
||||
right = middle;
|
||||
while (white(left - 1)) left--;
|
||||
while (white(right + 1)) right++;
|
||||
bandWidth = Math.min(
|
||||
bandWidth,
|
||||
white(middle) ? right - left + 1 : 0,
|
||||
);
|
||||
}
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const localY = dy + offset * s.rect.height;
|
||||
const rx = Math.floor(
|
||||
(dx / s.rect.width + 0.5) * s.raster.width,
|
||||
);
|
||||
const ry = Math.floor(
|
||||
(localY / s.rect.height + 0.5) * s.raster.height,
|
||||
);
|
||||
const i = (ry * s.raster.width + rx) * 4;
|
||||
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
|
||||
solid = false;
|
||||
candidates.push(
|
||||
screenPoint(
|
||||
s.screenX + dx * cosCore + localY * sinCore,
|
||||
s.screenY - dx * sinCore + localY * cosCore,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (solid && bandWidth > widestBand) {
|
||||
corePoints = candidates;
|
||||
widestBand = bandWidth;
|
||||
}
|
||||
}
|
||||
if (!corePoints) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason: 'no solid silhouette interior for nine samples',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Picking is independent of rendered brightness: an overlapping
|
||||
// label or sprite must not masquerade as a dim sensor signature.
|
||||
// Check the whole sampled core, not just its centre pick.
|
||||
let coreOwned = true;
|
||||
if (scene.pick) {
|
||||
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
let u = (s.x + dx) / width,
|
||||
v = 1 - (s.y + dy) / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
for (const core of corePoints) {
|
||||
let u = core.x / width,
|
||||
v = 1 - core.y / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
}
|
||||
if (gl.bindFramebuffer)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
}
|
||||
@@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) {
|
||||
continue;
|
||||
}
|
||||
let centre = 0;
|
||||
for (let dx = -1; dx <= 1; dx += 1)
|
||||
for (let dy = -1; dy <= 1; dy += 1)
|
||||
centre += readLuma(s.x + dx, s.y + dy) / 9;
|
||||
for (const core of corePoints)
|
||||
centre += readLuma(core.x, core.y) / corePoints.length;
|
||||
let ringMin = 1;
|
||||
let ringMax = 0;
|
||||
// Sample the actual rotated raster halo, not a fixed-radius circle
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||
export async function runTransitHeadingRegression(base, check) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { destroyTrack } = await import('/src/data/contactPlayback.js');
|
||||
const {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
attachFloorToPlace,
|
||||
} = await import('/src/layers/transit/movement.js');
|
||||
// The first three are parked: held east, reported east only, unknown.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
state._moving.delete(entry);
|
||||
destroyTrack(entry.track);
|
||||
initializePlayback(entry, state._historyBudget);
|
||||
recordFix(entry, {
|
||||
t: Date.now() - 30000,
|
||||
lat: entry.record.lat,
|
||||
lon: entry.record.lon,
|
||||
});
|
||||
attachFloorToPlace(entry, entry.record, 20);
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
entry.courseDeg = i === 0 ? 90 : null;
|
||||
entry.record.bearing = i === 1 ? 90 : null;
|
||||
entry.displayPaths.clear();
|
||||
parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
parts.rendering.syncRenderHold();
|
||||
window.__headingErrors = [];
|
||||
app.viewer.scene.renderError.addEventListener((scene, error) =>
|
||||
window.__headingErrors.push(error.message),
|
||||
);
|
||||
});
|
||||
const pose = async (heading) => {
|
||||
await page.evaluate((heading) => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
|
||||
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, heading);
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
};
|
||||
const sample = () =>
|
||||
page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(r) =>
|
||||
m[r] * v[0] +
|
||||
m[r + 4] * v[1] +
|
||||
m[r + 8] * v[2] +
|
||||
m[r + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const clip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
|
||||
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
|
||||
};
|
||||
};
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
const p = project(entry.marker.position);
|
||||
return {
|
||||
rotation: entry.marker.rotation,
|
||||
course: entry.courseDeg,
|
||||
moving: state._moving.has(entry),
|
||||
visible: entry.marker.show,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
await pose(0);
|
||||
const before = await sample();
|
||||
await pose(Math.PI / 2);
|
||||
const after = await sample();
|
||||
const deltaDeg = (a, b) =>
|
||||
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
|
||||
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
|
||||
const delta = deltaDeg(before[i].rotation, after[i].rotation);
|
||||
check(
|
||||
`parked ${label} reprojects through a 90 degree camera orbit`,
|
||||
before[i].visible &&
|
||||
after[i].visible &&
|
||||
!before[i].moving &&
|
||||
!after[i].moving &&
|
||||
Math.abs(delta - 90) <= 5,
|
||||
JSON.stringify({ before: before[i], after: after[i], delta }),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'unknown parked course remains screen-up',
|
||||
before[2].rotation === 0 && after[2].rotation === 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const moved = (await sample())[3];
|
||||
const dx = moved.x - after[3].x,
|
||||
dy = moved.y - after[3].y;
|
||||
const travelRotation = Math.atan2(-dx, -dy);
|
||||
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
|
||||
check(
|
||||
'moving vehicle still points along its rendered travel after orbit',
|
||||
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
|
||||
JSON.stringify({
|
||||
pixels: Math.hypot(dx, dy),
|
||||
error,
|
||||
after: after[3],
|
||||
moved,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'heading regression has no scene render errors',
|
||||
(await page.evaluate(() => window.__headingErrors)).length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
|
||||
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
||||
await mkdir(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView?.dataManager?.layers.has('transit'),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
return scene.globe.tilesLoaded && scene.globe.show;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return !cover || cover.classList.contains('hidden');
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const app = window.__godsEyeView,
|
||||
camera = app.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor,
|
||||
lat = 30.267 + 90 / 111320;
|
||||
const floor =
|
||||
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
floor + 600,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene,
|
||||
camera = app.viewer.camera;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { getRegisteredTransitFeed } =
|
||||
await import('/src/data/transitFeeds.js');
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
const C = camera.positionCartographic.constructor;
|
||||
const feed = {
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-camera-recovery',
|
||||
};
|
||||
const aim = (lat, height = 600) =>
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
height,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
// Model an outstanding floor lookup while reports arrive off screen.
|
||||
// Only acquisition is controlled; selection, visibility, paths and rendering
|
||||
// use their real owners. Release to the loaded terrain at the camera jump.
|
||||
const requestHeight = parts.height.requestHeight;
|
||||
parts.height.requestHeight = (lat, lon) => ({
|
||||
cell: `${lat},${lon}`,
|
||||
height: null,
|
||||
prior: null,
|
||||
});
|
||||
aim(30.28);
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = now - 135000 + i * 15000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'moving-bus',
|
||||
routeId: '1',
|
||||
tripId: 'qa',
|
||||
lat: 30.267 + (i * 90) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp: timestamp / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
|
||||
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
|
||||
parts.rendering.sampleIdle(entry);
|
||||
const before = {
|
||||
shown: entry.marker.show,
|
||||
heightPending: entry.heightPending,
|
||||
phase: entry.sample.phase,
|
||||
fixes: entry.track.count,
|
||||
};
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
const started = performance.now();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
|
||||
parts.height.requestHeight = requestHeight;
|
||||
const floor =
|
||||
scene.globe.getHeight(
|
||||
C.fromDegrees(entry.sample.lon, entry.sample.lat),
|
||||
) || 0;
|
||||
aim(entry.sample.lat, floor + 600);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let trail;
|
||||
while (performance.now() - started < 1400) {
|
||||
trail = parts.trails.diagnostics();
|
||||
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
|
||||
scene.requestRender();
|
||||
await wait(25);
|
||||
}
|
||||
const status = {
|
||||
before,
|
||||
preparedAtSelection,
|
||||
markerShow: entry.marker.show,
|
||||
body: !!trail?.body,
|
||||
bodyShow: trail?.body?.show === true,
|
||||
visibility: layer
|
||||
._transitVisibilityForTest()
|
||||
.find((e) => e.key === entry.key),
|
||||
recoveryMs: performance.now() - started,
|
||||
};
|
||||
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
|
||||
return {
|
||||
...status,
|
||||
reason: 'marker or body did not recover before pixel deadline',
|
||||
};
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
const V = entry.marker.position.constructor;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
const canvas = scene.canvas,
|
||||
gl = scene.context._gl,
|
||||
scale = canvas.width / canvas.clientWidth;
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(i) =>
|
||||
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const q = mul(
|
||||
camera.frustum.projectionMatrix,
|
||||
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
||||
inFront: q[3] > 0,
|
||||
};
|
||||
};
|
||||
const samples = Array.from({ length: 8 }, (_, i) => {
|
||||
const d = (lengths.at(-1) * (i + 1)) / 9;
|
||||
let j = 1;
|
||||
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
||||
const p = V.lerp(
|
||||
points[j - 1],
|
||||
points[j],
|
||||
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
||||
new V(),
|
||||
);
|
||||
const cart = C.fromCartesian(p);
|
||||
cart.height = scene.globe.getHeight(cart) || 0;
|
||||
return {
|
||||
...project(C.toCartesian(cart)),
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const sprite = {
|
||||
...project(entry.marker.position),
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
px < r ||
|
||||
py < r ||
|
||||
px + r >= canvas.width ||
|
||||
py + r >= canvas.height
|
||||
)
|
||||
return null;
|
||||
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
||||
gl.readPixels(
|
||||
px - r,
|
||||
py - r,
|
||||
2 * r + 1,
|
||||
2 * r + 1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
return Array.from(data);
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('No recovery frame'));
|
||||
}, 500);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timer);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
let visible = true;
|
||||
const remove = scene.preRender.addEventListener(() => {
|
||||
trail.body.show = visible;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = visible && trail.head.show;
|
||||
});
|
||||
const captures = [];
|
||||
try {
|
||||
for (const show of [false, true, false, true]) {
|
||||
visible = show;
|
||||
await frame();
|
||||
captures.push(await frame());
|
||||
}
|
||||
} finally {
|
||||
remove();
|
||||
parts.trails.update();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures;
|
||||
return {
|
||||
...status,
|
||||
samples,
|
||||
sprite,
|
||||
off,
|
||||
on,
|
||||
offAgain,
|
||||
onAgain,
|
||||
tilesReady:
|
||||
scene.globe.tilesLoaded &&
|
||||
parts.trails.diagnostics().body === trail.body,
|
||||
elapsedMs: performance.now() - started,
|
||||
attributes: Array.from({ length: 4 }, (_, i) => {
|
||||
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
|
||||
return { show: Array.from(a.show), color: Array.from(a.color) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.present = pixels.present;
|
||||
result.pixelPass = pixels.pass;
|
||||
result.pixelDetails = pixels;
|
||||
result.errors = errors;
|
||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
const pass =
|
||||
result.before?.shown === false &&
|
||||
result.before?.phase === 'playing' &&
|
||||
result.preparedAtSelection &&
|
||||
result.markerShow &&
|
||||
result.body &&
|
||||
result.bodyShow &&
|
||||
result.elapsedMs <= 2000 &&
|
||||
pixels.pass &&
|
||||
!errors.length;
|
||||
console.log(
|
||||
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
|
||||
);
|
||||
console.log(JSON.stringify(result));
|
||||
process.exitCode = pass ? 0 : 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||
|
||||
// Not registered or active: camera discovery and live polls never own this feed.
|
||||
/** CapMetro metadata under a private QA identity for selected-trail pixels. */
|
||||
export const TRAIL_VISIBILITY_FEED = Object.freeze({
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-trail-capmetro',
|
||||
});
|
||||
|
||||
import { reportTransitVisibility } from './qa-transit-browser.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
@@ -5,6 +14,7 @@ import {
|
||||
reduceMotion,
|
||||
reduceBackground,
|
||||
reduceSensorContrast,
|
||||
reduceTrailPixels,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
/** CDP includes objects collected during the sample, not just surviving objects. */
|
||||
@@ -581,3 +591,459 @@ export async function runBostonLive({
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */
|
||||
export async function runTrailVisibility({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
selectStyle,
|
||||
altitude = 600,
|
||||
pitch = 45,
|
||||
}) {
|
||||
const name = 'Google 3D selected trail visible at 6 of 8 projected samples';
|
||||
await selectStyle(page, 'normal');
|
||||
await page.evaluate(
|
||||
async ({ altitude, pitch }) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
if (app.mapStackController.isStackAvailable('photoreal'))
|
||||
await app.mapStackController.setStack('photoreal');
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
},
|
||||
{ altitude, pitch },
|
||||
);
|
||||
await wait(8000);
|
||||
const setup = await page.evaluate(
|
||||
async ({ altitude, pitch, feed }) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const google =
|
||||
!!app.tileset &&
|
||||
app.tileset.show !== false &&
|
||||
app.mapStackController.getActiveId() === 'photoreal';
|
||||
if (!google)
|
||||
return { google: false, reason: 'visible Google 3D tileset required' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const centreLat = 30.267 + 90 / 111320;
|
||||
const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat));
|
||||
if (Number.isFinite(floor))
|
||||
scene.camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
floor + altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
const { seek, setRate } = await import('/src/data/contactPlayback.js');
|
||||
const now = Date.now();
|
||||
// Nine straight street fixes, 30 m apart. Use the production surface path.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = (now - 120000 + i * 15000) / 1000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp * 1000,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'trail-visible',
|
||||
routeId: '1',
|
||||
tripId: 'trail-visible',
|
||||
lat: 30.267 + (i * 30) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:trail-visible`);
|
||||
entry.qaFixture = true;
|
||||
const clocks = { wallNowMs: now, monoNowMs: performance.now() };
|
||||
seek(entry.track, now - 45000, clocks);
|
||||
setRate(entry.track, 1, clocks);
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.height.anchorFloors();
|
||||
parts.rendering.refreshVisibility();
|
||||
parts.rendering.maintainPresentation();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
return {
|
||||
google,
|
||||
key: entry.key,
|
||||
fixes: entry.track.count,
|
||||
startDisplayT: now - 45000,
|
||||
startLat: entry.sample.lat,
|
||||
};
|
||||
},
|
||||
{ altitude, pitch, feed: TRAIL_VISIBILITY_FEED },
|
||||
);
|
||||
if (!setup.google) {
|
||||
check(name, false, JSON.stringify(setup), { unexercised: true });
|
||||
return;
|
||||
}
|
||||
const cost = await page.evaluate(async (key) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const parts = layer._transitPartsForTest(),
|
||||
entry = layer._transitStateForTest()._vehicles.get(key);
|
||||
const gl = scene.context._gl,
|
||||
originals = new Map(),
|
||||
wrapped = new Map();
|
||||
let owner = null,
|
||||
headBytes = 0,
|
||||
bodyBytes = 0,
|
||||
headUploadMax = 0,
|
||||
bodyUploadMax = 0,
|
||||
frames = 0;
|
||||
const headCpu = [];
|
||||
const wrap = (primitive, kind) => {
|
||||
if (!primitive || wrapped.has(primitive)) return;
|
||||
const update = primitive.update;
|
||||
wrapped.set(primitive, update);
|
||||
primitive.update = function (frameState) {
|
||||
const before = performance.now();
|
||||
owner = kind;
|
||||
try {
|
||||
return update.call(this, frameState);
|
||||
} finally {
|
||||
owner = null;
|
||||
if (kind === 'head') headCpu.push(performance.now() - before);
|
||||
}
|
||||
};
|
||||
};
|
||||
for (const name of ['bufferData', 'bufferSubData']) {
|
||||
const original = gl[name];
|
||||
originals.set(name, original);
|
||||
gl[name] = function (...args) {
|
||||
if (owner) {
|
||||
const value = args[name === 'bufferData' ? 1 : 2],
|
||||
offset = args[3] || 0,
|
||||
length = args[4];
|
||||
const bytes =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: value
|
||||
? length !== undefined
|
||||
? length * (value.BYTES_PER_ELEMENT || 1)
|
||||
: value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1)
|
||||
: 0;
|
||||
if (owner === 'head') headBytes += bytes;
|
||||
else bodyBytes += bytes;
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
const pre = scene.preRender.addEventListener(() => {
|
||||
headBytes = bodyBytes = 0;
|
||||
const trail = parts.trails.diagnostics();
|
||||
wrap(trail?.body, 'body');
|
||||
wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head');
|
||||
});
|
||||
const post = scene.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
headUploadMax = Math.max(headUploadMax, headBytes);
|
||||
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes);
|
||||
});
|
||||
try {
|
||||
// Include a full history upload, even if the first selection already rendered.
|
||||
entry.pathRevision = -1;
|
||||
parts.trails.prepareEntry(entry);
|
||||
scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20000));
|
||||
headCpu.sort((a, b) => a - b);
|
||||
return {
|
||||
frames,
|
||||
headUploadMax,
|
||||
bodyUploadMax,
|
||||
headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null,
|
||||
headCpuMaxMs: headCpu.at(-1) ?? null,
|
||||
};
|
||||
} finally {
|
||||
pre();
|
||||
post();
|
||||
for (const [name, original] of originals) gl[name] = original;
|
||||
for (const [primitive, update] of wrapped)
|
||||
if (!primitive.isDestroyed()) primitive.update = update;
|
||||
}
|
||||
}, setup.key);
|
||||
const result = await page.evaluate(async (setup) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const entry = state._vehicles.get(setup.key);
|
||||
const trail = parts.trails.diagnostics();
|
||||
if (!entry || !trail)
|
||||
return { ...setup, present: 0, reason: 'selection disappeared' };
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.rendering.syncRenderHold();
|
||||
const ready = () =>
|
||||
app.tileset?.show !== false &&
|
||||
app.tileset?.tilesLoaded === true &&
|
||||
(scene.globe?.show === false || scene.globe?.tilesLoaded === true) &&
|
||||
trail.body?.ready === true &&
|
||||
(!trail.headPrimitive || trail.headPrimitive.ready === true);
|
||||
// Require sustained readiness after freezing playback, before projecting
|
||||
// onto the refined surface. Repeated captures still check pixel stability.
|
||||
const tilesReady = await new Promise((resolve) => {
|
||||
const started = performance.now();
|
||||
let readySince = null;
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
readySince = ready() ? (readySince ?? now) : null;
|
||||
scene.requestRender();
|
||||
if (
|
||||
(readySince !== null && now - readySince >= 500) ||
|
||||
now - started >= 30000
|
||||
) {
|
||||
clearInterval(timer);
|
||||
resolve(readySince !== null && now - readySince >= 500);
|
||||
}
|
||||
}, 80);
|
||||
});
|
||||
if (!tilesReady)
|
||||
return {
|
||||
...setup,
|
||||
present: 0,
|
||||
reason: 'tiles or trail geometry did not become ready',
|
||||
};
|
||||
const canvas = scene.canvas,
|
||||
gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
if (trail.head?.show) points.push(...trail.head.positions);
|
||||
if (points.length < 2)
|
||||
return { ...setup, present: 0, reason: 'no completed path' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const V = entry.marker.position.constructor;
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(i) =>
|
||||
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
||||
);
|
||||
const samples = Array.from({ length: 8 }, (_, i) => {
|
||||
const d = (lengths.at(-1) * (i + 1)) / 9;
|
||||
let j = 1;
|
||||
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
||||
const p = V.lerp(
|
||||
points[j - 1],
|
||||
points[j],
|
||||
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
||||
new V(),
|
||||
);
|
||||
const cart = C.fromCartesian(p);
|
||||
const height = scene.sampleHeight(
|
||||
cart,
|
||||
[entry.marker, trail.body, trail.headPrimitive].filter(Boolean),
|
||||
);
|
||||
if (Number.isFinite(height)) cart.height = height;
|
||||
const surface = C.toCartesian(cart);
|
||||
const q = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
||||
inFront: q[3] > 0,
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const marker = entry.marker.position;
|
||||
const markerClip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]),
|
||||
);
|
||||
const sprite = {
|
||||
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
|
||||
// Include the padded, rotated sprite and its selection bracket.
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y, inFront }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
!inFront ||
|
||||
px < r ||
|
||||
py < r ||
|
||||
px + r >= canvas.width ||
|
||||
py + r >= canvas.height
|
||||
)
|
||||
return null;
|
||||
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
||||
gl.readPixels(
|
||||
px - r,
|
||||
py - r,
|
||||
2 * r + 1,
|
||||
2 * r + 1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
return data;
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('Trail framebuffer did not render within 5 s'));
|
||||
}, 5000);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const bodyShow = trail.body?.show,
|
||||
headShow = trail.head?.show,
|
||||
headPrimitiveShow = trail.headPrimitive?.show,
|
||||
backingShow = trail.backing?.show;
|
||||
let showTrail = false;
|
||||
const applyVisibility = () => {
|
||||
if (trail.body) trail.body.show = showTrail && bodyShow;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = showTrail && headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = showTrail && headShow;
|
||||
if (trail.backing) trail.backing.show = showTrail && backingShow;
|
||||
};
|
||||
// Override the layer's frame update in both states; selection stays fixed.
|
||||
const removeVisibility = scene.preRender.addEventListener(applyVisibility);
|
||||
const captures = [];
|
||||
let captureReady = true;
|
||||
try {
|
||||
for (const visible of [false, true, false, true]) {
|
||||
showTrail = visible;
|
||||
// Let post-processing settle before each measured framebuffer.
|
||||
for (let i = 0; i < 3; i++) await frame();
|
||||
captures.push(await frame());
|
||||
const current = parts.trails.diagnostics();
|
||||
captureReady &&=
|
||||
ready() &&
|
||||
current?.body === trail.body &&
|
||||
current?.headPrimitive === trail.headPrimitive;
|
||||
}
|
||||
} finally {
|
||||
removeVisibility();
|
||||
if (trail.body) trail.body.show = bodyShow;
|
||||
if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = headShow;
|
||||
if (trail.backing) trail.backing.show = backingShow;
|
||||
await frame();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures.map((patches) =>
|
||||
patches.map((p) => (p ? Array.from(p) : null)),
|
||||
);
|
||||
return {
|
||||
...setup,
|
||||
fixes: entry.track.count,
|
||||
selected: state._selectedKey === setup.key,
|
||||
pathMetres: lengths.at(-1),
|
||||
samples,
|
||||
on,
|
||||
off,
|
||||
onAgain,
|
||||
offAgain,
|
||||
sprite,
|
||||
tilesReady: captureReady,
|
||||
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
|
||||
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
|
||||
body: trail.body?.constructor.name,
|
||||
headStrategy: trail.headStrategy || 'PolylineCollection',
|
||||
headRebuilds: trail.headRebuilds ?? null,
|
||||
};
|
||||
}, setup);
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.cost = cost;
|
||||
result.altitude = altitude;
|
||||
result.pitch = pitch;
|
||||
result.present = pixels.present;
|
||||
result.total = pixels.total;
|
||||
result.samples = result.samples?.map((s, i) => ({
|
||||
...s,
|
||||
hit: pixels.hits[i],
|
||||
stable: pixels.stable[i],
|
||||
eligible: pixels.eligible[i],
|
||||
}));
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 95,
|
||||
});
|
||||
await writeFile(
|
||||
`${shots}/${tag}-${altitude}m-trail-visible.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
check(
|
||||
name,
|
||||
result.google &&
|
||||
result.selected &&
|
||||
result.fixes >= 5 &&
|
||||
result.pathMetres > 15 &&
|
||||
result.elapsedDisplayMs >= 19000 &&
|
||||
result.movedMetres >= 5 &&
|
||||
pixels.pass,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
check(
|
||||
'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads',
|
||||
cost.frames > 30 &&
|
||||
cost.bodyUploadMax > 0 &&
|
||||
cost.bodyUploadMax <= 2 * 1024 * 1024 &&
|
||||
cost.headUploadMax > 0 &&
|
||||
cost.headUploadMax <= 2048,
|
||||
JSON.stringify({ altitude, ...cost }),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitPartsForTest()
|
||||
.selection.clearSelection(),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
|
||||
import {
|
||||
boundPageEvaluations,
|
||||
reportTransitVisibility,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
runFleetBudgets,
|
||||
runBostonMatrix,
|
||||
runBostonLive,
|
||||
runTrailVisibility,
|
||||
} from './qa-transit-scenes.mjs';
|
||||
/**
|
||||
* Browser proof for the Transit layer against a running app.
|
||||
@@ -78,6 +80,18 @@ const check = (
|
||||
failures += 1;
|
||||
} else if (!passed) failures += 1;
|
||||
};
|
||||
// A focused deterministic regression can run independently of live-feed QA.
|
||||
if (process.argv.includes('--heading-only')) {
|
||||
try {
|
||||
await runTransitHeadingRegression(BASE, check);
|
||||
} catch (error) {
|
||||
check('heading regression completed', false, error.stack || error.message);
|
||||
}
|
||||
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
|
||||
|
||||
const renderingSource = await readFile(
|
||||
new URL('../src/layers/transit/rendering.js', import.meta.url),
|
||||
'utf8',
|
||||
@@ -1774,6 +1788,13 @@ try {
|
||||
selectStyle,
|
||||
sampleRendered,
|
||||
};
|
||||
if (runs('trail-visible'))
|
||||
for (const [altitude, pitch] of [
|
||||
[300, 55],
|
||||
[600, 45],
|
||||
[900, 45],
|
||||
])
|
||||
await runTrailVisibility({ ...sceneQA, altitude, pitch });
|
||||
if (runs('budgets')) await runFleetBudgets(sceneQA);
|
||||
if (runs('matrix')) await runBostonMatrix(sceneQA);
|
||||
if (runs('boston-live')) await runBostonLive(sceneQA);
|
||||
|
||||
@@ -625,12 +625,27 @@ async function runBehaviorLayer() {
|
||||
|
||||
// (4b) analyst engine end-to-end: count flights over Texas (region ring
|
||||
// via NE-pack/admin machinery), then a follow-up over the same set.
|
||||
// A cold boundary lookup can exceed the resolver's budget; it answers
|
||||
// region-timeout and keeps loading, so asking again must succeed.
|
||||
let texasTimeouts = 0;
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 });
|
||||
while (r?.code === 'region-timeout' && texasTimeouts < 8) {
|
||||
texasTimeouts += 1;
|
||||
await settle(5000);
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 });
|
||||
}
|
||||
report(r?.ok === true && Number.isFinite(r?.count) && r.count > 0 && String(r?.coverage?.scope || '').includes('Texas'),
|
||||
'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} err=${r?.error || ''}`);
|
||||
'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`);
|
||||
r = await run('analyst_query', { followUp: true, filters: [{ field: 'onGround', op: 'eq', value: false }], sortBy: 'altitudeM', limit: 3 });
|
||||
report(r?.ok === true && r?.coverage?.followUp === true,
|
||||
'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`);
|
||||
// A marine region resolves from the bundled Natural Earth pack in the page,
|
||||
// without the slower geocode fallback.
|
||||
const gulfStarted = Date.now();
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Gulf of Mexico' }, limit: 5 });
|
||||
const gulfMs = Date.now() - gulfStarted;
|
||||
report(r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && gulfMs < 3000,
|
||||
'behavior: analyst resolves the Gulf of Mexico from the bundled pack', `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`);
|
||||
|
||||
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
||||
r = await run('zoom_to_globe', {});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
|
||||
*
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -11,8 +11,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const appUrl = process.argv[2] || 'http://localhost:4189';
|
||||
const wavPath = process.argv[3]
|
||||
const pushToTalk = process.argv.includes('--push-to-talk');
|
||||
const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk');
|
||||
const targetUrl = new URL(positional[0] || 'http://localhost:4189');
|
||||
// Keep first-run focus changes outside this voice/keyboard acceptance test.
|
||||
targetUrl.searchParams.set('welcome', '0');
|
||||
const appUrl = targetUrl.href;
|
||||
const wavPath = positional[1]
|
||||
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
|
||||
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
|
||||
|
||||
@@ -60,6 +65,7 @@ try {
|
||||
await page.waitForFunction(() => (
|
||||
window.__godsEyeView?.voiceCommands
|
||||
&& document.getElementById('gev-voice-button')
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
), { timeout: 30_000 });
|
||||
|
||||
const readState = () => page.evaluate(() => {
|
||||
@@ -81,7 +87,26 @@ try {
|
||||
});
|
||||
|
||||
const initial = await readState();
|
||||
await page.evaluate(() => document.getElementById('gev-voice-button').click());
|
||||
let pushToTalkStarted = false;
|
||||
let microphoneReleased = false;
|
||||
if (pushToTalk) {
|
||||
await page.bringToFront();
|
||||
await page.focus('.cesium-widget canvas');
|
||||
await page.keyboard.down('Space');
|
||||
await page.waitForFunction(() => document.querySelector(
|
||||
'[data-push-to-talk="held"][data-microphone="active"]',
|
||||
), { timeout: 30_000 });
|
||||
pushToTalkStarted = true;
|
||||
// The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition.
|
||||
await new Promise(resolve => setTimeout(resolve, 10_000));
|
||||
await page.keyboard.up('Space');
|
||||
microphoneReleased = await page.evaluate(() => {
|
||||
const status = window.__godsEyeView.voiceCommands.status;
|
||||
return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]'));
|
||||
});
|
||||
} else {
|
||||
await page.click('#gev-voice-button');
|
||||
}
|
||||
|
||||
const timeline = [initial];
|
||||
let lastSignature = JSON.stringify(initial);
|
||||
@@ -113,10 +138,14 @@ try {
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: finalState.voiceStatus === 'idle'
|
||||
ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased))
|
||||
&& finalState.voiceStatus === 'idle'
|
||||
&& finalState.radioAudioState === 'playing'
|
||||
&& finalState.radioVoiceDucked === false
|
||||
&& Number(finalState.cameraHeightM) >= 10_000_000,
|
||||
mode: pushToTalk ? 'push-to-talk' : 'click',
|
||||
pushToTalkStarted,
|
||||
microphoneReleased,
|
||||
fixture: wavPath,
|
||||
fixtureSha256,
|
||||
appUrl,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -8,6 +8,8 @@ import { projectRoot } from './project-root.mjs';
|
||||
import { selectMapStartupRoute } from '../src/mapStartup.js';
|
||||
|
||||
const ROOT = projectRoot(import.meta.url);
|
||||
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
|
||||
const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']);
|
||||
|
||||
export const CREDENTIALS = Object.freeze([
|
||||
{ name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] },
|
||||
@@ -101,6 +103,21 @@ export function readDoctorDotenvValue(
|
||||
return String(values[key] ?? '');
|
||||
}
|
||||
|
||||
/** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */
|
||||
export function resolveOpenSkyAuthMode({
|
||||
authoritativeEnvironment = false,
|
||||
environment = process.env,
|
||||
rootDir = ROOT,
|
||||
} = {}) {
|
||||
const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE');
|
||||
const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim();
|
||||
const dotenvMode = authoritativeEnvironment && environmentDefinesMode
|
||||
? ''
|
||||
: readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir);
|
||||
const mode = (environmentMode || dotenvMode).trim().toLowerCase();
|
||||
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
|
||||
}
|
||||
|
||||
function hasKeychainItem(service, account) {
|
||||
if (process.platform !== 'darwin') return false;
|
||||
const result = spawnSync('security', [
|
||||
@@ -128,8 +145,25 @@ export function resolveCredential(spec, {
|
||||
return { configured: false, source: null };
|
||||
}
|
||||
|
||||
export function buildCapabilitySummary(credentials) {
|
||||
export function buildCapabilitySummary(
|
||||
credentials,
|
||||
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
|
||||
) {
|
||||
const configured = (name) => credentials[name]?.configured === true;
|
||||
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
||||
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
|
||||
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
|
||||
? requestedOpenSkyMode
|
||||
: OPENSKY_AUTH_MODE_DEFAULT;
|
||||
const flights = openSkyMode === 'anon'
|
||||
? 'OpenSky keyless anonymous access (rate-limited)'
|
||||
: openSkyMode === 'basic'
|
||||
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
|
||||
: openSkyMode === 'auto'
|
||||
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
||||
: hasOAuthCredentials
|
||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||
: 'OpenSky keyless anonymous access (rate-limited)';
|
||||
const route = selectMapStartupRoute({
|
||||
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
|
||||
cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '',
|
||||
@@ -140,9 +174,7 @@ export function buildCapabilitySummary(credentials) {
|
||||
: route === 'google-ion'
|
||||
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
|
||||
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
|
||||
flights: configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET')
|
||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||
: 'OpenSky OAuth credentials not configured',
|
||||
flights,
|
||||
voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added',
|
||||
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
|
||||
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
|
||||
@@ -173,7 +205,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
: { available: false, version: null },
|
||||
dependenciesInstalled,
|
||||
credentials,
|
||||
capabilities: buildCapabilitySummary(credentials),
|
||||
capabilities: buildCapabilitySummary(credentials, {
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,7 +45,9 @@ export function trackBackfillProxies() {
|
||||
RESPONSE_CAP_BYTES,
|
||||
);
|
||||
let body;
|
||||
let status = upstream.status;
|
||||
if (tooLarge) {
|
||||
status = 502;
|
||||
body = JSON.stringify({ error: 'Upstream track response too large' });
|
||||
} else if (!upstream.ok) {
|
||||
// Sanitize upstream error surface; status code is signal enough
|
||||
@@ -53,8 +55,10 @@ export function trackBackfillProxies() {
|
||||
} else {
|
||||
body = text;
|
||||
}
|
||||
cachePut(key, { at: Date.now(), status: upstream.status, body });
|
||||
res.statusCode = upstream.status;
|
||||
// Cache the 502 like any upstream error: /tracks/all costs OpenSky
|
||||
// credits, so a retry inside the window must not re-download the body.
|
||||
cachePut(key, { at: Date.now(), status, body });
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(body);
|
||||
|
||||
+132
-4
@@ -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, {
|
||||
@@ -308,11 +426,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
const timedOut =
|
||||
error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
||||
// GET /api/cctv/health serializes `message`, and the CCTV panel
|
||||
// renders it as a status label, so the raw error would leave the
|
||||
// server by a different door than the sanitized body below and
|
||||
// land on screen.
|
||||
//
|
||||
// The log line drops the text too, unlike the catch at the bottom
|
||||
// of this file: a media-fetch failure names the camera's upstream
|
||||
// host, and these lines get pasted into issues. The status codes
|
||||
// below carry the diagnosis — 504 for a timeout, 502 otherwise.
|
||||
console.warn('[CCTV Proxy] media fetch failed');
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'upstream',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: error?.message || 'Media fetch failed',
|
||||
message: 'Media fetch failed',
|
||||
});
|
||||
res.writeHead(timedOut ? 504 : 502, {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -253,5 +261,10 @@ export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Deadline for upstream response headers; live bodies keep streaming afterward. */
|
||||
export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||
/** Silence a live body may carry before the relay gives up on it. Twice the
|
||||
* header deadline, because a camera that is merely slow between frames is far
|
||||
* more common than one that has died mid-stream, and a viewer would rather
|
||||
* wait than be dropped. */
|
||||
export const CCTV_MEDIA_IDLE_TIMEOUT_MS = 30 * 1000;
|
||||
/** Declared size ceiling for fixed media responses. */
|
||||
export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
CCTV_MEDIA_FETCH_TIMEOUT_MS,
|
||||
CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
@@ -103,11 +104,17 @@ export function toReadable(body) {
|
||||
* @param {Response} upstream - fetch() Response object.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
|
||||
* allowed between upstream chunks before the stream is released. Injectable
|
||||
* only to keep the deadline unit-testable.
|
||||
*/
|
||||
export async function proxyMediaResponse(
|
||||
res,
|
||||
upstream,
|
||||
{ sourceHeader = 'upstream' } = {},
|
||||
{
|
||||
sourceHeader = 'upstream',
|
||||
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
@@ -153,7 +160,20 @@ export async function proxyMediaResponse(
|
||||
return;
|
||||
}
|
||||
|
||||
// The header deadline only covers the wait for a response line. Past that an
|
||||
// upstream can hold the connection open and send nothing at all, and the
|
||||
// relay would wait on it for as long as the camera host cared to. The
|
||||
// deadline below measures the gap between upstream chunks rather than the
|
||||
// life of the stream, so a feed that keeps delivering keeps its connection.
|
||||
let idleTimer = null;
|
||||
const clearIdleDeadline = () => {
|
||||
if (!idleTimer) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
};
|
||||
|
||||
stream.on('error', () => {
|
||||
clearIdleDeadline();
|
||||
if (!res.writableEnded) res.end();
|
||||
});
|
||||
|
||||
@@ -164,6 +184,7 @@ export async function proxyMediaResponse(
|
||||
const releaseUpstream = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
clearIdleDeadline();
|
||||
stream.unpipe(res);
|
||||
// Destroying the Node stream cancels the web body it wraps; the direct
|
||||
// cancel covers a body that was never wrapped, and rejects harmlessly when
|
||||
@@ -176,14 +197,37 @@ export async function proxyMediaResponse(
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
const armIdleDeadline = () => {
|
||||
clearIdleDeadline();
|
||||
idleTimer = setTimeout(onIdleDeadline, idleTimeoutMs);
|
||||
idleTimer.unref?.();
|
||||
};
|
||||
const onIdleDeadline = () => {
|
||||
// A viewer who cannot keep up pauses the pipe, and no upstream bytes arrive
|
||||
// while it is paused. That is a slow client rather than a dead camera, so
|
||||
// it gets the deadline again instead of a teardown.
|
||||
if (res.writableNeedDrain) {
|
||||
armIdleDeadline();
|
||||
return;
|
||||
}
|
||||
releaseUpstream();
|
||||
if (!res.writableEnded) res.end();
|
||||
};
|
||||
res.once('close', () => {
|
||||
clearIdleDeadline();
|
||||
if (!res.writableEnded) releaseUpstream();
|
||||
});
|
||||
res.once('error', releaseUpstream);
|
||||
stream.once('end', () => {
|
||||
clearIdleDeadline();
|
||||
released = true;
|
||||
});
|
||||
armIdleDeadline();
|
||||
stream.pipe(res);
|
||||
// Attached after the pipe because a data listener resumes the stream, and
|
||||
// flowing before the destination is attached would spill chunks nobody
|
||||
// forwards. Only bytes from upstream renew the deadline.
|
||||
stream.on('data', armIdleDeadline);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,17 +17,29 @@ async function readRequestBodyCapped(req, maxBytes) {
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
let bodyTooLarge = false;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
// Keep draining after the cap without retaining more bytes. Destroying
|
||||
// the request also destroys its socket, so the handler cannot deliver
|
||||
// the fixed error response it constructs for BODY_TOO_LARGE.
|
||||
if (bodyTooLarge) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
reject(new Error(`Request body exceeds ${maxBytes} bytes`));
|
||||
req.destroy();
|
||||
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
||||
// oversized body from a genuine failure without matching on text.
|
||||
bodyTooLarge = true;
|
||||
chunks.length = 0;
|
||||
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
||||
error.code = 'BODY_TOO_LARGE';
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('end', () => {
|
||||
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { normalizeFirePerimeterSnapshot } from '../../src/layers/perimeters/records.js';
|
||||
import {
|
||||
readResponseJsonCapped,
|
||||
readResponseTextCapped,
|
||||
coalesceProxyRequest,
|
||||
} from './common/http.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
|
||||
// NIFC WFIGS current interagency fire perimeters (public, keyless).
|
||||
// maxAllowableOffset trades ~100 m of boundary fidelity for a payload small
|
||||
// enough to refresh continuously (~1 MB for a typical fire season).
|
||||
const API_URL =
|
||||
'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/' +
|
||||
'WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?' +
|
||||
new URLSearchParams({
|
||||
where: '1=1',
|
||||
outFields: [
|
||||
'poly_IncidentName',
|
||||
'attr_UniqueFireIdentifier',
|
||||
'attr_IncidentSize',
|
||||
'attr_PercentContained',
|
||||
'attr_POOState',
|
||||
'attr_IncidentTypeCategory',
|
||||
'attr_FireDiscoveryDateTime',
|
||||
'poly_DateCurrent',
|
||||
'attr_FireCause',
|
||||
'attr_FireBehaviorGeneral',
|
||||
'attr_TotalIncidentPersonnel',
|
||||
'attr_POOCounty',
|
||||
'attr_EstimatedCostToDate',
|
||||
'attr_IncidentComplexityLevel',
|
||||
'attr_CpxName',
|
||||
].join(','),
|
||||
maxAllowableOffset: '0.001',
|
||||
outSR: '4326',
|
||||
f: 'geojson',
|
||||
}).toString();
|
||||
|
||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||
const MAX_PAGES = 5;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
payload?.properties?.exceededTransferLimit === true;
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
/** Fixed-origin, bounded WFIGS and InciWeb routes for dev and preview. */
|
||||
export function firePerimetersProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const publications = new Map();
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
|
||||
|
||||
async function upstream(url, cap, timeout, options = {}) {
|
||||
const signal = AbortSignal.timeout(timeout);
|
||||
const response = await fetchImpl(url, {
|
||||
...options,
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
return readResponseJsonCapped(response, cap, signal);
|
||||
}
|
||||
|
||||
async function fetchPerimeters() {
|
||||
const features = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url =
|
||||
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
|
||||
const payload = await upstream(url, 16 * MIB, 30_000);
|
||||
if (!Array.isArray(payload?.features))
|
||||
throw new Error('invalid_snapshot');
|
||||
features.push(...payload.features);
|
||||
if (!exceededTransferLimit(payload) || !payload.features.length) break;
|
||||
}
|
||||
const rows = normalizeFirePerimeterSnapshot({ features });
|
||||
return { fetchedAt: now(), rows };
|
||||
}
|
||||
|
||||
async function fetchIndex() {
|
||||
const rows = await upstream(
|
||||
'https://inciweb.wildfire.gov/api/single-publication/',
|
||||
4 * MIB,
|
||||
20_000,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '' }),
|
||||
},
|
||||
);
|
||||
if (!Array.isArray(rows)) throw new Error('invalid_index');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// InciWeb's publication API was retired; incident pages carry origin and update times.
|
||||
async function fetchPublication(id) {
|
||||
const signal = AbortSignal.timeout(15_000);
|
||||
let response = await fetchImpl(`https://inciweb.wildfire.gov/node/${id}`, {
|
||||
signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
const location = response.headers.get('location');
|
||||
if (!location || /\.\.|[?%\\]/.test(location))
|
||||
throw new Error('invalid_redirect');
|
||||
const url = new URL(location, 'https://inciweb.wildfire.gov/');
|
||||
if (
|
||||
url.hostname !== 'inciweb.wildfire.gov' ||
|
||||
!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)?$/.test(url.pathname) ||
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.port ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new Error('invalid_redirect');
|
||||
url.protocol = 'https:';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
response = await fetchImpl(url.href, { signal, redirect: 'error' });
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
const html = await readResponseTextCapped(response, 2 * MIB, signal);
|
||||
const changed = Date.parse(
|
||||
html.match(/<meta\s+property="og:updated_time"\s+content="([^"]+)"/)?.[1],
|
||||
);
|
||||
const created = Date.parse(
|
||||
html.match(
|
||||
/Date of Origin<\/th>\s*<td[^>]*>\s*<time\s+datetime="([^"]+)"/,
|
||||
)?.[1],
|
||||
);
|
||||
const createdMs = Number.isFinite(created) ? created : null;
|
||||
const changedMs = Number.isFinite(changed) ? changed : null;
|
||||
if (createdMs === null && changedMs === null)
|
||||
throw new Error('invalid_incident_page');
|
||||
return { createdMs, changedMs };
|
||||
}
|
||||
|
||||
async function acquire(key, ttl, load, store) {
|
||||
const previous = store.get(key);
|
||||
if (previous && now() - previous.savedAt < ttl)
|
||||
return { value: previous.value, stale: false };
|
||||
// Bound distinct concurrent publication requests as well as retained entries.
|
||||
if (!inFlight.has(key) && inFlight.size >= 256)
|
||||
throw Object.assign(new Error('busy'), { status: 429 });
|
||||
try {
|
||||
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
|
||||
const value = await load();
|
||||
store.delete(key);
|
||||
store.set(key, { value, savedAt: now() });
|
||||
if (store === publications && store.size > 256)
|
||||
store.delete(store.keys().next().value);
|
||||
return value;
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (previous) return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, stale = false) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const path = (req.url || '/').split('?')[0];
|
||||
let key,
|
||||
ttl,
|
||||
load,
|
||||
store = cache;
|
||||
if (path === '/' || path === '') {
|
||||
key = 'perimeters';
|
||||
ttl = 300_000;
|
||||
load = fetchPerimeters;
|
||||
} else if (path === '/inciweb/index') {
|
||||
key = 'index';
|
||||
ttl = 3_600_000;
|
||||
load = fetchIndex;
|
||||
} else if (path.startsWith('/inciweb/publication/')) {
|
||||
const id = path.slice('/inciweb/publication/'.length);
|
||||
if (!/^\d{1,9}$/.test(id))
|
||||
return json(400, { error: 'invalid_publication_id' });
|
||||
key = `publication:${id}`;
|
||||
ttl = 1_800_000;
|
||||
load = () => fetchPublication(id);
|
||||
store = publications;
|
||||
} else return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||
try {
|
||||
const { value, stale } = await acquire(key, ttl, load, store);
|
||||
json(
|
||||
200,
|
||||
key === 'perimeters' && stale ? { ...value, stale: true } : value,
|
||||
stale,
|
||||
);
|
||||
} catch (error) {
|
||||
json(error.status === 429 ? 429 : 502, {
|
||||
error: 'fire_perimeters_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'fire-perimeters',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/fire-perimeters', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/fire-perimeters', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
*
|
||||
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
|
||||
* satellites, no cross-source dedup) fetched sequentially with `days=2`
|
||||
* (`days=1` means "current UTC day", nearly empty just after 00:00Z) and
|
||||
* clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is
|
||||
* 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
|
||||
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
|
||||
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
|
||||
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
* TTL 30 min, single-flight refresh, serve-stale-on-failure, and a
|
||||
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
|
||||
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
|
||||
@@ -28,7 +29,12 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
export function firmsProxy() {
|
||||
const TTL_MS = 30 * 60_000;
|
||||
const STATUS_TTL_MS = 5 * 60_000;
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
|
||||
const SOURCES = [
|
||||
'VIIRS_NOAA20_NRT',
|
||||
'VIIRS_NOAA21_NRT',
|
||||
'VIIRS_SNPP_NRT',
|
||||
'MODIS_NRT',
|
||||
];
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
|
||||
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { Readable } from 'node:stream';
|
||||
import { coalesceProxyRequest, readResponseTextCapped } from './common/http.js';
|
||||
import { isLocalIpv4, parseTapAddress } from '../../src/data/tapAddress.js';
|
||||
import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js';
|
||||
|
||||
/**
|
||||
* Local decoder feeds for the Local ADS-B layer.
|
||||
*
|
||||
* A user who runs dump1090 / readsb / tar1090 (1090 MHz) or dump978-fa +
|
||||
* skyaware978 (978 MHz UAT) exposes an `aircraft.json` over HTTP. This route
|
||||
* reads those documents and returns them as Local ADS-B records.
|
||||
*
|
||||
* The feed list comes only from the operator's `LOCAL_RECEIVER_FEEDS`
|
||||
* environment value; the browser never supplies an address. Every host must
|
||||
* pass the shared receiver-tap address rule (`src/data/tapAddress.js`:
|
||||
* loopback, RFC1918, `localhost`, `*.local`), the scheme must be http(s) and
|
||||
* the path must end in `aircraft.json`. Invalid entries are logged at startup,
|
||||
* reported as `invalid` and never fetched.
|
||||
*
|
||||
* A name (`localhost`, `*.local`) is resolved before every read; each address
|
||||
* it resolves to must itself be loopback or RFC1918, and the connection is
|
||||
* pinned to exactly those validated addresses, so a later re-resolution
|
||||
* cannot redirect it anywhere else.
|
||||
*/
|
||||
|
||||
export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft';
|
||||
export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS';
|
||||
export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']);
|
||||
export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000;
|
||||
export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
export const LOCAL_RECEIVER_CACHE_MS = 1_000;
|
||||
/** A feed whose own `now` is older than this is reported stale. */
|
||||
export const LOCAL_RECEIVER_STALE_MS = 10_000;
|
||||
const MAX_FEEDS = 8;
|
||||
const LOG_PREFIX = '[local-receivers]';
|
||||
|
||||
const BAND_LABELS = Object.freeze({ 1090: '1090 MHz', 978: '978 MHz UAT' });
|
||||
/** Statuses whose `Response` must be constructed without a body. */
|
||||
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
||||
|
||||
function defaultPort(protocol) {
|
||||
return protocol === 'https:' ? 443 : 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one `band=url` entry.
|
||||
* @param {string} entry Raw entry text.
|
||||
* @returns {{band:string, url:string}|{band:string|null, reason:string}}
|
||||
*/
|
||||
function parseEntry(entry) {
|
||||
const separator = entry.indexOf('=');
|
||||
if (separator <= 0) return { band: null, reason: 'expected band=url' };
|
||||
const band = entry.slice(0, separator).trim();
|
||||
const rawUrl = entry.slice(separator + 1).trim();
|
||||
if (!LOCAL_RECEIVER_BANDS.includes(band))
|
||||
return { band: null, reason: 'band must be 1090 or 978' };
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { band, reason: 'not a valid URL' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||
return { band, reason: 'scheme must be http or https' };
|
||||
if (url.username || url.password)
|
||||
return { band, reason: 'credentials are not allowed in the URL' };
|
||||
if (url.search || url.hash || rawUrl.includes('?') || rawUrl.includes('#'))
|
||||
return { band, reason: 'query and fragment are not allowed' };
|
||||
const port = url.port ? Number(url.port) : defaultPort(url.protocol);
|
||||
const address = parseTapAddress(`${url.hostname}:${port}`);
|
||||
if (!address)
|
||||
return {
|
||||
band,
|
||||
reason: 'host must be loopback, RFC1918, localhost or *.local',
|
||||
};
|
||||
if (!/(?:^|\/)aircraft\.json$/.test(url.pathname))
|
||||
return { band, reason: 'path must end in aircraft.json' };
|
||||
return {
|
||||
band,
|
||||
url: `${url.protocol}//${address.host}:${address.port}${url.pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `LOCAL_RECEIVER_FEEDS` (`band=url[,band=url…]`).
|
||||
*
|
||||
* Labels name the band only (plus an ordinal when a band repeats), so feed
|
||||
* addresses never reach the browser.
|
||||
* @param {string|undefined} value Environment value.
|
||||
* @returns {{configured:boolean, feeds:object[]}} Each feed is
|
||||
* `{ id, band, label, url }` when valid or `{ id, band, label, reason }`
|
||||
* when invalid (never fetched).
|
||||
*/
|
||||
export function parseLocalReceiverFeeds(value) {
|
||||
const entries = String(value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const feeds = [];
|
||||
const perBand = new Map();
|
||||
entries.forEach((entry, index) => {
|
||||
const parsed =
|
||||
index < MAX_FEEDS
|
||||
? parseEntry(entry)
|
||||
: { band: null, reason: `at most ${MAX_FEEDS} feeds are read` };
|
||||
const band = parsed.band;
|
||||
const ordinal = (perBand.get(band) || 0) + 1;
|
||||
perBand.set(band, ordinal);
|
||||
const base = band ? BAND_LABELS[band] : `entry ${index + 1}`;
|
||||
const label = band && ordinal > 1 ? `${base} #${ordinal}` : base;
|
||||
feeds.push({
|
||||
id: `feed-${index + 1}`,
|
||||
band,
|
||||
label,
|
||||
...(parsed.url ? { url: parsed.url } : { reason: parsed.reason }),
|
||||
});
|
||||
});
|
||||
// Ordinals are only needed when a band actually repeats.
|
||||
for (const feed of feeds) {
|
||||
if (feed.band && perBand.get(feed.band) > 1 && !feed.label.includes('#'))
|
||||
feed.label = `${feed.label} #1`;
|
||||
}
|
||||
return { configured: feeds.length > 0, feeds };
|
||||
}
|
||||
|
||||
class FeedError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a resolved address satisfies the receiver-tap rule: loopback or
|
||||
* RFC1918 IPv4, the IPv6 loopback, or an IPv4-mapped form of the former.
|
||||
* @param {string} address Resolved IP address.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalReceiverAddress(address) {
|
||||
const value = String(address || '').toLowerCase();
|
||||
if (value === '::1') return true;
|
||||
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value);
|
||||
return isLocalIpv4(mapped ? mapped[1] : value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feed host name and require every address to be local.
|
||||
* @param {string} hostname `localhost` or a `*.local` name.
|
||||
* @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible.
|
||||
* @returns {Promise<Array<{address:string, family:number}>>}
|
||||
*/
|
||||
export async function resolveLocalReceiverAddresses(hostname, lookupImpl) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await lookupImpl(hostname, { all: true, verbatim: true });
|
||||
} catch {
|
||||
throw new FeedError('UNRESOLVED');
|
||||
}
|
||||
const addresses = (Array.isArray(resolved) ? resolved : [resolved])
|
||||
.map((row) => ({
|
||||
address: String(row?.address || ''),
|
||||
family:
|
||||
Number(row?.family) || (String(row?.address).includes(':') ? 6 : 4),
|
||||
}))
|
||||
.filter((row) => row.address);
|
||||
if (!addresses.length) throw new FeedError('UNRESOLVED');
|
||||
if (addresses.some((row) => !isLocalReceiverAddress(row.address)))
|
||||
throw new FeedError('FORBIDDEN_ADDRESS');
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle with `promise`, or reject with an AbortError as soon as `signal`
|
||||
* aborts; a later settlement of `promise` is ignored.
|
||||
*/
|
||||
function untilAborted(promise, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
// Handlers are attached first so a late rejection is never unhandled.
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** A `net`-style lookup that only ever answers the validated addresses. */
|
||||
function pinnedLookup(addresses) {
|
||||
return (_hostname, options, callback) => {
|
||||
const done = typeof options === 'function' ? options : callback;
|
||||
if (options?.all)
|
||||
done(
|
||||
null,
|
||||
addresses.map((row) => ({ ...row })),
|
||||
);
|
||||
else done(null, addresses[0].address, addresses[0].family);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default transport. With `options.lookup` (a pinned feed name) the request
|
||||
* is made with node:http(s) so the socket connects only to the validated
|
||||
* addresses (TLS still verifies the name); otherwise it is a plain fetch of
|
||||
* an IP-literal URL. Redirects are never followed either way.
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
function fetchLocalReceiverFeed(url, options) {
|
||||
if (typeof options?.lookup !== 'function') return fetch(url, options);
|
||||
const client = new URL(url).protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = client.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: options.headers,
|
||||
signal: options.signal,
|
||||
lookup: options.lookup,
|
||||
// A fresh socket per read: a pooled one could outlive the pin.
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
// Nothing thrown here may escape: this callback runs outside the
|
||||
// promise, so an exception would be uncaught and crash the server.
|
||||
try {
|
||||
const status = response.statusCode;
|
||||
// A `Response` only represents final statuses 200–599.
|
||||
if (!Number.isInteger(status) || status < 200 || status > 599)
|
||||
throw new FeedError('HTTP_STATUS');
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, String(value));
|
||||
}
|
||||
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||
const upstream = new Response(
|
||||
nullBody ? null : Readable.toWeb(response),
|
||||
{ status, statusText: response.statusMessage || '', headers },
|
||||
);
|
||||
if (nullBody) response.destroy();
|
||||
resolve(upstream);
|
||||
} catch (error) {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one validated feed document without following redirects and with the
|
||||
* body cap enforced while it streams.
|
||||
* @param {string} url Validated feed URL.
|
||||
* @param {object} options
|
||||
* @returns {Promise<object>} Parsed JSON document.
|
||||
*/
|
||||
export async function fetchLocalReceiverDocument(
|
||||
url,
|
||||
{
|
||||
fetchImpl = fetchLocalReceiverFeed,
|
||||
lookupImpl = dnsLookup,
|
||||
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
|
||||
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let upstream;
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
// IP literals were validated when the feed list was parsed; a name is
|
||||
// validated by what it resolves to now, and the socket is pinned there.
|
||||
// Resolution runs against the same deadline as the read: a stalled
|
||||
// lookup fails this feed instead of holding the whole snapshot.
|
||||
const lookup = isLocalIpv4(hostname)
|
||||
? undefined
|
||||
: pinnedLookup(
|
||||
await untilAborted(
|
||||
resolveLocalReceiverAddresses(hostname, lookupImpl),
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
upstream = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
...(lookup ? { lookup } : {}),
|
||||
});
|
||||
if (upstream.status >= 300 && upstream.status < 400)
|
||||
throw new FeedError('REDIRECT_REFUSED');
|
||||
// Opaque redirect responses from `redirect: 'manual'` report status 0.
|
||||
if (upstream.type === 'opaqueredirect')
|
||||
throw new FeedError('REDIRECT_REFUSED');
|
||||
if (!upstream.ok) throw new FeedError('HTTP_STATUS');
|
||||
const text = await readResponseTextCapped(
|
||||
upstream,
|
||||
maxBytes,
|
||||
controller.signal,
|
||||
);
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new FeedError('BAD_JSON');
|
||||
}
|
||||
if (!json || typeof json !== 'object' || !Array.isArray(json.aircraft))
|
||||
throw new FeedError('BAD_DOCUMENT');
|
||||
return json;
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
if (upstream?.body && !upstream.body.locked)
|
||||
void upstream.body.cancel().catch(() => {});
|
||||
if (error instanceof FeedError) throw error;
|
||||
if (error?.code === 'RESPONSE_TOO_LARGE')
|
||||
throw new FeedError('RESPONSE_TOO_LARGE');
|
||||
if (error?.name === 'AbortError' || controller.signal.aborted)
|
||||
throw new FeedError('TIMEOUT');
|
||||
throw new FeedError('UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one feed and describe it.
|
||||
* @returns {Promise<{status:object, records:object[]}>}
|
||||
*/
|
||||
async function readFeed(
|
||||
feed,
|
||||
{ fetchImpl, lookupImpl, now, timeoutMs, maxBytes, log },
|
||||
) {
|
||||
const summary = { band: feed.band, label: feed.label };
|
||||
if (!feed.url)
|
||||
return {
|
||||
status: { ...summary, status: 'invalid', aircraft: 0, ageMs: null },
|
||||
records: [],
|
||||
};
|
||||
let json;
|
||||
try {
|
||||
json = await fetchLocalReceiverDocument(feed.url, {
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
log(feed, 'unreachable', error.code);
|
||||
return {
|
||||
status: { ...summary, status: 'unreachable', aircraft: 0, ageMs: null },
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
const receivedAt = now();
|
||||
const feedNowS = Number(json.now);
|
||||
// The feed's own clock decides staleness. A clock ahead of ours reads as
|
||||
// age 0; a document without `now` is treated as current.
|
||||
const ageMs = Number.isFinite(feedNowS)
|
||||
? Math.max(0, Math.round(receivedAt - feedNowS * 1000))
|
||||
: 0;
|
||||
const status = ageMs > LOCAL_RECEIVER_STALE_MS ? 'stale' : 'live';
|
||||
log(feed, status);
|
||||
// Anchor record ages at the document time so a stale feed ages out
|
||||
// naturally, without trusting a receiver clock that runs ahead of ours.
|
||||
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
|
||||
band: feed.band,
|
||||
});
|
||||
return {
|
||||
status: { ...summary, status, aircraft: records.length, ageMs },
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the route handler. Exposed for tests; the plugin wraps it.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.feedsValue] `LOCAL_RECEIVER_FEEDS` value.
|
||||
* @param {typeof fetch} [options.fetchImpl] Receives `options.lookup` when
|
||||
* the feed host is a name; the connection must resolve only through it.
|
||||
* @param {Function} [options.lookupImpl] `dns.promises.lookup`-compatible.
|
||||
* @param {() => number} [options.now]
|
||||
* @param {{warn:Function, info?:Function}} [options.logger]
|
||||
* @returns {{handle:Function, config:object}}
|
||||
*/
|
||||
export function createLocalReceiversHandler({
|
||||
feedsValue = process.env[LOCAL_RECEIVER_FEEDS_ENV],
|
||||
fetchImpl = fetchLocalReceiverFeed,
|
||||
lookupImpl = dnsLookup,
|
||||
now = Date.now,
|
||||
logger = console,
|
||||
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
|
||||
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
|
||||
cacheMs = LOCAL_RECEIVER_CACHE_MS,
|
||||
} = {}) {
|
||||
const config = parseLocalReceiverFeeds(feedsValue);
|
||||
for (const feed of config.feeds) {
|
||||
if (feed.reason)
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} ${LOCAL_RECEIVER_FEEDS_ENV} ${feed.id} (${feed.label}) rejected: ${feed.reason}. It will not be fetched.`,
|
||||
);
|
||||
}
|
||||
const lastStatus = new Map();
|
||||
// Log a feed only when its status changes, so a feed that is down does not
|
||||
// write a line every second.
|
||||
const log = (feed, status, code) => {
|
||||
if (lastStatus.get(feed.id) === status) return;
|
||||
lastStatus.set(feed.id, status);
|
||||
if (status === 'live') logger.info?.(`${LOG_PREFIX} ${feed.label} live`);
|
||||
else
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} ${feed.label} ${status}${code ? ` (${code})` : ''}`,
|
||||
);
|
||||
};
|
||||
const inFlight = new Map();
|
||||
let cached = null;
|
||||
|
||||
async function snapshot() {
|
||||
const results = await Promise.all(
|
||||
config.feeds.map((feed) =>
|
||||
readFeed(feed, {
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
now,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
log,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
configured: true,
|
||||
generatedAt: now(),
|
||||
feeds: results.map((result) => result.status),
|
||||
records: results.flatMap((result) => result.records),
|
||||
};
|
||||
}
|
||||
|
||||
async function payload() {
|
||||
if (!config.configured)
|
||||
return { configured: false, feeds: [], records: [] };
|
||||
if (cached && now() - cached.at < cacheMs) return cached.payload;
|
||||
const { promise } = coalesceProxyRequest(inFlight, 'all', snapshot);
|
||||
const result = await promise;
|
||||
cached = { at: now(), payload: result };
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await payload();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
} catch {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Local receiver feeds unavailable' }));
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin mounting `GET /api/local-receivers/aircraft`.
|
||||
*
|
||||
* The environment is read when the server starts, so construction alone
|
||||
* neither parses configuration nor fetches anything.
|
||||
* @param {object} [options] Passed to {@link createLocalReceiversHandler}.
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function localReceiversProxy(options = {}) {
|
||||
const install = (server) => {
|
||||
const { handle } = createLocalReceiversHandler(options);
|
||||
server.middlewares.use(LOCAL_RECEIVERS_ROUTE, (req, res, next) => {
|
||||
const path = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (path !== '/' && path !== '') {
|
||||
if (typeof next === 'function') return next();
|
||||
return undefined;
|
||||
}
|
||||
return handle(req, res);
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'local-receivers-proxy',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js';
|
||||
import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { radioBrowserProxy } from './radio.js';
|
||||
import { gbfsProxy } from './gbfs.js';
|
||||
import { localReceiversProxy } from './local-receivers.js';
|
||||
import { transitProxy } from './transit.js';
|
||||
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
@@ -20,6 +21,10 @@ 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 { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
@@ -39,12 +44,17 @@ function localProviderPlugins() {
|
||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
localReceiversProxy(),
|
||||
transitProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
cycloneProxy(),
|
||||
firePerimetersProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,65 @@
|
||||
import { defaultSourceRoot } from '../common/source-root.js';
|
||||
import path from 'node:path';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import fs from 'node:fs';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
|
||||
/** Cap on one request body. */
|
||||
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Ceiling for the live log before it rotates. One generation is kept
|
||||
* (`…jsonl.1`), so the sink is bounded at twice this however long a session
|
||||
* runs. REALTIME_DEBUG_LOG_MAX_BYTES caps a single request; it never bounded
|
||||
* the file those requests accumulate into.
|
||||
*/
|
||||
const REALTIME_DEBUG_LOG_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Per-IP write ceiling. Always on, unlike the opt-in limiter the cost-bearing
|
||||
* OpenAI routes share: throttling those by default would change what a user
|
||||
* spends, while this sink spends disk and event-loop time. A voice session
|
||||
* writes far below this, so the cap needs no configuration to stay invisible.
|
||||
*/
|
||||
const REALTIME_DEBUG_LOG_MAX_PER_MIN = 120;
|
||||
|
||||
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(
|
||||
REALTIME_DEBUG_LOG_DIR,
|
||||
'realtime-conversations.jsonl',
|
||||
);
|
||||
const logDir = path.join(sourceRoot, '.gev-logs');
|
||||
const logFile = path.join(logDir, 'realtime-conversations.jsonl');
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: REALTIME_DEBUG_LOG_MAX_PER_MIN,
|
||||
globalMax: 400,
|
||||
});
|
||||
|
||||
// Appends are chained rather than fired concurrently because the rotation
|
||||
// check is a read-then-write on file size: run in parallel, every caller
|
||||
// stats a file still under the ceiling, none of them rotates, and the log
|
||||
// grows past it. The records themselves are O_APPEND and never interleave.
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function append(line) {
|
||||
const done = queue.then(async () => {
|
||||
await fsp.mkdir(logDir, { recursive: true });
|
||||
const size = await fsp.stat(logFile).then(
|
||||
(stat) => stat.size,
|
||||
() => 0,
|
||||
);
|
||||
if (size + Buffer.byteLength(line) > REALTIME_DEBUG_LOG_MAX_FILE_BYTES) {
|
||||
// Node does not replace an existing destination on Windows. Remove the
|
||||
// retained generation explicitly so every rotation behaves the same on
|
||||
// supported platforms rather than failing after the first 64 MB.
|
||||
await fsp.rm(`${logFile}.1`, { force: true });
|
||||
await fsp.rename(logFile, `${logFile}.1`);
|
||||
}
|
||||
await fsp.appendFile(logFile, line);
|
||||
});
|
||||
// The caller still sees its own rejection, but one failed append must not
|
||||
// poison the chain for every request queued behind it.
|
||||
queue = done.catch(() => {});
|
||||
return done;
|
||||
}
|
||||
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
@@ -19,12 +68,19 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '60',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
REALTIME_DEBUG_LOG_FILE,
|
||||
await append(
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
@@ -33,13 +89,16 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
// A malformed record or an oversized body is the caller's fault; a failed
|
||||
// write is ours, and only the second is worth a log line. Neither answer
|
||||
// carries the error text, which for an fs failure is an errno and an
|
||||
// absolute path.
|
||||
const callerFault =
|
||||
error instanceof SyntaxError || error?.code === 'BODY_TOO_LARGE';
|
||||
if (!callerFault) console.warn('[realtime-debug-log] append failed');
|
||||
res.statusCode = callerFault ? 400 : 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to write Realtime debug log',
|
||||
}),
|
||||
);
|
||||
res.end(JSON.stringify({ error: 'Failed to write Realtime debug log' }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -78,20 +75,23 @@ async function handleHudSummary(req, res) {
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!response.ok)
|
||||
console.warn(`[hud-summary] upstream HTTP ${response.status}`);
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
error: response.ok
|
||||
? null
|
||||
: data.error?.message || 'OpenAI HUD summary request failed',
|
||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||
// it carries request ids, organization hints and quota phrasing.
|
||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.warn('[hud-summary] request failed');
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'OpenAI HUD summary request failed',
|
||||
error: 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
@@ -48,7 +49,8 @@ function realtimeInstructions(
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
|
||||
@@ -132,26 +132,35 @@ function createRealtimeTokenHandler({
|
||||
});
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
// Which tier/model this secret was actually minted for. The upstream
|
||||
// body is passed through untouched (the client parses it verbatim), so
|
||||
// these headers are the authoritative echo — including the case where a
|
||||
// bogus ?tier= was silently downgraded to standard.
|
||||
// success body is passed through untouched (the client parses it
|
||||
// verbatim), so these headers are the authoritative echo — including the
|
||||
// case where a bogus ?tier= was silently downgraded to standard.
|
||||
res.setHeader('X-GEV-Voice-Tier', tier);
|
||||
res.setHeader('X-GEV-Voice-Model', model);
|
||||
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
|
||||
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.warn(`[realtime-token] upstream HTTP ${response.status}`);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Failed to create Realtime token' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// For a network fault this was a resolver message naming the upstream
|
||||
// host; the client only needs to know the mint failed.
|
||||
console.warn('[realtime-token] mint failed');
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to create Realtime token',
|
||||
error: 'Failed to create Realtime token',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
properties: {
|
||||
layerId: {
|
||||
description:
|
||||
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
|
||||
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
@@ -182,6 +182,29 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
set_cyber_sonar: {
|
||||
description:
|
||||
'Adjust Cyber-only sonar controls. Requires the Cyber HUD layout; never switches layout or HUD visibility. Omitted fields stay unchanged. Returns actual settings and separate map/contact sweep activity. A saved setting does not mean the effect is active.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
enabled: { description: 'Explicitly turn sonar on or off.' },
|
||||
rings: { description: 'Number of decorative sonar rings, 3–12.' },
|
||||
rangePct: {
|
||||
description:
|
||||
'Visual ring range, 60–120 percent; not geographic distance.',
|
||||
},
|
||||
intensityPct: {
|
||||
description: 'Sonar Power slider, 0–100 percent. Zero is valid.',
|
||||
},
|
||||
opacityPct: {
|
||||
description:
|
||||
'Contact opacity floor between passes, 35–100 percent; labels have a derived floor. Not whole-scene dimming.',
|
||||
},
|
||||
sectorDeg: { description: 'Sonar sweep sector width, 8–60 degrees.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
set_detection: {
|
||||
description:
|
||||
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
@@ -505,7 +528,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 +572,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 +594,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,
|
||||
},
|
||||
};
|
||||
@@ -23,9 +23,16 @@ import {
|
||||
* Budget governor (mirrors the OpenSky credit-governor philosophy — last-good
|
||||
* data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json,
|
||||
* keyed by UTC date, reset on day change) counts upstream fetch attempts
|
||||
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free
|
||||
* tier's ~50k/day). Over the cap the proxy serves stale tiles when available,
|
||||
* else 429 {error:'budget'}.
|
||||
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy
|
||||
* serves stale tiles when available, else 429 {error:'budget'}.
|
||||
*
|
||||
* The default is derived from TomTom's published free allowance, which is
|
||||
* granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests
|
||||
* per month (https://docs.tomtom.com/pricing/). The previous 40,000/day
|
||||
* default — and the "~50k/day" figure this comment used to cite — exhausted
|
||||
* a month's entire allowance in five days, leaving the traffic layer dead
|
||||
* for the rest of the billing period. 6,000/day keeps a full 31-day month
|
||||
* inside the allowance (186,000) with headroom to spare.
|
||||
*
|
||||
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
|
||||
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
|
||||
@@ -37,7 +44,8 @@ export function tomtomProxy() {
|
||||
const TILE_TTL_MS = 120_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom');
|
||||
const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json');
|
||||
const DEFAULT_DAILY_BUDGET = 40000;
|
||||
// 200,000/month free tier / 31 days = 6,451. Rounded down for headroom.
|
||||
const DEFAULT_DAILY_BUDGET = 6000;
|
||||
const MEM_MAX_ENTRIES = 256;
|
||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||
type: vesselTypeFromAis(message, staticData),
|
||||
destination: stringValue(message.Destination ?? staticData.destination),
|
||||
speed: numberValue(message.Sog ?? message.SOG),
|
||||
course: numberValue(message.Cog ?? message.COG),
|
||||
speed: normalizedSpeedOverGround(message.Sog ?? message.SOG),
|
||||
course: normalizedCourseOverGround(message.Cog ?? message.COG),
|
||||
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
|
||||
last_position_UTC: normalizeAisTimestamp(
|
||||
metadata.time_utc ?? metadata.TimeUtc,
|
||||
@@ -277,6 +277,28 @@ function normalizedHeading(value) {
|
||||
return heading !== null && heading >= 0 && heading <= 360 ? heading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speed over ground in knots, or null when the report carries AIS's "not
|
||||
* available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means
|
||||
* "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel
|
||||
* and not a reading. It is finite, so every consumer would otherwise treat it
|
||||
* as a genuine speed.
|
||||
*/
|
||||
function normalizedSpeedOverGround(value) {
|
||||
const speed = numberValue(value);
|
||||
return speed !== null && speed >= 0 && speed < 102.3 ? speed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Course over ground in degrees, or null when the report carries AIS's "not
|
||||
* available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at
|
||||
* 359.9; heading keeps its own separate sentinel of 511.
|
||||
*/
|
||||
function normalizedCourseOverGround(value) {
|
||||
const course = numberValue(value);
|
||||
return course !== null && course >= 0 && course < 360 ? course : null;
|
||||
}
|
||||
|
||||
function normalizeAisTimestamp(value) {
|
||||
const text = stringValue(value);
|
||||
if (!text) return new Date().toISOString();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,11 @@ export function hardenCredentialFile(
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
const aclRemoval = spawn('chmod', ['-N', filepath], {
|
||||
// `-N` (strip the ACL) is Apple-only. Spawn /bin/chmod by absolute
|
||||
// path, never by name: a Nix/Homebrew coreutils profile puts GNU chmod
|
||||
// first on PATH, and GNU chmod rejects `-N` — which was treated as a
|
||||
// hardening failure and refused every save on such machines.
|
||||
const aclRemoval = spawn('/bin/chmod', ['-N', filepath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (!commandCompletedSuccessfully(aclRemoval)) return false;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
/** Load this checkout's configuration and attach its local provider middleware. */
|
||||
export default defineConfig(({ mode }) => {
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const loaded = loadEnv(mode, root, '');
|
||||
for (const [key, value] of Object.entries(loaded)) {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
@@ -18,5 +18,6 @@ export default defineConfig(({ mode }) => {
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
command,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
|
||||
function featureSource(overrides = {}) {
|
||||
const source = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
source[method] = async () => null;
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
const texasGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 31.0,
|
||||
lng: -99.9,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||
const region = await resolveRegionRingForQuery(name);
|
||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||
assert.equal(region.error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas(_point, { signal }) {
|
||||
adminSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Texas',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
assert.ok(
|
||||
Date.now() - started < 1000,
|
||||
'returns at the budget, not the lookup',
|
||||
);
|
||||
// The lookup is left running so it can fill the boundary cache.
|
||||
assert.equal(adminSignal?.aborted ?? false, false);
|
||||
release(null);
|
||||
});
|
||||
|
||||
test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 30)),
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -1877,14 +1880,22 @@ export function createAnnotationResolver({
|
||||
* resolve to a region-like boundary — the analyst engine reports that
|
||||
* honestly rather than silently scoping to nothing.
|
||||
*
|
||||
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
|
||||
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
|
||||
* answer is not held for tens of seconds; the lookup keeps running and
|
||||
* fills the boundary cache, so asking again shortly is fast.
|
||||
*
|
||||
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
|
||||
* @param {AbortSignal} [signal]
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
|
||||
* @param {object} [placeSearch]
|
||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
*/
|
||||
async function resolveRegionRingForQuery(
|
||||
name,
|
||||
signal,
|
||||
placeSearch = unavailablePlaceSearch,
|
||||
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
|
||||
) {
|
||||
signal = lifetime
|
||||
? signal
|
||||
@@ -1901,6 +1912,23 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
|
||||
budgetMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([lookup, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminRegionRing(q, signal, placeSearch) {
|
||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.equal(typeof createApplicationViewer, 'function');
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
const container = {
|
||||
addEventListener(type, handler, options) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.deepEqual(options, { capture: true, passive: false });
|
||||
handlers.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler, capture) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.equal(capture, true);
|
||||
handlers.delete(handler);
|
||||
},
|
||||
emit(event) {
|
||||
for (const handler of handlers) handler(event);
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
dispatchEvent(event) {
|
||||
// A real canvas dispatch crosses the container's capture listener again.
|
||||
container.emit(event);
|
||||
relayed.push(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const controller = { zoomEventTypes };
|
||||
const viewer = {
|
||||
container,
|
||||
canvas,
|
||||
scene: { screenSpaceCameraController: controller },
|
||||
};
|
||||
const createEvent = (init = {}) => ({
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
deltaZ: 0,
|
||||
deltaMode: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {
|
||||
this.propagationStopped = true;
|
||||
},
|
||||
...init,
|
||||
});
|
||||
return { viewer, controller, container, handlers, relayed, createEvent };
|
||||
}
|
||||
|
||||
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
|
||||
const original = [1, 2, 3];
|
||||
const f = pinchFixture({ zoomEventTypes: original });
|
||||
const dispose = installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
|
||||
assert.equal(f.controller.zoomEventTypes.length, 4);
|
||||
|
||||
const plain = f.createEvent({ deltaY: 2 });
|
||||
f.container.emit(plain);
|
||||
assert.equal(plain.defaultPrevented, false);
|
||||
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
|
||||
f.container.emit(lineMode);
|
||||
assert.equal(lineMode.defaultPrevented, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.zoomEventTypes, original);
|
||||
assert.equal(f.handlers.size, 0);
|
||||
});
|
||||
|
||||
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
|
||||
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(small);
|
||||
assert.equal(small.defaultPrevented, true);
|
||||
assert.equal(small.propagationStopped, true);
|
||||
assert.equal(f.relayed.length, 1);
|
||||
assert.equal(f.relayed[0].deltaY, 16);
|
||||
assert.equal(f.relayed[0].ctrlKey, true);
|
||||
|
||||
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
|
||||
f.container.emit(large);
|
||||
assert.equal(f.relayed.length, 2);
|
||||
assert.equal(f.relayed[1].deltaY, -120);
|
||||
});
|
||||
|
||||
test('trackpad pinch falls back to the original event when relay construction fails', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent() {
|
||||
throw new Error('unsupported');
|
||||
},
|
||||
});
|
||||
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(event);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Workaround for Apple's Metal/ANGLE shader translator rejecting Cesium's
|
||||
* per-vertex model atmosphere.
|
||||
*
|
||||
* Cesium's `AtmosphereStageVS` calls a function whose `out` parameters are
|
||||
* bound DIRECTLY to varyings:
|
||||
*
|
||||
* czm_computeGroundAtmosphereScattering(
|
||||
* v_positionWC, lightDirection,
|
||||
* v_atmosphereRayleighColor, v_atmosphereMieColor, v_atmosphereOpacity);
|
||||
*
|
||||
* ANGLE's Metal backend wraps each `out` argument in an `ANGLE_out(...)`
|
||||
* helper that takes a `thread T &`. Vertex-output struct members live in
|
||||
* `__metal_generic`, which cannot bind to `thread`, so the program fails to
|
||||
* LINK (not compile) and Cesium tears the render loop down:
|
||||
*
|
||||
* RuntimeError: Program failed to link. ... MSL compilation error:
|
||||
* reference to type 'thread float3' could not bind to an lvalue of type
|
||||
* '__metal_generic float3'
|
||||
*
|
||||
* This is the only shader in Cesium that binds varyings to `out` params --
|
||||
* SkyAtmosphere and the ground-atmosphere fragment path both route through
|
||||
* locals first, so they are unaffected and stay on.
|
||||
*
|
||||
* Cesium attaches that stage in `ModelSceneGraph.configurePipeline` under a
|
||||
* single condition:
|
||||
*
|
||||
* const fogRenderable = frameState.fog.enabled && frameState.fog.renderable;
|
||||
* if (fogRenderable) modelPipelineStages.push(AtmospherePipelineStage);
|
||||
*
|
||||
* so clearing `scene.fog.renderable` keeps the broken stage out of the
|
||||
* pipeline entirely -- the program is never built, so it cannot fail to link.
|
||||
*
|
||||
* We clear `renderable` rather than `enabled` deliberately:
|
||||
* - `Fog.update` still computes fog density when only `renderable` is off,
|
||||
* preserving the distance-based screen-space-error scaling that governs
|
||||
* 3D Tiles refinement. Clearing `enabled` returns early and forfeits it.
|
||||
* - `visualSettings.js` saves and restores `scene.fog.enabled` around IR
|
||||
* boost. Parking our fix on `enabled` would let that restore resurrect
|
||||
* the broken shader mid-session; `renderable` is orthogonal to it.
|
||||
*
|
||||
* @module app/atmosphereCompat
|
||||
*/
|
||||
|
||||
/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */
|
||||
const PROBE_VERTEX_SHADER = `#version 300 es
|
||||
precision highp float;
|
||||
in vec3 a_position;
|
||||
out vec3 v_probe;
|
||||
void writeOut(out vec3 value) { value = vec3(1.0); }
|
||||
void main() {
|
||||
writeOut(v_probe);
|
||||
gl_Position = vec4(a_position, 1.0);
|
||||
}`;
|
||||
|
||||
const PROBE_FRAGMENT_SHADER = `#version 300 es
|
||||
precision highp float;
|
||||
in vec3 v_probe;
|
||||
out vec4 fragColor;
|
||||
void main() { fragColor = vec4(v_probe, 1.0); }`;
|
||||
|
||||
/**
|
||||
* True when the user agent is iOS or iPadOS.
|
||||
*
|
||||
* iPadOS 13+ defaults to desktop-mode browsing and reports `MacIntel` with no
|
||||
* iPad token, so it is identified by touch points instead -- real Macs report
|
||||
* `maxTouchPoints` of 0, including Apple Silicon ones that also claim
|
||||
* `MacIntel`. Brave/Chrome/Firefox on iOS are all WebKit underneath and share
|
||||
* the same Metal backend, so matching the platform rather than the browser is
|
||||
* what we want.
|
||||
*/
|
||||
export function isAppleMobilePlatform(navigatorLike = globalThis.navigator) {
|
||||
if (!navigatorLike) return false;
|
||||
const ua = String(navigatorLike.userAgent || '');
|
||||
if (/\b(iPad|iPhone|iPod)\b/.test(ua)) return true;
|
||||
// Desktop-mode iPadOS presents as a Mac. What separates it from a real Mac
|
||||
// is touch: Macs report 0 touch points, including Apple Silicon ones that
|
||||
// also claim `MacIntel`. `navigator.platform` is deprecated and may be
|
||||
// empty, so the UA's Macintosh token is accepted as an equal signal rather
|
||||
// than relying on platform alone.
|
||||
const platform = String(navigatorLike.platform || '');
|
||||
const macLike =
|
||||
platform === 'MacIntel' ||
|
||||
platform === 'MacARM' ||
|
||||
/\bMacintosh\b/.test(ua);
|
||||
const touchPoints = Number(navigatorLike.maxTouchPoints) || 0;
|
||||
return macLike && touchPoints > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the probe program and report whether the driver rejects the pattern.
|
||||
*
|
||||
* Returns `false` when WebGL2 is unavailable so that a missing context is
|
||||
* never mistaken for a broken driver -- Cesium has its own handling for that,
|
||||
* and the caller still falls back to platform detection.
|
||||
*/
|
||||
export function probeOutParamVaryingLinkFailure(gl) {
|
||||
if (!gl) return false;
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
const program = gl.createProgram();
|
||||
if (!vs || !fs || !program) return false;
|
||||
try {
|
||||
gl.shaderSource(vs, PROBE_VERTEX_SHADER);
|
||||
gl.compileShader(vs);
|
||||
gl.shaderSource(fs, PROBE_FRAGMENT_SHADER);
|
||||
gl.compileShader(fs);
|
||||
// A compile failure here is not the bug we are probing for: the driver
|
||||
// rejects this pattern at LINK time, after both stages compile cleanly.
|
||||
if (
|
||||
!gl.getShaderParameter(vs, gl.COMPILE_STATUS) ||
|
||||
!gl.getShaderParameter(fs, gl.COMPILE_STATUS)
|
||||
)
|
||||
return false;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return !gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
gl.deleteProgram(program);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a throwaway WebGL2 context for the probe, or null if unavailable. */
|
||||
function defaultProbeContext() {
|
||||
try {
|
||||
const canvas =
|
||||
typeof OffscreenCanvas === 'function'
|
||||
? new OffscreenCanvas(1, 1)
|
||||
: globalThis.document?.createElement('canvas');
|
||||
return canvas?.getContext('webgl2') ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the per-vertex model atmosphere must be kept out of the
|
||||
* pipeline on this device.
|
||||
*
|
||||
* The probe is authoritative when it fires, so a future driver fix silently
|
||||
* restores the effect without a code change. Platform detection is the
|
||||
* backstop for the case where no context can be created -- on Apple mobile a
|
||||
* link failure is fatal and unrecoverable, so we would rather lose fog than
|
||||
* gamble the whole render loop on the probe having run.
|
||||
*/
|
||||
export function shouldDisableModelAtmosphere({
|
||||
navigatorLike = globalThis.navigator,
|
||||
createProbeContext = defaultProbeContext,
|
||||
} = {}) {
|
||||
const gl = createProbeContext();
|
||||
if (gl) {
|
||||
let linkFails;
|
||||
try {
|
||||
linkFails = probeOutParamVaryingLinkFailure(gl);
|
||||
} finally {
|
||||
// Browsers cap live WebGL contexts (Safari especially); release the
|
||||
// throwaway probe context now rather than waiting for GC.
|
||||
try {
|
||||
gl.getExtension?.('WEBGL_lose_context')?.loseContext();
|
||||
} catch {
|
||||
// A context that cannot be released is left to GC.
|
||||
}
|
||||
}
|
||||
if (linkFails) return true;
|
||||
// Probe ran and the driver accepted the pattern. Apple mobile is still
|
||||
// quarantined: the probe program is far simpler than Cesium's, and a
|
||||
// false negative there costs the entire scene.
|
||||
return isAppleMobilePlatform(navigatorLike);
|
||||
}
|
||||
return isAppleMobilePlatform(navigatorLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Cesium's broken model-atmosphere stage out of the pipeline.
|
||||
*
|
||||
* Returns true when the workaround was applied. Safe to call on any scene;
|
||||
* scenes without a fog object are left untouched.
|
||||
*/
|
||||
export function applyModelAtmosphereWorkaround(scene, options = {}) {
|
||||
if (!scene?.fog) return false;
|
||||
if (!shouldDisableModelAtmosphere(options)) return false;
|
||||
scene.fog.renderable = false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isAppleMobilePlatform,
|
||||
probeOutParamVaryingLinkFailure,
|
||||
shouldDisableModelAtmosphere,
|
||||
applyModelAtmosphereWorkaround,
|
||||
} from './atmosphereCompat.js';
|
||||
|
||||
const IPAD_OS12 = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15',
|
||||
platform: 'iPad',
|
||||
maxTouchPoints: 5,
|
||||
};
|
||||
// iPadOS 13+ desktop mode: no iPad token, reports as a Mac.
|
||||
const IPAD_DESKTOP_MODE = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 5,
|
||||
};
|
||||
const MAC_DESKTOP = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 0,
|
||||
};
|
||||
const LINUX_DESKTOP = {
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/141',
|
||||
platform: 'Linux x86_64',
|
||||
maxTouchPoints: 0,
|
||||
};
|
||||
|
||||
/** Minimal GL double. `linkOk: false` mimics the Metal translator's refusal. */
|
||||
function fakeGl({ linkOk = true, compileOk = true, throwOn = null } = {}) {
|
||||
const deleted = { shaders: 0, programs: 0 };
|
||||
return {
|
||||
deleted,
|
||||
VERTEX_SHADER: 1,
|
||||
FRAGMENT_SHADER: 2,
|
||||
COMPILE_STATUS: 3,
|
||||
LINK_STATUS: 4,
|
||||
createShader: () => ({}),
|
||||
createProgram: () => ({}),
|
||||
shaderSource() {
|
||||
if (throwOn === 'shaderSource') throw new Error('context lost');
|
||||
},
|
||||
compileShader() {},
|
||||
attachShader() {},
|
||||
linkProgram() {},
|
||||
getShaderParameter: () => compileOk,
|
||||
getProgramParameter: () => linkOk,
|
||||
deleteShader: () => (deleted.shaders += 1),
|
||||
deleteProgram: () => (deleted.programs += 1),
|
||||
};
|
||||
}
|
||||
|
||||
test('identifies iPadOS in both legacy and desktop-mode guises', () => {
|
||||
assert.equal(isAppleMobilePlatform(IPAD_OS12), true);
|
||||
assert.equal(isAppleMobilePlatform(IPAD_DESKTOP_MODE), true);
|
||||
});
|
||||
|
||||
test('does not mistake a real Mac or a Linux desktop for Apple mobile', () => {
|
||||
// Regression guard: the desktop-mode check keys on touch points, and a Mac
|
||||
// reports the same MacIntel platform string with zero of them.
|
||||
assert.equal(isAppleMobilePlatform(MAC_DESKTOP), false);
|
||||
assert.equal(isAppleMobilePlatform(LINUX_DESKTOP), false);
|
||||
});
|
||||
|
||||
test('treats a missing navigator as not-Apple rather than throwing', () => {
|
||||
assert.equal(isAppleMobilePlatform(undefined), false);
|
||||
assert.equal(isAppleMobilePlatform(null), false);
|
||||
});
|
||||
|
||||
test('detects desktop-mode iPadOS without navigator.platform', () => {
|
||||
// `navigator.platform` is deprecated and can be absent or stripped; the UA's
|
||||
// Macintosh token plus touch points has to carry the detection on its own.
|
||||
assert.equal(
|
||||
isAppleMobilePlatform({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
maxTouchPoints: 5,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Same UA, no touch: a real Mac, which must stay untouched.
|
||||
assert.equal(
|
||||
isAppleMobilePlatform({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
maxTouchPoints: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('probe reports failure only when the program fails to link', () => {
|
||||
assert.equal(
|
||||
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: false })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a compile failure is not reported as the link bug', () => {
|
||||
// The Metal defect surfaces at link time; both stages compile cleanly.
|
||||
// Treating a compile error as the bug would disable fog for the wrong reason.
|
||||
assert.equal(
|
||||
probeOutParamVaryingLinkFailure(
|
||||
fakeGl({ compileOk: false, linkOk: false }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('probe releases its GL objects on both paths', () => {
|
||||
const ok = fakeGl({ linkOk: true });
|
||||
probeOutParamVaryingLinkFailure(ok);
|
||||
assert.equal(ok.deleted.shaders, 2);
|
||||
assert.equal(ok.deleted.programs, 1);
|
||||
|
||||
const thrown = fakeGl({ throwOn: 'shaderSource' });
|
||||
probeOutParamVaryingLinkFailure(thrown);
|
||||
assert.equal(thrown.deleted.shaders, 2, 'shaders freed even when GL throws');
|
||||
assert.equal(thrown.deleted.programs, 1);
|
||||
});
|
||||
|
||||
test('probe treats a null context as healthy, not broken', () => {
|
||||
assert.equal(probeOutParamVaryingLinkFailure(null), false);
|
||||
});
|
||||
|
||||
test('a failing probe disables the stage on any platform', () => {
|
||||
assert.equal(
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: LINUX_DESKTOP,
|
||||
createProbeContext: () => fakeGl({ linkOk: false }),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a passing probe still quarantines Apple mobile but frees desktops', () => {
|
||||
const createProbeContext = () => fakeGl({ linkOk: true });
|
||||
assert.equal(
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: IPAD_DESKTOP_MODE,
|
||||
createProbeContext,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: LINUX_DESKTOP,
|
||||
createProbeContext,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to platform detection when no context can be made', () => {
|
||||
const createProbeContext = () => null;
|
||||
assert.equal(
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: IPAD_OS12,
|
||||
createProbeContext,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: MAC_DESKTOP,
|
||||
createProbeContext,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('applies to the scene by clearing renderable, never enabled', () => {
|
||||
// `enabled` must survive: Fog.update short-circuits when it is false, which
|
||||
// forfeits the density used for 3D Tiles screen-space-error scaling, and
|
||||
// visualSettings' IR boost saves/restores that same flag.
|
||||
const scene = { fog: { enabled: true, renderable: true } };
|
||||
const applied = applyModelAtmosphereWorkaround(scene, {
|
||||
navigatorLike: IPAD_DESKTOP_MODE,
|
||||
createProbeContext: () => null,
|
||||
});
|
||||
assert.equal(applied, true);
|
||||
assert.equal(scene.fog.renderable, false);
|
||||
assert.equal(scene.fog.enabled, true);
|
||||
});
|
||||
|
||||
test('leaves unaffected devices completely untouched', () => {
|
||||
const scene = { fog: { enabled: true, renderable: true } };
|
||||
const applied = applyModelAtmosphereWorkaround(scene, {
|
||||
navigatorLike: LINUX_DESKTOP,
|
||||
createProbeContext: () => fakeGl({ linkOk: true }),
|
||||
});
|
||||
assert.equal(applied, false);
|
||||
assert.equal(scene.fog.renderable, true);
|
||||
});
|
||||
|
||||
test('tolerates a scene with no fog object', () => {
|
||||
assert.equal(applyModelAtmosphereWorkaround({}, {}), false);
|
||||
assert.equal(applyModelAtmosphereWorkaround(null, {}), false);
|
||||
});
|
||||
|
||||
test('releases the throwaway probe context after probing', () => {
|
||||
let lost = 0;
|
||||
const gl = fakeGl({ linkOk: true });
|
||||
gl.getExtension = (name) =>
|
||||
name === 'WEBGL_lose_context' ? { loseContext: () => (lost += 1) } : null;
|
||||
shouldDisableModelAtmosphere({
|
||||
navigatorLike: LINUX_DESKTOP,
|
||||
createProbeContext: () => gl,
|
||||
});
|
||||
assert.equal(lost, 1, 'probe context must not linger until GC');
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
militaryAwarenessLayer: 'military-awareness',
|
||||
militaryInstallationsLayer: 'military-installations',
|
||||
rocketLaunchesLayer: 'rocket-launches',
|
||||
localAdsbLayer: 'local-adsb',
|
||||
});
|
||||
|
||||
/** Capture the ordered application instances and their serialization metadata. */
|
||||
|
||||
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
'rocket-launches',
|
||||
'local-adsb',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[12]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -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';
|
||||
@@ -9,14 +13,17 @@ import { createApplicationRadio } from './layers/radio.js';
|
||||
import { createApplicationTraffic } from './layers/traffic.js';
|
||||
import { createApplicationBikeshare } from './layers/bikeshare.js';
|
||||
import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationRecentImagery } from './layers/recentImagery.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||
import { createApplicationAlpr } from './layers/alprCameras.js';
|
||||
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
||||
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
||||
import { createApplicationFirms } from './layers/firms.js';
|
||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -42,10 +49,28 @@ const SOURCE_METHODS = Object.freeze({
|
||||
launches: ['getLaunches', 'getActiveTle'],
|
||||
alpr: ['fetch'],
|
||||
firms: ['getSnapshot'],
|
||||
wind: ['getSnapshot'],
|
||||
weather: ['getSnapshot'],
|
||||
cyclones: ['getSnapshot'],
|
||||
earthquakes: ['getSnapshot'],
|
||||
'fire-perimeters': ['getSnapshot'],
|
||||
cables: ['fetch'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Hardware-local layers are registered like any other but never enter share
|
||||
* links or stored layer state: another browser cannot have this receiver.
|
||||
*/
|
||||
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
|
||||
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
|
||||
]);
|
||||
|
||||
/** Serialization metadata for every layer the application catalog constructs. */
|
||||
export const APPLICATION_LAYER_METADATA = Object.freeze([
|
||||
...LAYER_STATE_REGISTRY,
|
||||
...LOCAL_ONLY_LAYER_METADATA,
|
||||
]);
|
||||
|
||||
/** Construct the current catalog without choosing any source provider.
|
||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
||||
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||
@@ -54,7 +79,7 @@ export function createApplicationCatalog({
|
||||
surface,
|
||||
sources,
|
||||
signal,
|
||||
metadata = LAYER_STATE_REGISTRY,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
@@ -72,9 +97,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 {
|
||||
@@ -110,16 +137,26 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
flights,
|
||||
military,
|
||||
createApplicationLocalAdsb({
|
||||
surface,
|
||||
enrichment: sources.flights,
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationRecentImagery(),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -128,6 +165,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 +195,12 @@ export function createApplicationCatalog({
|
||||
],
|
||||
metadata,
|
||||
);
|
||||
return Object.freeze({ ...catalog, militaryRegistry, surface });
|
||||
return Object.freeze({
|
||||
...catalog,
|
||||
militaryRegistry,
|
||||
surface,
|
||||
weatherClock,
|
||||
});
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
|
||||
@@ -39,8 +39,32 @@ 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, 29);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
{ id: 'local-adsb', disposition: 'local-only' },
|
||||
'the hardware-local layer is never serialized into links',
|
||||
);
|
||||
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('fire-perimeters'));
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1),
|
||||
['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'],
|
||||
);
|
||||
assert.ok(first.get('bhote-koshi-2026'));
|
||||
assert.ok(first.get('bhote-koshi-locator'));
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
@@ -77,6 +101,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);
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -20,7 +21,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)
|
||||
@@ -53,6 +56,7 @@ export function createApplicationData({
|
||||
}
|
||||
presentation.mount(document.getElementById('data-toggles'));
|
||||
styleManager.attachDataManager(dataManager);
|
||||
defer(createCyberSonarScene(viewer, dataManager));
|
||||
|
||||
return { dataManager, catalog, presentation };
|
||||
}
|
||||
@@ -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) =>
|
||||
@@ -67,6 +70,12 @@ export class LayerPresentation {
|
||||
mount(container) {
|
||||
this.panel.mount(container);
|
||||
}
|
||||
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
|
||||
attachRecentImagery(factory) {
|
||||
// Detaching never builds a panel that teardown already released.
|
||||
if (factory) this.panel.attachRecentImagery(factory);
|
||||
else this._panel?.attachRecentImagery(null);
|
||||
}
|
||||
refresh() {
|
||||
this._panel?._refreshTogglePanel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js';
|
||||
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
|
||||
import { SdrController } from '../../sdr/controller.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as geoid from '../../data/geoid.js';
|
||||
import { markDetectionSourcesChanged } from '../../data/detection.js';
|
||||
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||
|
||||
/**
|
||||
* Construct the Local ADS-B layer, the browser RTL-SDR session it shares with
|
||||
* the Radio panel, and the decoder-feed session. Construction opens no device
|
||||
* and makes no request; the SDR starts only from an explicit Connect and the
|
||||
* feeds are polled only while the layer is enabled.
|
||||
*
|
||||
* `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference
|
||||
* (`{ models3d, models3dMode }`) so local aircraft follow the same toggle;
|
||||
* `enrichment` is the Flights source whose cached adsbdb proxy they share.
|
||||
*/
|
||||
export function createApplicationLocalAdsb({
|
||||
receiver = new SdrController(),
|
||||
feeds = createLocalReceiverFeeds(),
|
||||
surface = null,
|
||||
enrichment = null,
|
||||
displayParams = null,
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
} = {}) {
|
||||
return createLocalAdsbLayer({
|
||||
receiver,
|
||||
feeds,
|
||||
resolveAsset,
|
||||
services: {
|
||||
render,
|
||||
context,
|
||||
picking,
|
||||
trails,
|
||||
geoid,
|
||||
groundSnap: surface?.groundSnap || null,
|
||||
enrichment,
|
||||
display: displayParams ? { getParams: displayParams } : null,
|
||||
detection: { markSourcesChanged: markDetectionSourcesChanged },
|
||||
overlays: { refreshReadout: refreshTrackedReadout },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createFirePerimetersLayer,
|
||||
createInciwebIndexSource,
|
||||
createInciwebPublicationSource,
|
||||
} from '../../layers/perimeters/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire WFIGS fire perimeters into the application catalog. */
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
return createFirePerimetersLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
inciwebSource: createInciwebIndexSource(),
|
||||
inciwebPublications: createInciwebPublicationSource(),
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
||||
* browser thumbnail loader. It takes no data source: the catalog is NASA
|
||||
* CMR and the tiles are GIBS, both browser-direct and keyless.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationRecentImagery() {
|
||||
return createRecentImageryLayer({
|
||||
renderer: createRecentImageryRenderer({
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
thumbnails: createThumbnailLoader(),
|
||||
});
|
||||
}
|
||||
+5
-1
@@ -1,6 +1,9 @@
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApplicationViewer } from '../app/viewer.js';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
@@ -52,6 +55,7 @@ export async function createApplicationScene({
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
|
||||
@@ -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({
|
||||
|
||||
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user